From 9d252e8b37c441d62d6b299421870d28fe2ed398 Mon Sep 17 00:00:00 2001 From: zfurtak Date: Fri, 20 Sep 2024 13:52:27 +0100 Subject: [PATCH 01/25] Changed [] to at() --- .eslintrc.js | 6 + .../authorChecklist/authorChecklist.ts | 2 +- .../javascript/authorChecklist/index.js | 16 +- .../javascript/awaitStagingDeploys/index.js | 14 +- .../actions/javascript/bumpVersion/index.js | 2 +- .../javascript/bumpVersion/index.js.orig | 3804 ++++++++ .../javascript/checkDeployBlockers/index.js | 14 +- .../createOrUpdateStagingDeploy.ts | 13 +- .../createOrUpdateStagingDeploy/index.js | 24 +- .../javascript/getArtifactInfo/index.js | 14 +- .../getDeployPullRequestList/index.js | 14 +- .../javascript/getPullRequestDetails/index.js | 14 +- .../javascript/isStagingDeployLocked/index.js | 14 +- .../markPullRequestsAsDeployed/index.js | 19 +- .../markPullRequestsAsDeployed.ts | 5 +- .../javascript/postTestBuildComment/index.js | 14 +- .../javascript/proposalPoliceComment/index.js | 22 +- .../proposalPoliceComment.ts | 8 +- .../reopenIssueWithComment/index.js | 14 +- .../javascript/reviewerChecklist/index.js | 24 +- .../reviewerChecklist/reviewerChecklist.ts | 10 +- .../validateReassureOutput/index.js | 5 +- .../validateReassureOutput.ts | 7 +- .../javascript/verifySignedCommits/index.js | 14 +- .github/libs/GithubUtils.ts | 16 +- .github/libs/nativeVersionUpdater.ts | 2 +- .github/scripts/createDocsRoutes.ts | 5 +- .storybook/webpack.config.ts | 6 +- assets/emojis/index.ts | 2 +- config/webpack/webpack.common.ts | 2 +- jest/setup.ts | 2 +- package-lock.json | 8 +- package.json | 2 +- scripts/release-profile.ts | 2 +- src/CONST.ts | 3 +- src/components/AccountSwitcher.tsx | 2 +- src/components/ActiveGuidesEventListener.tsx | 21 - src/components/AddPlaidBankAccount.tsx | 2 +- .../AttachmentPicker/index.native.tsx | 12 +- .../AttachmentCarousel/index.native.tsx | 17 +- .../Attachments/AttachmentCarousel/index.tsx | 9 +- .../AttachmentViewPdf/index.android.tsx | 8 +- src/components/AvatarWithDisplayName.tsx | 5 +- .../ButtonWithDropdownMenu/index.tsx | 25 +- src/components/CategoryPicker.tsx | 4 +- src/components/Composer/index.native.tsx | 10 +- src/components/ConfirmedRoute.tsx | 2 +- .../CalendarPicker/generateMonthMatrix.ts | 4 +- .../DatePicker/CalendarPicker/index.tsx | 4 +- .../DisplayNames/DisplayNamesWithTooltip.tsx | 4 +- .../DistanceRequest/DistanceRequestFooter.tsx | 2 +- .../EmojiPickerMenu/index.native.tsx | 4 +- .../EmojiPicker/EmojiPickerMenu/index.tsx | 10 +- src/components/FilePicker/index.native.tsx | 2 +- .../HTMLRenderers/AnchorRenderer.tsx | 2 +- .../HTMLRenderers/MentionUserRenderer.tsx | 8 +- src/components/ImportColumn.tsx | 4 +- src/components/ImportSpreadsheetColumns.tsx | 4 +- .../InlineCodeBlock/WrappedText.tsx | 2 +- src/components/InteractiveStepSubHeader.tsx | 5 +- .../LHNOptionsList/LHNOptionsList.tsx | 2 +- .../LHNOptionsList/OptionRowLHN.tsx | 6 +- src/components/MagicCodeInput.tsx | 6 +- src/components/MapView/MapView.tsx | 2 +- src/components/MapView/MapView.website.tsx | 2 +- src/components/MapView/utils.ts | 8 +- src/components/MentionSuggestions.tsx | 10 +- src/components/MenuItem.tsx | 5 +- .../MoneyRequestConfirmationList.tsx | 4 +- .../MultiGestureCanvas/usePanGesture.ts | 8 +- src/components/MultipleAvatars.tsx | 56 +- src/components/OptionRow.tsx | 5 +- .../OptionsList/BaseOptionsList.tsx | 8 +- src/components/Picker/BasePicker.tsx | 5 +- src/components/PopoverMenu.tsx | 5 +- .../BaseProfilingToolMenu.tsx | 2 +- .../ReportActionItem/MoneyReportView.tsx | 2 +- .../MoneyRequestPreviewContent.tsx | 7 +- .../ReportActionItem/MoneyRequestView.tsx | 10 +- .../ReportActionItem/ReportPreview.tsx | 6 +- .../ReportActionItem/TripDetailsView.tsx | 2 +- src/components/RoomHeaderAvatars.tsx | 21 +- .../SelectionList/BaseSelectionList.tsx | 12 +- .../SelectionList/InviteMemberListItem.tsx | 5 +- .../SelectionList/Search/ReportListItem.tsx | 2 +- .../Search/TransactionListItemRow.tsx | 2 +- .../Search/TransactionListItemRow.tsx.orig | 457 + src/components/SelectionList/UserListItem.tsx | 5 +- .../SignInButtons/GetUserLanguage.ts | 2 +- src/components/TagPicker/index.tsx | 4 +- src/components/TaxPicker.tsx | 4 +- src/components/ValidateAccountMessage.tsx | 8 +- .../WorkspaceMembersSelectionList.tsx | 2 +- src/components/WorkspaceSwitcherButton.tsx | 3 +- src/hooks/useReviewDuplicatesNavigation.tsx | 5 +- src/hooks/useSubStep/index.ts | 4 +- src/hooks/useViolations.ts | 16 +- src/languages/en.ts | 4 +- src/languages/en.ts.orig | 4831 ++++++++++ src/languages/es.ts | 4 +- src/languages/es.ts.orig | 5348 +++++++++++ src/libs/ActiveClientManager/index.ts | 2 +- src/libs/CollectionUtils.ts | 2 +- src/libs/CurrencyUtils.ts | 2 +- src/libs/DistanceRequestUtils.ts | 2 +- src/libs/E2E/tests/linkingTest.e2e.ts | 4 +- src/libs/EmojiTrie.ts | 2 +- src/libs/EmojiUtils.ts | 11 +- src/libs/ErrorUtils.ts | 8 +- src/libs/GooglePlacesUtils.ts | 2 +- src/libs/IOUUtils.ts | 2 +- src/libs/LocaleDigitUtils.ts | 4 +- src/libs/Middleware/Pagination.ts | 5 +- src/libs/ModifiedExpenseMessage.ts | 12 +- .../Navigation/AppNavigator/AuthScreens.tsx | 11 +- .../createCustomBottomTabNavigator/index.tsx | 2 +- .../CustomFullScreenRouter.tsx | 2 +- .../createCustomStackNavigator/index.tsx | 24 +- src/libs/Navigation/OnyxTabNavigator.tsx | 4 +- .../linkingConfig/getAdaptedStateFromPath.ts | 8 +- .../getMatchingCentralPaneRouteForState.ts | 5 +- src/libs/Network/SequentialQueue.ts | 5 +- src/libs/NextStepUtils.ts | 2 +- src/libs/NextStepUtils.ts.orig | 313 + src/libs/OptionsListUtils.ts | 33 +- src/libs/PaginationUtils.ts | 39 +- src/libs/PersonalDetailsUtils.ts | 6 +- src/libs/PolicyUtils.ts | 21 +- src/libs/Pusher/pusher.ts | 4 +- src/libs/Queue/Queue.ts | 2 +- src/libs/ReportActionsUtils.ts | 38 +- src/libs/ReportActionsUtils.ts.orig | 1839 ++++ src/libs/ReportUtils.ts | 45 +- src/libs/ReportUtils.ts.orig | 8168 ++++++++++++++++ src/libs/SearchUtils.ts | 6 +- src/libs/SelectionScraper/index.ts | 5 +- src/libs/SidebarUtils.ts | 16 +- src/libs/StringUtils.ts | 2 +- src/libs/TransactionUtils/index.ts | 8 +- src/libs/Violations/ViolationsUtils.ts | 10 +- src/libs/WorkflowUtils.ts | 2 +- src/libs/actions/IOU.ts | 47 +- src/libs/actions/IOU.ts.orig | 8250 +++++++++++++++++ src/libs/actions/ImportSpreadsheet.ts | 13 +- .../actions/OnyxUpdateManager/utils/index.ts | 2 +- src/libs/actions/Policy/Category.ts | 11 +- src/libs/actions/Policy/Category.ts.orig | 1337 +++ src/libs/actions/Policy/DistanceRate.ts | 6 +- src/libs/actions/Policy/Member.ts | 4 +- src/libs/actions/Policy/Policy.ts | 16 +- src/libs/actions/Policy/Policy.ts.orig | 5026 ++++++++++ src/libs/actions/Policy/ReportField.ts | 4 +- src/libs/actions/Policy/Tag.ts | 49 +- src/libs/actions/Policy/Tag.ts.orig | 1027 ++ src/libs/actions/Policy/Tag.ts.rej | 14 + src/libs/actions/Report.ts | 31 +- src/libs/actions/Task.ts | 2 +- src/libs/actions/TaxRate.ts | 4 +- src/libs/actions/TeachersUnite.ts | 2 +- src/libs/actions/Transaction.ts | 8 +- src/libs/actions/User.ts | 11 +- src/libs/actions/Welcome/OnboardingFlow.ts | 8 +- src/libs/actions/Workflow.ts | 2 +- src/libs/fileDownload/FileUtils.ts | 4 +- src/libs/fileDownload/index.desktop.ts | 2 +- src/libs/mapChildrenFlat.ts | 2 +- src/libs/memoize/cache/ArrayCache.ts | 3 +- src/pages/Debug/DebugDetails.tsx | 2 +- .../Debug/DebugDetailsConstantPickerPage.tsx | 2 +- src/pages/EditReportFieldDropdown.tsx | 2 +- src/pages/EditReportFieldPage.tsx | 2 +- src/pages/EnablePayments/IdologyQuestions.tsx | 8 +- src/pages/NewChatPage.tsx | 2 +- src/pages/ReportDetailsPage.tsx | 4 +- src/pages/Search/SearchTypeMenuNarrow.tsx | 22 +- src/pages/home/HeaderView.tsx | 5 +- src/pages/home/ReportScreen.tsx | 2 +- src/pages/home/ReportScreen.tsx.orig | 821 ++ .../ReportActionCompose/SuggestionEmoji.tsx | 8 +- .../ReportActionCompose/SuggestionMention.tsx | 9 +- .../home/report/ReportActionItemSingle.tsx | 5 +- src/pages/home/report/ReportActionsList.tsx | 22 +- src/pages/home/report/ReportActionsView.tsx | 18 +- .../home/report/ReportTypingIndicator.tsx | 2 +- .../FloatingActionButtonAndPopover.tsx | 4 +- src/pages/iou/SplitBillDetailsPage.tsx | 2 +- .../iou/request/step/IOURequestStepAmount.tsx | 8 +- .../step/IOURequestStepConfirmation.tsx | 15 +- .../request/step/IOURequestStepDistance.tsx | 5 +- .../step/IOURequestStepDistance.tsx.orig | 550 ++ .../step/IOURequestStepDistanceRate.tsx | 2 +- .../step/IOURequestStepParticipants.tsx | 4 +- .../step/IOURequestStepScan/index.native.tsx | 16 +- .../request/step/IOURequestStepScan/index.tsx | 20 +- .../ValidateCodeForm/BaseValidateCodeForm.tsx | 2 +- .../settings/Wallet/ExpensifyCardPage.tsx | 4 +- .../settings/Wallet/PaymentMethodList.tsx | 9 +- .../settings/Wallet/TransferBalancePage.tsx | 2 +- src/pages/workspace/WorkspacesListPage.tsx | 14 +- src/pages/workspace/WorkspacesListRow.tsx | 2 +- .../netsuite/NetSuiteSubsidiarySelector.tsx | 2 +- .../qbo/advanced/QuickbooksAdvancedPage.tsx | 2 +- ...ompanyCardExpenseAccountSelectCardPage.tsx | 4 +- ...NonReimbursableDefaultVendorSelectPage.tsx | 2 +- ...ooksOutOfPocketExpenseEntitySelectPage.tsx | 2 +- .../WorkspaceCategoriesSettingsPage.tsx | 2 +- .../WorkspaceCompanyCardFeedSelectorPage.tsx | 2 +- .../WorkspaceCompanyCardsPage.tsx | 6 +- .../members/WorkspaceOwnerChangeCheck.tsx | 2 +- .../WorkspaceOwnerChangeWrapperPage.tsx | 2 +- .../members/WorkspaceOwnerPaymentCardForm.tsx | 2 +- .../ReportFieldsInitialListValuePicker.tsx | 4 +- .../ReportFieldTypePicker/index.tsx | 2 +- .../ReportFieldsListValuesPage.tsx | 12 +- .../ReportFieldsValueSettingsPage.tsx | 4 +- .../WorkspaceReportFieldsPage.tsx | 2 +- src/pages/workspace/tags/ImportedTagsPage.tsx | 2 +- .../workspace/tags/WorkspaceTagsPage.tsx | 2 +- .../tags/WorkspaceTagsSettingsPage.tsx | 66 +- .../workspace/tags/WorkspaceViewTagsPage.tsx | 2 +- .../workflows/WorkspaceWorkflowsPage.tsx | 2 +- .../workflows/WorkspaceWorkflowsPayerPage.tsx | 2 +- ...orkspaceWorkflowsApprovalsApproverPage.tsx | 4 +- ...paceWorkflowsApprovalsExpensesFromPage.tsx | 2 +- src/stories/PromotedActionBar.stories.tsx | 13 +- src/styles/utils/index.ts | 7 +- src/types/onyx/User.ts | 3 - src/utils/arraysEqual.ts | 2 +- src/utils/getDefaultIcon.ts | 12 + tests/actions/IOUTest.ts | 32 +- tests/actions/PolicyCategoryTest.ts | 10 +- tests/actions/PolicyTagTest.ts | 12 +- tests/actions/ReportTest.ts | 2 + tests/e2e/compare/output/markdownTable.ts | 30 +- tests/e2e/measure/math.ts | 8 +- tests/e2e/testRunner.ts | 10 +- tests/e2e/utils/installApp.ts | 2 +- .../perf-test/ReportActionsList.perf-test.tsx | 2 +- tests/ui/UnreadIndicatorsTest.tsx | 31 +- tests/unit/APITest.ts | 1 + tests/unit/CalendarPickerTest.tsx | 4 +- tests/unit/GithubUtilsTest.ts | 86 +- tests/unit/MiddlewareTest.ts | 1 + tests/unit/NetworkTest.ts | 4 +- tests/unit/OptionsListUtilsTest.ts | 90 +- tests/unit/ReportUtilsTest.ts | 66 +- tests/unit/RequestTest.ts | 6 +- tests/unit/SidebarOrderTest.ts | 116 +- tests/unit/SidebarTest.ts | 8 +- tests/unit/WorkflowUtilsTest.ts | 2 + tests/unit/createOrUpdateStagingDeployTest.ts | 66 +- tests/unit/generateMonthMatrixTest.ts | 4 +- tests/unit/versionUpdaterTest.ts | 4 +- tests/utils/LHNTestUtils.tsx | 2 +- tests/utils/ReportTestUtils.ts | 2 +- 255 files changed, 43153 insertions(+), 992 deletions(-) create mode 100644 .github/actions/javascript/bumpVersion/index.js.orig create mode 100644 src/components/SelectionList/Search/TransactionListItemRow.tsx.orig create mode 100755 src/languages/en.ts.orig create mode 100644 src/languages/es.ts.orig create mode 100644 src/libs/NextStepUtils.ts.orig create mode 100644 src/libs/ReportActionsUtils.ts.orig create mode 100644 src/libs/ReportUtils.ts.orig create mode 100644 src/libs/actions/IOU.ts.orig create mode 100644 src/libs/actions/Policy/Category.ts.orig create mode 100644 src/libs/actions/Policy/Policy.ts.orig create mode 100644 src/libs/actions/Policy/Tag.ts.orig create mode 100644 src/libs/actions/Policy/Tag.ts.rej create mode 100644 src/pages/home/ReportScreen.tsx.orig create mode 100644 src/pages/iou/request/step/IOURequestStepDistance.tsx.orig create mode 100644 src/utils/getDefaultIcon.ts diff --git a/.eslintrc.js b/.eslintrc.js index cb1219533278..661a6aa286b9 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -289,5 +289,11 @@ module.exports = { 'rulesdir/use-periods-for-error-messages': 'error', }, }, + { + files: ['*.ts', '*.tsx'], + rules: { + 'rulesdir/prefer-at': 'error', + }, + }, ], }; diff --git a/.github/actions/javascript/authorChecklist/authorChecklist.ts b/.github/actions/javascript/authorChecklist/authorChecklist.ts index f855c135cd39..ea6d879f12f5 100644 --- a/.github/actions/javascript/authorChecklist/authorChecklist.ts +++ b/.github/actions/javascript/authorChecklist/authorChecklist.ts @@ -54,7 +54,7 @@ function partitionWithChecklist(body: string): string[] { async function getNumberOfItemsFromAuthorChecklist(): Promise { const response = await fetch(pathToAuthorChecklist); const fileContents = await response.text(); - const checklist = partitionWithChecklist(fileContents)[1]; + const checklist = partitionWithChecklist(fileContents).at(1) ?? ''; const numberOfChecklistItems = (checklist.match(/\[ \]/g) ?? []).length; return numberOfChecklistItems; } diff --git a/.github/actions/javascript/authorChecklist/index.js b/.github/actions/javascript/authorChecklist/index.js index e09b95d572ff..d662b92764f9 100644 --- a/.github/actions/javascript/authorChecklist/index.js +++ b/.github/actions/javascript/authorChecklist/index.js @@ -16771,7 +16771,7 @@ function partitionWithChecklist(body) { async function getNumberOfItemsFromAuthorChecklist() { const response = await fetch(pathToAuthorChecklist); const fileContents = await response.text(); - const checklist = partitionWithChecklist(fileContents)[1]; + const checklist = partitionWithChecklist(fileContents).at(1) ?? ''; const numberOfChecklistItems = (checklist.match(/\[ \]/g) ?? []).length; return numberOfChecklistItems; } @@ -17180,7 +17180,11 @@ class GithubUtils { if (data.length > 1) { throw new Error(`Found more than one ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); } - return this.getStagingDeployCashData(data[0]); + const issue = data.at(0); + if (!issue) { + throw new Error(`Found an undefined ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); + } + return this.getStagingDeployCashData(issue); }); } /** @@ -17258,7 +17262,7 @@ class GithubUtils { } internalQASection = internalQASection[1]; const internalQAPRs = [...internalQASection.matchAll(new RegExp(`- \\[([ x])]\\s(${CONST_1.default.PULL_REQUEST_REGEX.source})`, 'g'))].map((match) => ({ - url: match[2].split('-')[0].trim(), + url: match[2].split('-').at(0)?.trim() ?? '', number: Number.parseInt(match[3], 10), isResolved: match[1] === 'x', })); @@ -17342,7 +17346,7 @@ class GithubUtils { * Fetch all pull requests given a list of PR numbers. */ static fetchAllPullRequests(pullRequestNumbers) { - const oldestPR = pullRequestNumbers.sort((a, b) => a - b)[0]; + const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, repo: CONST_1.default.APP_REPO, @@ -17416,7 +17420,7 @@ class GithubUtils { repo: CONST_1.default.APP_REPO, workflow_id: workflow, }) - .then((response) => response.data.workflow_runs[0]?.id); + .then((response) => response.data.workflow_runs.at(0)?.id ?? -1); } /** * Generate the URL of an New Expensify pull request given the PR number. @@ -17484,7 +17488,7 @@ class GithubUtils { per_page: 1, name: artifactName, }) - .then((response) => response.data.artifacts[0]); + .then((response) => response.data.artifacts.at(0)); } /** * Given an artifact ID, returns the download URL to a zip file containing the artifact. diff --git a/.github/actions/javascript/awaitStagingDeploys/index.js b/.github/actions/javascript/awaitStagingDeploys/index.js index 561cc980a4e5..cfc7e8b4cc4a 100644 --- a/.github/actions/javascript/awaitStagingDeploys/index.js +++ b/.github/actions/javascript/awaitStagingDeploys/index.js @@ -12421,7 +12421,11 @@ class GithubUtils { if (data.length > 1) { throw new Error(`Found more than one ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); } - return this.getStagingDeployCashData(data[0]); + const issue = data.at(0); + if (!issue) { + throw new Error(`Found an undefined ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); + } + return this.getStagingDeployCashData(issue); }); } /** @@ -12499,7 +12503,7 @@ class GithubUtils { } internalQASection = internalQASection[1]; const internalQAPRs = [...internalQASection.matchAll(new RegExp(`- \\[([ x])]\\s(${CONST_1.default.PULL_REQUEST_REGEX.source})`, 'g'))].map((match) => ({ - url: match[2].split('-')[0].trim(), + url: match[2].split('-').at(0)?.trim() ?? '', number: Number.parseInt(match[3], 10), isResolved: match[1] === 'x', })); @@ -12583,7 +12587,7 @@ class GithubUtils { * Fetch all pull requests given a list of PR numbers. */ static fetchAllPullRequests(pullRequestNumbers) { - const oldestPR = pullRequestNumbers.sort((a, b) => a - b)[0]; + const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, repo: CONST_1.default.APP_REPO, @@ -12657,7 +12661,7 @@ class GithubUtils { repo: CONST_1.default.APP_REPO, workflow_id: workflow, }) - .then((response) => response.data.workflow_runs[0]?.id); + .then((response) => response.data.workflow_runs.at(0)?.id ?? -1); } /** * Generate the URL of an New Expensify pull request given the PR number. @@ -12725,7 +12729,7 @@ class GithubUtils { per_page: 1, name: artifactName, }) - .then((response) => response.data.artifacts[0]); + .then((response) => response.data.artifacts.at(0)); } /** * Given an artifact ID, returns the download URL to a zip file containing the artifact. diff --git a/.github/actions/javascript/bumpVersion/index.js b/.github/actions/javascript/bumpVersion/index.js index 43bd09558c26..c96bc1bcf884 100644 --- a/.github/actions/javascript/bumpVersion/index.js +++ b/.github/actions/javascript/bumpVersion/index.js @@ -3536,7 +3536,7 @@ exports.updateAndroidVersion = updateAndroidVersion; * Updates the CFBundleShortVersionString and the CFBundleVersion. */ function updateiOSVersion(version) { - const shortVersion = version.split('-')[0]; + const shortVersion = version.split('-').at(0); const cfVersion = version.includes('-') ? version.replace('-', '.') : `${version}.0`; console.log('Updating iOS', `CFBundleShortVersionString: ${shortVersion}`, `CFBundleVersion: ${cfVersion}`); // Update Plists diff --git a/.github/actions/javascript/bumpVersion/index.js.orig b/.github/actions/javascript/bumpVersion/index.js.orig new file mode 100644 index 000000000000..43bd09558c26 --- /dev/null +++ b/.github/actions/javascript/bumpVersion/index.js.orig @@ -0,0 +1,3804 @@ +/** + * NOTE: This is a compiled file. DO NOT directly edit this file. + */ +/******/ (() => { // webpackBootstrap +/******/ var __webpack_modules__ = ({ + +/***/ 7351: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } }); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.issue = exports.issueCommand = void 0; +const os = __importStar(__nccwpck_require__(2037)); +const utils_1 = __nccwpck_require__(5278); +/** + * Commands + * + * Command Format: + * ::name key=value,key=value::message + * + * Examples: + * ::warning::This is the message + * ::set-env name=MY_VAR::some value + */ +function issueCommand(command, properties, message) { + const cmd = new Command(command, properties, message); + process.stdout.write(cmd.toString() + os.EOL); +} +exports.issueCommand = issueCommand; +function issue(name, message = '') { + issueCommand(name, {}, message); +} +exports.issue = issue; +const CMD_STRING = '::'; +class Command { + constructor(command, properties, message) { + if (!command) { + command = 'missing.command'; + } + this.command = command; + this.properties = properties; + this.message = message; + } + toString() { + let cmdStr = CMD_STRING + this.command; + if (this.properties && Object.keys(this.properties).length > 0) { + cmdStr += ' '; + let first = true; + for (const key in this.properties) { + if (this.properties.hasOwnProperty(key)) { + const val = this.properties[key]; + if (val) { + if (first) { + first = false; + } + else { + cmdStr += ','; + } + cmdStr += `${key}=${escapeProperty(val)}`; + } + } + } + } + cmdStr += `${CMD_STRING}${escapeData(this.message)}`; + return cmdStr; + } +} +function escapeData(s) { + return utils_1.toCommandValue(s) + .replace(/%/g, '%25') + .replace(/\r/g, '%0D') + .replace(/\n/g, '%0A'); +} +function escapeProperty(s) { + return utils_1.toCommandValue(s) + .replace(/%/g, '%25') + .replace(/\r/g, '%0D') + .replace(/\n/g, '%0A') + .replace(/:/g, '%3A') + .replace(/,/g, '%2C'); +} +//# sourceMappingURL=command.js.map + +/***/ }), + +/***/ 2186: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } }); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.getIDToken = exports.getState = exports.saveState = exports.group = exports.endGroup = exports.startGroup = exports.info = exports.notice = exports.warning = exports.error = exports.debug = exports.isDebug = exports.setFailed = exports.setCommandEcho = exports.setOutput = exports.getBooleanInput = exports.getMultilineInput = exports.getInput = exports.addPath = exports.setSecret = exports.exportVariable = exports.ExitCode = void 0; +const command_1 = __nccwpck_require__(7351); +const file_command_1 = __nccwpck_require__(717); +const utils_1 = __nccwpck_require__(5278); +const os = __importStar(__nccwpck_require__(2037)); +const path = __importStar(__nccwpck_require__(1017)); +const oidc_utils_1 = __nccwpck_require__(8041); +/** + * The code to exit an action + */ +var ExitCode; +(function (ExitCode) { + /** + * A code indicating that the action was successful + */ + ExitCode[ExitCode["Success"] = 0] = "Success"; + /** + * A code indicating that the action was a failure + */ + ExitCode[ExitCode["Failure"] = 1] = "Failure"; +})(ExitCode = exports.ExitCode || (exports.ExitCode = {})); +//----------------------------------------------------------------------- +// Variables +//----------------------------------------------------------------------- +/** + * Sets env variable for this action and future actions in the job + * @param name the name of the variable to set + * @param val the value of the variable. Non-string values will be converted to a string via JSON.stringify + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function exportVariable(name, val) { + const convertedVal = utils_1.toCommandValue(val); + process.env[name] = convertedVal; + const filePath = process.env['GITHUB_ENV'] || ''; + if (filePath) { + return file_command_1.issueFileCommand('ENV', file_command_1.prepareKeyValueMessage(name, val)); + } + command_1.issueCommand('set-env', { name }, convertedVal); +} +exports.exportVariable = exportVariable; +/** + * Registers a secret which will get masked from logs + * @param secret value of the secret + */ +function setSecret(secret) { + command_1.issueCommand('add-mask', {}, secret); +} +exports.setSecret = setSecret; +/** + * Prepends inputPath to the PATH (for this action and future actions) + * @param inputPath + */ +function addPath(inputPath) { + const filePath = process.env['GITHUB_PATH'] || ''; + if (filePath) { + file_command_1.issueFileCommand('PATH', inputPath); + } + else { + command_1.issueCommand('add-path', {}, inputPath); + } + process.env['PATH'] = `${inputPath}${path.delimiter}${process.env['PATH']}`; +} +exports.addPath = addPath; +/** + * Gets the value of an input. + * Unless trimWhitespace is set to false in InputOptions, the value is also trimmed. + * Returns an empty string if the value is not defined. + * + * @param name name of the input to get + * @param options optional. See InputOptions. + * @returns string + */ +function getInput(name, options) { + const val = process.env[`INPUT_${name.replace(/ /g, '_').toUpperCase()}`] || ''; + if (options && options.required && !val) { + throw new Error(`Input required and not supplied: ${name}`); + } + if (options && options.trimWhitespace === false) { + return val; + } + return val.trim(); +} +exports.getInput = getInput; +/** + * Gets the values of an multiline input. Each value is also trimmed. + * + * @param name name of the input to get + * @param options optional. See InputOptions. + * @returns string[] + * + */ +function getMultilineInput(name, options) { + const inputs = getInput(name, options) + .split('\n') + .filter(x => x !== ''); + if (options && options.trimWhitespace === false) { + return inputs; + } + return inputs.map(input => input.trim()); +} +exports.getMultilineInput = getMultilineInput; +/** + * Gets the input value of the boolean type in the YAML 1.2 "core schema" specification. + * Support boolean input list: `true | True | TRUE | false | False | FALSE` . + * The return value is also in boolean type. + * ref: https://yaml.org/spec/1.2/spec.html#id2804923 + * + * @param name name of the input to get + * @param options optional. See InputOptions. + * @returns boolean + */ +function getBooleanInput(name, options) { + const trueValue = ['true', 'True', 'TRUE']; + const falseValue = ['false', 'False', 'FALSE']; + const val = getInput(name, options); + if (trueValue.includes(val)) + return true; + if (falseValue.includes(val)) + return false; + throw new TypeError(`Input does not meet YAML 1.2 "Core Schema" specification: ${name}\n` + + `Support boolean input list: \`true | True | TRUE | false | False | FALSE\``); +} +exports.getBooleanInput = getBooleanInput; +/** + * Sets the value of an output. + * + * @param name name of the output to set + * @param value value to store. Non-string values will be converted to a string via JSON.stringify + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function setOutput(name, value) { + const filePath = process.env['GITHUB_OUTPUT'] || ''; + if (filePath) { + return file_command_1.issueFileCommand('OUTPUT', file_command_1.prepareKeyValueMessage(name, value)); + } + process.stdout.write(os.EOL); + command_1.issueCommand('set-output', { name }, utils_1.toCommandValue(value)); +} +exports.setOutput = setOutput; +/** + * Enables or disables the echoing of commands into stdout for the rest of the step. + * Echoing is disabled by default if ACTIONS_STEP_DEBUG is not set. + * + */ +function setCommandEcho(enabled) { + command_1.issue('echo', enabled ? 'on' : 'off'); +} +exports.setCommandEcho = setCommandEcho; +//----------------------------------------------------------------------- +// Results +//----------------------------------------------------------------------- +/** + * Sets the action status to failed. + * When the action exits it will be with an exit code of 1 + * @param message add error issue message + */ +function setFailed(message) { + process.exitCode = ExitCode.Failure; + error(message); +} +exports.setFailed = setFailed; +//----------------------------------------------------------------------- +// Logging Commands +//----------------------------------------------------------------------- +/** + * Gets whether Actions Step Debug is on or not + */ +function isDebug() { + return process.env['RUNNER_DEBUG'] === '1'; +} +exports.isDebug = isDebug; +/** + * Writes debug message to user log + * @param message debug message + */ +function debug(message) { + command_1.issueCommand('debug', {}, message); +} +exports.debug = debug; +/** + * Adds an error issue + * @param message error issue message. Errors will be converted to string via toString() + * @param properties optional properties to add to the annotation. + */ +function error(message, properties = {}) { + command_1.issueCommand('error', utils_1.toCommandProperties(properties), message instanceof Error ? message.toString() : message); +} +exports.error = error; +/** + * Adds a warning issue + * @param message warning issue message. Errors will be converted to string via toString() + * @param properties optional properties to add to the annotation. + */ +function warning(message, properties = {}) { + command_1.issueCommand('warning', utils_1.toCommandProperties(properties), message instanceof Error ? message.toString() : message); +} +exports.warning = warning; +/** + * Adds a notice issue + * @param message notice issue message. Errors will be converted to string via toString() + * @param properties optional properties to add to the annotation. + */ +function notice(message, properties = {}) { + command_1.issueCommand('notice', utils_1.toCommandProperties(properties), message instanceof Error ? message.toString() : message); +} +exports.notice = notice; +/** + * Writes info to log with console.log. + * @param message info message + */ +function info(message) { + process.stdout.write(message + os.EOL); +} +exports.info = info; +/** + * Begin an output group. + * + * Output until the next `groupEnd` will be foldable in this group + * + * @param name The name of the output group + */ +function startGroup(name) { + command_1.issue('group', name); +} +exports.startGroup = startGroup; +/** + * End an output group. + */ +function endGroup() { + command_1.issue('endgroup'); +} +exports.endGroup = endGroup; +/** + * Wrap an asynchronous function call in a group. + * + * Returns the same type as the function itself. + * + * @param name The name of the group + * @param fn The function to wrap in the group + */ +function group(name, fn) { + return __awaiter(this, void 0, void 0, function* () { + startGroup(name); + let result; + try { + result = yield fn(); + } + finally { + endGroup(); + } + return result; + }); +} +exports.group = group; +//----------------------------------------------------------------------- +// Wrapper action state +//----------------------------------------------------------------------- +/** + * Saves state for current action, the state can only be retrieved by this action's post job execution. + * + * @param name name of the state to store + * @param value value to store. Non-string values will be converted to a string via JSON.stringify + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function saveState(name, value) { + const filePath = process.env['GITHUB_STATE'] || ''; + if (filePath) { + return file_command_1.issueFileCommand('STATE', file_command_1.prepareKeyValueMessage(name, value)); + } + command_1.issueCommand('save-state', { name }, utils_1.toCommandValue(value)); +} +exports.saveState = saveState; +/** + * Gets the value of an state set by this action's main execution. + * + * @param name name of the state to get + * @returns string + */ +function getState(name) { + return process.env[`STATE_${name}`] || ''; +} +exports.getState = getState; +function getIDToken(aud) { + return __awaiter(this, void 0, void 0, function* () { + return yield oidc_utils_1.OidcClient.getIDToken(aud); + }); +} +exports.getIDToken = getIDToken; +/** + * Summary exports + */ +var summary_1 = __nccwpck_require__(1327); +Object.defineProperty(exports, "summary", ({ enumerable: true, get: function () { return summary_1.summary; } })); +/** + * @deprecated use core.summary + */ +var summary_2 = __nccwpck_require__(1327); +Object.defineProperty(exports, "markdownSummary", ({ enumerable: true, get: function () { return summary_2.markdownSummary; } })); +/** + * Path exports + */ +var path_utils_1 = __nccwpck_require__(2981); +Object.defineProperty(exports, "toPosixPath", ({ enumerable: true, get: function () { return path_utils_1.toPosixPath; } })); +Object.defineProperty(exports, "toWin32Path", ({ enumerable: true, get: function () { return path_utils_1.toWin32Path; } })); +Object.defineProperty(exports, "toPlatformPath", ({ enumerable: true, get: function () { return path_utils_1.toPlatformPath; } })); +//# sourceMappingURL=core.js.map + +/***/ }), + +/***/ 717: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +// For internal use, subject to change. +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } }); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.prepareKeyValueMessage = exports.issueFileCommand = void 0; +// We use any as a valid input type +/* eslint-disable @typescript-eslint/no-explicit-any */ +const fs = __importStar(__nccwpck_require__(7147)); +const os = __importStar(__nccwpck_require__(2037)); +const uuid_1 = __nccwpck_require__(5840); +const utils_1 = __nccwpck_require__(5278); +function issueFileCommand(command, message) { + const filePath = process.env[`GITHUB_${command}`]; + if (!filePath) { + throw new Error(`Unable to find environment variable for file command ${command}`); + } + if (!fs.existsSync(filePath)) { + throw new Error(`Missing file at path: ${filePath}`); + } + fs.appendFileSync(filePath, `${utils_1.toCommandValue(message)}${os.EOL}`, { + encoding: 'utf8' + }); +} +exports.issueFileCommand = issueFileCommand; +function prepareKeyValueMessage(key, value) { + const delimiter = `ghadelimiter_${uuid_1.v4()}`; + const convertedValue = utils_1.toCommandValue(value); + // These should realistically never happen, but just in case someone finds a + // way to exploit uuid generation let's not allow keys or values that contain + // the delimiter. + if (key.includes(delimiter)) { + throw new Error(`Unexpected input: name should not contain the delimiter "${delimiter}"`); + } + if (convertedValue.includes(delimiter)) { + throw new Error(`Unexpected input: value should not contain the delimiter "${delimiter}"`); + } + return `${key}<<${delimiter}${os.EOL}${convertedValue}${os.EOL}${delimiter}`; +} +exports.prepareKeyValueMessage = prepareKeyValueMessage; +//# sourceMappingURL=file-command.js.map + +/***/ }), + +/***/ 8041: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.OidcClient = void 0; +const http_client_1 = __nccwpck_require__(6255); +const auth_1 = __nccwpck_require__(5526); +const core_1 = __nccwpck_require__(2186); +class OidcClient { + static createHttpClient(allowRetry = true, maxRetry = 10) { + const requestOptions = { + allowRetries: allowRetry, + maxRetries: maxRetry + }; + return new http_client_1.HttpClient('actions/oidc-client', [new auth_1.BearerCredentialHandler(OidcClient.getRequestToken())], requestOptions); + } + static getRequestToken() { + const token = process.env['ACTIONS_ID_TOKEN_REQUEST_TOKEN']; + if (!token) { + throw new Error('Unable to get ACTIONS_ID_TOKEN_REQUEST_TOKEN env variable'); + } + return token; + } + static getIDTokenUrl() { + const runtimeUrl = process.env['ACTIONS_ID_TOKEN_REQUEST_URL']; + if (!runtimeUrl) { + throw new Error('Unable to get ACTIONS_ID_TOKEN_REQUEST_URL env variable'); + } + return runtimeUrl; + } + static getCall(id_token_url) { + var _a; + return __awaiter(this, void 0, void 0, function* () { + const httpclient = OidcClient.createHttpClient(); + const res = yield httpclient + .getJson(id_token_url) + .catch(error => { + throw new Error(`Failed to get ID Token. \n + Error Code : ${error.statusCode}\n + Error Message: ${error.result.message}`); + }); + const id_token = (_a = res.result) === null || _a === void 0 ? void 0 : _a.value; + if (!id_token) { + throw new Error('Response json body do not have ID Token field'); + } + return id_token; + }); + } + static getIDToken(audience) { + return __awaiter(this, void 0, void 0, function* () { + try { + // New ID Token is requested from action service + let id_token_url = OidcClient.getIDTokenUrl(); + if (audience) { + const encodedAudience = encodeURIComponent(audience); + id_token_url = `${id_token_url}&audience=${encodedAudience}`; + } + core_1.debug(`ID token url is ${id_token_url}`); + const id_token = yield OidcClient.getCall(id_token_url); + core_1.setSecret(id_token); + return id_token; + } + catch (error) { + throw new Error(`Error message: ${error.message}`); + } + }); + } +} +exports.OidcClient = OidcClient; +//# sourceMappingURL=oidc-utils.js.map + +/***/ }), + +/***/ 2981: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } }); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.toPlatformPath = exports.toWin32Path = exports.toPosixPath = void 0; +const path = __importStar(__nccwpck_require__(1017)); +/** + * toPosixPath converts the given path to the posix form. On Windows, \\ will be + * replaced with /. + * + * @param pth. Path to transform. + * @return string Posix path. + */ +function toPosixPath(pth) { + return pth.replace(/[\\]/g, '/'); +} +exports.toPosixPath = toPosixPath; +/** + * toWin32Path converts the given path to the win32 form. On Linux, / will be + * replaced with \\. + * + * @param pth. Path to transform. + * @return string Win32 path. + */ +function toWin32Path(pth) { + return pth.replace(/[/]/g, '\\'); +} +exports.toWin32Path = toWin32Path; +/** + * toPlatformPath converts the given path to a platform-specific path. It does + * this by replacing instances of / and \ with the platform-specific path + * separator. + * + * @param pth The path to platformize. + * @return string The platform-specific path. + */ +function toPlatformPath(pth) { + return pth.replace(/[/\\]/g, path.sep); +} +exports.toPlatformPath = toPlatformPath; +//# sourceMappingURL=path-utils.js.map + +/***/ }), + +/***/ 1327: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.summary = exports.markdownSummary = exports.SUMMARY_DOCS_URL = exports.SUMMARY_ENV_VAR = void 0; +const os_1 = __nccwpck_require__(2037); +const fs_1 = __nccwpck_require__(7147); +const { access, appendFile, writeFile } = fs_1.promises; +exports.SUMMARY_ENV_VAR = 'GITHUB_STEP_SUMMARY'; +exports.SUMMARY_DOCS_URL = 'https://docs.github.com/actions/using-workflows/workflow-commands-for-github-actions#adding-a-job-summary'; +class Summary { + constructor() { + this._buffer = ''; + } + /** + * Finds the summary file path from the environment, rejects if env var is not found or file does not exist + * Also checks r/w permissions. + * + * @returns step summary file path + */ + filePath() { + return __awaiter(this, void 0, void 0, function* () { + if (this._filePath) { + return this._filePath; + } + const pathFromEnv = process.env[exports.SUMMARY_ENV_VAR]; + if (!pathFromEnv) { + throw new Error(`Unable to find environment variable for $${exports.SUMMARY_ENV_VAR}. Check if your runtime environment supports job summaries.`); + } + try { + yield access(pathFromEnv, fs_1.constants.R_OK | fs_1.constants.W_OK); + } + catch (_a) { + throw new Error(`Unable to access summary file: '${pathFromEnv}'. Check if the file has correct read/write permissions.`); + } + this._filePath = pathFromEnv; + return this._filePath; + }); + } + /** + * Wraps content in an HTML tag, adding any HTML attributes + * + * @param {string} tag HTML tag to wrap + * @param {string | null} content content within the tag + * @param {[attribute: string]: string} attrs key-value list of HTML attributes to add + * + * @returns {string} content wrapped in HTML element + */ + wrap(tag, content, attrs = {}) { + const htmlAttrs = Object.entries(attrs) + .map(([key, value]) => ` ${key}="${value}"`) + .join(''); + if (!content) { + return `<${tag}${htmlAttrs}>`; + } + return `<${tag}${htmlAttrs}>${content}`; + } + /** + * Writes text in the buffer to the summary buffer file and empties buffer. Will append by default. + * + * @param {SummaryWriteOptions} [options] (optional) options for write operation + * + * @returns {Promise} summary instance + */ + write(options) { + return __awaiter(this, void 0, void 0, function* () { + const overwrite = !!(options === null || options === void 0 ? void 0 : options.overwrite); + const filePath = yield this.filePath(); + const writeFunc = overwrite ? writeFile : appendFile; + yield writeFunc(filePath, this._buffer, { encoding: 'utf8' }); + return this.emptyBuffer(); + }); + } + /** + * Clears the summary buffer and wipes the summary file + * + * @returns {Summary} summary instance + */ + clear() { + return __awaiter(this, void 0, void 0, function* () { + return this.emptyBuffer().write({ overwrite: true }); + }); + } + /** + * Returns the current summary buffer as a string + * + * @returns {string} string of summary buffer + */ + stringify() { + return this._buffer; + } + /** + * If the summary buffer is empty + * + * @returns {boolen} true if the buffer is empty + */ + isEmptyBuffer() { + return this._buffer.length === 0; + } + /** + * Resets the summary buffer without writing to summary file + * + * @returns {Summary} summary instance + */ + emptyBuffer() { + this._buffer = ''; + return this; + } + /** + * Adds raw text to the summary buffer + * + * @param {string} text content to add + * @param {boolean} [addEOL=false] (optional) append an EOL to the raw text (default: false) + * + * @returns {Summary} summary instance + */ + addRaw(text, addEOL = false) { + this._buffer += text; + return addEOL ? this.addEOL() : this; + } + /** + * Adds the operating system-specific end-of-line marker to the buffer + * + * @returns {Summary} summary instance + */ + addEOL() { + return this.addRaw(os_1.EOL); + } + /** + * Adds an HTML codeblock to the summary buffer + * + * @param {string} code content to render within fenced code block + * @param {string} lang (optional) language to syntax highlight code + * + * @returns {Summary} summary instance + */ + addCodeBlock(code, lang) { + const attrs = Object.assign({}, (lang && { lang })); + const element = this.wrap('pre', this.wrap('code', code), attrs); + return this.addRaw(element).addEOL(); + } + /** + * Adds an HTML list to the summary buffer + * + * @param {string[]} items list of items to render + * @param {boolean} [ordered=false] (optional) if the rendered list should be ordered or not (default: false) + * + * @returns {Summary} summary instance + */ + addList(items, ordered = false) { + const tag = ordered ? 'ol' : 'ul'; + const listItems = items.map(item => this.wrap('li', item)).join(''); + const element = this.wrap(tag, listItems); + return this.addRaw(element).addEOL(); + } + /** + * Adds an HTML table to the summary buffer + * + * @param {SummaryTableCell[]} rows table rows + * + * @returns {Summary} summary instance + */ + addTable(rows) { + const tableBody = rows + .map(row => { + const cells = row + .map(cell => { + if (typeof cell === 'string') { + return this.wrap('td', cell); + } + const { header, data, colspan, rowspan } = cell; + const tag = header ? 'th' : 'td'; + const attrs = Object.assign(Object.assign({}, (colspan && { colspan })), (rowspan && { rowspan })); + return this.wrap(tag, data, attrs); + }) + .join(''); + return this.wrap('tr', cells); + }) + .join(''); + const element = this.wrap('table', tableBody); + return this.addRaw(element).addEOL(); + } + /** + * Adds a collapsable HTML details element to the summary buffer + * + * @param {string} label text for the closed state + * @param {string} content collapsable content + * + * @returns {Summary} summary instance + */ + addDetails(label, content) { + const element = this.wrap('details', this.wrap('summary', label) + content); + return this.addRaw(element).addEOL(); + } + /** + * Adds an HTML image tag to the summary buffer + * + * @param {string} src path to the image you to embed + * @param {string} alt text description of the image + * @param {SummaryImageOptions} options (optional) addition image attributes + * + * @returns {Summary} summary instance + */ + addImage(src, alt, options) { + const { width, height } = options || {}; + const attrs = Object.assign(Object.assign({}, (width && { width })), (height && { height })); + const element = this.wrap('img', null, Object.assign({ src, alt }, attrs)); + return this.addRaw(element).addEOL(); + } + /** + * Adds an HTML section heading element + * + * @param {string} text heading text + * @param {number | string} [level=1] (optional) the heading level, default: 1 + * + * @returns {Summary} summary instance + */ + addHeading(text, level) { + const tag = `h${level}`; + const allowedTag = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tag) + ? tag + : 'h1'; + const element = this.wrap(allowedTag, text); + return this.addRaw(element).addEOL(); + } + /** + * Adds an HTML thematic break (
) to the summary buffer + * + * @returns {Summary} summary instance + */ + addSeparator() { + const element = this.wrap('hr', null); + return this.addRaw(element).addEOL(); + } + /** + * Adds an HTML line break (
) to the summary buffer + * + * @returns {Summary} summary instance + */ + addBreak() { + const element = this.wrap('br', null); + return this.addRaw(element).addEOL(); + } + /** + * Adds an HTML blockquote to the summary buffer + * + * @param {string} text quote text + * @param {string} cite (optional) citation url + * + * @returns {Summary} summary instance + */ + addQuote(text, cite) { + const attrs = Object.assign({}, (cite && { cite })); + const element = this.wrap('blockquote', text, attrs); + return this.addRaw(element).addEOL(); + } + /** + * Adds an HTML anchor tag to the summary buffer + * + * @param {string} text link text/content + * @param {string} href hyperlink + * + * @returns {Summary} summary instance + */ + addLink(text, href) { + const element = this.wrap('a', text, { href }); + return this.addRaw(element).addEOL(); + } +} +const _summary = new Summary(); +/** + * @deprecated use `core.summary` + */ +exports.markdownSummary = _summary; +exports.summary = _summary; +//# sourceMappingURL=summary.js.map + +/***/ }), + +/***/ 5278: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +// We use any as a valid input type +/* eslint-disable @typescript-eslint/no-explicit-any */ +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.toCommandProperties = exports.toCommandValue = void 0; +/** + * Sanitizes an input into a string so it can be passed into issueCommand safely + * @param input input to sanitize into a string + */ +function toCommandValue(input) { + if (input === null || input === undefined) { + return ''; + } + else if (typeof input === 'string' || input instanceof String) { + return input; + } + return JSON.stringify(input); +} +exports.toCommandValue = toCommandValue; +/** + * + * @param annotationProperties + * @returns The command properties to send with the actual annotation command + * See IssueCommandProperties: https://github.com/actions/runner/blob/main/src/Runner.Worker/ActionCommandManager.cs#L646 + */ +function toCommandProperties(annotationProperties) { + if (!Object.keys(annotationProperties).length) { + return {}; + } + return { + title: annotationProperties.title, + file: annotationProperties.file, + line: annotationProperties.startLine, + endLine: annotationProperties.endLine, + col: annotationProperties.startColumn, + endColumn: annotationProperties.endColumn + }; +} +exports.toCommandProperties = toCommandProperties; +//# sourceMappingURL=utils.js.map + +/***/ }), + +/***/ 5526: +/***/ (function(__unused_webpack_module, exports) { + +"use strict"; + +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.PersonalAccessTokenCredentialHandler = exports.BearerCredentialHandler = exports.BasicCredentialHandler = void 0; +class BasicCredentialHandler { + constructor(username, password) { + this.username = username; + this.password = password; + } + prepareRequest(options) { + if (!options.headers) { + throw Error('The request has no headers'); + } + options.headers['Authorization'] = `Basic ${Buffer.from(`${this.username}:${this.password}`).toString('base64')}`; + } + // This handler cannot handle 401 + canHandleAuthentication() { + return false; + } + handleAuthentication() { + return __awaiter(this, void 0, void 0, function* () { + throw new Error('not implemented'); + }); + } +} +exports.BasicCredentialHandler = BasicCredentialHandler; +class BearerCredentialHandler { + constructor(token) { + this.token = token; + } + // currently implements pre-authorization + // TODO: support preAuth = false where it hooks on 401 + prepareRequest(options) { + if (!options.headers) { + throw Error('The request has no headers'); + } + options.headers['Authorization'] = `Bearer ${this.token}`; + } + // This handler cannot handle 401 + canHandleAuthentication() { + return false; + } + handleAuthentication() { + return __awaiter(this, void 0, void 0, function* () { + throw new Error('not implemented'); + }); + } +} +exports.BearerCredentialHandler = BearerCredentialHandler; +class PersonalAccessTokenCredentialHandler { + constructor(token) { + this.token = token; + } + // currently implements pre-authorization + // TODO: support preAuth = false where it hooks on 401 + prepareRequest(options) { + if (!options.headers) { + throw Error('The request has no headers'); + } + options.headers['Authorization'] = `Basic ${Buffer.from(`PAT:${this.token}`).toString('base64')}`; + } + // This handler cannot handle 401 + canHandleAuthentication() { + return false; + } + handleAuthentication() { + return __awaiter(this, void 0, void 0, function* () { + throw new Error('not implemented'); + }); + } +} +exports.PersonalAccessTokenCredentialHandler = PersonalAccessTokenCredentialHandler; +//# sourceMappingURL=auth.js.map + +/***/ }), + +/***/ 6255: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } }); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.HttpClient = exports.isHttps = exports.HttpClientResponse = exports.HttpClientError = exports.getProxyUrl = exports.MediaTypes = exports.Headers = exports.HttpCodes = void 0; +const http = __importStar(__nccwpck_require__(3685)); +const https = __importStar(__nccwpck_require__(5687)); +const pm = __importStar(__nccwpck_require__(9835)); +const tunnel = __importStar(__nccwpck_require__(4294)); +var HttpCodes; +(function (HttpCodes) { + HttpCodes[HttpCodes["OK"] = 200] = "OK"; + HttpCodes[HttpCodes["MultipleChoices"] = 300] = "MultipleChoices"; + HttpCodes[HttpCodes["MovedPermanently"] = 301] = "MovedPermanently"; + HttpCodes[HttpCodes["ResourceMoved"] = 302] = "ResourceMoved"; + HttpCodes[HttpCodes["SeeOther"] = 303] = "SeeOther"; + HttpCodes[HttpCodes["NotModified"] = 304] = "NotModified"; + HttpCodes[HttpCodes["UseProxy"] = 305] = "UseProxy"; + HttpCodes[HttpCodes["SwitchProxy"] = 306] = "SwitchProxy"; + HttpCodes[HttpCodes["TemporaryRedirect"] = 307] = "TemporaryRedirect"; + HttpCodes[HttpCodes["PermanentRedirect"] = 308] = "PermanentRedirect"; + HttpCodes[HttpCodes["BadRequest"] = 400] = "BadRequest"; + HttpCodes[HttpCodes["Unauthorized"] = 401] = "Unauthorized"; + HttpCodes[HttpCodes["PaymentRequired"] = 402] = "PaymentRequired"; + HttpCodes[HttpCodes["Forbidden"] = 403] = "Forbidden"; + HttpCodes[HttpCodes["NotFound"] = 404] = "NotFound"; + HttpCodes[HttpCodes["MethodNotAllowed"] = 405] = "MethodNotAllowed"; + HttpCodes[HttpCodes["NotAcceptable"] = 406] = "NotAcceptable"; + HttpCodes[HttpCodes["ProxyAuthenticationRequired"] = 407] = "ProxyAuthenticationRequired"; + HttpCodes[HttpCodes["RequestTimeout"] = 408] = "RequestTimeout"; + HttpCodes[HttpCodes["Conflict"] = 409] = "Conflict"; + HttpCodes[HttpCodes["Gone"] = 410] = "Gone"; + HttpCodes[HttpCodes["TooManyRequests"] = 429] = "TooManyRequests"; + HttpCodes[HttpCodes["InternalServerError"] = 500] = "InternalServerError"; + HttpCodes[HttpCodes["NotImplemented"] = 501] = "NotImplemented"; + HttpCodes[HttpCodes["BadGateway"] = 502] = "BadGateway"; + HttpCodes[HttpCodes["ServiceUnavailable"] = 503] = "ServiceUnavailable"; + HttpCodes[HttpCodes["GatewayTimeout"] = 504] = "GatewayTimeout"; +})(HttpCodes = exports.HttpCodes || (exports.HttpCodes = {})); +var Headers; +(function (Headers) { + Headers["Accept"] = "accept"; + Headers["ContentType"] = "content-type"; +})(Headers = exports.Headers || (exports.Headers = {})); +var MediaTypes; +(function (MediaTypes) { + MediaTypes["ApplicationJson"] = "application/json"; +})(MediaTypes = exports.MediaTypes || (exports.MediaTypes = {})); +/** + * Returns the proxy URL, depending upon the supplied url and proxy environment variables. + * @param serverUrl The server URL where the request will be sent. For example, https://api.github.com + */ +function getProxyUrl(serverUrl) { + const proxyUrl = pm.getProxyUrl(new URL(serverUrl)); + return proxyUrl ? proxyUrl.href : ''; +} +exports.getProxyUrl = getProxyUrl; +const HttpRedirectCodes = [ + HttpCodes.MovedPermanently, + HttpCodes.ResourceMoved, + HttpCodes.SeeOther, + HttpCodes.TemporaryRedirect, + HttpCodes.PermanentRedirect +]; +const HttpResponseRetryCodes = [ + HttpCodes.BadGateway, + HttpCodes.ServiceUnavailable, + HttpCodes.GatewayTimeout +]; +const RetryableHttpVerbs = ['OPTIONS', 'GET', 'DELETE', 'HEAD']; +const ExponentialBackoffCeiling = 10; +const ExponentialBackoffTimeSlice = 5; +class HttpClientError extends Error { + constructor(message, statusCode) { + super(message); + this.name = 'HttpClientError'; + this.statusCode = statusCode; + Object.setPrototypeOf(this, HttpClientError.prototype); + } +} +exports.HttpClientError = HttpClientError; +class HttpClientResponse { + constructor(message) { + this.message = message; + } + readBody() { + return __awaiter(this, void 0, void 0, function* () { + return new Promise((resolve) => __awaiter(this, void 0, void 0, function* () { + let output = Buffer.alloc(0); + this.message.on('data', (chunk) => { + output = Buffer.concat([output, chunk]); + }); + this.message.on('end', () => { + resolve(output.toString()); + }); + })); + }); + } +} +exports.HttpClientResponse = HttpClientResponse; +function isHttps(requestUrl) { + const parsedUrl = new URL(requestUrl); + return parsedUrl.protocol === 'https:'; +} +exports.isHttps = isHttps; +class HttpClient { + constructor(userAgent, handlers, requestOptions) { + this._ignoreSslError = false; + this._allowRedirects = true; + this._allowRedirectDowngrade = false; + this._maxRedirects = 50; + this._allowRetries = false; + this._maxRetries = 1; + this._keepAlive = false; + this._disposed = false; + this.userAgent = userAgent; + this.handlers = handlers || []; + this.requestOptions = requestOptions; + if (requestOptions) { + if (requestOptions.ignoreSslError != null) { + this._ignoreSslError = requestOptions.ignoreSslError; + } + this._socketTimeout = requestOptions.socketTimeout; + if (requestOptions.allowRedirects != null) { + this._allowRedirects = requestOptions.allowRedirects; + } + if (requestOptions.allowRedirectDowngrade != null) { + this._allowRedirectDowngrade = requestOptions.allowRedirectDowngrade; + } + if (requestOptions.maxRedirects != null) { + this._maxRedirects = Math.max(requestOptions.maxRedirects, 0); + } + if (requestOptions.keepAlive != null) { + this._keepAlive = requestOptions.keepAlive; + } + if (requestOptions.allowRetries != null) { + this._allowRetries = requestOptions.allowRetries; + } + if (requestOptions.maxRetries != null) { + this._maxRetries = requestOptions.maxRetries; + } + } + } + options(requestUrl, additionalHeaders) { + return __awaiter(this, void 0, void 0, function* () { + return this.request('OPTIONS', requestUrl, null, additionalHeaders || {}); + }); + } + get(requestUrl, additionalHeaders) { + return __awaiter(this, void 0, void 0, function* () { + return this.request('GET', requestUrl, null, additionalHeaders || {}); + }); + } + del(requestUrl, additionalHeaders) { + return __awaiter(this, void 0, void 0, function* () { + return this.request('DELETE', requestUrl, null, additionalHeaders || {}); + }); + } + post(requestUrl, data, additionalHeaders) { + return __awaiter(this, void 0, void 0, function* () { + return this.request('POST', requestUrl, data, additionalHeaders || {}); + }); + } + patch(requestUrl, data, additionalHeaders) { + return __awaiter(this, void 0, void 0, function* () { + return this.request('PATCH', requestUrl, data, additionalHeaders || {}); + }); + } + put(requestUrl, data, additionalHeaders) { + return __awaiter(this, void 0, void 0, function* () { + return this.request('PUT', requestUrl, data, additionalHeaders || {}); + }); + } + head(requestUrl, additionalHeaders) { + return __awaiter(this, void 0, void 0, function* () { + return this.request('HEAD', requestUrl, null, additionalHeaders || {}); + }); + } + sendStream(verb, requestUrl, stream, additionalHeaders) { + return __awaiter(this, void 0, void 0, function* () { + return this.request(verb, requestUrl, stream, additionalHeaders); + }); + } + /** + * Gets a typed object from an endpoint + * Be aware that not found returns a null. Other errors (4xx, 5xx) reject the promise + */ + getJson(requestUrl, additionalHeaders = {}) { + return __awaiter(this, void 0, void 0, function* () { + additionalHeaders[Headers.Accept] = this._getExistingOrDefaultHeader(additionalHeaders, Headers.Accept, MediaTypes.ApplicationJson); + const res = yield this.get(requestUrl, additionalHeaders); + return this._processResponse(res, this.requestOptions); + }); + } + postJson(requestUrl, obj, additionalHeaders = {}) { + return __awaiter(this, void 0, void 0, function* () { + const data = JSON.stringify(obj, null, 2); + additionalHeaders[Headers.Accept] = this._getExistingOrDefaultHeader(additionalHeaders, Headers.Accept, MediaTypes.ApplicationJson); + additionalHeaders[Headers.ContentType] = this._getExistingOrDefaultHeader(additionalHeaders, Headers.ContentType, MediaTypes.ApplicationJson); + const res = yield this.post(requestUrl, data, additionalHeaders); + return this._processResponse(res, this.requestOptions); + }); + } + putJson(requestUrl, obj, additionalHeaders = {}) { + return __awaiter(this, void 0, void 0, function* () { + const data = JSON.stringify(obj, null, 2); + additionalHeaders[Headers.Accept] = this._getExistingOrDefaultHeader(additionalHeaders, Headers.Accept, MediaTypes.ApplicationJson); + additionalHeaders[Headers.ContentType] = this._getExistingOrDefaultHeader(additionalHeaders, Headers.ContentType, MediaTypes.ApplicationJson); + const res = yield this.put(requestUrl, data, additionalHeaders); + return this._processResponse(res, this.requestOptions); + }); + } + patchJson(requestUrl, obj, additionalHeaders = {}) { + return __awaiter(this, void 0, void 0, function* () { + const data = JSON.stringify(obj, null, 2); + additionalHeaders[Headers.Accept] = this._getExistingOrDefaultHeader(additionalHeaders, Headers.Accept, MediaTypes.ApplicationJson); + additionalHeaders[Headers.ContentType] = this._getExistingOrDefaultHeader(additionalHeaders, Headers.ContentType, MediaTypes.ApplicationJson); + const res = yield this.patch(requestUrl, data, additionalHeaders); + return this._processResponse(res, this.requestOptions); + }); + } + /** + * Makes a raw http request. + * All other methods such as get, post, patch, and request ultimately call this. + * Prefer get, del, post and patch + */ + request(verb, requestUrl, data, headers) { + return __awaiter(this, void 0, void 0, function* () { + if (this._disposed) { + throw new Error('Client has already been disposed.'); + } + const parsedUrl = new URL(requestUrl); + let info = this._prepareRequest(verb, parsedUrl, headers); + // Only perform retries on reads since writes may not be idempotent. + const maxTries = this._allowRetries && RetryableHttpVerbs.includes(verb) + ? this._maxRetries + 1 + : 1; + let numTries = 0; + let response; + do { + response = yield this.requestRaw(info, data); + // Check if it's an authentication challenge + if (response && + response.message && + response.message.statusCode === HttpCodes.Unauthorized) { + let authenticationHandler; + for (const handler of this.handlers) { + if (handler.canHandleAuthentication(response)) { + authenticationHandler = handler; + break; + } + } + if (authenticationHandler) { + return authenticationHandler.handleAuthentication(this, info, data); + } + else { + // We have received an unauthorized response but have no handlers to handle it. + // Let the response return to the caller. + return response; + } + } + let redirectsRemaining = this._maxRedirects; + while (response.message.statusCode && + HttpRedirectCodes.includes(response.message.statusCode) && + this._allowRedirects && + redirectsRemaining > 0) { + const redirectUrl = response.message.headers['location']; + if (!redirectUrl) { + // if there's no location to redirect to, we won't + break; + } + const parsedRedirectUrl = new URL(redirectUrl); + if (parsedUrl.protocol === 'https:' && + parsedUrl.protocol !== parsedRedirectUrl.protocol && + !this._allowRedirectDowngrade) { + throw new Error('Redirect from HTTPS to HTTP protocol. This downgrade is not allowed for security reasons. If you want to allow this behavior, set the allowRedirectDowngrade option to true.'); + } + // we need to finish reading the response before reassigning response + // which will leak the open socket. + yield response.readBody(); + // strip authorization header if redirected to a different hostname + if (parsedRedirectUrl.hostname !== parsedUrl.hostname) { + for (const header in headers) { + // header names are case insensitive + if (header.toLowerCase() === 'authorization') { + delete headers[header]; + } + } + } + // let's make the request with the new redirectUrl + info = this._prepareRequest(verb, parsedRedirectUrl, headers); + response = yield this.requestRaw(info, data); + redirectsRemaining--; + } + if (!response.message.statusCode || + !HttpResponseRetryCodes.includes(response.message.statusCode)) { + // If not a retry code, return immediately instead of retrying + return response; + } + numTries += 1; + if (numTries < maxTries) { + yield response.readBody(); + yield this._performExponentialBackoff(numTries); + } + } while (numTries < maxTries); + return response; + }); + } + /** + * Needs to be called if keepAlive is set to true in request options. + */ + dispose() { + if (this._agent) { + this._agent.destroy(); + } + this._disposed = true; + } + /** + * Raw request. + * @param info + * @param data + */ + requestRaw(info, data) { + return __awaiter(this, void 0, void 0, function* () { + return new Promise((resolve, reject) => { + function callbackForResult(err, res) { + if (err) { + reject(err); + } + else if (!res) { + // If `err` is not passed, then `res` must be passed. + reject(new Error('Unknown error')); + } + else { + resolve(res); + } + } + this.requestRawWithCallback(info, data, callbackForResult); + }); + }); + } + /** + * Raw request with callback. + * @param info + * @param data + * @param onResult + */ + requestRawWithCallback(info, data, onResult) { + if (typeof data === 'string') { + if (!info.options.headers) { + info.options.headers = {}; + } + info.options.headers['Content-Length'] = Buffer.byteLength(data, 'utf8'); + } + let callbackCalled = false; + function handleResult(err, res) { + if (!callbackCalled) { + callbackCalled = true; + onResult(err, res); + } + } + const req = info.httpModule.request(info.options, (msg) => { + const res = new HttpClientResponse(msg); + handleResult(undefined, res); + }); + let socket; + req.on('socket', sock => { + socket = sock; + }); + // If we ever get disconnected, we want the socket to timeout eventually + req.setTimeout(this._socketTimeout || 3 * 60000, () => { + if (socket) { + socket.end(); + } + handleResult(new Error(`Request timeout: ${info.options.path}`)); + }); + req.on('error', function (err) { + // err has statusCode property + // res should have headers + handleResult(err); + }); + if (data && typeof data === 'string') { + req.write(data, 'utf8'); + } + if (data && typeof data !== 'string') { + data.on('close', function () { + req.end(); + }); + data.pipe(req); + } + else { + req.end(); + } + } + /** + * Gets an http agent. This function is useful when you need an http agent that handles + * routing through a proxy server - depending upon the url and proxy environment variables. + * @param serverUrl The server URL where the request will be sent. For example, https://api.github.com + */ + getAgent(serverUrl) { + const parsedUrl = new URL(serverUrl); + return this._getAgent(parsedUrl); + } + _prepareRequest(method, requestUrl, headers) { + const info = {}; + info.parsedUrl = requestUrl; + const usingSsl = info.parsedUrl.protocol === 'https:'; + info.httpModule = usingSsl ? https : http; + const defaultPort = usingSsl ? 443 : 80; + info.options = {}; + info.options.host = info.parsedUrl.hostname; + info.options.port = info.parsedUrl.port + ? parseInt(info.parsedUrl.port) + : defaultPort; + info.options.path = + (info.parsedUrl.pathname || '') + (info.parsedUrl.search || ''); + info.options.method = method; + info.options.headers = this._mergeHeaders(headers); + if (this.userAgent != null) { + info.options.headers['user-agent'] = this.userAgent; + } + info.options.agent = this._getAgent(info.parsedUrl); + // gives handlers an opportunity to participate + if (this.handlers) { + for (const handler of this.handlers) { + handler.prepareRequest(info.options); + } + } + return info; + } + _mergeHeaders(headers) { + if (this.requestOptions && this.requestOptions.headers) { + return Object.assign({}, lowercaseKeys(this.requestOptions.headers), lowercaseKeys(headers || {})); + } + return lowercaseKeys(headers || {}); + } + _getExistingOrDefaultHeader(additionalHeaders, header, _default) { + let clientHeader; + if (this.requestOptions && this.requestOptions.headers) { + clientHeader = lowercaseKeys(this.requestOptions.headers)[header]; + } + return additionalHeaders[header] || clientHeader || _default; + } + _getAgent(parsedUrl) { + let agent; + const proxyUrl = pm.getProxyUrl(parsedUrl); + const useProxy = proxyUrl && proxyUrl.hostname; + if (this._keepAlive && useProxy) { + agent = this._proxyAgent; + } + if (this._keepAlive && !useProxy) { + agent = this._agent; + } + // if agent is already assigned use that agent. + if (agent) { + return agent; + } + const usingSsl = parsedUrl.protocol === 'https:'; + let maxSockets = 100; + if (this.requestOptions) { + maxSockets = this.requestOptions.maxSockets || http.globalAgent.maxSockets; + } + // This is `useProxy` again, but we need to check `proxyURl` directly for TypeScripts's flow analysis. + if (proxyUrl && proxyUrl.hostname) { + const agentOptions = { + maxSockets, + keepAlive: this._keepAlive, + proxy: Object.assign(Object.assign({}, ((proxyUrl.username || proxyUrl.password) && { + proxyAuth: `${proxyUrl.username}:${proxyUrl.password}` + })), { host: proxyUrl.hostname, port: proxyUrl.port }) + }; + let tunnelAgent; + const overHttps = proxyUrl.protocol === 'https:'; + if (usingSsl) { + tunnelAgent = overHttps ? tunnel.httpsOverHttps : tunnel.httpsOverHttp; + } + else { + tunnelAgent = overHttps ? tunnel.httpOverHttps : tunnel.httpOverHttp; + } + agent = tunnelAgent(agentOptions); + this._proxyAgent = agent; + } + // if reusing agent across request and tunneling agent isn't assigned create a new agent + if (this._keepAlive && !agent) { + const options = { keepAlive: this._keepAlive, maxSockets }; + agent = usingSsl ? new https.Agent(options) : new http.Agent(options); + this._agent = agent; + } + // if not using private agent and tunnel agent isn't setup then use global agent + if (!agent) { + agent = usingSsl ? https.globalAgent : http.globalAgent; + } + if (usingSsl && this._ignoreSslError) { + // we don't want to set NODE_TLS_REJECT_UNAUTHORIZED=0 since that will affect request for entire process + // http.RequestOptions doesn't expose a way to modify RequestOptions.agent.options + // we have to cast it to any and change it directly + agent.options = Object.assign(agent.options || {}, { + rejectUnauthorized: false + }); + } + return agent; + } + _performExponentialBackoff(retryNumber) { + return __awaiter(this, void 0, void 0, function* () { + retryNumber = Math.min(ExponentialBackoffCeiling, retryNumber); + const ms = ExponentialBackoffTimeSlice * Math.pow(2, retryNumber); + return new Promise(resolve => setTimeout(() => resolve(), ms)); + }); + } + _processResponse(res, options) { + return __awaiter(this, void 0, void 0, function* () { + return new Promise((resolve, reject) => __awaiter(this, void 0, void 0, function* () { + const statusCode = res.message.statusCode || 0; + const response = { + statusCode, + result: null, + headers: {} + }; + // not found leads to null obj returned + if (statusCode === HttpCodes.NotFound) { + resolve(response); + } + // get the result from the body + function dateTimeDeserializer(key, value) { + if (typeof value === 'string') { + const a = new Date(value); + if (!isNaN(a.valueOf())) { + return a; + } + } + return value; + } + let obj; + let contents; + try { + contents = yield res.readBody(); + if (contents && contents.length > 0) { + if (options && options.deserializeDates) { + obj = JSON.parse(contents, dateTimeDeserializer); + } + else { + obj = JSON.parse(contents); + } + response.result = obj; + } + response.headers = res.message.headers; + } + catch (err) { + // Invalid resource (contents not json); leaving result obj null + } + // note that 3xx redirects are handled by the http layer. + if (statusCode > 299) { + let msg; + // if exception/error in body, attempt to get better error + if (obj && obj.message) { + msg = obj.message; + } + else if (contents && contents.length > 0) { + // it may be the case that the exception is in the body message as string + msg = contents; + } + else { + msg = `Failed request: (${statusCode})`; + } + const err = new HttpClientError(msg, statusCode); + err.result = response.result; + reject(err); + } + else { + resolve(response); + } + })); + }); + } +} +exports.HttpClient = HttpClient; +const lowercaseKeys = (obj) => Object.keys(obj).reduce((c, k) => ((c[k.toLowerCase()] = obj[k]), c), {}); +//# sourceMappingURL=index.js.map + +/***/ }), + +/***/ 9835: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.checkBypass = exports.getProxyUrl = void 0; +function getProxyUrl(reqUrl) { + const usingSsl = reqUrl.protocol === 'https:'; + if (checkBypass(reqUrl)) { + return undefined; + } + const proxyVar = (() => { + if (usingSsl) { + return process.env['https_proxy'] || process.env['HTTPS_PROXY']; + } + else { + return process.env['http_proxy'] || process.env['HTTP_PROXY']; + } + })(); + if (proxyVar) { + return new URL(proxyVar); + } + else { + return undefined; + } +} +exports.getProxyUrl = getProxyUrl; +function checkBypass(reqUrl) { + if (!reqUrl.hostname) { + return false; + } + const noProxy = process.env['no_proxy'] || process.env['NO_PROXY'] || ''; + if (!noProxy) { + return false; + } + // Determine the request port + let reqPort; + if (reqUrl.port) { + reqPort = Number(reqUrl.port); + } + else if (reqUrl.protocol === 'http:') { + reqPort = 80; + } + else if (reqUrl.protocol === 'https:') { + reqPort = 443; + } + // Format the request hostname and hostname with port + const upperReqHosts = [reqUrl.hostname.toUpperCase()]; + if (typeof reqPort === 'number') { + upperReqHosts.push(`${upperReqHosts[0]}:${reqPort}`); + } + // Compare request host against noproxy + for (const upperNoProxyItem of noProxy + .split(',') + .map(x => x.trim().toUpperCase()) + .filter(x => x)) { + if (upperReqHosts.some(x => x === upperNoProxyItem)) { + return true; + } + } + return false; +} +exports.checkBypass = checkBypass; +//# sourceMappingURL=proxy.js.map + +/***/ }), + +/***/ 8088: +/***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { + +const debug = __nccwpck_require__(427) +const { MAX_LENGTH, MAX_SAFE_INTEGER } = __nccwpck_require__(2293) +const { safeRe: re, t } = __nccwpck_require__(9523) + +const parseOptions = __nccwpck_require__(785) +const { compareIdentifiers } = __nccwpck_require__(2463) +class SemVer { + constructor (version, options) { + options = parseOptions(options) + + if (version instanceof SemVer) { + if (version.loose === !!options.loose && + version.includePrerelease === !!options.includePrerelease) { + return version + } else { + version = version.version + } + } else if (typeof version !== 'string') { + throw new TypeError(`Invalid version. Must be a string. Got type "${typeof version}".`) + } + + if (version.length > MAX_LENGTH) { + throw new TypeError( + `version is longer than ${MAX_LENGTH} characters` + ) + } + + debug('SemVer', version, options) + this.options = options + this.loose = !!options.loose + // this isn't actually relevant for versions, but keep it so that we + // don't run into trouble passing this.options around. + this.includePrerelease = !!options.includePrerelease + + const m = version.trim().match(options.loose ? re[t.LOOSE] : re[t.FULL]) + + if (!m) { + throw new TypeError(`Invalid Version: ${version}`) + } + + this.raw = version + + // these are actually numbers + this.major = +m[1] + this.minor = +m[2] + this.patch = +m[3] + + if (this.major > MAX_SAFE_INTEGER || this.major < 0) { + throw new TypeError('Invalid major version') + } + + if (this.minor > MAX_SAFE_INTEGER || this.minor < 0) { + throw new TypeError('Invalid minor version') + } + + if (this.patch > MAX_SAFE_INTEGER || this.patch < 0) { + throw new TypeError('Invalid patch version') + } + + // numberify any prerelease numeric ids + if (!m[4]) { + this.prerelease = [] + } else { + this.prerelease = m[4].split('.').map((id) => { + if (/^[0-9]+$/.test(id)) { + const num = +id + if (num >= 0 && num < MAX_SAFE_INTEGER) { + return num + } + } + return id + }) + } + + this.build = m[5] ? m[5].split('.') : [] + this.format() + } + + format () { + this.version = `${this.major}.${this.minor}.${this.patch}` + if (this.prerelease.length) { + this.version += `-${this.prerelease.join('.')}` + } + return this.version + } + + toString () { + return this.version + } + + compare (other) { + debug('SemVer.compare', this.version, this.options, other) + if (!(other instanceof SemVer)) { + if (typeof other === 'string' && other === this.version) { + return 0 + } + other = new SemVer(other, this.options) + } + + if (other.version === this.version) { + return 0 + } + + return this.compareMain(other) || this.comparePre(other) + } + + compareMain (other) { + if (!(other instanceof SemVer)) { + other = new SemVer(other, this.options) + } + + return ( + compareIdentifiers(this.major, other.major) || + compareIdentifiers(this.minor, other.minor) || + compareIdentifiers(this.patch, other.patch) + ) + } + + comparePre (other) { + if (!(other instanceof SemVer)) { + other = new SemVer(other, this.options) + } + + // NOT having a prerelease is > having one + if (this.prerelease.length && !other.prerelease.length) { + return -1 + } else if (!this.prerelease.length && other.prerelease.length) { + return 1 + } else if (!this.prerelease.length && !other.prerelease.length) { + return 0 + } + + let i = 0 + do { + const a = this.prerelease[i] + const b = other.prerelease[i] + debug('prerelease compare', i, a, b) + if (a === undefined && b === undefined) { + return 0 + } else if (b === undefined) { + return 1 + } else if (a === undefined) { + return -1 + } else if (a === b) { + continue + } else { + return compareIdentifiers(a, b) + } + } while (++i) + } + + compareBuild (other) { + if (!(other instanceof SemVer)) { + other = new SemVer(other, this.options) + } + + let i = 0 + do { + const a = this.build[i] + const b = other.build[i] + debug('prerelease compare', i, a, b) + if (a === undefined && b === undefined) { + return 0 + } else if (b === undefined) { + return 1 + } else if (a === undefined) { + return -1 + } else if (a === b) { + continue + } else { + return compareIdentifiers(a, b) + } + } while (++i) + } + + // preminor will bump the version up to the next minor release, and immediately + // down to pre-release. premajor and prepatch work the same way. + inc (release, identifier, identifierBase) { + switch (release) { + case 'premajor': + this.prerelease.length = 0 + this.patch = 0 + this.minor = 0 + this.major++ + this.inc('pre', identifier, identifierBase) + break + case 'preminor': + this.prerelease.length = 0 + this.patch = 0 + this.minor++ + this.inc('pre', identifier, identifierBase) + break + case 'prepatch': + // If this is already a prerelease, it will bump to the next version + // drop any prereleases that might already exist, since they are not + // relevant at this point. + this.prerelease.length = 0 + this.inc('patch', identifier, identifierBase) + this.inc('pre', identifier, identifierBase) + break + // If the input is a non-prerelease version, this acts the same as + // prepatch. + case 'prerelease': + if (this.prerelease.length === 0) { + this.inc('patch', identifier, identifierBase) + } + this.inc('pre', identifier, identifierBase) + break + + case 'major': + // If this is a pre-major version, bump up to the same major version. + // Otherwise increment major. + // 1.0.0-5 bumps to 1.0.0 + // 1.1.0 bumps to 2.0.0 + if ( + this.minor !== 0 || + this.patch !== 0 || + this.prerelease.length === 0 + ) { + this.major++ + } + this.minor = 0 + this.patch = 0 + this.prerelease = [] + break + case 'minor': + // If this is a pre-minor version, bump up to the same minor version. + // Otherwise increment minor. + // 1.2.0-5 bumps to 1.2.0 + // 1.2.1 bumps to 1.3.0 + if (this.patch !== 0 || this.prerelease.length === 0) { + this.minor++ + } + this.patch = 0 + this.prerelease = [] + break + case 'patch': + // If this is not a pre-release version, it will increment the patch. + // If it is a pre-release it will bump up to the same patch version. + // 1.2.0-5 patches to 1.2.0 + // 1.2.0 patches to 1.2.1 + if (this.prerelease.length === 0) { + this.patch++ + } + this.prerelease = [] + break + // This probably shouldn't be used publicly. + // 1.0.0 'pre' would become 1.0.0-0 which is the wrong direction. + case 'pre': { + const base = Number(identifierBase) ? 1 : 0 + + if (!identifier && identifierBase === false) { + throw new Error('invalid increment argument: identifier is empty') + } + + if (this.prerelease.length === 0) { + this.prerelease = [base] + } else { + let i = this.prerelease.length + while (--i >= 0) { + if (typeof this.prerelease[i] === 'number') { + this.prerelease[i]++ + i = -2 + } + } + if (i === -1) { + // didn't increment anything + if (identifier === this.prerelease.join('.') && identifierBase === false) { + throw new Error('invalid increment argument: identifier already exists') + } + this.prerelease.push(base) + } + } + if (identifier) { + // 1.2.0-beta.1 bumps to 1.2.0-beta.2, + // 1.2.0-beta.fooblz or 1.2.0-beta bumps to 1.2.0-beta.0 + let prerelease = [identifier, base] + if (identifierBase === false) { + prerelease = [identifier] + } + if (compareIdentifiers(this.prerelease[0], identifier) === 0) { + if (isNaN(this.prerelease[1])) { + this.prerelease = prerelease + } + } else { + this.prerelease = prerelease + } + } + break + } + default: + throw new Error(`invalid increment argument: ${release}`) + } + this.raw = this.format() + if (this.build.length) { + this.raw += `+${this.build.join('.')}` + } + return this + } +} + +module.exports = SemVer + + +/***/ }), + +/***/ 6688: +/***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { + +const SemVer = __nccwpck_require__(8088) +const major = (a, loose) => new SemVer(a, loose).major +module.exports = major + + +/***/ }), + +/***/ 8447: +/***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { + +const SemVer = __nccwpck_require__(8088) +const minor = (a, loose) => new SemVer(a, loose).minor +module.exports = minor + + +/***/ }), + +/***/ 5925: +/***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { + +const SemVer = __nccwpck_require__(8088) +const parse = (version, options, throwErrors = false) => { + if (version instanceof SemVer) { + return version + } + try { + return new SemVer(version, options) + } catch (er) { + if (!throwErrors) { + return null + } + throw er + } +} + +module.exports = parse + + +/***/ }), + +/***/ 2866: +/***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { + +const SemVer = __nccwpck_require__(8088) +const patch = (a, loose) => new SemVer(a, loose).patch +module.exports = patch + + +/***/ }), + +/***/ 4016: +/***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { + +const parse = __nccwpck_require__(5925) +const prerelease = (version, options) => { + const parsed = parse(version, options) + return (parsed && parsed.prerelease.length) ? parsed.prerelease : null +} +module.exports = prerelease + + +/***/ }), + +/***/ 2293: +/***/ ((module) => { + +// Note: this is the semver.org version of the spec that it implements +// Not necessarily the package version of this code. +const SEMVER_SPEC_VERSION = '2.0.0' + +const MAX_LENGTH = 256 +const MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER || +/* istanbul ignore next */ 9007199254740991 + +// Max safe segment length for coercion. +const MAX_SAFE_COMPONENT_LENGTH = 16 + +const RELEASE_TYPES = [ + 'major', + 'premajor', + 'minor', + 'preminor', + 'patch', + 'prepatch', + 'prerelease', +] + +module.exports = { + MAX_LENGTH, + MAX_SAFE_COMPONENT_LENGTH, + MAX_SAFE_INTEGER, + RELEASE_TYPES, + SEMVER_SPEC_VERSION, + FLAG_INCLUDE_PRERELEASE: 0b001, + FLAG_LOOSE: 0b010, +} + + +/***/ }), + +/***/ 427: +/***/ ((module) => { + +const debug = ( + typeof process === 'object' && + process.env && + process.env.NODE_DEBUG && + /\bsemver\b/i.test(process.env.NODE_DEBUG) +) ? (...args) => console.error('SEMVER', ...args) + : () => {} + +module.exports = debug + + +/***/ }), + +/***/ 2463: +/***/ ((module) => { + +const numeric = /^[0-9]+$/ +const compareIdentifiers = (a, b) => { + const anum = numeric.test(a) + const bnum = numeric.test(b) + + if (anum && bnum) { + a = +a + b = +b + } + + return a === b ? 0 + : (anum && !bnum) ? -1 + : (bnum && !anum) ? 1 + : a < b ? -1 + : 1 +} + +const rcompareIdentifiers = (a, b) => compareIdentifiers(b, a) + +module.exports = { + compareIdentifiers, + rcompareIdentifiers, +} + + +/***/ }), + +/***/ 785: +/***/ ((module) => { + +// parse out just the options we care about +const looseOption = Object.freeze({ loose: true }) +const emptyOpts = Object.freeze({ }) +const parseOptions = options => { + if (!options) { + return emptyOpts + } + + if (typeof options !== 'object') { + return looseOption + } + + return options +} +module.exports = parseOptions + + +/***/ }), + +/***/ 9523: +/***/ ((module, exports, __nccwpck_require__) => { + +const { MAX_SAFE_COMPONENT_LENGTH } = __nccwpck_require__(2293) +const debug = __nccwpck_require__(427) +exports = module.exports = {} + +// The actual regexps go on exports.re +const re = exports.re = [] +const safeRe = exports.safeRe = [] +const src = exports.src = [] +const t = exports.t = {} +let R = 0 + +const createToken = (name, value, isGlobal) => { + // Replace all greedy whitespace to prevent regex dos issues. These regex are + // used internally via the safeRe object since all inputs in this library get + // normalized first to trim and collapse all extra whitespace. The original + // regexes are exported for userland consumption and lower level usage. A + // future breaking change could export the safer regex only with a note that + // all input should have extra whitespace removed. + const safe = value + .split('\\s*').join('\\s{0,1}') + .split('\\s+').join('\\s') + const index = R++ + debug(name, index, value) + t[name] = index + src[index] = value + re[index] = new RegExp(value, isGlobal ? 'g' : undefined) + safeRe[index] = new RegExp(safe, isGlobal ? 'g' : undefined) +} + +// The following Regular Expressions can be used for tokenizing, +// validating, and parsing SemVer version strings. + +// ## Numeric Identifier +// A single `0`, or a non-zero digit followed by zero or more digits. + +createToken('NUMERICIDENTIFIER', '0|[1-9]\\d*') +createToken('NUMERICIDENTIFIERLOOSE', '[0-9]+') + +// ## Non-numeric Identifier +// Zero or more digits, followed by a letter or hyphen, and then zero or +// more letters, digits, or hyphens. + +createToken('NONNUMERICIDENTIFIER', '\\d*[a-zA-Z-][a-zA-Z0-9-]*') + +// ## Main Version +// Three dot-separated numeric identifiers. + +createToken('MAINVERSION', `(${src[t.NUMERICIDENTIFIER]})\\.` + + `(${src[t.NUMERICIDENTIFIER]})\\.` + + `(${src[t.NUMERICIDENTIFIER]})`) + +createToken('MAINVERSIONLOOSE', `(${src[t.NUMERICIDENTIFIERLOOSE]})\\.` + + `(${src[t.NUMERICIDENTIFIERLOOSE]})\\.` + + `(${src[t.NUMERICIDENTIFIERLOOSE]})`) + +// ## Pre-release Version Identifier +// A numeric identifier, or a non-numeric identifier. + +createToken('PRERELEASEIDENTIFIER', `(?:${src[t.NUMERICIDENTIFIER] +}|${src[t.NONNUMERICIDENTIFIER]})`) + +createToken('PRERELEASEIDENTIFIERLOOSE', `(?:${src[t.NUMERICIDENTIFIERLOOSE] +}|${src[t.NONNUMERICIDENTIFIER]})`) + +// ## Pre-release Version +// Hyphen, followed by one or more dot-separated pre-release version +// identifiers. + +createToken('PRERELEASE', `(?:-(${src[t.PRERELEASEIDENTIFIER] +}(?:\\.${src[t.PRERELEASEIDENTIFIER]})*))`) + +createToken('PRERELEASELOOSE', `(?:-?(${src[t.PRERELEASEIDENTIFIERLOOSE] +}(?:\\.${src[t.PRERELEASEIDENTIFIERLOOSE]})*))`) + +// ## Build Metadata Identifier +// Any combination of digits, letters, or hyphens. + +createToken('BUILDIDENTIFIER', '[0-9A-Za-z-]+') + +// ## Build Metadata +// Plus sign, followed by one or more period-separated build metadata +// identifiers. + +createToken('BUILD', `(?:\\+(${src[t.BUILDIDENTIFIER] +}(?:\\.${src[t.BUILDIDENTIFIER]})*))`) + +// ## Full Version String +// A main version, followed optionally by a pre-release version and +// build metadata. + +// Note that the only major, minor, patch, and pre-release sections of +// the version string are capturing groups. The build metadata is not a +// capturing group, because it should not ever be used in version +// comparison. + +createToken('FULLPLAIN', `v?${src[t.MAINVERSION] +}${src[t.PRERELEASE]}?${ + src[t.BUILD]}?`) + +createToken('FULL', `^${src[t.FULLPLAIN]}$`) + +// like full, but allows v1.2.3 and =1.2.3, which people do sometimes. +// also, 1.0.0alpha1 (prerelease without the hyphen) which is pretty +// common in the npm registry. +createToken('LOOSEPLAIN', `[v=\\s]*${src[t.MAINVERSIONLOOSE] +}${src[t.PRERELEASELOOSE]}?${ + src[t.BUILD]}?`) + +createToken('LOOSE', `^${src[t.LOOSEPLAIN]}$`) + +createToken('GTLT', '((?:<|>)?=?)') + +// Something like "2.*" or "1.2.x". +// Note that "x.x" is a valid xRange identifer, meaning "any version" +// Only the first item is strictly required. +createToken('XRANGEIDENTIFIERLOOSE', `${src[t.NUMERICIDENTIFIERLOOSE]}|x|X|\\*`) +createToken('XRANGEIDENTIFIER', `${src[t.NUMERICIDENTIFIER]}|x|X|\\*`) + +createToken('XRANGEPLAIN', `[v=\\s]*(${src[t.XRANGEIDENTIFIER]})` + + `(?:\\.(${src[t.XRANGEIDENTIFIER]})` + + `(?:\\.(${src[t.XRANGEIDENTIFIER]})` + + `(?:${src[t.PRERELEASE]})?${ + src[t.BUILD]}?` + + `)?)?`) + +createToken('XRANGEPLAINLOOSE', `[v=\\s]*(${src[t.XRANGEIDENTIFIERLOOSE]})` + + `(?:\\.(${src[t.XRANGEIDENTIFIERLOOSE]})` + + `(?:\\.(${src[t.XRANGEIDENTIFIERLOOSE]})` + + `(?:${src[t.PRERELEASELOOSE]})?${ + src[t.BUILD]}?` + + `)?)?`) + +createToken('XRANGE', `^${src[t.GTLT]}\\s*${src[t.XRANGEPLAIN]}$`) +createToken('XRANGELOOSE', `^${src[t.GTLT]}\\s*${src[t.XRANGEPLAINLOOSE]}$`) + +// Coercion. +// Extract anything that could conceivably be a part of a valid semver +createToken('COERCE', `${'(^|[^\\d])' + + '(\\d{1,'}${MAX_SAFE_COMPONENT_LENGTH}})` + + `(?:\\.(\\d{1,${MAX_SAFE_COMPONENT_LENGTH}}))?` + + `(?:\\.(\\d{1,${MAX_SAFE_COMPONENT_LENGTH}}))?` + + `(?:$|[^\\d])`) +createToken('COERCERTL', src[t.COERCE], true) + +// Tilde ranges. +// Meaning is "reasonably at or greater than" +createToken('LONETILDE', '(?:~>?)') + +createToken('TILDETRIM', `(\\s*)${src[t.LONETILDE]}\\s+`, true) +exports.tildeTrimReplace = '$1~' + +createToken('TILDE', `^${src[t.LONETILDE]}${src[t.XRANGEPLAIN]}$`) +createToken('TILDELOOSE', `^${src[t.LONETILDE]}${src[t.XRANGEPLAINLOOSE]}$`) + +// Caret ranges. +// Meaning is "at least and backwards compatible with" +createToken('LONECARET', '(?:\\^)') + +createToken('CARETTRIM', `(\\s*)${src[t.LONECARET]}\\s+`, true) +exports.caretTrimReplace = '$1^' + +createToken('CARET', `^${src[t.LONECARET]}${src[t.XRANGEPLAIN]}$`) +createToken('CARETLOOSE', `^${src[t.LONECARET]}${src[t.XRANGEPLAINLOOSE]}$`) + +// A simple gt/lt/eq thing, or just "" to indicate "any version" +createToken('COMPARATORLOOSE', `^${src[t.GTLT]}\\s*(${src[t.LOOSEPLAIN]})$|^$`) +createToken('COMPARATOR', `^${src[t.GTLT]}\\s*(${src[t.FULLPLAIN]})$|^$`) + +// An expression to strip any whitespace between the gtlt and the thing +// it modifies, so that `> 1.2.3` ==> `>1.2.3` +createToken('COMPARATORTRIM', `(\\s*)${src[t.GTLT] +}\\s*(${src[t.LOOSEPLAIN]}|${src[t.XRANGEPLAIN]})`, true) +exports.comparatorTrimReplace = '$1$2$3' + +// Something like `1.2.3 - 1.2.4` +// Note that these all use the loose form, because they'll be +// checked against either the strict or loose comparator form +// later. +createToken('HYPHENRANGE', `^\\s*(${src[t.XRANGEPLAIN]})` + + `\\s+-\\s+` + + `(${src[t.XRANGEPLAIN]})` + + `\\s*$`) + +createToken('HYPHENRANGELOOSE', `^\\s*(${src[t.XRANGEPLAINLOOSE]})` + + `\\s+-\\s+` + + `(${src[t.XRANGEPLAINLOOSE]})` + + `\\s*$`) + +// Star ranges basically just allow anything at all. +createToken('STAR', '(<|>)?=?\\s*\\*') +// >=0.0.0 is like a star +createToken('GTE0', '^\\s*>=\\s*0\\.0\\.0\\s*$') +createToken('GTE0PRE', '^\\s*>=\\s*0\\.0\\.0-0\\s*$') + + +/***/ }), + +/***/ 4294: +/***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { + +module.exports = __nccwpck_require__(4219); + + +/***/ }), + +/***/ 4219: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + + +var net = __nccwpck_require__(1808); +var tls = __nccwpck_require__(4404); +var http = __nccwpck_require__(3685); +var https = __nccwpck_require__(5687); +var events = __nccwpck_require__(2361); +var assert = __nccwpck_require__(9491); +var util = __nccwpck_require__(3837); + + +exports.httpOverHttp = httpOverHttp; +exports.httpsOverHttp = httpsOverHttp; +exports.httpOverHttps = httpOverHttps; +exports.httpsOverHttps = httpsOverHttps; + + +function httpOverHttp(options) { + var agent = new TunnelingAgent(options); + agent.request = http.request; + return agent; +} + +function httpsOverHttp(options) { + var agent = new TunnelingAgent(options); + agent.request = http.request; + agent.createSocket = createSecureSocket; + agent.defaultPort = 443; + return agent; +} + +function httpOverHttps(options) { + var agent = new TunnelingAgent(options); + agent.request = https.request; + return agent; +} + +function httpsOverHttps(options) { + var agent = new TunnelingAgent(options); + agent.request = https.request; + agent.createSocket = createSecureSocket; + agent.defaultPort = 443; + return agent; +} + + +function TunnelingAgent(options) { + var self = this; + self.options = options || {}; + self.proxyOptions = self.options.proxy || {}; + self.maxSockets = self.options.maxSockets || http.Agent.defaultMaxSockets; + self.requests = []; + self.sockets = []; + + self.on('free', function onFree(socket, host, port, localAddress) { + var options = toOptions(host, port, localAddress); + for (var i = 0, len = self.requests.length; i < len; ++i) { + var pending = self.requests[i]; + if (pending.host === options.host && pending.port === options.port) { + // Detect the request to connect same origin server, + // reuse the connection. + self.requests.splice(i, 1); + pending.request.onSocket(socket); + return; + } + } + socket.destroy(); + self.removeSocket(socket); + }); +} +util.inherits(TunnelingAgent, events.EventEmitter); + +TunnelingAgent.prototype.addRequest = function addRequest(req, host, port, localAddress) { + var self = this; + var options = mergeOptions({request: req}, self.options, toOptions(host, port, localAddress)); + + if (self.sockets.length >= this.maxSockets) { + // We are over limit so we'll add it to the queue. + self.requests.push(options); + return; + } + + // If we are under maxSockets create a new one. + self.createSocket(options, function(socket) { + socket.on('free', onFree); + socket.on('close', onCloseOrRemove); + socket.on('agentRemove', onCloseOrRemove); + req.onSocket(socket); + + function onFree() { + self.emit('free', socket, options); + } + + function onCloseOrRemove(err) { + self.removeSocket(socket); + socket.removeListener('free', onFree); + socket.removeListener('close', onCloseOrRemove); + socket.removeListener('agentRemove', onCloseOrRemove); + } + }); +}; + +TunnelingAgent.prototype.createSocket = function createSocket(options, cb) { + var self = this; + var placeholder = {}; + self.sockets.push(placeholder); + + var connectOptions = mergeOptions({}, self.proxyOptions, { + method: 'CONNECT', + path: options.host + ':' + options.port, + agent: false, + headers: { + host: options.host + ':' + options.port + } + }); + if (options.localAddress) { + connectOptions.localAddress = options.localAddress; + } + if (connectOptions.proxyAuth) { + connectOptions.headers = connectOptions.headers || {}; + connectOptions.headers['Proxy-Authorization'] = 'Basic ' + + new Buffer(connectOptions.proxyAuth).toString('base64'); + } + + debug('making CONNECT request'); + var connectReq = self.request(connectOptions); + connectReq.useChunkedEncodingByDefault = false; // for v0.6 + connectReq.once('response', onResponse); // for v0.6 + connectReq.once('upgrade', onUpgrade); // for v0.6 + connectReq.once('connect', onConnect); // for v0.7 or later + connectReq.once('error', onError); + connectReq.end(); + + function onResponse(res) { + // Very hacky. This is necessary to avoid http-parser leaks. + res.upgrade = true; + } + + function onUpgrade(res, socket, head) { + // Hacky. + process.nextTick(function() { + onConnect(res, socket, head); + }); + } + + function onConnect(res, socket, head) { + connectReq.removeAllListeners(); + socket.removeAllListeners(); + + if (res.statusCode !== 200) { + debug('tunneling socket could not be established, statusCode=%d', + res.statusCode); + socket.destroy(); + var error = new Error('tunneling socket could not be established, ' + + 'statusCode=' + res.statusCode); + error.code = 'ECONNRESET'; + options.request.emit('error', error); + self.removeSocket(placeholder); + return; + } + if (head.length > 0) { + debug('got illegal response body from proxy'); + socket.destroy(); + var error = new Error('got illegal response body from proxy'); + error.code = 'ECONNRESET'; + options.request.emit('error', error); + self.removeSocket(placeholder); + return; + } + debug('tunneling connection has established'); + self.sockets[self.sockets.indexOf(placeholder)] = socket; + return cb(socket); + } + + function onError(cause) { + connectReq.removeAllListeners(); + + debug('tunneling socket could not be established, cause=%s\n', + cause.message, cause.stack); + var error = new Error('tunneling socket could not be established, ' + + 'cause=' + cause.message); + error.code = 'ECONNRESET'; + options.request.emit('error', error); + self.removeSocket(placeholder); + } +}; + +TunnelingAgent.prototype.removeSocket = function removeSocket(socket) { + var pos = this.sockets.indexOf(socket) + if (pos === -1) { + return; + } + this.sockets.splice(pos, 1); + + var pending = this.requests.shift(); + if (pending) { + // If we have pending requests and a socket gets closed a new one + // needs to be created to take over in the pool for the one that closed. + this.createSocket(pending, function(socket) { + pending.request.onSocket(socket); + }); + } +}; + +function createSecureSocket(options, cb) { + var self = this; + TunnelingAgent.prototype.createSocket.call(self, options, function(socket) { + var hostHeader = options.request.getHeader('host'); + var tlsOptions = mergeOptions({}, self.options, { + socket: socket, + servername: hostHeader ? hostHeader.replace(/:.*$/, '') : options.host + }); + + // 0 is dummy port for v0.6 + var secureSocket = tls.connect(0, tlsOptions); + self.sockets[self.sockets.indexOf(socket)] = secureSocket; + cb(secureSocket); + }); +} + + +function toOptions(host, port, localAddress) { + if (typeof host === 'string') { // since v0.10 + return { + host: host, + port: port, + localAddress: localAddress + }; + } + return host; // for v0.11 or later +} + +function mergeOptions(target) { + for (var i = 1, len = arguments.length; i < len; ++i) { + var overrides = arguments[i]; + if (typeof overrides === 'object') { + var keys = Object.keys(overrides); + for (var j = 0, keyLen = keys.length; j < keyLen; ++j) { + var k = keys[j]; + if (overrides[k] !== undefined) { + target[k] = overrides[k]; + } + } + } + } + return target; +} + + +var debug; +if (process.env.NODE_DEBUG && /\btunnel\b/.test(process.env.NODE_DEBUG)) { + debug = function() { + var args = Array.prototype.slice.call(arguments); + if (typeof args[0] === 'string') { + args[0] = 'TUNNEL: ' + args[0]; + } else { + args.unshift('TUNNEL:'); + } + console.error.apply(console, args); + } +} else { + debug = function() {}; +} +exports.debug = debug; // for test + + +/***/ }), + +/***/ 5840: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", ({ + value: true +})); +Object.defineProperty(exports, "v1", ({ + enumerable: true, + get: function () { + return _v.default; + } +})); +Object.defineProperty(exports, "v3", ({ + enumerable: true, + get: function () { + return _v2.default; + } +})); +Object.defineProperty(exports, "v4", ({ + enumerable: true, + get: function () { + return _v3.default; + } +})); +Object.defineProperty(exports, "v5", ({ + enumerable: true, + get: function () { + return _v4.default; + } +})); +Object.defineProperty(exports, "NIL", ({ + enumerable: true, + get: function () { + return _nil.default; + } +})); +Object.defineProperty(exports, "version", ({ + enumerable: true, + get: function () { + return _version.default; + } +})); +Object.defineProperty(exports, "validate", ({ + enumerable: true, + get: function () { + return _validate.default; + } +})); +Object.defineProperty(exports, "stringify", ({ + enumerable: true, + get: function () { + return _stringify.default; + } +})); +Object.defineProperty(exports, "parse", ({ + enumerable: true, + get: function () { + return _parse.default; + } +})); + +var _v = _interopRequireDefault(__nccwpck_require__(8628)); + +var _v2 = _interopRequireDefault(__nccwpck_require__(6409)); + +var _v3 = _interopRequireDefault(__nccwpck_require__(5122)); + +var _v4 = _interopRequireDefault(__nccwpck_require__(9120)); + +var _nil = _interopRequireDefault(__nccwpck_require__(5332)); + +var _version = _interopRequireDefault(__nccwpck_require__(1595)); + +var _validate = _interopRequireDefault(__nccwpck_require__(6900)); + +var _stringify = _interopRequireDefault(__nccwpck_require__(8950)); + +var _parse = _interopRequireDefault(__nccwpck_require__(2746)); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +/***/ }), + +/***/ 4569: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", ({ + value: true +})); +exports["default"] = void 0; + +var _crypto = _interopRequireDefault(__nccwpck_require__(6113)); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +function md5(bytes) { + if (Array.isArray(bytes)) { + bytes = Buffer.from(bytes); + } else if (typeof bytes === 'string') { + bytes = Buffer.from(bytes, 'utf8'); + } + + return _crypto.default.createHash('md5').update(bytes).digest(); +} + +var _default = md5; +exports["default"] = _default; + +/***/ }), + +/***/ 5332: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", ({ + value: true +})); +exports["default"] = void 0; +var _default = '00000000-0000-0000-0000-000000000000'; +exports["default"] = _default; + +/***/ }), + +/***/ 2746: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", ({ + value: true +})); +exports["default"] = void 0; + +var _validate = _interopRequireDefault(__nccwpck_require__(6900)); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +function parse(uuid) { + if (!(0, _validate.default)(uuid)) { + throw TypeError('Invalid UUID'); + } + + let v; + const arr = new Uint8Array(16); // Parse ########-....-....-....-............ + + arr[0] = (v = parseInt(uuid.slice(0, 8), 16)) >>> 24; + arr[1] = v >>> 16 & 0xff; + arr[2] = v >>> 8 & 0xff; + arr[3] = v & 0xff; // Parse ........-####-....-....-............ + + arr[4] = (v = parseInt(uuid.slice(9, 13), 16)) >>> 8; + arr[5] = v & 0xff; // Parse ........-....-####-....-............ + + arr[6] = (v = parseInt(uuid.slice(14, 18), 16)) >>> 8; + arr[7] = v & 0xff; // Parse ........-....-....-####-............ + + arr[8] = (v = parseInt(uuid.slice(19, 23), 16)) >>> 8; + arr[9] = v & 0xff; // Parse ........-....-....-....-############ + // (Use "/" to avoid 32-bit truncation when bit-shifting high-order bytes) + + arr[10] = (v = parseInt(uuid.slice(24, 36), 16)) / 0x10000000000 & 0xff; + arr[11] = v / 0x100000000 & 0xff; + arr[12] = v >>> 24 & 0xff; + arr[13] = v >>> 16 & 0xff; + arr[14] = v >>> 8 & 0xff; + arr[15] = v & 0xff; + return arr; +} + +var _default = parse; +exports["default"] = _default; + +/***/ }), + +/***/ 814: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", ({ + value: true +})); +exports["default"] = void 0; +var _default = /^(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|00000000-0000-0000-0000-000000000000)$/i; +exports["default"] = _default; + +/***/ }), + +/***/ 807: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", ({ + value: true +})); +exports["default"] = rng; + +var _crypto = _interopRequireDefault(__nccwpck_require__(6113)); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +const rnds8Pool = new Uint8Array(256); // # of random values to pre-allocate + +let poolPtr = rnds8Pool.length; + +function rng() { + if (poolPtr > rnds8Pool.length - 16) { + _crypto.default.randomFillSync(rnds8Pool); + + poolPtr = 0; + } + + return rnds8Pool.slice(poolPtr, poolPtr += 16); +} + +/***/ }), + +/***/ 5274: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", ({ + value: true +})); +exports["default"] = void 0; + +var _crypto = _interopRequireDefault(__nccwpck_require__(6113)); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +function sha1(bytes) { + if (Array.isArray(bytes)) { + bytes = Buffer.from(bytes); + } else if (typeof bytes === 'string') { + bytes = Buffer.from(bytes, 'utf8'); + } + + return _crypto.default.createHash('sha1').update(bytes).digest(); +} + +var _default = sha1; +exports["default"] = _default; + +/***/ }), + +/***/ 8950: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", ({ + value: true +})); +exports["default"] = void 0; + +var _validate = _interopRequireDefault(__nccwpck_require__(6900)); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +/** + * Convert array of 16 byte values to UUID string format of the form: + * XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX + */ +const byteToHex = []; + +for (let i = 0; i < 256; ++i) { + byteToHex.push((i + 0x100).toString(16).substr(1)); +} + +function stringify(arr, offset = 0) { + // Note: Be careful editing this code! It's been tuned for performance + // and works in ways you may not expect. See https://github.com/uuidjs/uuid/pull/434 + const uuid = (byteToHex[arr[offset + 0]] + byteToHex[arr[offset + 1]] + byteToHex[arr[offset + 2]] + byteToHex[arr[offset + 3]] + '-' + byteToHex[arr[offset + 4]] + byteToHex[arr[offset + 5]] + '-' + byteToHex[arr[offset + 6]] + byteToHex[arr[offset + 7]] + '-' + byteToHex[arr[offset + 8]] + byteToHex[arr[offset + 9]] + '-' + byteToHex[arr[offset + 10]] + byteToHex[arr[offset + 11]] + byteToHex[arr[offset + 12]] + byteToHex[arr[offset + 13]] + byteToHex[arr[offset + 14]] + byteToHex[arr[offset + 15]]).toLowerCase(); // Consistency check for valid UUID. If this throws, it's likely due to one + // of the following: + // - One or more input array values don't map to a hex octet (leading to + // "undefined" in the uuid) + // - Invalid input values for the RFC `version` or `variant` fields + + if (!(0, _validate.default)(uuid)) { + throw TypeError('Stringified UUID is invalid'); + } + + return uuid; +} + +var _default = stringify; +exports["default"] = _default; + +/***/ }), + +/***/ 8628: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", ({ + value: true +})); +exports["default"] = void 0; + +var _rng = _interopRequireDefault(__nccwpck_require__(807)); + +var _stringify = _interopRequireDefault(__nccwpck_require__(8950)); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +// **`v1()` - Generate time-based UUID** +// +// Inspired by https://github.com/LiosK/UUID.js +// and http://docs.python.org/library/uuid.html +let _nodeId; + +let _clockseq; // Previous uuid creation time + + +let _lastMSecs = 0; +let _lastNSecs = 0; // See https://github.com/uuidjs/uuid for API details + +function v1(options, buf, offset) { + let i = buf && offset || 0; + const b = buf || new Array(16); + options = options || {}; + let node = options.node || _nodeId; + let clockseq = options.clockseq !== undefined ? options.clockseq : _clockseq; // node and clockseq need to be initialized to random values if they're not + // specified. We do this lazily to minimize issues related to insufficient + // system entropy. See #189 + + if (node == null || clockseq == null) { + const seedBytes = options.random || (options.rng || _rng.default)(); + + if (node == null) { + // Per 4.5, create and 48-bit node id, (47 random bits + multicast bit = 1) + node = _nodeId = [seedBytes[0] | 0x01, seedBytes[1], seedBytes[2], seedBytes[3], seedBytes[4], seedBytes[5]]; + } + + if (clockseq == null) { + // Per 4.2.2, randomize (14 bit) clockseq + clockseq = _clockseq = (seedBytes[6] << 8 | seedBytes[7]) & 0x3fff; + } + } // UUID timestamps are 100 nano-second units since the Gregorian epoch, + // (1582-10-15 00:00). JSNumbers aren't precise enough for this, so + // time is handled internally as 'msecs' (integer milliseconds) and 'nsecs' + // (100-nanoseconds offset from msecs) since unix epoch, 1970-01-01 00:00. + + + let msecs = options.msecs !== undefined ? options.msecs : Date.now(); // Per 4.2.1.2, use count of uuid's generated during the current clock + // cycle to simulate higher resolution clock + + let nsecs = options.nsecs !== undefined ? options.nsecs : _lastNSecs + 1; // Time since last uuid creation (in msecs) + + const dt = msecs - _lastMSecs + (nsecs - _lastNSecs) / 10000; // Per 4.2.1.2, Bump clockseq on clock regression + + if (dt < 0 && options.clockseq === undefined) { + clockseq = clockseq + 1 & 0x3fff; + } // Reset nsecs if clock regresses (new clockseq) or we've moved onto a new + // time interval + + + if ((dt < 0 || msecs > _lastMSecs) && options.nsecs === undefined) { + nsecs = 0; + } // Per 4.2.1.2 Throw error if too many uuids are requested + + + if (nsecs >= 10000) { + throw new Error("uuid.v1(): Can't create more than 10M uuids/sec"); + } + + _lastMSecs = msecs; + _lastNSecs = nsecs; + _clockseq = clockseq; // Per 4.1.4 - Convert from unix epoch to Gregorian epoch + + msecs += 12219292800000; // `time_low` + + const tl = ((msecs & 0xfffffff) * 10000 + nsecs) % 0x100000000; + b[i++] = tl >>> 24 & 0xff; + b[i++] = tl >>> 16 & 0xff; + b[i++] = tl >>> 8 & 0xff; + b[i++] = tl & 0xff; // `time_mid` + + const tmh = msecs / 0x100000000 * 10000 & 0xfffffff; + b[i++] = tmh >>> 8 & 0xff; + b[i++] = tmh & 0xff; // `time_high_and_version` + + b[i++] = tmh >>> 24 & 0xf | 0x10; // include version + + b[i++] = tmh >>> 16 & 0xff; // `clock_seq_hi_and_reserved` (Per 4.2.2 - include variant) + + b[i++] = clockseq >>> 8 | 0x80; // `clock_seq_low` + + b[i++] = clockseq & 0xff; // `node` + + for (let n = 0; n < 6; ++n) { + b[i + n] = node[n]; + } + + return buf || (0, _stringify.default)(b); +} + +var _default = v1; +exports["default"] = _default; + +/***/ }), + +/***/ 6409: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", ({ + value: true +})); +exports["default"] = void 0; + +var _v = _interopRequireDefault(__nccwpck_require__(5998)); + +var _md = _interopRequireDefault(__nccwpck_require__(4569)); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +const v3 = (0, _v.default)('v3', 0x30, _md.default); +var _default = v3; +exports["default"] = _default; + +/***/ }), + +/***/ 5998: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", ({ + value: true +})); +exports["default"] = _default; +exports.URL = exports.DNS = void 0; + +var _stringify = _interopRequireDefault(__nccwpck_require__(8950)); + +var _parse = _interopRequireDefault(__nccwpck_require__(2746)); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +function stringToBytes(str) { + str = unescape(encodeURIComponent(str)); // UTF8 escape + + const bytes = []; + + for (let i = 0; i < str.length; ++i) { + bytes.push(str.charCodeAt(i)); + } + + return bytes; +} + +const DNS = '6ba7b810-9dad-11d1-80b4-00c04fd430c8'; +exports.DNS = DNS; +const URL = '6ba7b811-9dad-11d1-80b4-00c04fd430c8'; +exports.URL = URL; + +function _default(name, version, hashfunc) { + function generateUUID(value, namespace, buf, offset) { + if (typeof value === 'string') { + value = stringToBytes(value); + } + + if (typeof namespace === 'string') { + namespace = (0, _parse.default)(namespace); + } + + if (namespace.length !== 16) { + throw TypeError('Namespace must be array-like (16 iterable integer values, 0-255)'); + } // Compute hash of namespace and value, Per 4.3 + // Future: Use spread syntax when supported on all platforms, e.g. `bytes = + // hashfunc([...namespace, ... value])` + + + let bytes = new Uint8Array(16 + value.length); + bytes.set(namespace); + bytes.set(value, namespace.length); + bytes = hashfunc(bytes); + bytes[6] = bytes[6] & 0x0f | version; + bytes[8] = bytes[8] & 0x3f | 0x80; + + if (buf) { + offset = offset || 0; + + for (let i = 0; i < 16; ++i) { + buf[offset + i] = bytes[i]; + } + + return buf; + } + + return (0, _stringify.default)(bytes); + } // Function#name is not settable on some platforms (#270) + + + try { + generateUUID.name = name; // eslint-disable-next-line no-empty + } catch (err) {} // For CommonJS default export support + + + generateUUID.DNS = DNS; + generateUUID.URL = URL; + return generateUUID; +} + +/***/ }), + +/***/ 5122: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", ({ + value: true +})); +exports["default"] = void 0; + +var _rng = _interopRequireDefault(__nccwpck_require__(807)); + +var _stringify = _interopRequireDefault(__nccwpck_require__(8950)); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +function v4(options, buf, offset) { + options = options || {}; + + const rnds = options.random || (options.rng || _rng.default)(); // Per 4.4, set bits for version and `clock_seq_hi_and_reserved` + + + rnds[6] = rnds[6] & 0x0f | 0x40; + rnds[8] = rnds[8] & 0x3f | 0x80; // Copy bytes to buffer, if provided + + if (buf) { + offset = offset || 0; + + for (let i = 0; i < 16; ++i) { + buf[offset + i] = rnds[i]; + } + + return buf; + } + + return (0, _stringify.default)(rnds); +} + +var _default = v4; +exports["default"] = _default; + +/***/ }), + +/***/ 9120: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", ({ + value: true +})); +exports["default"] = void 0; + +var _v = _interopRequireDefault(__nccwpck_require__(5998)); + +var _sha = _interopRequireDefault(__nccwpck_require__(5274)); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +const v5 = (0, _v.default)('v5', 0x50, _sha.default); +var _default = v5; +exports["default"] = _default; + +/***/ }), + +/***/ 6900: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", ({ + value: true +})); +exports["default"] = void 0; + +var _regex = _interopRequireDefault(__nccwpck_require__(814)); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +function validate(uuid) { + return typeof uuid === 'string' && _regex.default.test(uuid); +} + +var _default = validate; +exports["default"] = _default; + +/***/ }), + +/***/ 1595: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", ({ + value: true +})); +exports["default"] = void 0; + +var _validate = _interopRequireDefault(__nccwpck_require__(6900)); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +function version(uuid) { + if (!(0, _validate.default)(uuid)) { + throw TypeError('Invalid UUID'); + } + + return parseInt(uuid.substr(14, 1), 16); +} + +var _default = version; +exports["default"] = _default; + +/***/ }), + +/***/ 7361: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", ({ value: true })); +const core = __importStar(__nccwpck_require__(2186)); +const child_process_1 = __nccwpck_require__(2081); +const fs_1 = __importDefault(__nccwpck_require__(7147)); +const util_1 = __nccwpck_require__(3837); +const nativeVersionUpdater_1 = __nccwpck_require__(9095); +const versionUpdater = __importStar(__nccwpck_require__(8982)); +const exec = (0, util_1.promisify)(child_process_1.exec); +/** + * Update the native app versions. + */ +function updateNativeVersions(version) { + console.log(`Updating native versions to ${version}`); + // Update Android + const androidVersionCode = (0, nativeVersionUpdater_1.generateAndroidVersionCode)(version); + (0, nativeVersionUpdater_1.updateAndroidVersion)(version, androidVersionCode) + .then(() => { + console.log('Successfully updated Android!'); + }) + .catch((err) => { + console.error('Error updating Android'); + core.setFailed(err); + }); + // Update iOS + try { + const cfBundleVersion = (0, nativeVersionUpdater_1.updateiOSVersion)(version); + if (typeof cfBundleVersion === 'string' && cfBundleVersion.split('.').length === 4) { + core.setOutput('NEW_IOS_VERSION', cfBundleVersion); + console.log('Successfully updated iOS!'); + } + else { + core.setFailed(`Failed to set NEW_IOS_VERSION. CFBundleVersion: ${cfBundleVersion}`); + } + } + catch (err) { + console.error('Error updating iOS'); + if (err instanceof Error) { + core.setFailed(err); + } + } +} +let semanticVersionLevel = core.getInput('SEMVER_LEVEL', { required: true }); +if (!semanticVersionLevel || !versionUpdater.isValidSemverLevel(semanticVersionLevel)) { + semanticVersionLevel = versionUpdater.SEMANTIC_VERSION_LEVELS.BUILD; + console.log(`Invalid input for 'SEMVER_LEVEL': ${semanticVersionLevel}`, `Defaulting to: ${semanticVersionLevel}`); +} +const { version: previousVersion } = JSON.parse(fs_1.default.readFileSync('./package.json').toString()); +if (!previousVersion) { + core.setFailed('Error: Could not read package.json'); +} +const newVersion = versionUpdater.incrementVersion(previousVersion ?? '', semanticVersionLevel); +console.log(`Previous version: ${previousVersion}`, `New version: ${newVersion}`); +updateNativeVersions(newVersion); +console.log(`Setting npm version to ${newVersion}`); +exec(`npm --no-git-tag-version version ${newVersion} -m "Update version to ${newVersion}"`) + .then(({ stdout }) => { + // NPM and native versions successfully updated, output new version + console.log(stdout); + core.setOutput('NEW_VERSION', newVersion); +}) + .catch(({ stdout, stderr }) => { + // Log errors and retry + console.log(stdout); + console.error(stderr); + core.setFailed('An error occurred in the `npm version` command'); +}); + + +/***/ }), + +/***/ 9095: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.PLIST_PATH_TEST = exports.PLIST_PATH = exports.BUILD_GRADLE_PATH = exports.generateAndroidVersionCode = exports.updateAndroidVersion = exports.updateiOSVersion = void 0; +const child_process_1 = __nccwpck_require__(2081); +const fs_1 = __nccwpck_require__(7147); +const path_1 = __importDefault(__nccwpck_require__(1017)); +const major_1 = __importDefault(__nccwpck_require__(6688)); +const minor_1 = __importDefault(__nccwpck_require__(8447)); +const patch_1 = __importDefault(__nccwpck_require__(2866)); +const prerelease_1 = __importDefault(__nccwpck_require__(4016)); +// Filepath constants +const BUILD_GRADLE_PATH = process.env.NODE_ENV === 'test' ? path_1.default.resolve(__dirname, '../../android/app/build.gradle') : './android/app/build.gradle'; +exports.BUILD_GRADLE_PATH = BUILD_GRADLE_PATH; +const PLIST_PATH = './ios/NewExpensify/Info.plist'; +exports.PLIST_PATH = PLIST_PATH; +const PLIST_PATH_TEST = './ios/NewExpensifyTests/Info.plist'; +exports.PLIST_PATH_TEST = PLIST_PATH_TEST; +const PLIST_PATH_NSE = './ios/NotificationServiceExtension/Info.plist'; +/** + * Pad a number to be two digits (with leading zeros if necessary). + */ +function padToTwoDigits(value) { + if (value >= 10) { + return value.toString(); + } + return `0${value.toString()}`; +} +/** + * Generate the 10-digit versionCode for android. + * This version code allocates two digits each for PREFIX, MAJOR, MINOR, PATCH, and BUILD versions. + * As a result, our max version is 99.99.99-99. + */ +function generateAndroidVersionCode(npmVersion) { + // All Android versions will be prefixed with '10' due to previous versioning + const prefix = '10'; + return ''.concat(prefix, padToTwoDigits((0, major_1.default)(npmVersion) ?? 0), padToTwoDigits((0, minor_1.default)(npmVersion) ?? 0), padToTwoDigits((0, patch_1.default)(npmVersion) ?? 0), padToTwoDigits(Number((0, prerelease_1.default)(npmVersion)) ?? 0)); +} +exports.generateAndroidVersionCode = generateAndroidVersionCode; +/** + * Update the Android app versionName and versionCode. + */ +function updateAndroidVersion(versionName, versionCode) { + console.log('Updating android:', `versionName: ${versionName}`, `versionCode: ${versionCode}`); + return fs_1.promises + .readFile(BUILD_GRADLE_PATH, { encoding: 'utf8' }) + .then((content) => { + let updatedContent = content.toString().replace(/versionName "([0-9.-]*)"/, `versionName "${versionName}"`); + return (updatedContent = updatedContent.replace(/versionCode ([0-9]*)/, `versionCode ${versionCode}`)); + }) + .then((updatedContent) => fs_1.promises.writeFile(BUILD_GRADLE_PATH, updatedContent, { encoding: 'utf8' })); +} +exports.updateAndroidVersion = updateAndroidVersion; +/** + * Update the iOS app version. + * Updates the CFBundleShortVersionString and the CFBundleVersion. + */ +function updateiOSVersion(version) { + const shortVersion = version.split('-')[0]; + const cfVersion = version.includes('-') ? version.replace('-', '.') : `${version}.0`; + console.log('Updating iOS', `CFBundleShortVersionString: ${shortVersion}`, `CFBundleVersion: ${cfVersion}`); + // Update Plists + (0, child_process_1.execSync)(`/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${shortVersion}" ${PLIST_PATH}`); + (0, child_process_1.execSync)(`/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${shortVersion}" ${PLIST_PATH_TEST}`); + (0, child_process_1.execSync)(`/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${shortVersion}" ${PLIST_PATH_NSE}`); + (0, child_process_1.execSync)(`/usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${cfVersion}" ${PLIST_PATH}`); + (0, child_process_1.execSync)(`/usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${cfVersion}" ${PLIST_PATH_TEST}`); + (0, child_process_1.execSync)(`/usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${cfVersion}" ${PLIST_PATH_NSE}`); + // Return the cfVersion so we can set the NEW_IOS_VERSION in ios.yml + return cfVersion; +} +exports.updateiOSVersion = updateiOSVersion; + + +/***/ }), + +/***/ 8982: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.getPreviousVersion = exports.incrementPatch = exports.incrementMinor = exports.SEMANTIC_VERSION_LEVELS = exports.MAX_INCREMENTS = exports.incrementVersion = exports.getVersionStringFromNumber = exports.getVersionNumberFromString = exports.isValidSemverLevel = void 0; +const SEMANTIC_VERSION_LEVELS = { + MAJOR: 'MAJOR', + MINOR: 'MINOR', + PATCH: 'PATCH', + BUILD: 'BUILD', +}; +exports.SEMANTIC_VERSION_LEVELS = SEMANTIC_VERSION_LEVELS; +const MAX_INCREMENTS = 99; +exports.MAX_INCREMENTS = MAX_INCREMENTS; +function isValidSemverLevel(str) { + return Object.keys(SEMANTIC_VERSION_LEVELS).includes(str); +} +exports.isValidSemverLevel = isValidSemverLevel; +/** + * Transforms a versions string into a number + */ +const getVersionNumberFromString = (versionString) => { + const [version, build] = versionString.split('-'); + const [major, minor, patch] = version.split('.').map((n) => Number(n)); + return [major, minor, patch, Number.isInteger(Number(build)) ? Number(build) : 0]; +}; +exports.getVersionNumberFromString = getVersionNumberFromString; +/** + * Transforms version numbers components into a version string + */ +const getVersionStringFromNumber = (major, minor, patch, build = 0) => `${major}.${minor}.${patch}-${build}`; +exports.getVersionStringFromNumber = getVersionStringFromNumber; +/** + * Increments a minor version + */ +const incrementMinor = (major, minor) => { + if (minor < MAX_INCREMENTS) { + return getVersionStringFromNumber(major, minor + 1, 0, 0); + } + return getVersionStringFromNumber(major + 1, 0, 0, 0); +}; +exports.incrementMinor = incrementMinor; +/** + * Increments a Patch version + */ +const incrementPatch = (major, minor, patch) => { + if (patch < MAX_INCREMENTS) { + return getVersionStringFromNumber(major, minor, patch + 1, 0); + } + return incrementMinor(major, minor); +}; +exports.incrementPatch = incrementPatch; +/** + * Increments a build version + */ +const incrementVersion = (version, level) => { + const [major, minor, patch, build] = getVersionNumberFromString(version); + // Majors will always be incremented + if (level === SEMANTIC_VERSION_LEVELS.MAJOR) { + return getVersionStringFromNumber(major + 1, 0, 0, 0); + } + if (level === SEMANTIC_VERSION_LEVELS.MINOR) { + return incrementMinor(major, minor); + } + if (level === SEMANTIC_VERSION_LEVELS.PATCH) { + return incrementPatch(major, minor, patch); + } + if (build < MAX_INCREMENTS) { + return getVersionStringFromNumber(major, minor, patch, build + 1); + } + return incrementPatch(major, minor, patch); +}; +exports.incrementVersion = incrementVersion; +function getPreviousVersion(currentVersion, level) { + const [major, minor, patch, build] = getVersionNumberFromString(currentVersion); + if (level === SEMANTIC_VERSION_LEVELS.MAJOR) { + if (major === 1) { + return getVersionStringFromNumber(1, 0, 0, 0); + } + return getVersionStringFromNumber(major - 1, 0, 0, 0); + } + if (level === SEMANTIC_VERSION_LEVELS.MINOR) { + if (minor === 0) { + return getPreviousVersion(currentVersion, SEMANTIC_VERSION_LEVELS.MAJOR); + } + return getVersionStringFromNumber(major, minor - 1, 0, 0); + } + if (level === SEMANTIC_VERSION_LEVELS.PATCH) { + if (patch === 0) { + return getPreviousVersion(currentVersion, SEMANTIC_VERSION_LEVELS.MINOR); + } + return getVersionStringFromNumber(major, minor, patch - 1, 0); + } + if (build === 0) { + return getPreviousVersion(currentVersion, SEMANTIC_VERSION_LEVELS.PATCH); + } + return getVersionStringFromNumber(major, minor, patch, build - 1); +} +exports.getPreviousVersion = getPreviousVersion; + + +/***/ }), + +/***/ 9491: +/***/ ((module) => { + +"use strict"; +module.exports = require("assert"); + +/***/ }), + +/***/ 2081: +/***/ ((module) => { + +"use strict"; +module.exports = require("child_process"); + +/***/ }), + +/***/ 6113: +/***/ ((module) => { + +"use strict"; +module.exports = require("crypto"); + +/***/ }), + +/***/ 2361: +/***/ ((module) => { + +"use strict"; +module.exports = require("events"); + +/***/ }), + +/***/ 7147: +/***/ ((module) => { + +"use strict"; +module.exports = require("fs"); + +/***/ }), + +/***/ 3685: +/***/ ((module) => { + +"use strict"; +module.exports = require("http"); + +/***/ }), + +/***/ 5687: +/***/ ((module) => { + +"use strict"; +module.exports = require("https"); + +/***/ }), + +/***/ 1808: +/***/ ((module) => { + +"use strict"; +module.exports = require("net"); + +/***/ }), + +/***/ 2037: +/***/ ((module) => { + +"use strict"; +module.exports = require("os"); + +/***/ }), + +/***/ 1017: +/***/ ((module) => { + +"use strict"; +module.exports = require("path"); + +/***/ }), + +/***/ 4404: +/***/ ((module) => { + +"use strict"; +module.exports = require("tls"); + +/***/ }), + +/***/ 3837: +/***/ ((module) => { + +"use strict"; +module.exports = require("util"); + +/***/ }) + +/******/ }); +/************************************************************************/ +/******/ // The module cache +/******/ var __webpack_module_cache__ = {}; +/******/ +/******/ // The require function +/******/ function __nccwpck_require__(moduleId) { +/******/ // Check if module is in cache +/******/ var cachedModule = __webpack_module_cache__[moduleId]; +/******/ if (cachedModule !== undefined) { +/******/ return cachedModule.exports; +/******/ } +/******/ // Create a new module (and put it into the cache) +/******/ var module = __webpack_module_cache__[moduleId] = { +/******/ // no module.id needed +/******/ // no module.loaded needed +/******/ exports: {} +/******/ }; +/******/ +/******/ // Execute the module function +/******/ var threw = true; +/******/ try { +/******/ __webpack_modules__[moduleId].call(module.exports, module, module.exports, __nccwpck_require__); +/******/ threw = false; +/******/ } finally { +/******/ if(threw) delete __webpack_module_cache__[moduleId]; +/******/ } +/******/ +/******/ // Return the exports of the module +/******/ return module.exports; +/******/ } +/******/ +/************************************************************************/ +/******/ /* webpack/runtime/compat */ +/******/ +/******/ if (typeof __nccwpck_require__ !== 'undefined') __nccwpck_require__.ab = __dirname + "/"; +/******/ +/************************************************************************/ +/******/ +/******/ // startup +/******/ // Load entry module and return exports +/******/ // This entry module is referenced by other modules so it can't be inlined +/******/ var __webpack_exports__ = __nccwpck_require__(7361); +/******/ module.exports = __webpack_exports__; +/******/ +/******/ })() +; diff --git a/.github/actions/javascript/checkDeployBlockers/index.js b/.github/actions/javascript/checkDeployBlockers/index.js index 74cd1509fbfa..1e9626511e5e 100644 --- a/.github/actions/javascript/checkDeployBlockers/index.js +++ b/.github/actions/javascript/checkDeployBlockers/index.js @@ -11704,7 +11704,11 @@ class GithubUtils { if (data.length > 1) { throw new Error(`Found more than one ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); } - return this.getStagingDeployCashData(data[0]); + const issue = data.at(0); + if (!issue) { + throw new Error(`Found an undefined ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); + } + return this.getStagingDeployCashData(issue); }); } /** @@ -11782,7 +11786,7 @@ class GithubUtils { } internalQASection = internalQASection[1]; const internalQAPRs = [...internalQASection.matchAll(new RegExp(`- \\[([ x])]\\s(${CONST_1.default.PULL_REQUEST_REGEX.source})`, 'g'))].map((match) => ({ - url: match[2].split('-')[0].trim(), + url: match[2].split('-').at(0)?.trim() ?? '', number: Number.parseInt(match[3], 10), isResolved: match[1] === 'x', })); @@ -11866,7 +11870,7 @@ class GithubUtils { * Fetch all pull requests given a list of PR numbers. */ static fetchAllPullRequests(pullRequestNumbers) { - const oldestPR = pullRequestNumbers.sort((a, b) => a - b)[0]; + const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, repo: CONST_1.default.APP_REPO, @@ -11940,7 +11944,7 @@ class GithubUtils { repo: CONST_1.default.APP_REPO, workflow_id: workflow, }) - .then((response) => response.data.workflow_runs[0]?.id); + .then((response) => response.data.workflow_runs.at(0)?.id ?? -1); } /** * Generate the URL of an New Expensify pull request given the PR number. @@ -12008,7 +12012,7 @@ class GithubUtils { per_page: 1, name: artifactName, }) - .then((response) => response.data.artifacts[0]); + .then((response) => response.data.artifacts.at(0)); } /** * Given an artifact ID, returns the download URL to a zip file containing the artifact. diff --git a/.github/actions/javascript/createOrUpdateStagingDeploy/createOrUpdateStagingDeploy.ts b/.github/actions/javascript/createOrUpdateStagingDeploy/createOrUpdateStagingDeploy.ts index caff455e9fa5..1964b143146d 100644 --- a/.github/actions/javascript/createOrUpdateStagingDeploy/createOrUpdateStagingDeploy.ts +++ b/.github/actions/javascript/createOrUpdateStagingDeploy/createOrUpdateStagingDeploy.ts @@ -29,15 +29,24 @@ async function run(): Promise { // Look at the state of the most recent StagingDeployCash, // if it is open then we'll update the existing one, otherwise, we'll create a new one. - const mostRecentChecklist = recentDeployChecklists[0]; + const mostRecentChecklist = recentDeployChecklists.at(0); + + if (!mostRecentChecklist) { + throw new Error('Could not find the most recent checklist'); + } + const shouldCreateNewDeployChecklist = mostRecentChecklist.state !== 'open'; - const previousChecklist = shouldCreateNewDeployChecklist ? mostRecentChecklist : recentDeployChecklists[1]; + const previousChecklist = shouldCreateNewDeployChecklist ? mostRecentChecklist : recentDeployChecklists.at(1); if (shouldCreateNewDeployChecklist) { console.log('Latest StagingDeployCash is closed, creating a new one.', mostRecentChecklist); } else { console.log('Latest StagingDeployCash is open, updating it instead of creating a new one.', 'Current:', mostRecentChecklist, 'Previous:', previousChecklist); } + if (!previousChecklist) { + throw new Error('Could not find the previous checklist'); + } + // Parse the data from the previous and current checklists into the format used to generate the checklist const previousChecklistData = GithubUtils.getStagingDeployCashData(previousChecklist); const currentChecklistData: StagingDeployCashData | undefined = shouldCreateNewDeployChecklist ? undefined : GithubUtils.getStagingDeployCashData(mostRecentChecklist); diff --git a/.github/actions/javascript/createOrUpdateStagingDeploy/index.js b/.github/actions/javascript/createOrUpdateStagingDeploy/index.js index d3e249ee6f47..1b7cccb730ff 100644 --- a/.github/actions/javascript/createOrUpdateStagingDeploy/index.js +++ b/.github/actions/javascript/createOrUpdateStagingDeploy/index.js @@ -14229,15 +14229,21 @@ async function run() { }); // Look at the state of the most recent StagingDeployCash, // if it is open then we'll update the existing one, otherwise, we'll create a new one. - const mostRecentChecklist = recentDeployChecklists[0]; + const mostRecentChecklist = recentDeployChecklists.at(0); + if (!mostRecentChecklist) { + throw new Error('Could not find the most recent checklist'); + } const shouldCreateNewDeployChecklist = mostRecentChecklist.state !== 'open'; - const previousChecklist = shouldCreateNewDeployChecklist ? mostRecentChecklist : recentDeployChecklists[1]; + const previousChecklist = shouldCreateNewDeployChecklist ? mostRecentChecklist : recentDeployChecklists.at(1); if (shouldCreateNewDeployChecklist) { console.log('Latest StagingDeployCash is closed, creating a new one.', mostRecentChecklist); } else { console.log('Latest StagingDeployCash is open, updating it instead of creating a new one.', 'Current:', mostRecentChecklist, 'Previous:', previousChecklist); } + if (!previousChecklist) { + throw new Error('Could not find the previous checklist'); + } // Parse the data from the previous and current checklists into the format used to generate the checklist const previousChecklistData = GithubUtils_1.default.getStagingDeployCashData(previousChecklist); const currentChecklistData = shouldCreateNewDeployChecklist ? undefined : GithubUtils_1.default.getStagingDeployCashData(mostRecentChecklist); @@ -14735,7 +14741,11 @@ class GithubUtils { if (data.length > 1) { throw new Error(`Found more than one ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); } - return this.getStagingDeployCashData(data[0]); + const issue = data.at(0); + if (!issue) { + throw new Error(`Found an undefined ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); + } + return this.getStagingDeployCashData(issue); }); } /** @@ -14813,7 +14823,7 @@ class GithubUtils { } internalQASection = internalQASection[1]; const internalQAPRs = [...internalQASection.matchAll(new RegExp(`- \\[([ x])]\\s(${CONST_1.default.PULL_REQUEST_REGEX.source})`, 'g'))].map((match) => ({ - url: match[2].split('-')[0].trim(), + url: match[2].split('-').at(0)?.trim() ?? '', number: Number.parseInt(match[3], 10), isResolved: match[1] === 'x', })); @@ -14897,7 +14907,7 @@ class GithubUtils { * Fetch all pull requests given a list of PR numbers. */ static fetchAllPullRequests(pullRequestNumbers) { - const oldestPR = pullRequestNumbers.sort((a, b) => a - b)[0]; + const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, repo: CONST_1.default.APP_REPO, @@ -14971,7 +14981,7 @@ class GithubUtils { repo: CONST_1.default.APP_REPO, workflow_id: workflow, }) - .then((response) => response.data.workflow_runs[0]?.id); + .then((response) => response.data.workflow_runs.at(0)?.id ?? -1); } /** * Generate the URL of an New Expensify pull request given the PR number. @@ -15039,7 +15049,7 @@ class GithubUtils { per_page: 1, name: artifactName, }) - .then((response) => response.data.artifacts[0]); + .then((response) => response.data.artifacts.at(0)); } /** * Given an artifact ID, returns the download URL to a zip file containing the artifact. diff --git a/.github/actions/javascript/getArtifactInfo/index.js b/.github/actions/javascript/getArtifactInfo/index.js index 82bf90ef6d2b..76cacef0221f 100644 --- a/.github/actions/javascript/getArtifactInfo/index.js +++ b/.github/actions/javascript/getArtifactInfo/index.js @@ -11665,7 +11665,11 @@ class GithubUtils { if (data.length > 1) { throw new Error(`Found more than one ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); } - return this.getStagingDeployCashData(data[0]); + const issue = data.at(0); + if (!issue) { + throw new Error(`Found an undefined ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); + } + return this.getStagingDeployCashData(issue); }); } /** @@ -11743,7 +11747,7 @@ class GithubUtils { } internalQASection = internalQASection[1]; const internalQAPRs = [...internalQASection.matchAll(new RegExp(`- \\[([ x])]\\s(${CONST_1.default.PULL_REQUEST_REGEX.source})`, 'g'))].map((match) => ({ - url: match[2].split('-')[0].trim(), + url: match[2].split('-').at(0)?.trim() ?? '', number: Number.parseInt(match[3], 10), isResolved: match[1] === 'x', })); @@ -11827,7 +11831,7 @@ class GithubUtils { * Fetch all pull requests given a list of PR numbers. */ static fetchAllPullRequests(pullRequestNumbers) { - const oldestPR = pullRequestNumbers.sort((a, b) => a - b)[0]; + const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, repo: CONST_1.default.APP_REPO, @@ -11901,7 +11905,7 @@ class GithubUtils { repo: CONST_1.default.APP_REPO, workflow_id: workflow, }) - .then((response) => response.data.workflow_runs[0]?.id); + .then((response) => response.data.workflow_runs.at(0)?.id ?? -1); } /** * Generate the URL of an New Expensify pull request given the PR number. @@ -11969,7 +11973,7 @@ class GithubUtils { per_page: 1, name: artifactName, }) - .then((response) => response.data.artifacts[0]); + .then((response) => response.data.artifacts.at(0)); } /** * Given an artifact ID, returns the download URL to a zip file containing the artifact. diff --git a/.github/actions/javascript/getDeployPullRequestList/index.js b/.github/actions/javascript/getDeployPullRequestList/index.js index 918d631778d3..cde96b76b6e6 100644 --- a/.github/actions/javascript/getDeployPullRequestList/index.js +++ b/.github/actions/javascript/getDeployPullRequestList/index.js @@ -12027,7 +12027,11 @@ class GithubUtils { if (data.length > 1) { throw new Error(`Found more than one ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); } - return this.getStagingDeployCashData(data[0]); + const issue = data.at(0); + if (!issue) { + throw new Error(`Found an undefined ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); + } + return this.getStagingDeployCashData(issue); }); } /** @@ -12105,7 +12109,7 @@ class GithubUtils { } internalQASection = internalQASection[1]; const internalQAPRs = [...internalQASection.matchAll(new RegExp(`- \\[([ x])]\\s(${CONST_1.default.PULL_REQUEST_REGEX.source})`, 'g'))].map((match) => ({ - url: match[2].split('-')[0].trim(), + url: match[2].split('-').at(0)?.trim() ?? '', number: Number.parseInt(match[3], 10), isResolved: match[1] === 'x', })); @@ -12189,7 +12193,7 @@ class GithubUtils { * Fetch all pull requests given a list of PR numbers. */ static fetchAllPullRequests(pullRequestNumbers) { - const oldestPR = pullRequestNumbers.sort((a, b) => a - b)[0]; + const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, repo: CONST_1.default.APP_REPO, @@ -12263,7 +12267,7 @@ class GithubUtils { repo: CONST_1.default.APP_REPO, workflow_id: workflow, }) - .then((response) => response.data.workflow_runs[0]?.id); + .then((response) => response.data.workflow_runs.at(0)?.id ?? -1); } /** * Generate the URL of an New Expensify pull request given the PR number. @@ -12331,7 +12335,7 @@ class GithubUtils { per_page: 1, name: artifactName, }) - .then((response) => response.data.artifacts[0]); + .then((response) => response.data.artifacts.at(0)); } /** * Given an artifact ID, returns the download URL to a zip file containing the artifact. diff --git a/.github/actions/javascript/getPullRequestDetails/index.js b/.github/actions/javascript/getPullRequestDetails/index.js index 8580842b380c..b1c096ed0be8 100644 --- a/.github/actions/javascript/getPullRequestDetails/index.js +++ b/.github/actions/javascript/getPullRequestDetails/index.js @@ -11767,7 +11767,11 @@ class GithubUtils { if (data.length > 1) { throw new Error(`Found more than one ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); } - return this.getStagingDeployCashData(data[0]); + const issue = data.at(0); + if (!issue) { + throw new Error(`Found an undefined ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); + } + return this.getStagingDeployCashData(issue); }); } /** @@ -11845,7 +11849,7 @@ class GithubUtils { } internalQASection = internalQASection[1]; const internalQAPRs = [...internalQASection.matchAll(new RegExp(`- \\[([ x])]\\s(${CONST_1.default.PULL_REQUEST_REGEX.source})`, 'g'))].map((match) => ({ - url: match[2].split('-')[0].trim(), + url: match[2].split('-').at(0)?.trim() ?? '', number: Number.parseInt(match[3], 10), isResolved: match[1] === 'x', })); @@ -11929,7 +11933,7 @@ class GithubUtils { * Fetch all pull requests given a list of PR numbers. */ static fetchAllPullRequests(pullRequestNumbers) { - const oldestPR = pullRequestNumbers.sort((a, b) => a - b)[0]; + const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, repo: CONST_1.default.APP_REPO, @@ -12003,7 +12007,7 @@ class GithubUtils { repo: CONST_1.default.APP_REPO, workflow_id: workflow, }) - .then((response) => response.data.workflow_runs[0]?.id); + .then((response) => response.data.workflow_runs.at(0)?.id ?? -1); } /** * Generate the URL of an New Expensify pull request given the PR number. @@ -12071,7 +12075,7 @@ class GithubUtils { per_page: 1, name: artifactName, }) - .then((response) => response.data.artifacts[0]); + .then((response) => response.data.artifacts.at(0)); } /** * Given an artifact ID, returns the download URL to a zip file containing the artifact. diff --git a/.github/actions/javascript/isStagingDeployLocked/index.js b/.github/actions/javascript/isStagingDeployLocked/index.js index 9e823e8da5ae..d7196cad32f7 100644 --- a/.github/actions/javascript/isStagingDeployLocked/index.js +++ b/.github/actions/javascript/isStagingDeployLocked/index.js @@ -11665,7 +11665,11 @@ class GithubUtils { if (data.length > 1) { throw new Error(`Found more than one ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); } - return this.getStagingDeployCashData(data[0]); + const issue = data.at(0); + if (!issue) { + throw new Error(`Found an undefined ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); + } + return this.getStagingDeployCashData(issue); }); } /** @@ -11743,7 +11747,7 @@ class GithubUtils { } internalQASection = internalQASection[1]; const internalQAPRs = [...internalQASection.matchAll(new RegExp(`- \\[([ x])]\\s(${CONST_1.default.PULL_REQUEST_REGEX.source})`, 'g'))].map((match) => ({ - url: match[2].split('-')[0].trim(), + url: match[2].split('-').at(0)?.trim() ?? '', number: Number.parseInt(match[3], 10), isResolved: match[1] === 'x', })); @@ -11827,7 +11831,7 @@ class GithubUtils { * Fetch all pull requests given a list of PR numbers. */ static fetchAllPullRequests(pullRequestNumbers) { - const oldestPR = pullRequestNumbers.sort((a, b) => a - b)[0]; + const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, repo: CONST_1.default.APP_REPO, @@ -11901,7 +11905,7 @@ class GithubUtils { repo: CONST_1.default.APP_REPO, workflow_id: workflow, }) - .then((response) => response.data.workflow_runs[0]?.id); + .then((response) => response.data.workflow_runs.at(0)?.id ?? -1); } /** * Generate the URL of an New Expensify pull request given the PR number. @@ -11969,7 +11973,7 @@ class GithubUtils { per_page: 1, name: artifactName, }) - .then((response) => response.data.artifacts[0]); + .then((response) => response.data.artifacts.at(0)); } /** * Given an artifact ID, returns the download URL to a zip file containing the artifact. diff --git a/.github/actions/javascript/markPullRequestsAsDeployed/index.js b/.github/actions/javascript/markPullRequestsAsDeployed/index.js index 9f97e4a72d20..6b78a204b57a 100644 --- a/.github/actions/javascript/markPullRequestsAsDeployed/index.js +++ b/.github/actions/javascript/markPullRequestsAsDeployed/index.js @@ -12733,7 +12733,10 @@ async function run() { labels: CONST_1.default.LABELS.STAGING_DEPLOY, state: 'closed', }); - const previousChecklistID = deployChecklists[0].number; + const previousChecklistID = deployChecklists.at(0)?.number; + if (!previousChecklistID) { + throw new Error('Could not find the previous checklist ID'); + } // who closed the last deploy checklist? const deployer = await GithubUtils_1.default.getActorWhoClosedIssue(previousChecklistID); // Create comment on each pull request (one at a time to avoid throttling issues) @@ -13049,7 +13052,11 @@ class GithubUtils { if (data.length > 1) { throw new Error(`Found more than one ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); } - return this.getStagingDeployCashData(data[0]); + const issue = data.at(0); + if (!issue) { + throw new Error(`Found an undefined ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); + } + return this.getStagingDeployCashData(issue); }); } /** @@ -13127,7 +13134,7 @@ class GithubUtils { } internalQASection = internalQASection[1]; const internalQAPRs = [...internalQASection.matchAll(new RegExp(`- \\[([ x])]\\s(${CONST_1.default.PULL_REQUEST_REGEX.source})`, 'g'))].map((match) => ({ - url: match[2].split('-')[0].trim(), + url: match[2].split('-').at(0)?.trim() ?? '', number: Number.parseInt(match[3], 10), isResolved: match[1] === 'x', })); @@ -13211,7 +13218,7 @@ class GithubUtils { * Fetch all pull requests given a list of PR numbers. */ static fetchAllPullRequests(pullRequestNumbers) { - const oldestPR = pullRequestNumbers.sort((a, b) => a - b)[0]; + const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, repo: CONST_1.default.APP_REPO, @@ -13285,7 +13292,7 @@ class GithubUtils { repo: CONST_1.default.APP_REPO, workflow_id: workflow, }) - .then((response) => response.data.workflow_runs[0]?.id); + .then((response) => response.data.workflow_runs.at(0)?.id ?? -1); } /** * Generate the URL of an New Expensify pull request given the PR number. @@ -13353,7 +13360,7 @@ class GithubUtils { per_page: 1, name: artifactName, }) - .then((response) => response.data.artifacts[0]); + .then((response) => response.data.artifacts.at(0)); } /** * Given an artifact ID, returns the download URL to a zip file containing the artifact. diff --git a/.github/actions/javascript/markPullRequestsAsDeployed/markPullRequestsAsDeployed.ts b/.github/actions/javascript/markPullRequestsAsDeployed/markPullRequestsAsDeployed.ts index 71a5c7d5c6ee..986c6c12afb9 100644 --- a/.github/actions/javascript/markPullRequestsAsDeployed/markPullRequestsAsDeployed.ts +++ b/.github/actions/javascript/markPullRequestsAsDeployed/markPullRequestsAsDeployed.ts @@ -78,7 +78,10 @@ async function run() { labels: CONST.LABELS.STAGING_DEPLOY, state: 'closed', }); - const previousChecklistID = deployChecklists[0].number; + const previousChecklistID = deployChecklists.at(0)?.number; + if (!previousChecklistID) { + throw new Error('Could not find the previous checklist ID'); + } // who closed the last deploy checklist? const deployer = await GithubUtils.getActorWhoClosedIssue(previousChecklistID); diff --git a/.github/actions/javascript/postTestBuildComment/index.js b/.github/actions/javascript/postTestBuildComment/index.js index 4f62879a4419..265d62c4b321 100644 --- a/.github/actions/javascript/postTestBuildComment/index.js +++ b/.github/actions/javascript/postTestBuildComment/index.js @@ -11764,7 +11764,11 @@ class GithubUtils { if (data.length > 1) { throw new Error(`Found more than one ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); } - return this.getStagingDeployCashData(data[0]); + const issue = data.at(0); + if (!issue) { + throw new Error(`Found an undefined ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); + } + return this.getStagingDeployCashData(issue); }); } /** @@ -11842,7 +11846,7 @@ class GithubUtils { } internalQASection = internalQASection[1]; const internalQAPRs = [...internalQASection.matchAll(new RegExp(`- \\[([ x])]\\s(${CONST_1.default.PULL_REQUEST_REGEX.source})`, 'g'))].map((match) => ({ - url: match[2].split('-')[0].trim(), + url: match[2].split('-').at(0)?.trim() ?? '', number: Number.parseInt(match[3], 10), isResolved: match[1] === 'x', })); @@ -11926,7 +11930,7 @@ class GithubUtils { * Fetch all pull requests given a list of PR numbers. */ static fetchAllPullRequests(pullRequestNumbers) { - const oldestPR = pullRequestNumbers.sort((a, b) => a - b)[0]; + const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, repo: CONST_1.default.APP_REPO, @@ -12000,7 +12004,7 @@ class GithubUtils { repo: CONST_1.default.APP_REPO, workflow_id: workflow, }) - .then((response) => response.data.workflow_runs[0]?.id); + .then((response) => response.data.workflow_runs.at(0)?.id ?? -1); } /** * Generate the URL of an New Expensify pull request given the PR number. @@ -12068,7 +12072,7 @@ class GithubUtils { per_page: 1, name: artifactName, }) - .then((response) => response.data.artifacts[0]); + .then((response) => response.data.artifacts.at(0)); } /** * Given an artifact ID, returns the download URL to a zip file containing the artifact. diff --git a/.github/actions/javascript/proposalPoliceComment/index.js b/.github/actions/javascript/proposalPoliceComment/index.js index 9b5b56f11a11..a3c613a7018c 100644 --- a/.github/actions/javascript/proposalPoliceComment/index.js +++ b/.github/actions/javascript/proposalPoliceComment/index.js @@ -18026,7 +18026,7 @@ async function run() { if (assistantResponse.includes(`[${CONST_1.default.NO_ACTION}]`)) { // extract the text after [NO_ACTION] from assistantResponse since this is a // bot related action keyword - const noActionContext = assistantResponse.split(`[${CONST_1.default.NO_ACTION}] `)?.[1]?.replace('"', ''); + const noActionContext = assistantResponse.split(`[${CONST_1.default.NO_ACTION}] `)?.at(1)?.replace('"', ''); console.log('[NO_ACTION] w/ context: ', noActionContext); return; } @@ -18047,10 +18047,10 @@ async function run() { else if (assistantResponse.includes('[EDIT_COMMENT]') && !payload.comment?.body.includes('Edited by **proposal-police**')) { // extract the text after [EDIT_COMMENT] from assistantResponse since this is a // bot related action keyword - let extractedNotice = assistantResponse.split('[EDIT_COMMENT] ')?.[1]?.replace('"', ''); + let extractedNotice = assistantResponse.split('[EDIT_COMMENT] ').at(1)?.replace('"', ''); // format the date like: 2024-01-24 13:15:24 UTC not 2024-01-28 18:18:28.000 UTC - const formattedDate = `${date.toISOString()?.split('.')?.[0]?.replace('T', ' ')} UTC`; - extractedNotice = extractedNotice.replace('{updated_timestamp}', formattedDate); + const formattedDate = `${date.toISOString()?.split('.').at(0)?.replace('T', ' ')} UTC`; + extractedNotice = extractedNotice?.replace('{updated_timestamp}', formattedDate); console.log('ProposalPolice™ editing issue comment...', payload.comment.id); await GithubUtils_1.default.octokit.issues.updateComment({ ...github_1.context.repo, @@ -18253,7 +18253,11 @@ class GithubUtils { if (data.length > 1) { throw new Error(`Found more than one ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); } - return this.getStagingDeployCashData(data[0]); + const issue = data.at(0); + if (!issue) { + throw new Error(`Found an undefined ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); + } + return this.getStagingDeployCashData(issue); }); } /** @@ -18331,7 +18335,7 @@ class GithubUtils { } internalQASection = internalQASection[1]; const internalQAPRs = [...internalQASection.matchAll(new RegExp(`- \\[([ x])]\\s(${CONST_1.default.PULL_REQUEST_REGEX.source})`, 'g'))].map((match) => ({ - url: match[2].split('-')[0].trim(), + url: match[2].split('-').at(0)?.trim() ?? '', number: Number.parseInt(match[3], 10), isResolved: match[1] === 'x', })); @@ -18415,7 +18419,7 @@ class GithubUtils { * Fetch all pull requests given a list of PR numbers. */ static fetchAllPullRequests(pullRequestNumbers) { - const oldestPR = pullRequestNumbers.sort((a, b) => a - b)[0]; + const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, repo: CONST_1.default.APP_REPO, @@ -18489,7 +18493,7 @@ class GithubUtils { repo: CONST_1.default.APP_REPO, workflow_id: workflow, }) - .then((response) => response.data.workflow_runs[0]?.id); + .then((response) => response.data.workflow_runs.at(0)?.id ?? -1); } /** * Generate the URL of an New Expensify pull request given the PR number. @@ -18557,7 +18561,7 @@ class GithubUtils { per_page: 1, name: artifactName, }) - .then((response) => response.data.artifacts[0]); + .then((response) => response.data.artifacts.at(0)); } /** * Given an artifact ID, returns the download URL to a zip file containing the artifact. diff --git a/.github/actions/javascript/proposalPoliceComment/proposalPoliceComment.ts b/.github/actions/javascript/proposalPoliceComment/proposalPoliceComment.ts index 19d3037a80a5..53aeb44cd311 100644 --- a/.github/actions/javascript/proposalPoliceComment/proposalPoliceComment.ts +++ b/.github/actions/javascript/proposalPoliceComment/proposalPoliceComment.ts @@ -64,7 +64,7 @@ async function run() { if (assistantResponse.includes(`[${CONST.NO_ACTION}]`)) { // extract the text after [NO_ACTION] from assistantResponse since this is a // bot related action keyword - const noActionContext = assistantResponse.split(`[${CONST.NO_ACTION}] `)?.[1]?.replace('"', ''); + const noActionContext = assistantResponse.split(`[${CONST.NO_ACTION}] `)?.at(1)?.replace('"', ''); console.log('[NO_ACTION] w/ context: ', noActionContext); return; } @@ -88,10 +88,10 @@ async function run() { } else if (assistantResponse.includes('[EDIT_COMMENT]') && !payload.comment?.body.includes('Edited by **proposal-police**')) { // extract the text after [EDIT_COMMENT] from assistantResponse since this is a // bot related action keyword - let extractedNotice = assistantResponse.split('[EDIT_COMMENT] ')?.[1]?.replace('"', ''); + let extractedNotice = assistantResponse.split('[EDIT_COMMENT] ').at(1)?.replace('"', ''); // format the date like: 2024-01-24 13:15:24 UTC not 2024-01-28 18:18:28.000 UTC - const formattedDate = `${date.toISOString()?.split('.')?.[0]?.replace('T', ' ')} UTC`; - extractedNotice = extractedNotice.replace('{updated_timestamp}', formattedDate); + const formattedDate = `${date.toISOString()?.split('.').at(0)?.replace('T', ' ')} UTC`; + extractedNotice = extractedNotice?.replace('{updated_timestamp}', formattedDate); console.log('ProposalPolice™ editing issue comment...', payload.comment.id); await GithubUtils.octokit.issues.updateComment({ ...context.repo, diff --git a/.github/actions/javascript/reopenIssueWithComment/index.js b/.github/actions/javascript/reopenIssueWithComment/index.js index 83131f363ef8..9c97e3c612a9 100644 --- a/.github/actions/javascript/reopenIssueWithComment/index.js +++ b/.github/actions/javascript/reopenIssueWithComment/index.js @@ -11675,7 +11675,11 @@ class GithubUtils { if (data.length > 1) { throw new Error(`Found more than one ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); } - return this.getStagingDeployCashData(data[0]); + const issue = data.at(0); + if (!issue) { + throw new Error(`Found an undefined ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); + } + return this.getStagingDeployCashData(issue); }); } /** @@ -11753,7 +11757,7 @@ class GithubUtils { } internalQASection = internalQASection[1]; const internalQAPRs = [...internalQASection.matchAll(new RegExp(`- \\[([ x])]\\s(${CONST_1.default.PULL_REQUEST_REGEX.source})`, 'g'))].map((match) => ({ - url: match[2].split('-')[0].trim(), + url: match[2].split('-').at(0)?.trim() ?? '', number: Number.parseInt(match[3], 10), isResolved: match[1] === 'x', })); @@ -11837,7 +11841,7 @@ class GithubUtils { * Fetch all pull requests given a list of PR numbers. */ static fetchAllPullRequests(pullRequestNumbers) { - const oldestPR = pullRequestNumbers.sort((a, b) => a - b)[0]; + const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, repo: CONST_1.default.APP_REPO, @@ -11911,7 +11915,7 @@ class GithubUtils { repo: CONST_1.default.APP_REPO, workflow_id: workflow, }) - .then((response) => response.data.workflow_runs[0]?.id); + .then((response) => response.data.workflow_runs.at(0)?.id ?? -1); } /** * Generate the URL of an New Expensify pull request given the PR number. @@ -11979,7 +11983,7 @@ class GithubUtils { per_page: 1, name: artifactName, }) - .then((response) => response.data.artifacts[0]); + .then((response) => response.data.artifacts.at(0)); } /** * Given an artifact ID, returns the download URL to a zip file containing the artifact. diff --git a/.github/actions/javascript/reviewerChecklist/index.js b/.github/actions/javascript/reviewerChecklist/index.js index 2a0977db8016..93a3ccf1a0f3 100644 --- a/.github/actions/javascript/reviewerChecklist/index.js +++ b/.github/actions/javascript/reviewerChecklist/index.js @@ -11549,14 +11549,14 @@ function checkIssueForCompletedChecklist(numberOfChecklistItems) { break; } const whitespace = /([\n\r])/gm; - const comment = combinedComments[i].replace(whitespace, ''); - console.log(`Comment ${i} starts with: ${comment.slice(0, 20)}...`); + const comment = combinedComments.at(i)?.replace(whitespace, ''); + console.log(`Comment ${i} starts with: ${comment?.slice(0, 20)}...`); // Found the reviewer checklist, so count how many completed checklist items there are - if (comment.indexOf(reviewerChecklistContains) !== -1) { + if (comment?.indexOf(reviewerChecklistContains) !== -1) { console.log('Found the reviewer checklist!'); foundReviewerChecklist = true; - numberOfFinishedChecklistItems = (comment.match(/- \[x\]/gi) ?? []).length; - numberOfUnfinishedChecklistItems = (comment.match(/- \[ \]/g) ?? []).length; + numberOfFinishedChecklistItems = (comment?.match(/- \[x\]/gi) ?? []).length; + numberOfUnfinishedChecklistItems = (comment?.match(/- \[ \]/g) ?? []).length; } } if (!foundReviewerChecklist) { @@ -11767,7 +11767,11 @@ class GithubUtils { if (data.length > 1) { throw new Error(`Found more than one ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); } - return this.getStagingDeployCashData(data[0]); + const issue = data.at(0); + if (!issue) { + throw new Error(`Found an undefined ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); + } + return this.getStagingDeployCashData(issue); }); } /** @@ -11845,7 +11849,7 @@ class GithubUtils { } internalQASection = internalQASection[1]; const internalQAPRs = [...internalQASection.matchAll(new RegExp(`- \\[([ x])]\\s(${CONST_1.default.PULL_REQUEST_REGEX.source})`, 'g'))].map((match) => ({ - url: match[2].split('-')[0].trim(), + url: match[2].split('-').at(0)?.trim() ?? '', number: Number.parseInt(match[3], 10), isResolved: match[1] === 'x', })); @@ -11929,7 +11933,7 @@ class GithubUtils { * Fetch all pull requests given a list of PR numbers. */ static fetchAllPullRequests(pullRequestNumbers) { - const oldestPR = pullRequestNumbers.sort((a, b) => a - b)[0]; + const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, repo: CONST_1.default.APP_REPO, @@ -12003,7 +12007,7 @@ class GithubUtils { repo: CONST_1.default.APP_REPO, workflow_id: workflow, }) - .then((response) => response.data.workflow_runs[0]?.id); + .then((response) => response.data.workflow_runs.at(0)?.id ?? -1); } /** * Generate the URL of an New Expensify pull request given the PR number. @@ -12071,7 +12075,7 @@ class GithubUtils { per_page: 1, name: artifactName, }) - .then((response) => response.data.artifacts[0]); + .then((response) => response.data.artifacts.at(0)); } /** * Given an artifact ID, returns the download URL to a zip file containing the artifact. diff --git a/.github/actions/javascript/reviewerChecklist/reviewerChecklist.ts b/.github/actions/javascript/reviewerChecklist/reviewerChecklist.ts index f57ef6c36a04..2d2f3978fa1d 100644 --- a/.github/actions/javascript/reviewerChecklist/reviewerChecklist.ts +++ b/.github/actions/javascript/reviewerChecklist/reviewerChecklist.ts @@ -55,16 +55,16 @@ function checkIssueForCompletedChecklist(numberOfChecklistItems: number) { } const whitespace = /([\n\r])/gm; - const comment = combinedComments[i].replace(whitespace, ''); + const comment = combinedComments.at(i)?.replace(whitespace, ''); - console.log(`Comment ${i} starts with: ${comment.slice(0, 20)}...`); + console.log(`Comment ${i} starts with: ${comment?.slice(0, 20)}...`); // Found the reviewer checklist, so count how many completed checklist items there are - if (comment.indexOf(reviewerChecklistContains) !== -1) { + if (comment?.indexOf(reviewerChecklistContains) !== -1) { console.log('Found the reviewer checklist!'); foundReviewerChecklist = true; - numberOfFinishedChecklistItems = (comment.match(/- \[x\]/gi) ?? []).length; - numberOfUnfinishedChecklistItems = (comment.match(/- \[ \]/g) ?? []).length; + numberOfFinishedChecklistItems = (comment?.match(/- \[x\]/gi) ?? []).length; + numberOfUnfinishedChecklistItems = (comment?.match(/- \[ \]/g) ?? []).length; } } diff --git a/.github/actions/javascript/validateReassureOutput/index.js b/.github/actions/javascript/validateReassureOutput/index.js index 99881e8ad9db..9eff1ee3101e 100644 --- a/.github/actions/javascript/validateReassureOutput/index.js +++ b/.github/actions/javascript/validateReassureOutput/index.js @@ -2735,7 +2735,10 @@ const run = () => { } console.log(`Processing ${regressionOutput.countChanged.length} measurements...`); for (let i = 0; i < regressionOutput.countChanged.length; i++) { - const measurement = regressionOutput.countChanged[i]; + const measurement = regressionOutput.countChanged.at(i); + if (!measurement) { + continue; + } const baseline = measurement.baseline; const current = measurement.current; console.log(`Processing measurement ${i + 1}: ${measurement.name}`); diff --git a/.github/actions/javascript/validateReassureOutput/validateReassureOutput.ts b/.github/actions/javascript/validateReassureOutput/validateReassureOutput.ts index 7e5cfd0bd9f9..24901ca55aee 100644 --- a/.github/actions/javascript/validateReassureOutput/validateReassureOutput.ts +++ b/.github/actions/javascript/validateReassureOutput/validateReassureOutput.ts @@ -15,7 +15,12 @@ const run = (): boolean => { console.log(`Processing ${regressionOutput.countChanged.length} measurements...`); for (let i = 0; i < regressionOutput.countChanged.length; i++) { - const measurement = regressionOutput.countChanged[i]; + const measurement = regressionOutput.countChanged.at(i); + + if (!measurement) { + continue; + } + const baseline: MeasureEntry = measurement.baseline; const current: MeasureEntry = measurement.current; diff --git a/.github/actions/javascript/verifySignedCommits/index.js b/.github/actions/javascript/verifySignedCommits/index.js index 49a4341b84af..8920086eea46 100644 --- a/.github/actions/javascript/verifySignedCommits/index.js +++ b/.github/actions/javascript/verifySignedCommits/index.js @@ -11707,7 +11707,11 @@ class GithubUtils { if (data.length > 1) { throw new Error(`Found more than one ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); } - return this.getStagingDeployCashData(data[0]); + const issue = data.at(0); + if (!issue) { + throw new Error(`Found an undefined ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); + } + return this.getStagingDeployCashData(issue); }); } /** @@ -11785,7 +11789,7 @@ class GithubUtils { } internalQASection = internalQASection[1]; const internalQAPRs = [...internalQASection.matchAll(new RegExp(`- \\[([ x])]\\s(${CONST_1.default.PULL_REQUEST_REGEX.source})`, 'g'))].map((match) => ({ - url: match[2].split('-')[0].trim(), + url: match[2].split('-').at(0)?.trim() ?? '', number: Number.parseInt(match[3], 10), isResolved: match[1] === 'x', })); @@ -11869,7 +11873,7 @@ class GithubUtils { * Fetch all pull requests given a list of PR numbers. */ static fetchAllPullRequests(pullRequestNumbers) { - const oldestPR = pullRequestNumbers.sort((a, b) => a - b)[0]; + const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, repo: CONST_1.default.APP_REPO, @@ -11943,7 +11947,7 @@ class GithubUtils { repo: CONST_1.default.APP_REPO, workflow_id: workflow, }) - .then((response) => response.data.workflow_runs[0]?.id); + .then((response) => response.data.workflow_runs.at(0)?.id ?? -1); } /** * Generate the URL of an New Expensify pull request given the PR number. @@ -12011,7 +12015,7 @@ class GithubUtils { per_page: 1, name: artifactName, }) - .then((response) => response.data.artifacts[0]); + .then((response) => response.data.artifacts.at(0)); } /** * Given an artifact ID, returns the download URL to a zip file containing the artifact. diff --git a/.github/libs/GithubUtils.ts b/.github/libs/GithubUtils.ts index 36363684a351..ae74621b356a 100644 --- a/.github/libs/GithubUtils.ts +++ b/.github/libs/GithubUtils.ts @@ -168,7 +168,13 @@ class GithubUtils { throw new Error(`Found more than one ${CONST.LABELS.STAGING_DEPLOY} issue.`); } - return this.getStagingDeployCashData(data[0]); + const issue = data.at(0); + + if (!issue) { + throw new Error(`Found an undefined ${CONST.LABELS.STAGING_DEPLOY} issue.`); + } + + return this.getStagingDeployCashData(issue); }); } @@ -254,7 +260,7 @@ class GithubUtils { } internalQASection = internalQASection[1]; const internalQAPRs = [...internalQASection.matchAll(new RegExp(`- \\[([ x])]\\s(${CONST.PULL_REQUEST_REGEX.source})`, 'g'))].map((match) => ({ - url: match[2].split('-')[0].trim(), + url: match[2].split('-').at(0)?.trim() ?? '', number: Number.parseInt(match[3], 10), isResolved: match[1] === 'x', })); @@ -367,7 +373,7 @@ class GithubUtils { * Fetch all pull requests given a list of PR numbers. */ static fetchAllPullRequests(pullRequestNumbers: number[]): Promise { - const oldestPR = pullRequestNumbers.sort((a, b) => a - b)[0]; + const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate( this.octokit.pulls.list, { @@ -459,7 +465,7 @@ class GithubUtils { repo: CONST.APP_REPO, workflow_id: workflow, }) - .then((response) => response.data.workflow_runs[0]?.id); + .then((response) => response.data.workflow_runs.at(0)?.id ?? -1); } /** @@ -533,7 +539,7 @@ class GithubUtils { per_page: 1, name: artifactName, }) - .then((response) => response.data.artifacts[0]); + .then((response) => response.data.artifacts.at(0)); } /** diff --git a/.github/libs/nativeVersionUpdater.ts b/.github/libs/nativeVersionUpdater.ts index 4ecf9d64966c..1684614b059e 100644 --- a/.github/libs/nativeVersionUpdater.ts +++ b/.github/libs/nativeVersionUpdater.ts @@ -59,7 +59,7 @@ function updateAndroidVersion(versionName: string, versionCode: string): Promise * Updates the CFBundleShortVersionString and the CFBundleVersion. */ function updateiOSVersion(version: string): string { - const shortVersion = version.split('-')[0]; + const shortVersion = version.split('-').at(0); const cfVersion = version.includes('-') ? version.replace('-', '.') : `${version}.0`; console.log('Updating iOS', `CFBundleShortVersionString: ${shortVersion}`, `CFBundleVersion: ${cfVersion}`); diff --git a/.github/scripts/createDocsRoutes.ts b/.github/scripts/createDocsRoutes.ts index a8ac3d511ff9..264422e27b99 100644 --- a/.github/scripts/createDocsRoutes.ts +++ b/.github/scripts/createDocsRoutes.ts @@ -93,7 +93,10 @@ function pushOrCreateEntry(hubs: Hub[], hub: string, } function getOrderFromArticleFrontMatter(path: string): number | undefined { - const frontmatter = fs.readFileSync(path, 'utf8').split('---')[1]; + const frontmatter = fs.readFileSync(path, 'utf8').split('---').at(1); + if (!frontmatter) { + return; + } const frontmatterObject = yaml.load(frontmatter) as Record; return frontmatterObject.order as number | undefined; } diff --git a/.storybook/webpack.config.ts b/.storybook/webpack.config.ts index f0fff8bda698..3f54b7040d06 100644 --- a/.storybook/webpack.config.ts +++ b/.storybook/webpack.config.ts @@ -67,8 +67,8 @@ const webpackConfig = ({config}: {config: Configuration}) => { // Necessary to overwrite the values in the existing DefinePlugin hardcoded to the Config staging values const definePluginIndex = config.plugins.findIndex((plugin) => plugin instanceof DefinePlugin); - if (definePluginIndex !== -1 && config.plugins[definePluginIndex] instanceof DefinePlugin) { - const definePlugin = config.plugins[definePluginIndex] as DefinePlugin; + if (definePluginIndex !== -1 && config.plugins.at(definePluginIndex) instanceof DefinePlugin) { + const definePlugin = config.plugins.at(definePluginIndex) as DefinePlugin; if (definePlugin.definitions) { definePlugin.definitions.__REACT_WEB_CONFIG__ = JSON.stringify(env); } @@ -76,7 +76,7 @@ const webpackConfig = ({config}: {config: Configuration}) => { config.resolve.extensions = custom.resolve.extensions; const babelRulesIndex = custom.module.rules.findIndex((rule) => rule.loader === 'babel-loader'); - const babelRule = custom.module.rules[babelRulesIndex]; + const babelRule = custom.module.rules.at(babelRulesIndex); if (babelRule) { config.module.rules?.push(babelRule); } diff --git a/assets/emojis/index.ts b/assets/emojis/index.ts index d230e8eec2be..4b05c8caddfa 100644 --- a/assets/emojis/index.ts +++ b/assets/emojis/index.ts @@ -33,7 +33,7 @@ const localeEmojis: LocaleEmojis = { }; const importEmojiLocale = (locale: Locale) => { - const normalizedLocale = locale.toLowerCase().split('-')[0] as Locale; + const normalizedLocale = locale.toLowerCase().split('-').at(0) as Locale; if (!localeEmojis[normalizedLocale]) { const emojiImportPromise = normalizedLocale === 'en' ? import('./en') : import('./es'); return emojiImportPromise.then((esEmojiModule) => { diff --git a/config/webpack/webpack.common.ts b/config/webpack/webpack.common.ts index 1bab57905d0e..91fc4b1bf528 100644 --- a/config/webpack/webpack.common.ts +++ b/config/webpack/webpack.common.ts @@ -47,7 +47,7 @@ const environmentToLogoSuffixMap: Record = { }; function mapEnvironmentToLogoSuffix(environmentFile: string): string { - let environment = environmentFile.split('.')[2]; + let environment = environmentFile.split('.').at(2); if (typeof environment === 'undefined') { environment = 'dev'; } diff --git a/jest/setup.ts b/jest/setup.ts index 51385ad19e45..6901ad3c66f3 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -35,7 +35,7 @@ jest.mock('react-native/Libraries/LogBox/LogBox', () => ({ // Turn off the console logs for timing events. They are not relevant for unit tests and create a lot of noise jest.spyOn(console, 'debug').mockImplementation((...params: string[]) => { - if (params[0].startsWith('Timing:')) { + if (params.at(0)?.startsWith('Timing:')) { return; } diff --git a/package-lock.json b/package-lock.json index a5ae221a1e14..f2063482c6ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -216,7 +216,7 @@ "electron-builder": "25.0.0", "eslint": "^8.57.0", "eslint-config-airbnb-typescript": "^18.0.0", - "eslint-config-expensify": "^2.0.58", + "eslint-config-expensify": "^2.0.59", "eslint-config-prettier": "^9.1.0", "eslint-plugin-deprecation": "^3.0.0", "eslint-plugin-jest": "^28.6.0", @@ -22786,9 +22786,9 @@ } }, "node_modules/eslint-config-expensify": { - "version": "2.0.58", - "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-2.0.58.tgz", - "integrity": "sha512-iLDJeXwMYLcBRDnInVReHWjMUsNrHMnWfyoQbvuDTChcJANc+QzuDU0gdsDpBx2xjxVF0vckwEXnzmWcUW1Bpw==", + "version": "2.0.59", + "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-2.0.59.tgz", + "integrity": "sha512-U6ZpXHHtGvTusDLFuVBWJ3r8HwngoE3Gc+BRGEGzBmDA1UkWKLxwshjafq1JQJ+HHwmvN5jwsSnZjNQRkkmbUw==", "dev": true, "dependencies": { "@lwc/eslint-plugin-lwc": "^1.7.2", diff --git a/package.json b/package.json index 3d808af65ba2..1ef7deac8d46 100644 --- a/package.json +++ b/package.json @@ -278,7 +278,7 @@ "electron-builder": "25.0.0", "eslint": "^8.57.0", "eslint-config-airbnb-typescript": "^18.0.0", - "eslint-config-expensify": "^2.0.58", + "eslint-config-expensify": "^2.0.59", "eslint-config-prettier": "^9.1.0", "eslint-plugin-deprecation": "^3.0.0", "eslint-plugin-jest": "^28.6.0", diff --git a/scripts/release-profile.ts b/scripts/release-profile.ts index a83fb55fa5ff..615f009d743d 100755 --- a/scripts/release-profile.ts +++ b/scripts/release-profile.ts @@ -36,7 +36,7 @@ if (cpuProfiles.length === 0) { process.exit(1); } else { // Construct the command - const cpuprofileName = cpuProfiles[0]; + const cpuprofileName = cpuProfiles.at(0); const command = `npx react-native-release-profiler --local "${cpuprofileName}" --sourcemap-path "${sourcemapPath}"`; console.log(`Executing: ${command}`); diff --git a/src/CONST.ts b/src/CONST.ts index 9254044ee99e..a55b21856f4a 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -1263,7 +1263,6 @@ const CONST = { PUSHER: { PRIVATE_USER_CHANNEL_PREFIX: 'private-encrypted-user-accountID-', PRIVATE_REPORT_CHANNEL_PREFIX: 'private-report-reportID-', - PRESENCE_ACTIVE_GUIDES: 'presence-activeGuides', }, EMOJI_SPACER: 'SPACER', @@ -4197,7 +4196,7 @@ const CONST = { PADDING: 32, DEFAULT_ZOOM: 15, SINGLE_MARKER_ZOOM: 15, - DEFAULT_COORDINATE: [-122.4021, 37.7911], + DEFAULT_COORDINATE: [-122.4021, 37.7911] as [number, number], STYLE_URL: 'mapbox://styles/expensify/cllcoiqds00cs01r80kp34tmq', ANIMATION_DURATION_ON_CENTER_ME: 1000, CENTER_BUTTON_FADE_DURATION: 300, diff --git a/src/components/AccountSwitcher.tsx b/src/components/AccountSwitcher.tsx index 71970b88eac9..538009b2565e 100644 --- a/src/components/AccountSwitcher.tsx +++ b/src/components/AccountSwitcher.tsx @@ -50,7 +50,7 @@ function AccountSwitcher() { const canSwitchAccounts = canUseNewDotCopilot && (delegators.length > 0 || isActingAsDelegate); const createBaseMenuItem = (personalDetails: PersonalDetails | undefined, errors?: Errors, additionalProps: MenuItemWithLink = {}): MenuItemWithLink => { - const error = Object.values(errors ?? {})[0] ?? ''; + const error = Object.values(errors ?? {}).at(0) ?? ''; return { title: personalDetails?.displayName ?? personalDetails?.login, description: Str.removeSMSDomain(personalDetails?.login ?? ''), diff --git a/src/components/ActiveGuidesEventListener.tsx b/src/components/ActiveGuidesEventListener.tsx index 2599576c9c23..e69de29bb2d1 100644 --- a/src/components/ActiveGuidesEventListener.tsx +++ b/src/components/ActiveGuidesEventListener.tsx @@ -1,21 +0,0 @@ -import {useEffect, useRef} from 'react'; -import {useOnyx} from 'react-native-onyx'; -import {subscribeToActiveGuides} from '@userActions/User'; -import ONYXKEYS from '@src/ONYXKEYS'; - -function ActiveGuidesEventListener() { - const [user] = useOnyx(ONYXKEYS.USER); - const didSubscribeToActiveGuides = useRef(false); - useEffect(() => { - if (didSubscribeToActiveGuides.current) { - return; - } - if (user?.isGuide) { - didSubscribeToActiveGuides.current = true; - subscribeToActiveGuides(); - } - }, [user]); - return null; -} - -export default ActiveGuidesEventListener; diff --git a/src/components/AddPlaidBankAccount.tsx b/src/components/AddPlaidBankAccount.tsx index 4de286183ea8..11b0010ed253 100644 --- a/src/components/AddPlaidBankAccount.tsx +++ b/src/components/AddPlaidBankAccount.tsx @@ -173,7 +173,7 @@ function AddPlaidBankAccount({ const {icon, iconSize, iconStyles} = getBankIcon({styles}); const plaidErrors = plaidData?.errors; // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style - const plaidDataErrorMessage = !isEmptyObject(plaidErrors) ? (Object.values(plaidErrors)[0] as string) : ''; + const plaidDataErrorMessage = !isEmptyObject(plaidErrors) ? (Object.values(plaidErrors).at(0) as string) : ''; const bankName = plaidData?.bankName; /** diff --git a/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx index 366366423324..dd6534218359 100644 --- a/src/components/AttachmentPicker/index.native.tsx +++ b/src/components/AttachmentPicker/index.native.tsx @@ -177,7 +177,10 @@ function AttachmentPicker({type = CONST.ATTACHMENT_PICKER_TYPE.FILE, children, s const uri = manipResult.uri; const convertedAsset = { uri, - name: uri.substring(uri.lastIndexOf('/') + 1).split('?')[0], + name: uri + .substring(uri.lastIndexOf('/') + 1) + .split('?') + .at(0), type: 'image/jpeg', width: manipResult.width, height: manipResult.height, @@ -363,8 +366,11 @@ function AttachmentPicker({type = CONST.ATTACHMENT_PICKER_TYPE.FILE, children, s if (focusedIndex === -1) { return; } - selectItem(menuItemData[focusedIndex]); - setFocusedIndex(-1); // Reset the focusedIndex on selecting any menu + const item = menuItemData.at(focusedIndex); + if (item) { + selectItem(item); + setFocusedIndex(-1); // Reset the focusedIndex on selecting any menu + } }, { isActive: isVisible, diff --git a/src/components/Attachments/AttachmentCarousel/index.native.tsx b/src/components/Attachments/AttachmentCarousel/index.native.tsx index e0f7571af8c7..aae621d0197f 100644 --- a/src/components/Attachments/AttachmentCarousel/index.native.tsx +++ b/src/components/Attachments/AttachmentCarousel/index.native.tsx @@ -52,9 +52,11 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, setDownloadButtonVisibility(initialPage !== -1); } + const attachment = targetAttachments.at(initialPage); + // Update the parent modal's state with the source and name from the mapped attachments - if (targetAttachments[initialPage] !== undefined && onNavigate) { - onNavigate(targetAttachments[initialPage]); + if (attachment !== undefined && onNavigate) { + onNavigate(attachment); } } // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps @@ -66,13 +68,14 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, Keyboard.dismiss(); setShouldShowArrows(true); - const item = attachments[newPageIndex]; + const item = attachments.at(newPageIndex); setPage(newPageIndex); - setActiveSource(item.source); - - if (onNavigate) { - onNavigate(item); + if (item) { + setActiveSource(item.source); + if (onNavigate) { + onNavigate(item); + } } }, [setShouldShowArrows, attachments, onNavigate], diff --git a/src/components/Attachments/AttachmentCarousel/index.tsx b/src/components/Attachments/AttachmentCarousel/index.tsx index 72e0f17aa310..14fbd3925127 100644 --- a/src/components/Attachments/AttachmentCarousel/index.tsx +++ b/src/components/Attachments/AttachmentCarousel/index.tsx @@ -102,9 +102,10 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, setDownloadButtonVisibility(initialPage !== -1); } + const attachment = targetAttachments.at(initialPage); // Update the parent modal's state with the source and name from the mapped attachments - if (targetAttachments[initialPage] !== undefined && onNavigate) { - onNavigate(targetAttachments[initialPage]); + if (attachment !== undefined && onNavigate) { + onNavigate(attachment); } } }, [report.privateNotes, reportActions, parentReportActions, compareImage, report.parentReportActionID, attachments, setDownloadButtonVisibility, onNavigate, accountID, type]); @@ -131,7 +132,7 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, // Since we can have only one item in view at a time, we can use the first item in the array // to get the index of the current page - const entry = viewableItems[0]; + const entry = viewableItems.at(0); if (!entry) { setActiveSource(null); return; @@ -158,7 +159,7 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, } const nextIndex = page + deltaSlide; - const nextItem = attachments[nextIndex]; + const nextItem = attachments.at(nextIndex); if (!nextItem || !scrollRef.current) { return; diff --git a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.tsx b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.tsx index 8c4af3275bd8..1e3cded92bd5 100644 --- a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.tsx +++ b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.tsx @@ -33,8 +33,8 @@ function AttachmentViewPdf(props: AttachmentViewPdfProps) { .manualActivation(true) .onTouchesMove((evt) => { if (offsetX.value !== 0 && offsetY.value !== 0 && isScrollEnabled) { - const translateX = Math.abs(evt.allTouches[0].absoluteX - offsetX.value); - const translateY = Math.abs(evt.allTouches[0].absoluteY - offsetY.value); + const translateX = Math.abs((evt.allTouches.at(0)?.absoluteX ?? 0) - offsetX.value); + const translateY = Math.abs((evt.allTouches.at(0)?.absoluteY ?? 0) - offsetY.value); const allowEnablingScroll = !isPanGestureActive.value || isScrollEnabled.value; // if the value of X is greater than Y and the pdf is not zoomed in, @@ -49,8 +49,8 @@ function AttachmentViewPdf(props: AttachmentViewPdfProps) { } isPanGestureActive.value = true; - offsetX.value = evt.allTouches[0].absoluteX; - offsetY.value = evt.allTouches[0].absoluteY; + offsetX.value = evt.allTouches.at(0)?.absoluteX ?? 0; + offsetY.value = evt.allTouches.at(0)?.absoluteY ?? 0; }) .onTouchesUp(() => { isPanGestureActive.value = false; diff --git a/src/components/AvatarWithDisplayName.tsx b/src/components/AvatarWithDisplayName.tsx index 1cd1bfb36d83..05d15a2ad995 100644 --- a/src/components/AvatarWithDisplayName.tsx +++ b/src/components/AvatarWithDisplayName.tsx @@ -13,6 +13,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {PersonalDetailsList, Policy, Report, ReportActions} from '@src/types/onyx'; +import fallbackIcon from '@src/utils/getDefaultIcon'; import CaretWrapper from './CaretWrapper'; import DisplayNames from './DisplayNames'; import MultipleAvatars from './MultipleAvatars'; @@ -121,8 +122,8 @@ function AvatarWithDisplayName({ {shouldShowSubscriptAvatar ? ( ) : ( diff --git a/src/components/ButtonWithDropdownMenu/index.tsx b/src/components/ButtonWithDropdownMenu/index.tsx index c1b662c3680e..2fdbaad73c4e 100644 --- a/src/components/ButtonWithDropdownMenu/index.tsx +++ b/src/components/ButtonWithDropdownMenu/index.tsx @@ -53,7 +53,7 @@ function ButtonWithDropdownMenu({ const {windowWidth, windowHeight} = useWindowDimensions(); const dropdownAnchor = useRef(null); const dropdownButtonRef = isSplitButton ? buttonRef : mergeRefs(buttonRef, dropdownAnchor); - const selectedItem = options[selectedItemIndex] || options[0]; + const selectedItem = options.at(selectedItemIndex) ?? options.at(0); const innerStyleDropButton = StyleUtils.getDropDownButtonHeight(buttonSize); const isButtonSizeLarge = buttonSize === CONST.DROPDOWN_BUTTON_SIZE.LARGE; const nullCheckRef = (ref: MutableRefObject) => ref ?? null; @@ -86,9 +86,14 @@ function ButtonWithDropdownMenu({ setIsMenuVisible(!isMenuVisible); return; } - onPress(e, selectedItem?.value); + if (selectedItem?.value) { + onPress(e, selectedItem?.value); + } } else { - onPress(e, options[0]?.value); + const option = options.at(0); + if (option?.value) { + onPress(e, option?.value); + } } }, { @@ -107,8 +112,9 @@ function ButtonWithDropdownMenu({ success={success} pressOnEnter={pressOnEnter} ref={dropdownButtonRef} - onPress={(event) => (!isSplitButton ? setIsMenuVisible(!isMenuVisible) : onPress(event, selectedItem.value))} - text={customText ?? selectedItem.text} + // eslint-disable-next-line no-nested-ternary + onPress={(event) => (!isSplitButton ? setIsMenuVisible(!isMenuVisible) : selectedItem?.value ? onPress(event, selectedItem?.value) : undefined)} + text={customText ?? selectedItem?.text ?? ''} isDisabled={isDisabled || !!selectedItem?.disabled} isLoading={isLoading} shouldRemoveRightBorderRadius @@ -156,12 +162,15 @@ function ButtonWithDropdownMenu({ success={success} ref={buttonRef} pressOnEnter={pressOnEnter} - isDisabled={isDisabled || !!options[0].disabled} + isDisabled={isDisabled || !!options.at(0)?.disabled} style={[styles.w100, style]} disabledStyle={disabledStyle} isLoading={isLoading} - text={selectedItem.text} - onPress={(event) => onPress(event, options[0].value)} + text={selectedItem?.text} + onPress={(event) => { + const option = options.at(0); + return option ? onPress(event, option.value) : undefined; + }} large={buttonSize === CONST.DROPDOWN_BUTTON_SIZE.LARGE} medium={buttonSize === CONST.DROPDOWN_BUTTON_SIZE.MEDIUM} small={buttonSize === CONST.DROPDOWN_BUTTON_SIZE.SMALL} diff --git a/src/components/CategoryPicker.tsx b/src/components/CategoryPicker.tsx index e5c85a8f5f6d..73d07cfba229 100644 --- a/src/components/CategoryPicker.tsx +++ b/src/components/CategoryPicker.tsx @@ -62,7 +62,7 @@ function CategoryPicker({selectedCategory, policyCategories, policyRecentlyUsedC false, ); - const categoryData = categoryOptions?.[0]?.data ?? []; + const categoryData = categoryOptions?.at(0)?.data ?? []; const header = OptionsListUtils.getHeaderMessageForNonUserList(categoryData.length > 0, debouncedSearchValue); const categoriesCount = OptionsListUtils.getEnabledCategoriesCount(categories); const isCategoriesCountBelowThreshold = categoriesCount < CONST.CATEGORY_LIST_THRESHOLD; @@ -71,7 +71,7 @@ function CategoryPicker({selectedCategory, policyCategories, policyRecentlyUsedC return [categoryOptions, header, showInput]; }, [policyRecentlyUsedCategories, debouncedSearchValue, selectedOptions, policyCategories, policyCategoriesDraft]); - const selectedOptionKey = useMemo(() => (sections?.[0]?.data ?? []).filter((category) => category.searchText === selectedCategory)[0]?.keyForList, [sections, selectedCategory]); + const selectedOptionKey = useMemo(() => (sections?.at(0)?.data ?? []).filter((category) => category.searchText === selectedCategory).at(0)?.keyForList, [sections, selectedCategory]); return ( ) => { - const clipboardContent = e.nativeEvent.items[0]; - if (clipboardContent.type === 'text/plain') { + const clipboardContent = e.nativeEvent.items.at(0); + if (clipboardContent?.type === 'text/plain') { return; } - const mimeType = clipboardContent.type; - const fileURI = clipboardContent.data; - const baseFileName = fileURI.split('/').pop() ?? 'file'; + const mimeType = clipboardContent?.type ?? ''; + const fileURI = clipboardContent?.data; + const baseFileName = fileURI?.split('/').pop() ?? 'file'; const {fileName: stem, fileExtension: originalFileExtension} = FileUtils.splitExtensionFromFileName(baseFileName); const fileExtension = originalFileExtension || (mimeDb[mimeType].extensions?.[0] ?? 'bin'); const fileName = `${stem}.${fileExtension}`; diff --git a/src/components/ConfirmedRoute.tsx b/src/components/ConfirmedRoute.tsx index 4940a276cbf8..e1580dcae7d0 100644 --- a/src/components/ConfirmedRoute.tsx +++ b/src/components/ConfirmedRoute.tsx @@ -112,7 +112,7 @@ function ConfirmedRoute({mapboxAccessToken, transaction, isSmallerIcon, shouldHa pitchEnabled={false} initialState={{ zoom: CONST.MAPBOX.DEFAULT_ZOOM, - location: waypointMarkers?.[0]?.coordinate ?? (CONST.MAPBOX.DEFAULT_COORDINATE as [number, number]), + location: waypointMarkers?.at(0)?.coordinate ?? CONST.MAPBOX.DEFAULT_COORDINATE, }} directionCoordinates={coordinates as Array<[number, number]>} style={[styles.mapView, shouldHaveBorderRadius && styles.br4]} diff --git a/src/components/DatePicker/CalendarPicker/generateMonthMatrix.ts b/src/components/DatePicker/CalendarPicker/generateMonthMatrix.ts index 21e7ff752794..9a5cf7d7f741 100644 --- a/src/components/DatePicker/CalendarPicker/generateMonthMatrix.ts +++ b/src/components/DatePicker/CalendarPicker/generateMonthMatrix.ts @@ -51,8 +51,8 @@ export default function generateMonthMatrix(year: number, month: number) { } // Add null values for days before the first day of the month - for (let i = matrix[0].length; i < 7; i++) { - matrix[0].unshift(undefined); + for (let i = matrix.at(0)?.length ?? 0; i < 7; i++) { + matrix.at(0)?.unshift(undefined); } return matrix; diff --git a/src/components/DatePicker/CalendarPicker/index.tsx b/src/components/DatePicker/CalendarPicker/index.tsx index 533586d4bdbf..287ec3359175 100644 --- a/src/components/DatePicker/CalendarPicker/index.tsx +++ b/src/components/DatePicker/CalendarPicker/index.tsx @@ -170,7 +170,7 @@ function CalendarPicker({ testID="currentMonthText" accessibilityLabel={translate('common.currentMonth')} > - {monthNames[currentMonthView]} + {monthNames.at(currentMonthView)} ))} - {calendarDaysMatrix.map((week) => ( + {calendarDaysMatrix?.map((week) => ( { // Only shift the tooltip in case the containerLayout or Refs to the text node are available - if (!containerRef.current || !childRefs.current[index]) { + if (!containerRef.current || !childRefs.current.at(index)) { return 0; } const {width: containerWidth, left: containerLeft} = containerRef.current.getBoundingClientRect(); // We have to return the value as Number so we can't use `measureWindow` which takes a callback - const {width: textNodeWidth, left: textNodeLeft} = childRefs.current[index].getBoundingClientRect(); + const {width: textNodeWidth, left: textNodeLeft} = childRefs.current.at(index)?.getBoundingClientRect() ?? {width: 0, left: 0}; const tooltipX = textNodeWidth / 2 + textNodeLeft; const containerRight = containerWidth + containerLeft; const textNodeRight = textNodeWidth + textNodeLeft; diff --git a/src/components/DistanceRequest/DistanceRequestFooter.tsx b/src/components/DistanceRequest/DistanceRequestFooter.tsx index 43dd88f6e36c..8a4455e02bd6 100644 --- a/src/components/DistanceRequest/DistanceRequestFooter.tsx +++ b/src/components/DistanceRequest/DistanceRequestFooter.tsx @@ -107,7 +107,7 @@ function DistanceRequestFooter({waypoints, transaction, mapboxAccessToken, navig pitchEnabled={false} initialState={{ zoom: CONST.MAPBOX.DEFAULT_ZOOM, - location: waypointMarkers?.[0]?.coordinate ?? (CONST.MAPBOX.DEFAULT_COORDINATE as [number, number]), + location: waypointMarkers?.at(0)?.coordinate ?? CONST.MAPBOX.DEFAULT_COORDINATE, }} directionCoordinates={(transaction?.routes?.route0?.geometry?.coordinates as Array<[number, number]>) ?? []} style={[styles.mapView, styles.mapEditView]} diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx b/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx index b5b4c2d7e71c..e5f36075b13f 100644 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx @@ -96,7 +96,7 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji}: EmojiPickerMenuProps, r ); } - const emojiCode = typeof preferredSkinTone === 'number' && types?.[preferredSkinTone] ? types?.[preferredSkinTone] : code; + const emojiCode = typeof preferredSkinTone === 'number' && types?.[preferredSkinTone] ? types?.at(preferredSkinTone) : code; const shouldEmojiBeHighlighted = !!activeEmoji && EmojiUtils.getRemovedSkinToneEmoji(emojiCode) === EmojiUtils.getRemovedSkinToneEmoji(activeEmoji); return ( @@ -107,7 +107,7 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji}: EmojiPickerMenuProps, r } onEmojiSelected(emoji, item); })} - emoji={emojiCode} + emoji={emojiCode ?? ''} isHighlighted={shouldEmojiBeHighlighted} /> ); diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.tsx b/src/components/EmojiPicker/EmojiPickerMenu/index.tsx index 92c35d7c7685..4c7483e55a6b 100755 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.tsx +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.tsx @@ -178,13 +178,13 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji}: EmojiPickerMenuProps, r indexToSelect = 0; } - const item = filteredEmojis[indexToSelect]; + const item = filteredEmojis.at(indexToSelect); if (!item) { return; } if ('types' in item || 'name' in item) { - const emoji = typeof preferredSkinTone === 'number' && item?.types?.[preferredSkinTone] ? item?.types?.[preferredSkinTone] : item.code; - onEmojiSelected(emoji, item); + const emoji = typeof preferredSkinTone === 'number' && item?.types?.[preferredSkinTone] ? item?.types.at(preferredSkinTone) : item.code; + onEmojiSelected(emoji ?? '', item); } }, {shouldPreventDefault: true, shouldStopPropagation: true}, @@ -269,7 +269,7 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji}: EmojiPickerMenuProps, r ); } - const emojiCode = typeof preferredSkinTone === 'number' && types?.[preferredSkinTone] ? types[preferredSkinTone] : code; + const emojiCode = typeof preferredSkinTone === 'number' && types?.[preferredSkinTone] ? types.at(preferredSkinTone) : code; const isEmojiFocused = index === focusedIndex && isUsingKeyboardMovement; const shouldEmojiBeHighlighted = @@ -292,7 +292,7 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji}: EmojiPickerMenuProps, r } setIsUsingKeyboardMovement(false); }} - emoji={emojiCode} + emoji={emojiCode ?? ''} onFocus={() => setFocusedIndex(index)} isFocused={isEmojiFocused} isHighlighted={shouldFirstEmojiBeHighlighted || shouldEmojiBeHighlighted} diff --git a/src/components/FilePicker/index.native.tsx b/src/components/FilePicker/index.native.tsx index b74a68432cab..cc9c73d72c56 100644 --- a/src/components/FilePicker/index.native.tsx +++ b/src/components/FilePicker/index.native.tsx @@ -112,7 +112,7 @@ function FilePicker({children}: FilePickerProps) { onCanceled.current(); return Promise.resolve(); } - const fileData = files[0]; + const fileData = files.at(0); if (!fileData) { onCanceled.current(); diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx index 7892d8624699..d2e407ff8b55 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx @@ -23,7 +23,7 @@ function AnchorRenderer({tnode, style, key}: AnchorRendererProps) { const {environmentURL} = useEnvironment(); // An auth token is needed to download Expensify chat attachments const isAttachment = !!htmlAttribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE]; - const tNodeChild = tnode?.domNode?.children?.[0]; + const tNodeChild = tnode?.domNode?.children?.at(0); const displayName = tNodeChild && 'data' in tNodeChild && typeof tNodeChild.data === 'string' ? tNodeChild.data : ''; const parentStyle = tnode.parent?.styles?.nativeTextRet ?? {}; const attrHref = htmlAttribs.href || htmlAttribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE] || ''; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx index 6acef20cd833..9abc63e1ba5f 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx @@ -53,22 +53,22 @@ function MentionUserRenderer({style, tnode, TDefaultRenderer, currentUserPersona } // Otherwise, the emails must be of the same private domain, so we should remove the domain part - return displayText.split('@')[0]; + return displayText.split('@').at(0); }; if (!isEmpty(htmlAttribAccountID)) { const user = personalDetails[htmlAttribAccountID]; accountID = parseInt(htmlAttribAccountID, 10); mentionDisplayText = LocalePhoneNumber.formatPhoneNumber(user?.login ?? '') || PersonalDetailsUtils.getDisplayNameOrDefault(user); - mentionDisplayText = getShortMentionIfFound(mentionDisplayText, htmlAttributeAccountID, user?.login ?? ''); + mentionDisplayText = getShortMentionIfFound(mentionDisplayText, htmlAttributeAccountID, user?.login ?? '') ?? ''; navigationRoute = ROUTES.PROFILE.getRoute(htmlAttribAccountID); } else if ('data' in tnodeClone && !isEmptyObject(tnodeClone.data)) { // We need to remove the LTR unicode and leading @ from data as it is not part of the login mentionDisplayText = tnodeClone.data.replace(CONST.UNICODE.LTR, '').slice(1); // We need to replace tnode.data here because we will pass it to TNodeChildrenRenderer below - asMutable(tnodeClone).data = tnodeClone.data.replace(mentionDisplayText, Str.removeSMSDomain(getShortMentionIfFound(mentionDisplayText, htmlAttributeAccountID))); + asMutable(tnodeClone).data = tnodeClone.data.replace(mentionDisplayText, Str.removeSMSDomain(getShortMentionIfFound(mentionDisplayText, htmlAttributeAccountID) ?? '')); - accountID = PersonalDetailsUtils.getAccountIDsByLogins([mentionDisplayText])?.[0]; + accountID = PersonalDetailsUtils.getAccountIDsByLogins([mentionDisplayText])?.at(0) ?? -1; navigationRoute = ROUTES.PROFILE.getRoute(accountID, undefined, mentionDisplayText); mentionDisplayText = Str.removeSMSDomain(mentionDisplayText); } else { diff --git a/src/components/ImportColumn.tsx b/src/components/ImportColumn.tsx index 49601787a207..94cdfd8a4df6 100644 --- a/src/components/ImportColumn.tsx +++ b/src/components/ImportColumn.tsx @@ -161,7 +161,7 @@ function ImportColumn({column, columnName, columnRoles, columnIndex}: ImportColu const columnValuesString = column.slice(containsHeader ? 1 : 0).join(', '); - const colName = findColumnName(column[0]); + const colName = findColumnName(column.at(0) ?? ''); const defaultSelectedIndex = columnRoles.findIndex((item) => item.value === colName); useEffect(() => { @@ -172,7 +172,7 @@ function ImportColumn({column, columnName, columnRoles, columnIndex}: ImportColu // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- we don't want this effect to run again }, []); - const columnHeader = containsHeader ? column[0] : translate('spreadsheet.column', columnName); + const columnHeader = containsHeader ? column.at(0) : translate('spreadsheet.column', columnName); return ( diff --git a/src/components/ImportSpreadsheetColumns.tsx b/src/components/ImportSpreadsheetColumns.tsx index 95a6ea4516ac..ef2c237eb5c6 100644 --- a/src/components/ImportSpreadsheetColumns.tsx +++ b/src/components/ImportSpreadsheetColumns.tsx @@ -70,9 +70,9 @@ function ImportSpreeadsheetColumns({spreadsheetColumns, columnNames, columnRoles {spreadsheetColumns.map((column, index) => { return ( diff --git a/src/components/InlineCodeBlock/WrappedText.tsx b/src/components/InlineCodeBlock/WrappedText.tsx index 3045c15c471b..00ab8ec20906 100644 --- a/src/components/InlineCodeBlock/WrappedText.tsx +++ b/src/components/InlineCodeBlock/WrappedText.tsx @@ -52,7 +52,7 @@ function WrappedText({children, wordStyles, textStyles}: WrappedTextProps) { return textMatrix.map((rowText, rowIndex) => ( {rowText.map((colText, colIndex) => ( // Outer View is important to vertically center the Text diff --git a/src/components/InteractiveStepSubHeader.tsx b/src/components/InteractiveStepSubHeader.tsx index d8899a317df5..81c4659c0c72 100644 --- a/src/components/InteractiveStepSubHeader.tsx +++ b/src/components/InteractiveStepSubHeader.tsx @@ -70,7 +70,10 @@ function InteractiveStepSubHeader({stepNames, startStepIndex = 0, onStepSelected return; } setCurrentStep(index); - onStepSelected(stepNames[index]); + const step = stepNames.at(index); + if (step) { + onStepSelected(step); + } }; return ( diff --git a/src/components/LHNOptionsList/LHNOptionsList.tsx b/src/components/LHNOptionsList/LHNOptionsList.tsx index 624e8f18e69e..08240a211804 100644 --- a/src/components/LHNOptionsList/LHNOptionsList.tsx +++ b/src/components/LHNOptionsList/LHNOptionsList.tsx @@ -140,7 +140,7 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio const itemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; const hasDraftComment = DraftCommentUtils.isValidDraftComment(draftComments?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`]); const sortedReportActions = ReportActionsUtils.getSortedReportActionsForDisplay(itemReportActions); - const lastReportAction = sortedReportActions[0]; + const lastReportAction = sortedReportActions.at(0); // Get the transaction for the last report action let lastReportActionTransactionID = ''; diff --git a/src/components/LHNOptionsList/OptionRowLHN.tsx b/src/components/LHNOptionsList/OptionRowLHN.tsx index 353bacdc0a25..7b7b2e643ce7 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN.tsx @@ -34,6 +34,7 @@ import * as User from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import fallbackIcon from '@src/utils/getDefaultIcon'; import type {OptionRowLHNProps} from './types'; function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, optionItem, viewMode = 'default', style, onLayout = () => {}, hasDraftComment}: OptionRowLHNProps) { @@ -207,7 +208,6 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti if (!event) { return; } - // Prevent composer blur on left click event.preventDefault(); }} @@ -243,8 +243,8 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti (optionItem.shouldShowSubscript ? ( ) : ( diff --git a/src/components/MagicCodeInput.tsx b/src/components/MagicCodeInput.tsx index 79c2445f7ae7..89b9fc9a9e16 100644 --- a/src/components/MagicCodeInput.tsx +++ b/src/components/MagicCodeInput.tsx @@ -276,7 +276,7 @@ function MagicCodeInput( if (isDisableKeyboard && focusedIndex === undefined) { const indexBeforeLastEditIndex = editIndex === 0 ? editIndex : editIndex - 1; - const indexToFocus = numbers[editIndex] === CONST.MAGIC_CODE_EMPTY_CHAR ? indexBeforeLastEditIndex : editIndex; + const indexToFocus = numbers.at(editIndex) === CONST.MAGIC_CODE_EMPTY_CHAR ? indexBeforeLastEditIndex : editIndex; const formElement = inputRefs.current as HTMLFormElement | null; (formElement?.[indexToFocus] as HTMLInputElement)?.focus(); onChangeTextProp(value.substring(0, indexToFocus)); @@ -286,7 +286,7 @@ function MagicCodeInput( // If the currently focused index already has a value, it will delete // that value but maintain the focus on the same input. - if (focusedIndex !== undefined && numbers?.[focusedIndex] !== CONST.MAGIC_CODE_EMPTY_CHAR) { + if (focusedIndex !== undefined && numbers?.at(focusedIndex) !== CONST.MAGIC_CODE_EMPTY_CHAR) { setInput(TEXT_INPUT_EMPTY_STATE); numbers = [...numbers.slice(0, focusedIndex), CONST.MAGIC_CODE_EMPTY_CHAR, ...numbers.slice(focusedIndex + 1, maxLength)]; setEditIndex(focusedIndex); @@ -420,7 +420,7 @@ function MagicCodeInput( focusedIndex === index ? styles.borderColorFocus : {}, ]} > - {decomposeString(value, maxLength)[index] || ''} + {decomposeString(value, maxLength).at(index) ?? ''} ))} diff --git a/src/components/MapView/MapView.tsx b/src/components/MapView/MapView.tsx index 0f0a46fa0a2a..cbe604661c05 100644 --- a/src/components/MapView/MapView.tsx +++ b/src/components/MapView/MapView.tsx @@ -119,7 +119,7 @@ const MapView = forwardRef( cameraRef.current?.setCamera({ zoomLevel: CONST.MAPBOX.SINGLE_MARKER_ZOOM, animationDuration: 1500, - centerCoordinate: waypoints[0].coordinate, + centerCoordinate: waypoints.at(0)?.coordinate, }); } else { const {southWest, northEast} = utils.getBounds( diff --git a/src/components/MapView/MapView.website.tsx b/src/components/MapView/MapView.website.tsx index 58f5ff26c2dc..d30fce35a901 100644 --- a/src/components/MapView/MapView.website.tsx +++ b/src/components/MapView/MapView.website.tsx @@ -133,7 +133,7 @@ const MapView = forwardRef( if (waypoints.length === 1) { mapRef.flyTo({ - center: waypoints[0].coordinate, + center: waypoints.at(0)?.coordinate, zoom: CONST.MAPBOX.SINGLE_MARKER_ZOOM, }); return; diff --git a/src/components/MapView/utils.ts b/src/components/MapView/utils.ts index 04176d715281..ae35bdfdeba9 100644 --- a/src/components/MapView/utils.ts +++ b/src/components/MapView/utils.ts @@ -18,10 +18,10 @@ function getBounds(waypoints: Array<[number, number]>, directionCoordinates: und function haversineDistance(coordinate1: number[], coordinate2: number[]) { // Radius of the Earth in meters const R = 6371e3; - const lat1 = (coordinate1[0] * Math.PI) / 180; - const lat2 = (coordinate2[0] * Math.PI) / 180; - const deltaLat = ((coordinate2[0] - coordinate1[0]) * Math.PI) / 180; - const deltaLon = ((coordinate2[1] - coordinate1[1]) * Math.PI) / 180; + const lat1 = ((coordinate1.at(0) ?? 0) * Math.PI) / 180; + const lat2 = ((coordinate2.at(0) ?? 0) * Math.PI) / 180; + const deltaLat = (((coordinate2.at(0) ?? 0) - (coordinate1.at(0) ?? 0)) * Math.PI) / 180; + const deltaLon = (((coordinate2.at(1) ?? 0) - (coordinate1.at(1) ?? 0)) * Math.PI) / 180; // The square of half the chord length between the points const halfChordLengthSq = Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) + Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLon / 2) * Math.sin(deltaLon / 2); diff --git a/src/components/MentionSuggestions.tsx b/src/components/MentionSuggestions.tsx index bdc19316d491..142813355118 100644 --- a/src/components/MentionSuggestions.tsx +++ b/src/components/MentionSuggestions.tsx @@ -91,13 +91,13 @@ function MentionSuggestions({ {item.icons && !!item.icons.length && ( )} diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index 2524658d6ffc..5f36e0bc0edd 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -20,6 +20,7 @@ import CONST from '@src/CONST'; import type {Icon as IconType} from '@src/types/onyx/OnyxCommon'; import type {TooltipAnchorAlignment} from '@src/types/utils/AnchorAlignment'; import type IconAsset from '@src/types/utils/IconAsset'; +import getDefaultIcon from '@src/utils/getDefaultIcon'; import Avatar from './Avatar'; import Badge from './Badge'; import DisplayNames from './DisplayNames'; @@ -776,8 +777,8 @@ function MenuItem( {shouldShowSubscriptRightAvatar ? ( ) : ( diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 743a5b276c98..262a14d183b3 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -700,7 +700,7 @@ function MoneyRequestConfirmationList({ if (iouCategory || !shouldShowCategories || enabledCategories.length !== 1 || !isCategoryRequired) { return; } - IOU.setMoneyRequestCategory(transactionID, enabledCategories[0].name); + IOU.setMoneyRequestCategory(transactionID, enabledCategories.at(0)?.name ?? ''); // Keep 'transaction' out to ensure that we autoselect the option only once // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [shouldShowCategories, policyCategories, isCategoryRequired]); @@ -714,7 +714,7 @@ function MoneyRequestConfirmationList({ if (!isTagListRequired || enabledTags.length !== 1 || TransactionUtils.getTag(transaction, index)) { return; } - updatedTagsString = IOUUtils.insertTagIntoTransactionTagsString(updatedTagsString, enabledTags[0] ? enabledTags[0].name : '', index); + updatedTagsString = IOUUtils.insertTagIntoTransactionTagsString(updatedTagsString, enabledTags.at(0)?.name ?? '', index); }); if (updatedTagsString !== TransactionUtils.getTag(transaction) && updatedTagsString) { IOU.setMoneyRequestTag(transactionID, updatedTagsString); diff --git a/src/components/MultiGestureCanvas/usePanGesture.ts b/src/components/MultiGestureCanvas/usePanGesture.ts index fa27e48eea4c..b31e310055ae 100644 --- a/src/components/MultiGestureCanvas/usePanGesture.ts +++ b/src/components/MultiGestureCanvas/usePanGesture.ts @@ -177,8 +177,8 @@ const usePanGesture = ({ // TODO: this needs tuning to work properly if (!shouldDisableTransformationGestures.value && zoomScale.value === 1 && previousTouch.value !== null) { - const velocityX = Math.abs(evt.allTouches[0].x - previousTouch.value.x); - const velocityY = evt.allTouches[0].y - previousTouch.value.y; + const velocityX = Math.abs((evt.allTouches.at(0)?.x ?? 0) - previousTouch.value.x); + const velocityY = (evt.allTouches.at(0)?.y ?? 0) - previousTouch.value.y; if (Math.abs(velocityY) > velocityX && velocityY > 20) { state.activate(); @@ -192,8 +192,8 @@ const usePanGesture = ({ if (previousTouch.value === null) { previousTouch.value = { - x: evt.allTouches[0].x, - y: evt.allTouches[0].y, + x: evt.allTouches.at(0)?.x ?? 0, + y: evt.allTouches.at(0)?.y ?? 0, }; } }) diff --git a/src/components/MultipleAvatars.tsx b/src/components/MultipleAvatars.tsx index 601b8f7edfed..d947e13fa708 100644 --- a/src/components/MultipleAvatars.tsx +++ b/src/components/MultipleAvatars.tsx @@ -144,21 +144,21 @@ function MultipleAvatars({ if (icons.length === 1 && !shouldStackHorizontally) { return ( @@ -233,7 +233,7 @@ function MultipleAvatars({ // Set overlay background color with RGBA value so that the text will not inherit opacity StyleUtils.getBackgroundColorWithOpacityStyle(theme.overlay, variables.overlayOpacity), StyleUtils.getHorizontalStackedOverlayAvatarStyle(oneAvatarSize, oneAvatarBorderWidth), - icons[3].type === CONST.ICON_TYPE_WORKSPACE && StyleUtils.getAvatarBorderRadius(size, icons[3].type), + icons.at(3)?.type === CONST.ICON_TYPE_WORKSPACE && StyleUtils.getAvatarBorderRadius(size, icons.at(3)?.type), ]} > @@ -249,45 +249,45 @@ function MultipleAvatars({ )) ) : ( - + {/* View is necessary for tooltip to show for multiple avatars in LHN */} - + {icons.length === 2 ? ( diff --git a/src/components/OptionRow.tsx b/src/components/OptionRow.tsx index 029bd57cd876..3570ea15c9f3 100644 --- a/src/components/OptionRow.tsx +++ b/src/components/OptionRow.tsx @@ -10,6 +10,7 @@ import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as ReportUtils from '@libs/ReportUtils'; import type {OptionData} from '@libs/ReportUtils'; import CONST from '@src/CONST'; +import fallbackIcon from '@src/utils/getDefaultIcon'; import Button from './Button'; import DisplayNames from './DisplayNames'; import Hoverable from './Hoverable'; @@ -209,8 +210,8 @@ function OptionRow({ {!!option.icons?.length && (option.shouldShowSubscript ? ( diff --git a/src/components/OptionsList/BaseOptionsList.tsx b/src/components/OptionsList/BaseOptionsList.tsx index c08f1d697dad..d7c625e7837c 100644 --- a/src/components/OptionsList/BaseOptionsList.tsx +++ b/src/components/OptionsList/BaseOptionsList.tsx @@ -138,13 +138,13 @@ function BaseOptionsList( */ // eslint-disable-next-line @typescript-eslint/naming-convention const getItemLayout = (_data: OptionsListDataWithIndexOffset[] | null, flatDataArrayIndex: number) => { - if (!flattenedData.current[flatDataArrayIndex]) { + if (!flattenedData.current.at(flatDataArrayIndex)) { flattenedData.current = buildFlatSectionArray(); } - const targetItem = flattenedData.current[flatDataArrayIndex]; + const targetItem = flattenedData.current.at(flatDataArrayIndex); return { - length: targetItem.length, - offset: targetItem.offset, + length: targetItem?.length ?? 0, + offset: targetItem?.offset ?? 0, index: flatDataArrayIndex, }; }; diff --git a/src/components/Picker/BasePicker.tsx b/src/components/Picker/BasePicker.tsx index ddd6cd544f3e..3c5fd4dc61d0 100644 --- a/src/components/Picker/BasePicker.tsx +++ b/src/components/Picker/BasePicker.tsx @@ -59,7 +59,10 @@ function BasePicker( // When there is only 1 element in the selector, we do the user a favor and automatically select it for them // so they don't have to spend extra time selecting the only possible value. - onInputChange(items[0].value, 0); + const item = items.at(0); + if (item) { + onInputChange(item.value, 0); + } // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [items]); diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index 3b074bf772e6..05a8305deaa1 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -141,7 +141,10 @@ function PopoverMenu({ const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({initialFocusedIndex: currentMenuItemsFocusedIndex, maxIndex: currentMenuItems.length - 1, isActive: isVisible}); const selectItem = (index: number) => { - const selectedItem = currentMenuItems[index]; + const selectedItem = currentMenuItems.at(index); + if (!selectedItem) { + return; + } if (selectedItem?.subMenuItems) { setCurrentMenuItems([...selectedItem.subMenuItems]); setEnteredSubMenuIndexes([...enteredSubMenuIndexes, index]); diff --git a/src/components/ProfilingToolMenu/BaseProfilingToolMenu.tsx b/src/components/ProfilingToolMenu/BaseProfilingToolMenu.tsx index d9e106ec3f52..a288396ad204 100644 --- a/src/components/ProfilingToolMenu/BaseProfilingToolMenu.tsx +++ b/src/components/ProfilingToolMenu/BaseProfilingToolMenu.tsx @@ -44,7 +44,7 @@ function formatBytes(bytes: number, decimals = 2) { const i = Math.floor(Math.log(bytes) / Math.log(k)); - return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`; + return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes.at(i)}`; } // WARNING: When changing this name make sure that the "scripts/symbolicate-profile.ts" script is still working! diff --git a/src/components/ReportActionItem/MoneyReportView.tsx b/src/components/ReportActionItem/MoneyReportView.tsx index 6ab2db05c49f..b9303f044005 100644 --- a/src/components/ReportActionItem/MoneyReportView.tsx +++ b/src/components/ReportActionItem/MoneyReportView.tsx @@ -73,7 +73,7 @@ function MoneyReportView({report, policy, isCombinedReport = false, shouldShowTo }, [policy, report]); const enabledReportFields = sortedPolicyReportFields.filter((reportField) => !ReportUtils.isReportFieldDisabled(report, reportField, policy)); - const isOnlyTitleFieldEnabled = enabledReportFields.length === 1 && ReportUtils.isReportFieldOfTypeTitle(enabledReportFields[0]); + const isOnlyTitleFieldEnabled = enabledReportFields.length === 1 && ReportUtils.isReportFieldOfTypeTitle(enabledReportFields.at(0)); const shouldShowReportField = !ReportUtils.isClosedExpenseReportWithNoExpenses(report) && ReportUtils.isPaidGroupPolicyExpenseReport(report) && (!isCombinedReport || !isOnlyTitleFieldEnabled); diff --git a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx index f2460cae71ed..2108797ed3fd 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx @@ -204,9 +204,10 @@ function MoneyRequestPreviewContent({ if (shouldShowHoldMessage) { return `${message} ${CONST.DOT_SEPARATOR} ${translate('violations.hold')}`; } - if (violations?.[0]) { - const violationMessage = ViolationsUtils.getViolationTranslation(violations[0], translate); - const violationsCount = violations.filter((v) => v.type === CONST.VIOLATION_TYPES.VIOLATION).length; + const firstViolation = violations?.at(0); + if (firstViolation) { + const violationMessage = ViolationsUtils.getViolationTranslation(firstViolation, translate); + const violationsCount = violations?.filter((v) => v.type === CONST.VIOLATION_TYPES.VIOLATION).length ?? 0; const isTooLong = violationsCount > 1 || violationMessage.length > 15; const hasViolationsAndFieldErrors = violationsCount > 0 && hasFieldErrors; diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 878fdd8a9c10..efd5b1f99a79 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -304,8 +304,12 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals // Return violations if there are any if (hasViolations(field, data, policyHasDependentTags, tagValue)) { - const violations = getViolationsForField(field, data, policyHasDependentTags, tagValue); - return ViolationsUtils.getViolationTranslation(violations[0], translate); + const violations = getViolationsForField(field, data, policyHasDependentTags, tagValue) as OnyxTypes.TransactionViolation[]; + const firstViolation = violations.at(0); + + if (firstViolation) { + return ViolationsUtils.getViolationTranslation(firstViolation, translate); + } } return ''; @@ -646,7 +650,7 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals {translate('common.billable')} {!!getErrorForField('billable') && ( ({...ReceiptUtils.getThumbnailAndImageURIs(transaction), transaction})); const showRTERViolationMessage = numberOfRequests === 1 && - TransactionUtils.hasPendingUI(allTransactions[0], TransactionUtils.getTransactionViolations(allTransactions[0]?.transactionID ?? '-1', transactionViolations)); + TransactionUtils.hasPendingUI(allTransactions.at(0), TransactionUtils.getTransactionViolations(allTransactions.at(0)?.transactionID ?? '-1', transactionViolations)); - let formattedMerchant = numberOfRequests === 1 ? TransactionUtils.getMerchant(allTransactions[0]) : null; - const formattedDescription = numberOfRequests === 1 ? TransactionUtils.getDescription(allTransactions[0]) : null; + let formattedMerchant = numberOfRequests === 1 ? TransactionUtils.getMerchant(allTransactions.at(0)) : null; + const formattedDescription = numberOfRequests === 1 ? TransactionUtils.getDescription(allTransactions.at(0)) : null; if (TransactionUtils.isPartialMerchant(formattedMerchant ?? '')) { formattedMerchant = null; diff --git a/src/components/ReportActionItem/TripDetailsView.tsx b/src/components/ReportActionItem/TripDetailsView.tsx index 28408731d700..d9576c79b030 100644 --- a/src/components/ReportActionItem/TripDetailsView.tsx +++ b/src/components/ReportActionItem/TripDetailsView.tsx @@ -62,7 +62,7 @@ function ReservationView({reservation}: ReservationViewProps) { const formattedDate = getFormattedDate(); const bottomDescription = useMemo(() => { - const code = `${reservation.confirmations && reservation.confirmations?.length > 0 ? `${reservation.confirmations[0].value} • ` : ''}`; + const code = `${reservation.confirmations && reservation.confirmations?.length > 0 ? `${reservation.confirmations.at(0)?.value} • ` : ''}`; if (reservation.type === CONST.RESERVATION_TYPE.FLIGHT) { const longName = reservation.company?.longName ? `${reservation.company?.longName} • ` : ''; const shortName = reservation?.company?.shortName ? `${reservation?.company?.shortName} ` : ''; diff --git a/src/components/RoomHeaderAvatars.tsx b/src/components/RoomHeaderAvatars.tsx index f5c4a5fad051..f50a9241d275 100644 --- a/src/components/RoomHeaderAvatars.tsx +++ b/src/components/RoomHeaderAvatars.tsx @@ -36,22 +36,27 @@ function RoomHeaderAvatars({icons, reportID}: RoomHeaderAvatarsProps) { } if (icons.length === 1) { + const icon = icons.at(0); + if (!icon) { + return; + } + return ( navigateToAvatarPage(icons[0])} + onPress={() => navigateToAvatarPage(icon)} accessibilityRole={CONST.ROLE.BUTTON} - accessibilityLabel={icons[0].name ?? ''} - disabled={icons[0].source === Expensicons.FallbackAvatar} + accessibilityLabel={icon.name ?? ''} + disabled={icon.source === Expensicons.FallbackAvatar} > ); diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 9ee264bc9bf9..4dd0204551a2 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -242,7 +242,7 @@ function BaseSelectionList( */ const scrollToIndex = useCallback( (index: number, animated = true) => { - const item = flattenedSections.allOptions[index]; + const item = flattenedSections.allOptions.at(index) ?? 0; if (!listRef.current || !item) { return; @@ -330,7 +330,7 @@ function BaseSelectionList( }; const selectFocusedOption = () => { - const focusedOption = flattenedSections.allOptions[focusedIndex]; + const focusedOption = flattenedSections.allOptions.at(focusedIndex); if (!focusedOption || (focusedOption.isDisabled && !focusedOption.isSelected)) { return; @@ -354,7 +354,7 @@ function BaseSelectionList( * [{header}, {sectionHeader}, {item}, {item}, {sectionHeader}, {item}, {item}, {footer}] */ const getItemLayout = (data: Array>> | null, flatDataArrayIndex: number) => { - const targetItem = flattenedSections.itemLayouts[flatDataArrayIndex]; + const targetItem = flattenedSections.itemLayouts.at(flatDataArrayIndex); if (!targetItem) { return { @@ -621,7 +621,7 @@ function BaseSelectionList( /** Selects row when pressing Enter */ useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, selectFocusedOption, { captureOnInputs: true, - shouldBubble: !flattenedSections.allOptions[focusedIndex], + shouldBubble: !flattenedSections.allOptions.at(focusedIndex), shouldStopPropagation, isActive: !disableKeyboardShortcuts && !disableEnterShortcut && isFocused, }); @@ -630,7 +630,7 @@ function BaseSelectionList( useKeyboardShortcut( CONST.KEYBOARD_SHORTCUTS.CTRL_ENTER, (e) => { - const focusedOption = flattenedSections.allOptions[focusedIndex]; + const focusedOption = flattenedSections.allOptions.at(focusedIndex); if (onConfirm) { onConfirm(e, focusedOption); return; @@ -639,7 +639,7 @@ function BaseSelectionList( }, { captureOnInputs: true, - shouldBubble: !flattenedSections.allOptions[focusedIndex], + shouldBubble: !flattenedSections.allOptions.at(focusedIndex), isActive: !disableKeyboardShortcuts && isFocused, }, ); diff --git a/src/components/SelectionList/InviteMemberListItem.tsx b/src/components/SelectionList/InviteMemberListItem.tsx index 553adfd23c5c..dec58fb43d9d 100644 --- a/src/components/SelectionList/InviteMemberListItem.tsx +++ b/src/components/SelectionList/InviteMemberListItem.tsx @@ -12,6 +12,7 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; +import fallbackIcon from '@src/utils/getDefaultIcon'; import BaseListItem from './BaseListItem'; import type {InviteMemberListItemProps, ListItem} from './types'; @@ -77,8 +78,8 @@ function InviteMemberListItem({ {!!item.icons && (item.shouldShowSubscript ? ( diff --git a/src/components/SelectionList/Search/ReportListItem.tsx b/src/components/SelectionList/Search/ReportListItem.tsx index a0b96547bcd8..f5128dde973d 100644 --- a/src/components/SelectionList/Search/ReportListItem.tsx +++ b/src/components/SelectionList/Search/ReportListItem.tsx @@ -100,7 +100,7 @@ function ReportListItem({ const participantToDisplayName = participantTo?.displayName ?? participantTo?.login ?? ''; if (reportItem.transactions.length === 1) { - const transactionItem = reportItem.transactions[0]; + const transactionItem = reportItem.transactions.at(0); return ( ); diff --git a/src/components/SelectionList/Search/TransactionListItemRow.tsx.orig b/src/components/SelectionList/Search/TransactionListItemRow.tsx.orig new file mode 100644 index 000000000000..7804ffa047f4 --- /dev/null +++ b/src/components/SelectionList/Search/TransactionListItemRow.tsx.orig @@ -0,0 +1,457 @@ +import {Str} from 'expensify-common'; +import React from 'react'; +import type {StyleProp, ViewStyle} from 'react-native'; +import {View} from 'react-native'; +import Checkbox from '@components/Checkbox'; +import Icon from '@components/Icon'; +import * as Expensicons from '@components/Icon/Expensicons'; +import {PressableWithFeedback} from '@components/Pressable'; +import ReceiptImage from '@components/ReceiptImage'; +import type {TransactionListItemType} from '@components/SelectionList/types'; +import TextWithTooltip from '@components/TextWithTooltip'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as CurrencyUtils from '@libs/CurrencyUtils'; +import DateUtils from '@libs/DateUtils'; +import {getFileName} from '@libs/fileDownload/FileUtils'; +import Parser from '@libs/Parser'; +import {getThumbnailAndImageURIs} from '@libs/ReceiptUtils'; +import StringUtils from '@libs/StringUtils'; +import * as TransactionUtils from '@libs/TransactionUtils'; +import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import type {SearchTransactionType} from '@src/types/onyx/SearchResults'; +import ActionCell from './ActionCell'; +import ExpenseItemHeaderNarrow from './ExpenseItemHeaderNarrow'; +import TextWithIconCell from './TextWithIconCell'; +import UserInfoCell from './UserInfoCell'; + +type CellProps = { + // eslint-disable-next-line react/no-unused-prop-types + showTooltip: boolean; + // eslint-disable-next-line react/no-unused-prop-types + isLargeScreenWidth: boolean; +}; + +type TransactionCellProps = { + transactionItem: TransactionListItemType; +} & CellProps; + +type TotalCellProps = { + // eslint-disable-next-line react/no-unused-prop-types + isChildListItem: boolean; +} & TransactionCellProps; + +type TransactionListItemRowProps = { + item: TransactionListItemType; + showTooltip: boolean; + onButtonPress: () => void; + onCheckboxPress: () => void; + showItemHeaderOnNarrowLayout?: boolean; + containerStyle?: StyleProp; + isChildListItem?: boolean; + isDisabled: boolean; + canSelectMultiple: boolean; + isButtonSelected?: boolean; + parentAction?: string; + shouldShowTransactionCheckbox?: boolean; +}; + +const getTypeIcon = (type?: SearchTransactionType) => { + switch (type) { + case CONST.SEARCH.TRANSACTION_TYPE.CASH: + return Expensicons.Cash; + case CONST.SEARCH.TRANSACTION_TYPE.CARD: + return Expensicons.CreditCard; + case CONST.SEARCH.TRANSACTION_TYPE.DISTANCE: + return Expensicons.Car; + default: + return Expensicons.Cash; + } +}; + +function ReceiptCell({transactionItem}: TransactionCellProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + + const backgroundStyles = transactionItem.isSelected ? StyleUtils.getBackgroundColorStyle(theme.buttonHoveredBG) : StyleUtils.getBackgroundColorStyle(theme.border); + + let source = transactionItem?.receipt?.source ?? ''; + if (source) { + const filename = getFileName(source); + const receiptURIs = getThumbnailAndImageURIs(transactionItem, null, filename); + const isReceiptPDF = Str.isPDF(filename); + source = tryResolveUrlFromApiRoot(isReceiptPDF && !receiptURIs.isLocalFile ? receiptURIs.thumbnail ?? '' : receiptURIs.image ?? ''); + } + + return ( + + + + ); +} + +function DateCell({transactionItem, showTooltip, isLargeScreenWidth}: TransactionCellProps) { + const styles = useThemeStyles(); + + const created = TransactionUtils.getCreated(transactionItem); + const date = DateUtils.formatWithUTCTimeZone(created, DateUtils.doesDateBelongToAPastYear(created) ? CONST.DATE.MONTH_DAY_YEAR_ABBR_FORMAT : CONST.DATE.MONTH_DAY_ABBR_FORMAT); + + return ( + + ); +} + +function MerchantCell({transactionItem, showTooltip, isLargeScreenWidth}: TransactionCellProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const description = TransactionUtils.getDescription(transactionItem); + let merchantOrDescriptionToDisplay = transactionItem.formattedMerchant; + if (!merchantOrDescriptionToDisplay && !isLargeScreenWidth) { + merchantOrDescriptionToDisplay = Parser.htmlToText(Parser.replace(description)); + } + let merchant = transactionItem.shouldShowMerchant ? merchantOrDescriptionToDisplay : Parser.htmlToText(Parser.replace(description)); + + if (TransactionUtils.hasReceipt(transactionItem) && TransactionUtils.isReceiptBeingScanned(transactionItem) && transactionItem.shouldShowMerchant) { + merchant = translate('iou.receiptStatusTitle'); + } + const merchantToDisplay = StringUtils.getFirstLine(merchant); + return ( + + ); +} + +function TotalCell({showTooltip, isLargeScreenWidth, transactionItem}: TotalCellProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const currency = TransactionUtils.getCurrency(transactionItem); + let amount = CurrencyUtils.convertToDisplayString(transactionItem.formattedTotal, currency); + + if (TransactionUtils.hasReceipt(transactionItem) && TransactionUtils.isReceiptBeingScanned(transactionItem)) { + amount = translate('iou.receiptStatusTitle'); + } + + return ( + + ); +} + +function TypeCell({transactionItem, isLargeScreenWidth}: TransactionCellProps) { + const theme = useTheme(); + const typeIcon = getTypeIcon(transactionItem.transactionType); + + return ( + + ); +} + +function CategoryCell({isLargeScreenWidth, showTooltip, transactionItem}: TransactionCellProps) { + const styles = useThemeStyles(); + return ( + + ); +} + +function TagCell({isLargeScreenWidth, showTooltip, transactionItem}: TransactionCellProps) { + const styles = useThemeStyles(); + return isLargeScreenWidth ? ( + + ) : ( + + ); +} + +function TaxCell({transactionItem, showTooltip}: TransactionCellProps) { + const styles = useThemeStyles(); + + const isFromExpenseReport = transactionItem.reportType === CONST.REPORT.TYPE.EXPENSE; + const taxAmount = TransactionUtils.getTaxAmount(transactionItem, isFromExpenseReport); + const currency = TransactionUtils.getCurrency(transactionItem); + + return ( + + ); +} + +function TransactionListItemRow({ + item, + showTooltip, + isDisabled, + canSelectMultiple, + onButtonPress, + onCheckboxPress, + showItemHeaderOnNarrowLayout = true, + containerStyle, + isChildListItem = false, + isButtonSelected = false, + parentAction = '', + shouldShowTransactionCheckbox, +}: TransactionListItemRowProps) { + const styles = useThemeStyles(); + const {isLargeScreenWidth} = useResponsiveLayout(); + const StyleUtils = useStyleUtils(); + const theme = useTheme(); + + if (!isLargeScreenWidth) { + return ( + + {showItemHeaderOnNarrowLayout && ( + + )} + + + {canSelectMultiple && shouldShowTransactionCheckbox && ( + + + {item.isSelected && ( + + )} + + + )} + + + + {!!item.category && ( + + + + )} + + + + + + + + + + + ); + } + + return ( + + {canSelectMultiple && ( + + )} + + + + + + + + + + + + + + + + + + + + {item.shouldShowCategory && ( + + + + )} + {item.shouldShowTag && ( + + + + )} + {item.shouldShowTax && ( + + + + )} + + + + + + + + + + ); +} + +TransactionListItemRow.displayName = 'TransactionListItemRow'; + +export default TransactionListItemRow; diff --git a/src/components/SelectionList/UserListItem.tsx b/src/components/SelectionList/UserListItem.tsx index 104990cf479c..d87426d90dd8 100644 --- a/src/components/SelectionList/UserListItem.tsx +++ b/src/components/SelectionList/UserListItem.tsx @@ -13,6 +13,7 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; +import fallbackIcon from '@src/utils/getDefaultIcon'; import BaseListItem from './BaseListItem'; import type {ListItem, UserListItemProps} from './types'; @@ -100,8 +101,8 @@ function UserListItem({ {!!item.icons && (item.shouldShowSubscript ? ( diff --git a/src/components/SignInButtons/GetUserLanguage.ts b/src/components/SignInButtons/GetUserLanguage.ts index a1101e92f656..9fda67e1079b 100644 --- a/src/components/SignInButtons/GetUserLanguage.ts +++ b/src/components/SignInButtons/GetUserLanguage.ts @@ -10,7 +10,7 @@ type LocaleCode = ValueOf; const GetUserLanguage = (): LocaleCode => { const userLanguage = navigator.language || navigator.userLanguage; - const languageCode = userLanguage.split('-')[0] as LanguageCode; + const languageCode = userLanguage.split('-').at(0) as LanguageCode; return localeCodes[languageCode] || 'en_US'; }; diff --git a/src/components/TagPicker/index.tsx b/src/components/TagPicker/index.tsx index 8abcdb25aa60..1d72285be9a0 100644 --- a/src/components/TagPicker/index.tsx +++ b/src/components/TagPicker/index.tsx @@ -91,9 +91,9 @@ function TagPicker({selectedTag, tagListName, policyTags, tagListIndex, policyRe [searchValue, enabledTags, selectedOptions, policyRecentlyUsedTagsList], ); - const headerMessage = OptionsListUtils.getHeaderMessageForNonUserList((sections?.[0]?.data?.length ?? 0) > 0, searchValue); + const headerMessage = OptionsListUtils.getHeaderMessageForNonUserList((sections?.at(0)?.data?.length ?? 0) > 0, searchValue); - const selectedOptionKey = sections[0]?.data?.filter((policyTag) => policyTag.searchText === selectedTag)?.[0]?.keyForList; + const selectedOptionKey = sections.at(0)?.data?.filter((policyTag) => policyTag.searchText === selectedTag)?.[0]?.keyForList; return ( 0, searchValue); + const headerMessage = OptionsListUtils.getHeaderMessageForNonUserList((sections.at(0)?.data?.length ?? 0) > 0, searchValue); - const selectedOptionKey = useMemo(() => sections?.[0]?.data?.find((taxRate) => taxRate.searchText === selectedTaxRate)?.keyForList, [sections, selectedTaxRate]); + const selectedOptionKey = useMemo(() => sections?.at(0)?.data?.find((taxRate) => taxRate.searchText === selectedTaxRate)?.keyForList, [sections, selectedTaxRate]); const handleSelectRow = useCallback( (newSelectedOption: OptionsListUtils.TaxRatesOption) => { diff --git a/src/components/ValidateAccountMessage.tsx b/src/components/ValidateAccountMessage.tsx index 277b113b2351..d9810e859bfa 100644 --- a/src/components/ValidateAccountMessage.tsx +++ b/src/components/ValidateAccountMessage.tsx @@ -42,13 +42,13 @@ function ValidateAccountMessage({backTo}: ValidateAccountMessageProps) { { - const loginName = loginNames?.[0]; - const login = loginList?.[loginName] ?? {}; + const loginName = loginNames?.at(0); + const login = loginList?.[loginName ?? ''] ?? {}; if (!login?.validatedDate && !login?.validateCodeSent) { - User.requestContactMethodValidateCode(loginName); + User.requestContactMethodValidateCode(loginName ?? ''); } - Navigation.navigate(ROUTES.SETTINGS_CONTACT_METHOD_DETAILS.getRoute(login?.partnerUserID ?? loginNames?.[0], backTo)); + Navigation.navigate(ROUTES.SETTINGS_CONTACT_METHOD_DETAILS.getRoute(login?.partnerUserID ?? loginNames?.at(0) ?? '', backTo)); }} > {translate('bankAccount.validateAccountError.phrase4')} diff --git a/src/components/WorkspaceMembersSelectionList.tsx b/src/components/WorkspaceMembersSelectionList.tsx index 99f949903a2b..aff095f09839 100644 --- a/src/components/WorkspaceMembersSelectionList.tsx +++ b/src/components/WorkspaceMembersSelectionList.tsx @@ -94,7 +94,7 @@ function WorkspaceMembersSelectionList({policyID, selectedApprover, setApprover} setApprover(approver.login); }; - const headerMessage = useMemo(() => (searchTerm && !sections[0].data.length ? translate('common.noResultsFound') : ''), [searchTerm, sections, translate]); + const headerMessage = useMemo(() => (searchTerm && !sections.at(0)?.data.length ? translate('common.noResultsFound') : ''), [searchTerm, sections, translate]); return ( {({hovered}) => ( { diff --git a/src/hooks/useSubStep/index.ts b/src/hooks/useSubStep/index.ts index c89bb5fd5735..eb4a30037ab0 100644 --- a/src/hooks/useSubStep/index.ts +++ b/src/hooks/useSubStep/index.ts @@ -1,3 +1,4 @@ +import type {ComponentType} from 'react'; import {useCallback, useRef, useState} from 'react'; import type {SubStepProps, UseSubStep} from './types'; @@ -59,7 +60,8 @@ export default function useSubStep({bodyContent, on }, [bodyContent]); return { - componentToRender: bodyContent[screenIndex], + // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style + componentToRender: bodyContent.at(screenIndex) as ComponentType, isEditing: isEditing.current, screenIndex, prevScreen, diff --git a/src/hooks/useViolations.ts b/src/hooks/useViolations.ts index f645b03a99c3..01b6ef74e6a8 100644 --- a/src/hooks/useViolations.ts +++ b/src/hooks/useViolations.ts @@ -83,7 +83,11 @@ function useViolations(violations: TransactionViolation[], shouldShowOnlyViolati // someTagLevelsRequired has special logic becase data.errorIndexes is a bit unique in how it denotes the tag list that has the violation // tagListIndex can be 0 so we compare with undefined - if (currentViolations[0]?.name === CONST.VIOLATIONS.SOME_TAG_LEVELS_REQUIRED && data?.tagListIndex !== undefined && Array.isArray(currentViolations[0]?.data?.errorIndexes)) { + if ( + currentViolations.at(0)?.name === CONST.VIOLATIONS.SOME_TAG_LEVELS_REQUIRED && + data?.tagListIndex !== undefined && + Array.isArray(currentViolations.at(0)?.data?.errorIndexes) + ) { return currentViolations .filter((violation) => violation.data?.errorIndexes?.includes(data?.tagListIndex ?? -1)) .map((violation) => ({ @@ -97,12 +101,12 @@ function useViolations(violations: TransactionViolation[], shouldShowOnlyViolati // missingTag has special logic for policies with dependent tags, because only one violation is returned for all tags // when no tags are present, so the tag name isn't set in the violation data. That's why we add it here - if (policyHasDependentTags && currentViolations[0]?.name === CONST.VIOLATIONS.MISSING_TAG && data?.tagListName) { + if (policyHasDependentTags && currentViolations.at(0)?.name === CONST.VIOLATIONS.MISSING_TAG && data?.tagListName) { return [ { - ...currentViolations[0], + ...currentViolations.at(0), data: { - ...currentViolations[0].data, + ...currentViolations.at(0)?.data, tagName: data?.tagListName, }, }, @@ -110,13 +114,13 @@ function useViolations(violations: TransactionViolation[], shouldShowOnlyViolati } // tagOutOfPolicy has special logic because we have to account for multi-level tags and use tagName to find the right tag to put the violation on - if (currentViolations[0]?.name === CONST.VIOLATIONS.TAG_OUT_OF_POLICY && data?.tagListName !== undefined && currentViolations[0]?.data?.tagName) { + if (currentViolations.at(0)?.name === CONST.VIOLATIONS.TAG_OUT_OF_POLICY && data?.tagListName !== undefined && currentViolations.at(0)?.data?.tagName) { return currentViolations.filter((violation) => violation.data?.tagName === data?.tagListName); } // allTagLevelsRequired has special logic because it is returned when one but not all the tags are set, // so we need to return the violation for the tag fields without a tag set - if (currentViolations[0]?.name === CONST.VIOLATIONS.ALL_TAG_LEVELS_REQUIRED && tagValue) { + if (currentViolations.at(0)?.name === CONST.VIOLATIONS.ALL_TAG_LEVELS_REQUIRED && tagValue) { return currentViolations.filter((violation) => violation.data?.tagName === data?.tagListName); } diff --git a/src/languages/en.ts b/src/languages/en.ts index bd5ff405529e..78763695bc45 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -3951,11 +3951,11 @@ export default { removedFromApprovalWorkflow: ({submittersNames}: {submittersNames: string[]}) => { let joinedNames = ''; if (submittersNames.length === 1) { - joinedNames = submittersNames[0]; + joinedNames = submittersNames.at(0) ?? ''; } else if (submittersNames.length === 2) { joinedNames = submittersNames.join(' and '); } else if (submittersNames.length > 2) { - joinedNames = `${submittersNames.slice(0, submittersNames.length - 1).join(', ')} and ${submittersNames[submittersNames.length - 1]}`; + joinedNames = `${submittersNames.slice(0, submittersNames.length - 1).join(', ')} and ${submittersNames.at(submittersNames.length - 1)}`; } const workflowWord = Str.pluralize('workflow', 'workflows', submittersNames.length); const chatWord = Str.pluralize('chat', 'chats', submittersNames.length); diff --git a/src/languages/en.ts.orig b/src/languages/en.ts.orig new file mode 100755 index 000000000000..bd5ff405529e --- /dev/null +++ b/src/languages/en.ts.orig @@ -0,0 +1,4831 @@ +import {CONST as COMMON_CONST, Str} from 'expensify-common'; +import startCase from 'lodash/startCase'; +import CONST from '@src/CONST'; +import type {Country} from '@src/CONST'; +import type {ConnectionName, PolicyConnectionSyncStage, SageIntacctMappingName} from '@src/types/onyx/Policy'; +import type { + AccountOwnerParams, + AddressLineParams, + AdminCanceledRequestParams, + AlreadySignedInParams, + ApprovalWorkflowErrorParams, + ApprovedAmountParams, + AssignCardParams, + BeginningOfChatHistoryAdminRoomPartOneParams, + BeginningOfChatHistoryAnnounceRoomPartOneParams, + BeginningOfChatHistoryAnnounceRoomPartTwo, + BeginningOfChatHistoryDomainRoomPartOneParams, + CanceledRequestParams, + ChangeFieldParams, + ChangePolicyParams, + ChangeTypeParams, + CharacterLimitParams, + CompanyCardFeedNameParams, + ConfirmHoldExpenseParams, + ConfirmThatParams, + DateShouldBeAfterParams, + DateShouldBeBeforeParams, + DelegateSubmitParams, + DeleteActionParams, + DeleteConfirmationParams, + DeleteExpenseTranslationParams, + DidSplitAmountMessageParams, + DistanceRateOperationsParams, + EditActionParams, + ElectronicFundsParams, + EnterMagicCodeParams, + ExportedToIntegrationParams, + FormattedMaxLengthParams, + ForwardedAmountParams, + GoBackMessageParams, + GoToRoomParams, + InstantSummaryParams, + IssueVirtualCardParams, + LocalTimeParams, + LoggedInAsParams, + LogSizeParams, + ManagerApprovedAmountParams, + ManagerApprovedParams, + MarkedReimbursedParams, + MarkReimbursedFromIntegrationParams, + NoLongerHaveAccessParams, + NotAllowedExtensionParams, + NotYouParams, + OOOEventSummaryFullDayParams, + OOOEventSummaryPartialDayParams, + OurEmailProviderParams, + PaidElsewhereWithAmountParams, + PaidWithExpensifyWithAmountParams, + ParentNavigationSummaryParams, + PayerOwesAmountParams, + PayerOwesParams, + PayerPaidAmountParams, + PayerPaidParams, + PayerSettledParams, + PaySomeoneParams, + ReimbursementRateParams, + RemovedTheRequestParams, + RemoveMembersWarningPrompt, + RenamedRoomActionParams, + ReportArchiveReasonsClosedParams, + ReportArchiveReasonsInvoiceReceiverPolicyDeletedParams, + ReportArchiveReasonsMergedParams, + ReportArchiveReasonsPolicyDeletedParams, + ReportArchiveReasonsRemovedFromPolicyParams, + RequestAmountParams, + RequestCountParams, + RequestedAmountMessageParams, + ResolutionConstraintsParams, + RoomNameReservedErrorParams, + RoomRenamedToParams, + SetTheDistanceMerchantParams, + SetTheRequestParams, + SettledAfterAddedBankAccountParams, + SettleExpensifyCardParams, + ShareParams, + SignUpNewFaceCodeParams, + SizeExceededParams, + SplitAmountParams, + StepCounterParams, + StripePaidParams, + TaskCreatedActionParams, + TermsParams, + ThreadRequestReportNameParams, + ThreadSentMoneyReportNameParams, + ToValidateLoginParams, + TransferParams, + TranslationBase, + UnapprovedParams, + UnshareParams, + UntilTimeParams, + UpdatedTheDistanceMerchantParams, + UpdatedTheRequestParams, + UsePlusButtonParams, + UserIsAlreadyMemberParams, + UserSplitParams, + ViolationsAutoReportedRejectedExpenseParams, + ViolationsCashExpenseWithNoReceiptParams, + ViolationsConversionSurchargeParams, + ViolationsInvoiceMarkupParams, + ViolationsMaxAgeParams, + ViolationsMissingTagParams, + ViolationsModifiedAmountParams, + ViolationsOverCategoryLimitParams, + ViolationsOverLimitParams, + ViolationsPerDayLimitParams, + ViolationsReceiptRequiredParams, + ViolationsRterParams, + ViolationsTagOutOfPolicyParams, + ViolationsTaxOutOfPolicyParams, + WaitingOnBankAccountParams, + WalletProgramParams, + WelcomeEnterMagicCodeParams, + WelcomeNoteParams, + WelcomeToRoomParams, + WeSentYouMagicSignInLinkParams, + ZipCodeExampleFormatParams, +} from './types'; + +type StateValue = { + stateISO: string; + stateName: string; +}; + +type States = Record; + +type AllCountries = Record; + +/* eslint-disable max-len */ +export default { + common: { + cancel: 'Cancel', + dismiss: 'Dismiss', + yes: 'Yes', + no: 'No', + ok: 'OK', + notNow: 'Not now', + learnMore: 'Learn more', + buttonConfirm: 'Got it', + name: 'Name', + attachment: 'Attachment', + attachments: 'Attachments', + center: 'Center', + from: 'From', + to: 'To', + in: 'In', + optional: 'Optional', + new: 'New', + search: 'Search', + find: 'Find', + searchWithThreeDots: 'Search...', + next: 'Next', + previous: 'Previous', + goBack: 'Go back', + create: 'Create', + add: 'Add', + resend: 'Resend', + save: 'Save', + select: 'Select', + selectMultiple: 'Select multiple', + saveChanges: 'Save changes', + submit: 'Submit', + rotate: 'Rotate', + zoom: 'Zoom', + password: 'Password', + magicCode: 'Magic code', + twoFactorCode: 'Two-factor code', + workspaces: 'Workspaces', + inbox: 'Inbox', + group: 'Group', + profile: 'Profile', + referral: 'Referral', + payments: 'Payments', + approvals: 'Approvals', + wallet: 'Wallet', + preferences: 'Preferences', + view: 'View', + review: 'Review', + not: 'Not', + signIn: 'Sign in', + signInWithGoogle: 'Sign in with Google', + signInWithApple: 'Sign in with Apple', + signInWith: 'Sign in with', + continue: 'Continue', + firstName: 'First name', + lastName: 'Last name', + addCardTermsOfService: 'Expensify Terms of Service', + phone: 'Phone', + phoneNumber: 'Phone number', + phoneNumberPlaceholder: '(xxx) xxx-xxxx', + email: 'Email', + and: 'and', + details: 'Details', + privacy: 'Privacy', + privacyPolicy: 'Privacy Policy', + hidden: 'Hidden', + visible: 'Visible', + delete: 'Delete', + archived: 'archived', + contacts: 'Contacts', + recents: 'Recents', + close: 'Close', + download: 'Download', + downloading: 'Downloading', + pin: 'Pin', + unPin: 'Unpin', + back: 'Back', + saveAndContinue: 'Save & continue', + settings: 'Settings', + termsOfService: 'Terms of Service', + members: 'Members', + invite: 'Invite', + here: 'here', + date: 'Date', + dob: 'Date of birth', + currentYear: 'Current year', + currentMonth: 'Current month', + ssnLast4: 'Last 4 digits of SSN', + ssnFull9: 'Full 9 digits of SSN', + addressLine: ({lineNumber}: AddressLineParams) => `Address line ${lineNumber}`, + personalAddress: 'Personal address', + companyAddress: 'Company address', + noPO: 'PO boxes and mail drop addresses are not allowed', + city: 'City', + state: 'State', + streetAddress: 'Street address', + stateOrProvince: 'State / Province', + country: 'Country', + zip: 'Zip code', + zipPostCode: 'Zip / Postcode', + whatThis: "What's this?", + iAcceptThe: 'I accept the ', + remove: 'Remove', + admin: 'Admin', + owner: 'Owner', + dateFormat: 'YYYY-MM-DD', + send: 'Send', + notifications: 'Notifications', + na: 'N/A', + noResultsFound: 'No results found', + recentDestinations: 'Recent destinations', + timePrefix: "It's", + conjunctionFor: 'for', + todayAt: 'Today at', + tomorrowAt: 'Tomorrow at', + yesterdayAt: 'Yesterday at', + conjunctionAt: 'at', + conjunctionTo: 'to', + genericErrorMessage: 'Oops... something went wrong and your request could not be completed. Please try again later.', + percentage: 'Percentage', + error: { + invalidAmount: 'Invalid amount.', + acceptTerms: 'You must accept the Terms of Service to continue.', + phoneNumber: `Please enter a valid phone number, with the country code (e.g. ${CONST.EXAMPLE_PHONE_NUMBER})`, + fieldRequired: 'This field is required.', + requestModified: 'This request is being modified by another member.', + characterLimit: ({limit}: CharacterLimitParams) => `Exceeds the maximum length of ${limit} characters`, + characterLimitExceedCounter: ({length, limit}) => `Character limit exceeded (${length}/${limit})`, + dateInvalid: 'Please select a valid date.', + invalidDateShouldBeFuture: 'Please choose today or a future date.', + invalidTimeShouldBeFuture: 'Please choose a time at least one minute ahead.', + invalidCharacter: 'Invalid character.', + enterMerchant: 'Enter a merchant name.', + enterAmount: 'Enter an amount.', + enterDate: 'Enter a date.', + invalidTimeRange: 'Please enter a time using the 12-hour clock format (e.g., 2:30 PM).', + pleaseCompleteForm: 'Please complete the form above to continue.', + pleaseSelectOne: 'Please select an option above.', + }, + comma: 'comma', + semicolon: 'semicolon', + please: 'Please', + contactUs: 'contact us', + pleaseEnterEmailOrPhoneNumber: 'Please enter an email or phone number', + fixTheErrors: 'fix the errors', + inTheFormBeforeContinuing: 'in the form before continuing', + confirm: 'Confirm', + reset: 'Reset', + done: 'Done', + more: 'More', + debitCard: 'Debit card', + bankAccount: 'Bank account', + personalBankAccount: 'Personal bank account', + businessBankAccount: 'Business bank account', + join: 'Join', + leave: 'Leave', + decline: 'Decline', + transferBalance: 'Transfer balance', + cantFindAddress: "Can't find your address? ", + enterManually: 'Enter it manually', + message: 'Message ', + leaveThread: 'Leave thread', + you: 'You', + youAfterPreposition: 'you', + your: 'your', + conciergeHelp: 'Please reach out to Concierge for help.', + youAppearToBeOffline: 'You appear to be offline.', + thisFeatureRequiresInternet: 'This feature requires an active internet connection.', + attachementWillBeAvailableOnceBackOnline: 'Attachment will become available once back online.', + areYouSure: 'Are you sure?', + verify: 'Verify', + yesContinue: 'Yes, continue', + websiteExample: 'e.g. https://www.expensify.com', + zipCodeExampleFormat: ({zipSampleFormat}: ZipCodeExampleFormatParams) => (zipSampleFormat ? `e.g. ${zipSampleFormat}` : ''), + description: 'Description', + with: 'with', + shareCode: 'Share code', + share: 'Share', + per: 'per', + mi: 'mile', + km: 'kilometer', + copied: 'Copied!', + someone: 'Someone', + total: 'Total', + edit: 'Edit', + letsDoThis: `Let's do this!`, + letsStart: `Let's start`, + showMore: 'Show more', + merchant: 'Merchant', + category: 'Category', + billable: 'Billable', + nonBillable: 'Non-billable', + tag: 'Tag', + receipt: 'Receipt', + verified: 'Verified', + replace: 'Replace', + distance: 'Distance', + mile: 'mile', + miles: 'miles', + kilometer: 'kilometer', + kilometers: 'kilometers', + recent: 'Recent', + all: 'All', + am: 'AM', + pm: 'PM', + tbd: 'TBD', + selectCurrency: 'Select a currency', + card: 'Card', + whyDoWeAskForThis: 'Why do we ask for this?', + required: 'Required', + showing: 'Showing', + of: 'of', + default: 'Default', + update: 'Update', + member: 'Member', + auditor: 'Auditor', + role: 'Role', + currency: 'Currency', + rate: 'Rate', + emptyLHN: { + title: 'Woohoo! All caught up.', + subtitleText1: 'Find a chat using the', + subtitleText2: 'button above, or create something using the', + subtitleText3: 'button below.', + }, + businessName: 'Business name', + clear: 'Clear', + type: 'Type', + action: 'Action', + expenses: 'Expenses', + tax: 'Tax', + shared: 'Shared', + drafts: 'Drafts', + finished: 'Finished', + upgrade: 'Upgrade', + companyID: 'Company ID', + userID: 'User ID', + disable: 'Disable', + export: 'Export', + initialValue: 'Initial value', + currentDate: 'Current date', + value: 'Value', + downloadFailedTitle: 'Download failed', + downloadFailedDescription: "Your download couldn't be completed. Please try again later.", + filterLogs: 'Filter Logs', + network: 'Network', + reportID: 'Report ID', + chooseFile: 'Choose file', + dropTitle: 'Let it go', + dropMessage: 'Drop your file here', + ignore: 'Ignore', + enabled: 'Enabled', + import: 'Import', + offlinePrompt: "You can't take this action right now.", + outstanding: 'Outstanding', + chats: 'Chats', + unread: 'Unread', + sent: 'Sent', + links: 'Links', + days: 'days', + rename: 'Rename', + }, + location: { + useCurrent: 'Use current location', + notFound: 'We were unable to find your location. Please try again or enter an address manually.', + permissionDenied: "It looks like you've denied access to your location.", + please: 'Please', + allowPermission: 'allow location access in settings', + tryAgain: 'and try again.', + }, + anonymousReportFooter: { + logoTagline: 'Join the discussion.', + }, + attachmentPicker: { + cameraPermissionRequired: 'Camera access', + expensifyDoesntHaveAccessToCamera: "Expensify can't take photos without access to your camera. Tap settings to update permissions.", + attachmentError: 'Attachment error', + errorWhileSelectingAttachment: 'An error occurred while selecting an attachment. Please try again.', + errorWhileSelectingCorruptedAttachment: 'An error occurred while selecting a corrupted attachment. Please try another file.', + takePhoto: 'Take photo', + chooseFromGallery: 'Choose from gallery', + chooseDocument: 'Choose file', + attachmentTooLarge: 'Attachment is too large', + sizeExceeded: 'Attachment size is larger than 24 MB limit', + attachmentTooSmall: 'Attachment is too small', + sizeNotMet: 'Attachment size must be greater than 240 bytes', + wrongFileType: 'Invalid file type', + notAllowedExtension: 'This file type is not allowed. Please try a different file type.', + folderNotAllowedMessage: 'Uploading a folder is not allowed. Please try a different file.', + protectedPDFNotSupported: 'Password-protected PDF is not supported', + attachmentImageResized: 'This image has been resized for previewing. Download for full resolution.', + attachmentImageTooLarge: 'This image is too large to preview before uploading.', + }, + filePicker: { + fileError: 'File error', + errorWhileSelectingFile: 'An error occurred while selecting an file. Please try again.', + }, + connectionComplete: { + title: 'Connection complete', + supportingText: 'You can close this window and head back to the Expensify app.', + }, + avatarCropModal: { + title: 'Edit photo', + description: 'Drag, zoom, and rotate your image however you like.', + }, + composer: { + noExtensionFoundForMimeType: 'No extension found for mime type', + problemGettingImageYouPasted: 'There was a problem getting the image you pasted', + commentExceededMaxLength: ({formattedMaxLength}: FormattedMaxLengthParams) => `The maximum comment length is ${formattedMaxLength} characters.`, + }, + baseUpdateAppModal: { + updateApp: 'Update app', + updatePrompt: 'A new version of this app is available.\nUpdate now or restart the app later to download the latest changes.', + }, + deeplinkWrapper: { + launching: 'Launching Expensify', + expired: 'Your session has expired.', + signIn: 'Please sign in again.', + redirectedToDesktopApp: "We've redirected you to the desktop app.", + youCanAlso: 'You can also', + openLinkInBrowser: 'open this link in your browser', + loggedInAs: ({email}: LoggedInAsParams) => `You're logged in as ${email}. Click "Open link" in the prompt to log into the desktop app with this account.`, + doNotSeePrompt: "Can't see the prompt?", + tryAgain: 'Try again', + or: ', or', + continueInWeb: 'continue to the web app', + }, + validateCodeModal: { + successfulSignInTitle: "Abracadabra,\nyou're signed in!", + successfulSignInDescription: 'Head back to your original tab to continue.', + title: "Here's your magic code", + description: 'Please enter the code from the device\nwhere it was originally requested', + or: ', or', + signInHere: 'just sign in here', + expiredCodeTitle: 'Magic code expired', + expiredCodeDescription: 'Go back to the original device and request a new code', + successfulNewCodeRequest: 'Code requested. Please check your device.', + tfaRequiredTitle: 'Two-factor authentication\nrequired', + tfaRequiredDescription: "Please enter the two-factor authentication code\nwhere you're trying to sign in.", + requestOneHere: 'request one here.', + }, + moneyRequestConfirmationList: { + paidBy: 'Paid by', + whatsItFor: "What's it for?", + }, + selectionList: { + nameEmailOrPhoneNumber: 'Name, email, or phone number', + findMember: 'Find a member', + }, + emptyList: { + [CONST.IOU.TYPE.SUBMIT]: { + title: 'Submit an expense', + subtitleText1: 'Submit to someone and ', + subtitleText2: `get $${CONST.REFERRAL_PROGRAM.REVENUE}`, + subtitleText3: ' when they become a customer.', + }, + [CONST.IOU.TYPE.SPLIT]: { + title: 'Split an expense', + subtitleText1: 'Split with a friend and ', + subtitleText2: `get $${CONST.REFERRAL_PROGRAM.REVENUE}`, + subtitleText3: ' when they become a customer.', + }, + [CONST.IOU.TYPE.PAY]: { + title: 'Pay someone', + subtitleText1: 'Pay anyone and ', + subtitleText2: `get $${CONST.REFERRAL_PROGRAM.REVENUE}`, + subtitleText3: ' when they become a customer.', + }, + }, + videoChatButtonAndMenu: { + tooltip: 'Book a call', + }, + hello: 'Hello', + phoneCountryCode: '1', + welcomeText: { + getStarted: 'Get started below.', + anotherLoginPageIsOpen: 'Another login page is open.', + anotherLoginPageIsOpenExplanation: "You've opened the login page in a separate tab. Please log in from that tab.", + welcome: 'Welcome!', + welcomeWithoutExclamation: 'Welcome', + phrase2: "Money talks. And now that chat and payments are in one place, it's also easy.", + phrase3: 'Your payments get to you as fast as you can get your point across.', + enterPassword: 'Please enter your password', + welcomeNewFace: ({login}: SignUpNewFaceCodeParams) => `${login}, it's always great to see a new face around here!`, + welcomeEnterMagicCode: ({login}: WelcomeEnterMagicCodeParams) => `Please enter the magic code sent to ${login}. It should arrive within a minute or two.`, + }, + login: { + hero: { + header: 'Manage spend, split expenses, and chat with your team.', + body: 'Welcome to the future of Expensify, your new go-to place for financial collaboration with friends and teammates alike.', + }, + }, + thirdPartySignIn: { + alreadySignedIn: ({email}: AlreadySignedInParams) => `You're already signed in as ${email}.`, + goBackMessage: ({provider}: GoBackMessageParams) => `Don't want to sign in with ${provider}?`, + continueWithMyCurrentSession: 'Continue with my current session', + redirectToDesktopMessage: "We'll redirect you to the desktop app once you finish signing in.", + signInAgreementMessage: 'By logging in, you agree to the', + termsOfService: 'Terms of Service', + privacy: 'Privacy', + }, + samlSignIn: { + welcomeSAMLEnabled: 'Continue logging in with single sign-on:', + orContinueWithMagicCode: 'You can also sign in with a magic code', + useSingleSignOn: 'Use single sign-on', + useMagicCode: 'Use magic code', + launching: 'Launching...', + oneMoment: "One moment while we redirect you to your company's single sign-on portal.", + }, + reportActionCompose: { + dropToUpload: 'Drop to upload', + sendAttachment: 'Send attachment', + addAttachment: 'Add attachment', + writeSomething: 'Write something...', + blockedFromConcierge: 'Communication is barred', + fileUploadFailed: 'Upload failed. File is not supported.', + localTime: ({user, time}: LocalTimeParams) => `It's ${time} for ${user}`, + edited: '(edited)', + emoji: 'Emoji', + collapse: 'Collapse', + expand: 'Expand', + tooltip: { + title: 'Get started!', + subtitle: ' Submit your first expense', + }, + }, + reportActionContextMenu: { + copyToClipboard: 'Copy to clipboard', + copied: 'Copied!', + copyLink: 'Copy link', + copyURLToClipboard: 'Copy URL to clipboard', + copyEmailToClipboard: 'Copy email to clipboard', + markAsUnread: 'Mark as unread', + markAsRead: 'Mark as read', + editAction: ({action}: EditActionParams) => `Edit ${action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? 'expense' : 'comment'}`, + deleteAction: ({action}: DeleteActionParams) => `Delete ${action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? 'expense' : 'comment'}`, + deleteConfirmation: ({action}: DeleteConfirmationParams) => `Are you sure you want to delete this ${action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? 'expense' : 'comment'}?`, + onlyVisible: 'Only visible to', + replyInThread: 'Reply in thread', + joinThread: 'Join thread', + leaveThread: 'Leave thread', + copyOnyxData: 'Copy Onyx data', + flagAsOffensive: 'Flag as offensive', + menu: 'Menu', + }, + emojiReactions: { + addReactionTooltip: 'Add reaction', + reactedWith: 'reacted with', + }, + reportActionsView: { + beginningOfArchivedRoomPartOne: 'You missed the party in ', + beginningOfArchivedRoomPartTwo: ", there's nothing to see here.", + beginningOfChatHistoryDomainRoomPartOne: ({domainRoom}: BeginningOfChatHistoryDomainRoomPartOneParams) => `Collaboration with everyone at ${domainRoom} starts here! 🎉\nUse `, + beginningOfChatHistoryDomainRoomPartTwo: ' to chat with colleagues, share tips, and ask questions.', + beginningOfChatHistoryAdminRoomPartOne: ({workspaceName}: BeginningOfChatHistoryAdminRoomPartOneParams) => `Collaboration among ${workspaceName} admins starts here! 🎉\nUse `, + beginningOfChatHistoryAdminRoomPartTwo: ' to chat about topics such as workspace configurations and more.', + beginningOfChatHistoryAnnounceRoomPartOne: ({workspaceName}: BeginningOfChatHistoryAnnounceRoomPartOneParams) => + `Collaboration between all ${workspaceName} members starts here! 🎉\nUse `, + beginningOfChatHistoryAnnounceRoomPartTwo: ({workspaceName}: BeginningOfChatHistoryAnnounceRoomPartTwo) => ` to chat about anything ${workspaceName} related.`, + beginningOfChatHistoryUserRoomPartOne: 'Collaboration starts here! 🎉\nUse this space to chat about anything ', + beginningOfChatHistoryUserRoomPartTwo: ' related.', + beginningOfChatHistoryInvoiceRoom: 'Collaboration starts here! 🎉 Use this room to view, discuss, and pay invoices.', + beginningOfChatHistory: 'This is the beginning of your chat with ', + beginningOfChatHistoryPolicyExpenseChatPartOne: 'Collaboration between ', + beginningOfChatHistoryPolicyExpenseChatPartTwo: ' and ', + beginningOfChatHistoryPolicyExpenseChatPartThree: ' starts here! 🎉 This is the place to chat, submit expenses and settle up.', + beginningOfChatHistorySelfDM: 'This is your personal space. Use it for notes, tasks, drafts, and reminders.', + beginningOfChatHistorySystemDM: "Welcome! Let's get you set up.", + chatWithAccountManager: 'Chat with your account manager here', + sayHello: 'Say hello!', + yourSpace: 'Your space', + welcomeToRoom: ({roomName}: WelcomeToRoomParams) => `Welcome to ${roomName}!`, + usePlusButton: ({additionalText}: UsePlusButtonParams) => `\nYou can also use the + button to ${additionalText}, or assign a task!`, + iouTypes: { + pay: 'pay expenses', + split: 'split an expense', + submit: 'submit an expense', + track: 'track an expense', + invoice: 'invoice an expense', + }, + }, + adminOnlyCanPost: 'Only admins can send messages in this room.', + reportAction: { + asCopilot: 'as copilot for', + }, + mentionSuggestions: { + hereAlternateText: 'Notify everyone in this conversation', + }, + newMessages: 'New messages', + youHaveBeenBanned: "Note: You've been banned from chatting in this channel.", + reportTypingIndicator: { + isTyping: 'is typing...', + areTyping: 'are typing...', + multipleUsers: 'Multiple users', + }, + reportArchiveReasons: { + [CONST.REPORT.ARCHIVE_REASON.DEFAULT]: 'This chat room has been archived.', + [CONST.REPORT.ARCHIVE_REASON.ACCOUNT_CLOSED]: ({displayName}: ReportArchiveReasonsClosedParams) => `This chat is no longer active because ${displayName} closed their account.`, + [CONST.REPORT.ARCHIVE_REASON.ACCOUNT_MERGED]: ({displayName, oldDisplayName}: ReportArchiveReasonsMergedParams) => + `This chat is no longer active because ${oldDisplayName} has merged their account with ${displayName}.`, + [CONST.REPORT.ARCHIVE_REASON.REMOVED_FROM_POLICY]: ({displayName, policyName, shouldUseYou = false}: ReportArchiveReasonsRemovedFromPolicyParams) => + shouldUseYou + ? `This chat is no longer active because you are no longer a member of the ${policyName} workspace.` + : `This chat is no longer active because ${displayName} is no longer a member of the ${policyName} workspace.`, + [CONST.REPORT.ARCHIVE_REASON.POLICY_DELETED]: ({policyName}: ReportArchiveReasonsPolicyDeletedParams) => + `This chat is no longer active because ${policyName} is no longer an active workspace.`, + [CONST.REPORT.ARCHIVE_REASON.INVOICE_RECEIVER_POLICY_DELETED]: ({policyName}: ReportArchiveReasonsInvoiceReceiverPolicyDeletedParams) => + `This chat is no longer active because ${policyName} is no longer an active workspace.`, + [CONST.REPORT.ARCHIVE_REASON.BOOKING_END_DATE_HAS_PASSED]: 'This booking is archived.', + }, + writeCapabilityPage: { + label: 'Who can post', + writeCapability: { + all: 'All members', + admins: 'Admins only', + }, + }, + sidebarScreen: { + buttonFind: 'Find something...', + buttonMySettings: 'My settings', + fabNewChat: 'Start chat', + fabNewChatExplained: 'Start chat (Floating action)', + chatPinned: 'Chat pinned', + draftedMessage: 'Drafted message', + listOfChatMessages: 'List of chat messages', + listOfChats: 'List of chats', + saveTheWorld: 'Save the world', + tooltip: 'Get started here!', + }, + allSettingsScreen: { + subscription: 'Subscription', + domains: 'Domains', + }, + tabSelector: { + chat: 'Chat', + room: 'Room', + distance: 'Distance', + manual: 'Manual', + scan: 'Scan', + }, + spreadsheet: { + upload: 'Upload a spreadsheet', + dragAndDrop: 'Drag and drop your spreadsheet here, or choose a file below. Supported formats: .csv, .txt, .xls, and .xlsx.', + chooseSpreadsheet: 'Select a spreadsheet file to import. Supported formats: .csv, .txt, .xls, and .xlsx.', + fileContainsHeader: 'File contains column headers', + column: (name: string) => `Column ${name}`, + fieldNotMapped: (fieldName: string) => `Oops! A required field ("${fieldName}") hasn't been mapped. Please review and try again.`, + singleFieldMultipleColumns: (fieldName: string) => `Oops! You've mapped a single field ("${fieldName}") to multiple columns. Please review and try again.`, + importSuccessfullTitle: 'Import successful', + importCategoriesSuccessfullDescription: (categories: number) => (categories > 1 ? `${categories} categories have been added.` : '1 category has been added.'), + importTagsSuccessfullDescription: (tags: number) => (tags > 1 ? `${tags} tags have been added.` : '1 tag has been added.'), + importFailedTitle: 'Import failed', + importFailedDescription: 'Please ensure all fields are filled out correctly and try again. If the problem persists, please reach out to Concierge.', + importDescription: 'Choose which fields to map from your spreadsheet by clicking the dropdown next to each imported column below.', + sizeNotMet: 'File size must be greater than 0 bytes', + invalidFileMessage: + 'The file you uploaded is either empty or contains invalid data. Please ensure that the file is correctly formatted and contains the necessary information before uploading it again.', + importSpreadsheet: 'Import spreadsheet', + downloadCSV: 'Download CSV', + }, + receipt: { + upload: 'Upload receipt', + dragReceiptBeforeEmail: 'Drag a receipt onto this page, forward a receipt to ', + dragReceiptAfterEmail: ' or choose a file to upload below.', + chooseReceipt: 'Choose a receipt to upload or forward a receipt to ', + takePhoto: 'Take a photo', + cameraAccess: 'Camera access is required to take pictures of receipts.', + cameraErrorTitle: 'Camera error', + cameraErrorMessage: 'An error occurred while taking a photo. Please try again.', + locationAccessTitle: 'Allow location access', + locationAccessMessage: 'Location access helps us keep your timezone and currency accurate wherever you go.', + locationErrorTitle: 'Allow location access', + locationErrorMessage: 'Location access helps us keep your timezone and currency accurate wherever you go.', + dropTitle: 'Let it go', + dropMessage: 'Drop your file here', + flash: 'flash', + shutter: 'shutter', + gallery: 'gallery', + deleteReceipt: 'Delete receipt', + deleteConfirmation: 'Are you sure you want to delete this receipt?', + addReceipt: 'Add receipt', + }, + quickAction: { + scanReceipt: 'Scan receipt', + recordDistance: 'Record distance', + requestMoney: 'Submit expense', + splitBill: 'Split expense', + splitScan: 'Split receipt', + splitDistance: 'Split distance', + paySomeone: (name: string) => `Pay ${name ?? 'someone'}`, + assignTask: 'Assign task', + header: 'Quick action', + trackManual: 'Track expense', + trackScan: 'Track receipt', + trackDistance: 'Track distance', + noLongerHaveReportAccess: 'You no longer have access to your previous quick action destination. Pick a new one below.', + updateDestination: 'Update destination', + tooltip: { + title: 'Quick action! ', + subtitle: 'Just a tap away.', + }, + }, + iou: { + amount: 'Amount', + taxAmount: 'Tax amount', + taxRate: 'Tax rate', + approve: 'Approve', + approved: 'Approved', + cash: 'Cash', + card: 'Card', + original: 'Original', + split: 'Split', + splitExpense: 'Split expense', + paySomeone: ({name}: PaySomeoneParams) => `Pay ${name ?? 'someone'}`, + expense: 'Expense', + categorize: 'Categorize', + share: 'Share', + participants: 'Participants', + submitExpense: 'Submit expense', + trackExpense: 'Track expense', + pay: 'Pay', + cancelPayment: 'Cancel payment', + cancelPaymentConfirmation: 'Are you sure that you want to cancel this payment?', + viewDetails: 'View details', + pending: 'Pending', + canceled: 'Canceled', + posted: 'Posted', + deleteReceipt: 'Delete receipt', + pendingMatchWithCreditCard: 'Receipt pending match with card transaction', + pendingMatchWithCreditCardDescription: 'Receipt pending match with card transaction. Mark as cash to cancel.', + markAsCash: 'Mark as cash', + routePending: 'Route pending...', + receiptScanning: 'Receipt scanning...', + receiptScanInProgress: 'Receipt scan in progress', + receiptScanInProgressDescription: 'Receipt scan in progress. Check back later or enter the details now.', + receiptIssuesFound: (count: number) => `${count === 1 ? 'Issue' : 'Issues'} found`, + fieldPending: 'Pending...', + defaultRate: 'Default rate', + receiptMissingDetails: 'Receipt missing details', + missingAmount: 'Missing amount', + missingMerchant: 'Missing merchant', + receiptStatusTitle: 'Scanning…', + receiptStatusText: "Only you can see this receipt when it's scanning. Check back later or enter the details now.", + receiptScanningFailed: 'Receipt scanning failed. Please enter the details manually.', + transactionPendingDescription: 'Transaction pending. It may take a few days to post.', + companyInfo: 'Company info', + companyInfoDescription: 'We need a few more details before you can send your first invoice.', + yourCompanyName: 'Your company name', + yourCompanyWebsite: 'Your company website', + yourCompanyWebsiteNote: "If you don't have a website, you can provide your company's LinkedIn or social media profile instead.", + invalidDomainError: 'You have entered an invalid domain. To continue, please enter a valid domain.', + publicDomainError: 'You have entered a public domain. To continue, please enter a private domain.', + expenseCount: ({count, scanningReceipts = 0, pendingReceipts = 0}: RequestCountParams) => { + const expenseText = `${count} ${Str.pluralize('expense', 'expenses', count)}`; + const statusText = []; + if (scanningReceipts > 0) { + statusText.push(`${scanningReceipts} scanning`); + } + if (pendingReceipts > 0) { + statusText.push(`${pendingReceipts} pending`); + } + return statusText.length > 0 ? `${expenseText} (${statusText.join(', ')})` : expenseText; + }, + deleteExpense: ({count}: DeleteExpenseTranslationParams = {count: 1}) => `Delete ${Str.pluralize('expense', 'expenses', count)}`, + deleteConfirmation: ({count}: DeleteExpenseTranslationParams = {count: 1}) => `Are you sure that you want to delete ${Str.pluralize('this expense', 'these expenses', count)}?`, + settledExpensify: 'Paid', + settledElsewhere: 'Paid elsewhere', + individual: 'Individual', + business: 'Business', + settleExpensify: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pay ${formattedAmount} with Expensify` : `Pay with Expensify`), + settlePersonal: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pay ${formattedAmount} as an individual` : `Pay as an individual`), + settlePayment: ({formattedAmount}: SettleExpensifyCardParams) => `Pay ${formattedAmount}`, + settleBusiness: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pay ${formattedAmount} as a business` : `Pay as a business`), + payElsewhere: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pay ${formattedAmount} elsewhere` : `Pay elsewhere`), + nextStep: 'Next steps', + finished: 'Finished', + sendInvoice: ({amount}: RequestAmountParams) => `Send ${amount} invoice`, + submitAmount: ({amount}: RequestAmountParams) => `submit ${amount}`, + submittedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `submitted ${formattedAmount}${comment ? ` for ${comment}` : ''}`, + trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `tracking ${formattedAmount}${comment ? ` for ${comment}` : ''}`, + splitAmount: ({amount}: SplitAmountParams) => `split ${amount}`, + didSplitAmount: ({formattedAmount, comment}: DidSplitAmountMessageParams) => `split ${formattedAmount}${comment ? ` for ${comment}` : ''}`, + yourSplit: ({amount}: UserSplitParams) => `Your split ${amount}`, + payerOwesAmount: ({payer, amount, comment}: PayerOwesAmountParams) => `${payer} owes ${amount}${comment ? ` for ${comment}` : ''}`, + payerOwes: ({payer}: PayerOwesParams) => `${payer} owes: `, + payerPaidAmount: ({payer, amount}: PayerPaidAmountParams): string => `${payer ? `${payer} ` : ''}paid ${amount}`, + payerPaid: ({payer}: PayerPaidParams) => `${payer} paid: `, + payerSpentAmount: ({payer, amount}: PayerPaidAmountParams): string => `${payer} spent ${amount}`, + payerSpent: ({payer}: PayerPaidParams) => `${payer} spent: `, + managerApproved: ({manager}: ManagerApprovedParams) => `${manager} approved:`, + managerApprovedAmount: ({manager, amount}: ManagerApprovedAmountParams) => `${manager} approved ${amount}`, + payerSettled: ({amount}: PayerSettledParams) => `paid ${amount}`, + payerSettledWithMissingBankAccount: ({amount}: PayerSettledParams) => `paid ${amount}. Add a bank account to receive your payment.`, + approvedAmount: ({amount}: ApprovedAmountParams) => `approved ${amount}`, + forwardedAmount: ({amount}: ForwardedAmountParams) => `approved ${amount}`, + rejectedThisReport: 'rejected this report', + waitingOnBankAccount: ({submitterDisplayName}: WaitingOnBankAccountParams) => `started settling up. Payment is on hold until ${submitterDisplayName} adds a bank account.`, + adminCanceledRequest: ({manager, amount}: AdminCanceledRequestParams) => `${manager ? `${manager}: ` : ''}cancelled the ${amount} payment.`, + canceledRequest: ({amount, submitterDisplayName}: CanceledRequestParams) => + `canceled the ${amount} payment, because ${submitterDisplayName} did not enable their Expensify Wallet within 30 days`, + settledAfterAddedBankAccount: ({submitterDisplayName, amount}: SettledAfterAddedBankAccountParams) => + `${submitterDisplayName} added a bank account. The ${amount} payment has been made.`, + paidElsewhereWithAmount: ({payer, amount}: PaidElsewhereWithAmountParams) => `${payer ? `${payer} ` : ''}paid ${amount} elsewhere`, + paidWithExpensifyWithAmount: ({payer, amount}: PaidWithExpensifyWithAmountParams) => `${payer ? `${payer} ` : ''}paid ${amount} with Expensify`, + noReimbursableExpenses: 'This report has an invalid amount', + pendingConversionMessage: "Total will update when you're back online", + changedTheExpense: 'changed the expense', + setTheRequest: ({valueName, newValueToDisplay}: SetTheRequestParams) => `the ${valueName} to ${newValueToDisplay}`, + setTheDistanceMerchant: ({translatedChangedField, newMerchant, newAmountToDisplay}: SetTheDistanceMerchantParams) => + `set the ${translatedChangedField} to ${newMerchant}, which set the amount to ${newAmountToDisplay}`, + removedTheRequest: ({valueName, oldValueToDisplay}: RemovedTheRequestParams) => `the ${valueName} (previously ${oldValueToDisplay})`, + updatedTheRequest: ({valueName, newValueToDisplay, oldValueToDisplay}: UpdatedTheRequestParams) => `the ${valueName} to ${newValueToDisplay} (previously ${oldValueToDisplay})`, + updatedTheDistanceMerchant: ({translatedChangedField, newMerchant, oldMerchant, newAmountToDisplay, oldAmountToDisplay}: UpdatedTheDistanceMerchantParams) => + `changed the ${translatedChangedField} to ${newMerchant} (previously ${oldMerchant}), which updated the amount to ${newAmountToDisplay} (previously ${oldAmountToDisplay})`, + threadExpenseReportName: ({formattedAmount, comment}: ThreadRequestReportNameParams) => `${formattedAmount} ${comment ? `for ${comment}` : 'expense'}`, + threadTrackReportName: ({formattedAmount, comment}: ThreadRequestReportNameParams) => `Tracking ${formattedAmount} ${comment ? `for ${comment}` : ''}`, + threadPaySomeoneReportName: ({formattedAmount, comment}: ThreadSentMoneyReportNameParams) => `${formattedAmount} sent${comment ? ` for ${comment}` : ''}`, + tagSelection: 'Select a tag to better organize your spend.', + categorySelection: 'Select a category to better organize your spend.', + error: { + invalidCategoryLength: 'The category name exceeds 255 characters. Please shorten it or choose a different category.', + invalidAmount: 'Please enter a valid amount before continuing.', + invalidTaxAmount: ({amount}: RequestAmountParams) => `Maximum tax amount is ${amount}`, + invalidSplit: 'The sum of splits must equal the total amount.', + invalidSplitParticipants: 'Please enter an amount greater than zero for at least two participants.', + invalidSplitYourself: 'Please enter a non-zero amount for your split.', + other: 'Unexpected error. Please try again later.', + genericCreateFailureMessage: 'Unexpected error submitting this expense. Please try again later.', + genericCreateInvoiceFailureMessage: 'Unexpected error sending this invoice. Please try again later.', + genericHoldExpenseFailureMessage: 'Unexpected error holding this expense. Please try again later.', + genericUnholdExpenseFailureMessage: 'Unexpected error taking this expense off hold. Please try again later.', + receiptDeleteFailureError: 'Unexpected error deleting this receipt. Please try again later.', + receiptFailureMessage: "The receipt didn't upload.", + // eslint-disable-next-line rulesdir/use-periods-for-error-messages + saveFileMessage: 'Download the file ', + loseFileMessage: 'or dismiss this error and lose it.', + genericDeleteFailureMessage: 'Unexpected error deleting this expense. Please try again later.', + genericEditFailureMessage: 'Unexpected error editing this expense. Please try again later.', + genericSmartscanFailureMessage: 'Transaction is missing fields.', + duplicateWaypointsErrorMessage: 'Please remove duplicate waypoints.', + atLeastTwoDifferentWaypoints: 'Please enter at least two different addresses.', + splitExpenseMultipleParticipantsErrorMessage: 'An expense cannot be split between a workspace and other members. Please update your selection.', + invalidMerchant: 'Please enter a correct merchant.', + }, + waitingOnEnabledWallet: ({submitterDisplayName}: WaitingOnBankAccountParams) => `started settling up. Payment is on hold until ${submitterDisplayName} enables their wallet.`, + enableWallet: 'Enable wallet', + hold: 'Hold', + unhold: 'Unhold', + holdExpense: 'Hold expense', + unholdExpense: 'Unhold expense', + heldExpense: 'held this expense', + unheldExpense: 'unheld this expense', + explainHold: "Explain why you're holding this expense.", + reason: 'Reason', + holdReasonRequired: 'A reason is required when holding.', + expenseOnHold: 'This expense was put on hold. Please review the comments for next steps.', + expensesOnHold: 'All expenses were put on hold. Please review the comments for next steps.', + expenseDuplicate: 'This expense has the same details as another one. Please review the duplicates to remove the hold.', + someDuplicatesArePaid: 'Some of these duplicates have been approved or paid already.', + reviewDuplicates: 'Review duplicates', + keepAll: 'Keep all', + confirmApprove: 'Confirm approval amount', + confirmApprovalAmount: 'Approve only compliant expenses, or approve the entire report.', + confirmApprovalAllHoldAmount: ({transactionCount}: ConfirmHoldExpenseParams) => + `${Str.pluralize('This expense is', 'These expenses are', transactionCount)} on hold. Do you want to approve anyway?`, + confirmPay: 'Confirm payment amount', + confirmPayAmount: "Pay what's not on hold, or pay the entire report.", + confirmPayAllHoldAmount: ({transactionCount}: ConfirmHoldExpenseParams) => + `${Str.pluralize('This expense is', 'These expenses are', transactionCount)} on hold. Do you want to pay anyway?`, + payOnly: 'Pay only', + approveOnly: 'Approve only', + holdEducationalTitle: 'This expense is on', + whatIsHoldTitle: 'What is hold?', + whatIsHoldExplain: 'Hold is our way of streamlining financial collaboration. "Reject" is so harsh!', + holdIsTemporaryTitle: 'Hold is usually temporary', + holdIsTemporaryExplain: "Hold is used to clear up confusion or clarify an important detail before payment. Don't worry, it's not permanent!", + deleteHoldTitle: "Delete whatever won't be paid", + deleteHoldExplain: "In the rare case where something's put on hold and won't be paid, it's on the person requesting payment to delete it.", + set: 'set', + changed: 'changed', + removed: 'removed', + transactionPending: 'Transaction pending.', + chooseARate: ({unit}: ReimbursementRateParams) => `Select a workspace reimbursement rate per ${unit}`, + unapprove: 'Unapprove', + unapproveReport: 'Unapprove report', + headsUp: 'Heads up!', + unapproveWithIntegrationWarning: (accountingIntegration: string) => + `This report has already been exported to ${accountingIntegration}. Changes to this report in Expensify may lead to data discrepancies and Expensify Card reconciliation issues. Are you sure you want to unapprove this report?`, + reimbursable: 'reimbursable', + nonReimbursable: 'non-reimbursable', + bookingPending: 'This booking is pending', + bookingPendingDescription: "This booking is pending because it hasn't been paid yet.", + bookingArchived: 'This booking is archived', + bookingArchivedDescription: 'This booking is archived because the trip date has passed. Add an expense for the final amount if needed.', + }, + notificationPreferencesPage: { + header: 'Notification preferences', + label: 'Notify me about new messages', + notificationPreferences: { + always: 'Immediately', + daily: 'Daily', + mute: 'Mute', + hidden: 'Hidden', + }, + }, + loginField: { + numberHasNotBeenValidated: "The number hasn't been validated. Click the button to resend the validation link via text.", + emailHasNotBeenValidated: "The email hasn't been validated. Click the button to resend the validation link via text.", + }, + avatarWithImagePicker: { + uploadPhoto: 'Upload photo', + removePhoto: 'Remove photo', + editImage: 'Edit photo', + viewPhoto: 'View photo', + imageUploadFailed: 'Image upload failed', + deleteWorkspaceError: 'Sorry, there was an unexpected problem deleting your workspace avatar', + sizeExceeded: ({maxUploadSizeInMB}: SizeExceededParams) => `The selected image exceeds the maximum upload size of ${maxUploadSizeInMB}MB.`, + resolutionConstraints: ({minHeightInPx, minWidthInPx, maxHeightInPx, maxWidthInPx}: ResolutionConstraintsParams) => + `Please upload an image larger than ${minHeightInPx}x${minWidthInPx} pixels and smaller than ${maxHeightInPx}x${maxWidthInPx} pixels.`, + notAllowedExtension: ({allowedExtensions}: NotAllowedExtensionParams) => `Profile picture must be one of the following types: ${allowedExtensions.join(', ')}.`, + }, + profilePage: { + profile: 'Profile', + preferredPronouns: 'Preferred pronouns', + selectYourPronouns: 'Select your pronouns', + selfSelectYourPronoun: 'Self-select your pronoun', + emailAddress: 'Email address', + setMyTimezoneAutomatically: 'Set my timezone automatically', + timezone: 'Timezone', + invalidFileMessage: 'Invalid file. Please try a different image.', + avatarUploadFailureMessage: 'An error occurred uploading the avatar. Please try again.', + online: 'Online', + offline: 'Offline', + syncing: 'Syncing', + profileAvatar: 'Profile avatar', + publicSection: { + title: 'Public', + subtitle: 'These details are displayed on your public profile. Anyone can see them.', + }, + privateSection: { + title: 'Private', + subtitle: "These details are used for travel and payments. They're never shown on your public profile.", + }, + }, + securityPage: { + title: 'Security options', + subtitle: 'Enable two-factor authentication to keep your account safe.', + }, + shareCodePage: { + title: 'Your code', + subtitle: 'Invite members to Expensify by sharing your personal QR code or referral link.', + }, + pronounsPage: { + pronouns: 'Pronouns', + isShownOnProfile: 'Your pronouns are shown on your profile.', + placeholderText: 'Search to see options', + }, + contacts: { + contactMethod: 'Contact method', + contactMethods: 'Contact methods', + helpTextBeforeEmail: 'Add more ways for people to find you, and forward receipts to ', + helpTextAfterEmail: ' from multiple email addresses.', + pleaseVerify: 'Please verify this contact method', + getInTouch: "Whenever we need to get in touch with you, we'll use this contact method.", + enterMagicCode: ({contactMethod}: EnterMagicCodeParams) => `Please enter the magic code sent to ${contactMethod}`, + setAsDefault: 'Set as default', + yourDefaultContactMethod: "This is your current default contact method. Before you can delete it, you'll need to choose another contact method and click “Set as default”.", + removeContactMethod: 'Remove contact method', + removeAreYouSure: "Are you sure you want to remove this contact method? This action can't be undone.", + failedNewContact: 'Failed to add this contact method.', + genericFailureMessages: { + requestContactMethodValidateCode: 'Failed to send a new magic code. Please wait a bit and try again.', + validateSecondaryLogin: 'Incorrect or invalid magic code. Please try again or request a new code.', + deleteContactMethod: 'Failed to delete contact method. Please reach out to Concierge for help.', + setDefaultContactMethod: 'Failed to set a new default contact method. Please reach out to Concierge for help.', + addContactMethod: 'Failed to add this contact method. Please reach out to Concierge for help.', + enteredMethodIsAlreadySubmited: 'This contact method already exists.', + passwordRequired: 'password required.', + contactMethodRequired: 'Contact method is required.', + invalidContactMethod: 'Invalid contact method', + }, + newContactMethod: 'New contact method', + goBackContactMethods: 'Go back to contact methods', + }, + pronouns: { + coCos: 'Co / Cos', + eEyEmEir: 'E / Ey / Em / Eir', + faeFaer: 'Fae / Faer', + heHimHis: 'He / Him / His', + heHimHisTheyThemTheirs: 'He / Him / His / They / Them / Theirs', + sheHerHers: 'She / Her / Hers', + sheHerHersTheyThemTheirs: 'She / Her / Hers / They / Them / Theirs', + merMers: 'Mer / Mers', + neNirNirs: 'Ne / Nir / Nirs', + neeNerNers: 'Nee / Ner / Ners', + perPers: 'Per / Pers', + theyThemTheirs: 'They / Them / Theirs', + thonThons: 'Thon / Thons', + veVerVis: 'Ve / Ver / Vis', + viVir: 'Vi / Vir', + xeXemXyr: 'Xe / Xem / Xyr', + zeZieZirHir: 'Ze / Zie / Zir / Hir', + zeHirHirs: 'Ze / Hir', + callMeByMyName: 'Call me by my name', + }, + displayNamePage: { + headerTitle: 'Display name', + isShownOnProfile: 'Your display name is shown on your profile.', + }, + timezonePage: { + timezone: 'Timezone', + isShownOnProfile: 'Your timezone is shown on your profile.', + getLocationAutomatically: 'Automatically determine your location', + }, + updateRequiredView: { + updateRequired: 'Update required', + pleaseInstall: 'Please update to the latest version of New Expensify', + toGetLatestChanges: 'For mobile or desktop, download and install the latest version. For web, refresh your browser.', + }, + initialSettingsPage: { + about: 'About', + aboutPage: { + description: 'The New Expensify App is built by a community of open-source developers from around the world. Help us build the future of Expensify.', + appDownloadLinks: 'App download links', + viewKeyboardShortcuts: 'View keyboard shortcuts', + viewTheCode: 'View the code', + viewOpenJobs: 'View open jobs', + reportABug: 'Report a bug', + troubleshoot: 'Troubleshoot', + }, + appDownloadLinks: { + android: { + label: 'Android', + }, + ios: { + label: 'iOS', + }, + desktop: { + label: 'macOS', + }, + }, + troubleshoot: { + clearCacheAndRestart: 'Clear cache and restart', + viewConsole: 'View debug console', + debugConsole: 'Debug console', + description: 'Use the tools below to help troubleshoot the Expensify experience. If you encounter any issues, please', + submitBug: 'submit a bug', + confirmResetDescription: 'All unsent draft messages will be lost, but the rest of your data is safe.', + resetAndRefresh: 'Reset and refresh', + clientSideLogging: 'Client side logging', + noLogsToShare: 'No logs to share', + useProfiling: 'Use profiling', + profileTrace: 'Profile trace', + releaseOptions: 'Release options', + testingPreferences: 'Testing preferences', + useStagingServer: 'Use Staging Server', + forceOffline: 'Force offline', + simulatFailingNetworkRequests: 'Simulate failing network requests', + authenticationStatus: 'Authentication status', + deviceCredentials: 'Device credentials', + invalidate: 'Invalidate', + destroy: 'Destroy', + maskExportOnyxStateData: 'Mask fragile user data while exporting Onyx state', + exportOnyxState: 'Export Onyx state', + testCrash: 'Test crash', + debugMode: 'Debug mode', + }, + debugConsole: { + saveLog: 'Save log', + shareLog: 'Share log', + enterCommand: 'Enter command', + execute: 'Execute', + noLogsAvailable: 'No logs available', + logSizeTooLarge: ({size}: LogSizeParams) => `Log size exceeds the limit of ${size} MB. Please use "Save log" to download the log file instead.`, + logs: 'Logs', + viewConsole: 'View console', + }, + security: 'Security', + signOut: 'Sign out', + restoreStashed: 'Restore stashed login', + signOutConfirmationText: "You'll lose any offline changes if you sign out.", + versionLetter: 'v', + readTheTermsAndPrivacy: { + phrase1: 'Read the', + phrase2: 'Terms of Service', + phrase3: 'and', + phrase4: 'Privacy', + }, + returnToClassic: 'Switch to Expensify Classic', + help: 'Help', + accountSettings: 'Account settings', + account: 'Account', + general: 'General', + }, + closeAccountPage: { + closeAccount: 'Close account', + reasonForLeavingPrompt: 'We’d hate to see you go! Would you kindly tell us why, so we can improve?', + enterMessageHere: 'Enter message here', + closeAccountWarning: 'Closing your account cannot be undone.', + closeAccountPermanentlyDeleteData: 'Are you sure you want to delete your account? This will permanently delete any outstanding expenses.', + enterDefaultContactToConfirm: 'Please enter your default contact method to confirm you wish to close your account. Your default contact method is:', + enterDefaultContact: 'Enter your default contact method', + defaultContact: 'Default contact method:', + enterYourDefaultContactMethod: 'Please enter your default contact method to close your account.', + }, + passwordPage: { + changePassword: 'Change password', + changingYourPasswordPrompt: 'Changing your password will update your password for both your Expensify.com and New Expensify accounts.', + currentPassword: 'Current password', + newPassword: 'New password', + newPasswordPrompt: 'Your new password must be different from your old password and contain at least 8 characters, 1 capital letter, 1 lowercase letter, and 1 number.', + }, + twoFactorAuth: { + headerTitle: 'Two-factor authentication', + twoFactorAuthEnabled: 'Two-factor authentication enabled', + whatIsTwoFactorAuth: 'Two-factor authentication (2FA) helps keep your account safe. When logging in, you’ll need to enter a code generated by your preferred authenticator app.', + disableTwoFactorAuth: 'Disable two-factor authentication', + explainProcessToRemove: 'In order to disable two-factor authentication (2FA), please enter a valid code from your authentication app.', + disabled: 'Two-factor authentication is now disabled', + noAuthenticatorApp: 'You’ll no longer require an authenticator app to log into Expensify.', + stepCodes: 'Recovery codes', + keepCodesSafe: 'Keep these recovery codes safe!', + codesLoseAccess: + "If you lose access to your authenticator app and don’t have these codes, you'll lose access to your account. \n\nNote: Setting up two-factor authentication will log you out of all other active sessions.", + errorStepCodes: 'Please copy or download codes before continuing.', + stepVerify: 'Verify', + scanCode: 'Scan the QR code using your', + authenticatorApp: 'authenticator app', + addKey: 'Or add this secret key to your authenticator app:', + enterCode: 'Then enter the six-digit code generated from your authenticator app.', + stepSuccess: 'Finished', + enabled: 'Two-factor authentication is now enabled!', + congrats: 'Congrats, now you’ve got that extra security.', + copy: 'Copy', + disable: 'Disable', + enableTwoFactorAuth: 'Enable two-factor authentication', + pleaseEnableTwoFactorAuth: 'Please enable two-factor authentication.', + twoFactorAuthIsRequiredDescription: 'Two-factor authentication is required for connecting to Xero. Please enable two-factor authentication to continue.', + twoFactorAuthIsRequiredForAdminsDescription: 'Two-factor authentication is required for Xero workspace admins. Please enable two-factor authentication to continue.', + }, + recoveryCodeForm: { + error: { + pleaseFillRecoveryCode: 'Please enter your recovery code.', + incorrectRecoveryCode: 'Incorrect recovery code. Please try again.', + }, + useRecoveryCode: 'Use recovery code', + recoveryCode: 'Recovery code', + use2fa: 'Use two-factor authentication code', + }, + twoFactorAuthForm: { + error: { + pleaseFillTwoFactorAuth: 'Please enter your two-factor authentication code.', + incorrect2fa: 'Incorrect two-factor authentication code. Please try again.', + }, + }, + passwordConfirmationScreen: { + passwordUpdated: 'Password updated!', + allSet: 'You’re all set. Keep your new password safe.', + }, + privateNotes: { + title: 'Private notes', + personalNoteMessage: "Keep notes about this chat here. You're the only person who can add, edit, or view these notes.", + sharedNoteMessage: 'Keep notes about this chat here. Expensify employees and other members on the team.expensify.com domain can view these notes.', + composerLabel: 'Notes', + myNote: 'My note', + error: { + genericFailureMessage: "Private notes couldn't be saved.", + }, + }, + billingCurrency: { + error: { + securityCode: 'Please enter a valid security code.', + }, + securityCode: 'Security code', + changeBillingCurrency: 'Change billing currency', + changePaymentCurrency: 'Change payment currency', + paymentCurrency: 'Payment currency', + note: 'Note: Changing your payment currency can impact how much you’ll pay for Expensify. Refer to our', + noteLink: 'pricing page', + noteDetails: 'for full details.', + }, + addDebitCardPage: { + addADebitCard: 'Add a debit card', + nameOnCard: 'Name on card', + debitCardNumber: 'Debit card number', + expiration: 'Expiration date', + expirationDate: 'MMYY', + cvv: 'CVV', + billingAddress: 'Billing address', + growlMessageOnSave: 'Your debit card was successfully added', + expensifyPassword: 'Expensify password', + error: { + invalidName: 'Name can only include letters.', + addressZipCode: 'Please enter a valid zip code.', + debitCardNumber: 'Please enter a valid debit card number.', + expirationDate: 'Please select a valid expiration date.', + securityCode: 'Please enter a valid security code.', + addressStreet: "Please enter a valid billing address that's not a PO box.", + addressState: 'Please select a state.', + addressCity: 'Please enter a city.', + genericFailureMessage: 'An error occurred while adding your card. Please try again.', + password: 'Please enter your Expensify password.', + }, + }, + addPaymentCardPage: { + addAPaymentCard: 'Add payment card', + nameOnCard: 'Name on card', + paymentCardNumber: 'Card number', + expiration: 'Expiration date', + expirationDate: 'MMYY', + cvv: 'CVV', + billingAddress: 'Billing address', + growlMessageOnSave: 'Your payment card was successfully added', + expensifyPassword: 'Expensify password', + error: { + invalidName: 'Name can only include letters.', + addressZipCode: 'Please enter a valid zip code.', + paymentCardNumber: 'Please enter a valid card number.', + expirationDate: 'Please select a valid expiration date.', + securityCode: 'Please enter a valid security code.', + addressStreet: "Please enter a valid billing address that's not a PO box.", + addressState: 'Please select a state.', + addressCity: 'Please enter a city.', + genericFailureMessage: 'An error occurred while adding your card. Please try again.', + password: 'Please enter your Expensify password.', + }, + }, + walletPage: { + balance: 'Balance', + paymentMethodsTitle: 'Payment methods', + setDefaultConfirmation: 'Make default payment method', + setDefaultSuccess: 'Default payment method set!', + deleteAccount: 'Delete account', + deleteConfirmation: 'Are you sure you want to delete this account?', + error: { + notOwnerOfBankAccount: 'An error occurred while setting this bank account as your default payment method.', + invalidBankAccount: 'This bank account is temporarily suspended.', + notOwnerOfFund: 'An error occurred while setting this card as your default payment method.', + setDefaultFailure: 'Something went wrong. Please chat with Concierge for further assistance.', + }, + addBankAccountFailure: 'An unexpected error occurred while trying to add your bank account. Please try again.', + getPaidFaster: 'Get paid faster', + addPaymentMethod: 'Add a payment method to send and receive payments directly in the app.', + getPaidBackFaster: 'Get paid back faster', + secureAccessToYourMoney: 'Secure access to your money', + receiveMoney: 'Receive money in your local currency', + expensifyWallet: 'Expensify Wallet', + sendAndReceiveMoney: 'Send and receive money with friends', + enableWalletToSendAndReceiveMoney: 'Enable your wallet to send and receive money with friends.', + walletEnabledToSendAndReceiveMoney: 'Your wallet has been enabled to send and receive money with friends.', + enableWallet: 'Enable wallet', + bankAccounts: 'Bank accounts', + addBankAccountToSendAndReceive: 'Adding a bank account allows you to get paid back for expenses you submit to a workspace.', + addBankAccount: 'Add bank account', + assignedCards: 'Assigned cards', + assignedCardsDescription: 'These are cards assigned by a workspace admin to manage company spend.', + expensifyCard: 'Expensify Card', + walletActivationPending: "We're reviewing your information. Please check back in a few minutes!", + walletActivationFailed: "Unfortunately, your wallet can't be enabled at this time. Please chat with Concierge for further assistance.", + addYourBankAccount: 'Add your bank account', + addBankAccountBody: "Let's connect your bank account to Expensify so it’s easier than ever to send and receive payments directly in the app.", + chooseYourBankAccount: 'Choose your bank account', + chooseAccountBody: 'Make sure that you select the right one.', + confirmYourBankAccount: 'Confirm your bank account', + }, + cardPage: { + expensifyCard: 'Expensify Card', + availableSpend: 'Remaining limit', + smartLimit: { + name: 'Smart limit', + title: (formattedLimit: string) => `You can spend up to ${formattedLimit} on this card, and the limit will reset as your submitted expenses are approved.`, + }, + fixedLimit: { + name: 'Fixed limit', + title: (formattedLimit: string) => `You can spend up to ${formattedLimit} on this card, and then it will deactivate.`, + }, + monthlyLimit: { + name: 'Monthly limit', + title: (formattedLimit: string) => `You can spend up to ${formattedLimit} on this card per month. The limit will reset on the 1st day of each calendar month.`, + }, + virtualCardNumber: 'Virtual card number', + physicalCardNumber: 'Physical card number', + getPhysicalCard: 'Get physical card', + reportFraud: 'Report virtual card fraud', + reviewTransaction: 'Review transaction', + suspiciousBannerTitle: 'Suspicious transaction', + suspiciousBannerDescription: 'We noticed suspicious transactions on your card. Tap below to review.', + cardLocked: "Your card is temporarily locked while our team reviews your company's account.", + cardDetails: { + cardNumber: 'Virtual card number', + expiration: 'Expiration', + cvv: 'CVV', + address: 'Address', + revealDetails: 'Reveal details', + copyCardNumber: 'Copy card number', + updateAddress: 'Update address', + }, + cardDetailsLoadingFailure: 'An error occurred while loading the card details. Please check your internet connection and try again.', + validateCardTitle: "Let's make sure it's you", + enterMagicCode: ({contactMethod}: EnterMagicCodeParams) => `Please enter the magic code sent to ${contactMethod} to view your card details`, + }, + workflowsPage: { + workflowTitle: 'Spend', + workflowDescription: 'Configure a workflow from the moment spend occurs, including approval and payment.', + delaySubmissionTitle: 'Delay submissions', + delaySubmissionDescription: 'Delay expense submissions based on a custom schedule, or keep this option disabled to maintain realtime spend visibility.', + submissionFrequency: 'Submission frequency', + submissionFrequencyDateOfMonth: 'Date of month', + addApprovalsTitle: 'Add approvals', + addApprovalButton: 'Add approval workflow', + addApprovalTip: 'This default workflow applies to all members, unless a more specific workflow exists', + approver: 'Approver', + connectBankAccount: 'Connect bank account', + addApprovalsDescription: 'Require additional approval before authorizing a payment.', + makeOrTrackPaymentsTitle: 'Make or track payments', + makeOrTrackPaymentsDescription: 'Add an authorized payer for payments made in Expensify, or simply track payments made elsewhere.', + editor: { + submissionFrequency: 'Choose how long Expensify should wait before sharing error-free spend.', + }, + frequencyDescription: 'Choose how often you’d like expenses to submit automatically, or make it manual', + frequencies: { + weekly: 'Weekly', + monthly: 'Monthly', + twiceAMonth: 'Twice a month', + byTrip: 'By trip', + manually: 'Manually', + daily: 'Daily', + lastDayOfMonth: 'Last day of the month', + lastBusinessDayOfMonth: 'Last business day of the month', + ordinals: { + one: 'st', + two: 'nd', + few: 'rd', + other: 'th', + /* eslint-disable @typescript-eslint/naming-convention */ + '1': 'First', + '2': 'Second', + '3': 'Third', + '4': 'Fourth', + '5': 'Fifth', + '6': 'Sixth', + '7': 'Seventh', + '8': 'Eighth', + '9': 'Ninth', + '10': 'Tenth', + /* eslint-enable @typescript-eslint/naming-convention */ + }, + }, + approverInMultipleWorkflows: 'This member already belongs to another approval workflow. Any updates here will reflect there too.', + approverCircularReference: ({name1, name2}: ApprovalWorkflowErrorParams) => + `${name1} already approves reports to ${name2}. Please choose a different approver to avoid a circular workflow.`, + emptyContent: { + title: 'No members to display', + expensesFromSubtitle: 'All workspace members already belong to an existing approval workflow.', + approverSubtitle: 'All approvers belong to an existing workflow.', + }, + }, + workflowsDelayedSubmissionPage: { + autoReportingErrorMessage: "Delayed submission couldn't be changed. Please try again or contact support.", + autoReportingFrequencyErrorMessage: "Submission frequency couldn't be changed. Please try again or contact support.", + monthlyOffsetErrorMessage: "Monthly frequency couldn't be changed. Please try again or contact support.", + }, + workflowsCreateApprovalsPage: { + title: 'Confirm', + header: 'Add more approvers and confirm.', + additionalApprover: 'Additional approver', + submitButton: 'Add workflow', + }, + workflowsEditApprovalsPage: { + title: 'Edit approval workflow', + deleteTitle: 'Delete approval workflow', + deletePrompt: 'Are you sure you want to delete this approval workflow? All members will subsequently follow the default workflow.', + }, + workflowsExpensesFromPage: { + title: 'Expenses from', + header: 'When the following members submit expenses:', + }, + workflowsApproverPage: { + genericErrorMessage: "The approver couldn't be changed. Please try again or contact support.", + header: 'Send to this member for approval:', + }, + workflowsPayerPage: { + title: 'Authorized payer', + genericErrorMessage: 'The authorized payer could not be changed. Please try again.', + admins: 'Admins', + payer: 'Payer', + paymentAccount: 'Payment account', + }, + reportFraudPage: { + title: 'Report virtual card fraud', + description: 'If your virtual card details have been stolen or compromised, we’ll permanently deactivate your existing card and provide you with a new virtual card and number.', + deactivateCard: 'Deactivate card', + reportVirtualCardFraud: 'Report virtual card fraud', + }, + activateCardPage: { + activateCard: 'Activate card', + pleaseEnterLastFour: 'Please enter the last four digits of your card.', + activatePhysicalCard: 'Activate physical card', + error: { + thatDidntMatch: "That didn't match the last 4 digits on your card. Please try again.", + throttled: + "You've incorrectly entered the last 4 digits of your Expensify Card too many times. If you're sure the numbers are correct, please reach out to Concierge to resolve. Otherwise, try again later.", + }, + }, + getPhysicalCard: { + header: 'Get physical card', + nameMessage: 'Enter your first and last name, as this will be shown on your card.', + legalName: 'Legal name', + legalFirstName: 'Legal first name', + legalLastName: 'Legal last name', + phoneMessage: 'Enter your phone number.', + phoneNumber: 'Phone number', + address: 'Address', + addressMessage: 'Enter your shipping address.', + streetAddress: 'Street Address', + city: 'City', + state: 'State', + zipPostcode: 'Zip/Postcode', + country: 'Country', + confirmMessage: 'Please confirm your details below.', + estimatedDeliveryMessage: 'Your physical card will arrive in 2-3 business days.', + next: 'Next', + getPhysicalCard: 'Get physical card', + shipCard: 'Ship card', + }, + transferAmountPage: { + transfer: ({amount}: TransferParams) => `Transfer${amount ? ` ${amount}` : ''}`, + instant: 'Instant (Debit card)', + instantSummary: ({rate, minAmount}: InstantSummaryParams) => `${rate}% fee (${minAmount} minimum)`, + ach: '1-3 Business days (Bank account)', + achSummary: 'No fee', + whichAccount: 'Which account?', + fee: 'Fee', + transferSuccess: 'Transfer successful!', + transferDetailBankAccount: 'Your money should arrive in the next 1-3 business days.', + transferDetailDebitCard: 'Your money should arrive immediately.', + failedTransfer: 'Your balance isn’t fully settled. Please transfer to a bank account.', + notHereSubTitle: 'Please transfer your balance from the wallet page', + goToWallet: 'Go to Wallet', + }, + chooseTransferAccountPage: { + chooseAccount: 'Choose account', + }, + paymentMethodList: { + addPaymentMethod: 'Add payment method', + addNewDebitCard: 'Add new debit card', + addNewBankAccount: 'Add new bank account', + accountLastFour: 'Ending in', + cardLastFour: 'Card ending in', + addFirstPaymentMethod: 'Add a payment method to send and receive payments directly in the app.', + defaultPaymentMethod: 'Default', + }, + preferencesPage: { + appSection: { + title: 'App preferences', + subtitle: 'Customize your Expensify account.', + }, + testSection: { + title: 'Test preferences', + subtitle: 'Settings to help debug and test the app on staging.', + }, + receiveRelevantFeatureUpdatesAndExpensifyNews: 'Receive relevant feature updates and Expensify news', + muteAllSounds: 'Mute all sounds from Expensify', + }, + priorityModePage: { + priorityMode: 'Priority mode', + explainerText: 'Choose whether to #focus on unread and pinned chats only, or show everything with the most recent and pinned chats at the top.', + priorityModes: { + default: { + label: 'Most recent', + description: 'Show all chats sorted by most recent', + }, + gsd: { + label: '#focus', + description: 'Only show unread sorted alphabetically', + }, + }, + }, + reportDetailsPage: { + inWorkspace: ({policyName}) => `in ${policyName}`, + }, + reportDescriptionPage: { + roomDescription: 'Room description', + roomDescriptionOptional: 'Room description (optional)', + explainerText: 'Set a custom description for the room.', + }, + groupConfirmPage: { + groupName: 'Group name', + }, + groupChat: { + lastMemberTitle: 'Heads up!', + lastMemberWarning: "Since you're the last person here, leaving will make this chat inaccessible to all users. Are you sure you want to leave?", + defaultReportName: ({displayName}: {displayName: string}) => `${displayName}'s group chat`, + }, + languagePage: { + language: 'Language', + languages: { + en: { + label: 'English', + }, + es: { + label: 'Spanish', + }, + }, + }, + themePage: { + theme: 'Theme', + themes: { + dark: { + label: 'Dark', + }, + light: { + label: 'Light', + }, + system: { + label: 'Use device settings', + }, + }, + chooseThemeBelowOrSync: 'Choose a theme below, or sync with your device settings.', + }, + termsOfUse: { + phrase1: 'By logging in, you agree to the', + phrase2: 'Terms of Service', + phrase3: 'and', + phrase4: 'Privacy', + phrase5: `Money transmission is provided by ${CONST.WALLET.PROGRAM_ISSUERS.EXPENSIFY_PAYMENTS} (NMLS ID:2017010) pursuant to its`, + phrase6: 'licenses', + }, + validateCodeForm: { + magicCodeNotReceived: "Didn't receive a magic code?", + enterAuthenticatorCode: 'Please enter your authenticator code', + enterRecoveryCode: 'Please enter your recovery code', + requiredWhen2FAEnabled: 'Required when 2FA is enabled', + requestNewCode: 'Request a new code in ', + requestNewCodeAfterErrorOccurred: 'Request a new code', + error: { + pleaseFillMagicCode: 'Please enter your magic code.', + incorrectMagicCode: 'Incorrect magic code.', + pleaseFillTwoFactorAuth: 'Please enter your two-factor authentication code.', + }, + }, + passwordForm: { + pleaseFillOutAllFields: 'Please fill out all fields', + pleaseFillPassword: 'Please enter your password', + pleaseFillTwoFactorAuth: 'Please enter your two-factor code', + enterYourTwoFactorAuthenticationCodeToContinue: 'Enter your two-factor authentication code to continue', + forgot: 'Forgot?', + requiredWhen2FAEnabled: 'Required when 2FA is enabled', + error: { + incorrectPassword: 'Incorrect password. Please try again.', + incorrectLoginOrPassword: 'Incorrect login or password. Please try again.', + incorrect2fa: 'Incorrect two-factor authentication code. Please try again.', + twoFactorAuthenticationEnabled: 'You have 2FA enabled on this account. Please sign in using your email or phone number.', + invalidLoginOrPassword: 'Invalid login or password. Please try again or reset your password.', + unableToResetPassword: + 'We were unable to change your password. This is likely due to an expired password reset link in an old password reset email. We have emailed you a new link so you can try again. Check your Inbox and your Spam folder; it should arrive in just a few minutes.', + noAccess: 'You do not have access to this application. Please add your GitHub username for access.', + accountLocked: 'Your account has been locked after too many unsuccessful attempts. Please try again after 1 hour.', + fallback: 'Something went wrong. Please try again later.', + }, + }, + loginForm: { + phoneOrEmail: 'Phone or email', + error: { + invalidFormatEmailLogin: 'The email entered is invalid. Please fix the format and try again.', + }, + cannotGetAccountDetails: "Couldn't retrieve account details. Please try to sign in again.", + loginForm: 'Login form', + notYou: ({user}: NotYouParams) => `Not ${user}?`, + }, + onboarding: { + welcome: 'Welcome!', + explanationModal: { + title: 'Welcome to Expensify', + description: 'One app to handle your business and personal spend at the speed of chat. Try it out and let us know what you think. Much more to come!', + secondaryDescription: 'To switch back to Expensify Classic, just tap your profile picture > Go to Expensify Classic.', + }, + welcomeVideo: { + title: 'Welcome to Expensify', + description: 'One app to handle all your business and personal spend in a chat. Built for your business, your team, and your friends.', + }, + getStarted: 'Get started', + whatsYourName: "What's your name?", + whereYouWork: 'Where do you work?', + purpose: { + title: 'What do you want to do today?', + errorSelection: 'Please make a selection to continue.', + errorContinue: 'Please press continue to get set up.', + errorBackButton: 'Please finish the setup questions to start using the app.', + [CONST.ONBOARDING_CHOICES.EMPLOYER]: 'Get paid back by my employer', + [CONST.ONBOARDING_CHOICES.MANAGE_TEAM]: "Manage my team's expenses", + [CONST.ONBOARDING_CHOICES.PERSONAL_SPEND]: 'Track and budget expenses', + [CONST.ONBOARDING_CHOICES.CHAT_SPLIT]: 'Chat and split expenses with friends', + [CONST.ONBOARDING_CHOICES.LOOKING_AROUND]: 'Something else', + }, + error: { + requiredFirstName: 'Please input your first name to continue.', + }, + }, + featureTraining: { + doNotShowAgain: "Don't show me this again", + }, + personalDetails: { + error: { + containsReservedWord: 'Name cannot contain the words Expensify or Concierge.', + hasInvalidCharacter: 'Name cannot contain a comma or semicolon.', + requiredFirstName: 'First name cannot be empty.', + }, + }, + privatePersonalDetails: { + enterLegalName: "What's your legal name?", + enterDateOfBirth: "What's your date of birth?", + enterAddress: "What's your address?", + enterPhoneNumber: "What's your phone number?", + personalDetails: 'Personal details', + privateDataMessage: 'These details are used for travel and payments. They are never shown on your public profile.', + legalName: 'Legal name', + legalFirstName: 'Legal first name', + legalLastName: 'Legal last name', + address: 'Address', + error: { + dateShouldBeBefore: ({dateString}: DateShouldBeBeforeParams) => `Date should be before ${dateString}.`, + dateShouldBeAfter: ({dateString}: DateShouldBeAfterParams) => `Date should be after ${dateString}.`, + hasInvalidCharacter: 'Name can only include Latin characters.', + incorrectZipFormat: (zipFormat?: string) => `Incorrect zip code format.${zipFormat ? ` Acceptable format: ${zipFormat}` : ''}`, + }, + }, + resendValidationForm: { + linkHasBeenResent: 'Link has been re-sent', + weSentYouMagicSignInLink: ({login, loginType}: WeSentYouMagicSignInLinkParams) => `I've sent a magic sign-in link to ${login}. Please check your ${loginType} to sign in.`, + resendLink: 'Resend link', + }, + unlinkLoginForm: { + toValidateLogin: ({primaryLogin, secondaryLogin}: ToValidateLoginParams) => + `To validate ${secondaryLogin}, please resend the magic code from the Account Settings of ${primaryLogin}.`, + noLongerHaveAccess: ({primaryLogin}: NoLongerHaveAccessParams) => `If you no longer have access to ${primaryLogin}, please unlink your accounts.`, + unlink: 'Unlink', + linkSent: 'Link sent!', + succesfullyUnlinkedLogin: 'Secondary login successfully unlinked!', + }, + emailDeliveryFailurePage: { + ourEmailProvider: (user: OurEmailProviderParams) => + `Our email provider has temporarily suspended emails to ${user.login} due to delivery issues. To unblock your login, please follow these steps:`, + confirmThat: ({login}: ConfirmThatParams) => `Confirm that ${login} is spelled correctly and is a real, deliverable email address. `, + emailAliases: 'Email aliases such as "expenses@domain.com" must have access to their own email inbox for it to be a valid Expensify login.', + ensureYourEmailClient: 'Ensure your email client allows expensify.com emails. ', + youCanFindDirections: 'You can find directions on how to complete this step ', + helpConfigure: ' but you may need your IT department to help configure your email settings.', + onceTheAbove: 'Once the above steps are completed, please reach out to ', + toUnblock: ' to unblock your login.', + }, + welcomeSignUpForm: { + join: 'Join', + }, + detailsPage: { + localTime: 'Local time', + }, + newChatPage: { + startGroup: 'Start group', + addToGroup: 'Add to group', + }, + yearPickerPage: { + year: 'Year', + selectYear: 'Please select a year', + }, + focusModeUpdateModal: { + title: 'Welcome to #focus mode!', + prompt: "Stay on top of things by only seeing unread chats or chats that need your attention. Don't worry, you can change this at any point in ", + settings: 'settings', + }, + notFound: { + chatYouLookingForCannotBeFound: 'The chat you are looking for cannot be found.', + getMeOutOfHere: 'Get me out of here', + iouReportNotFound: 'The payment details you are looking for cannot be found.', + notHere: "Hmm... it's not here", + pageNotFound: 'Oops, this page cannot be found', + noAccess: "You don't have access to this chat", + goBackHome: 'Go back to home page', + }, + setPasswordPage: { + enterPassword: 'Enter a password', + setPassword: 'Set password', + newPasswordPrompt: 'Your password must have at least 8 characters, 1 capital letter, 1 lowercase letter, and 1 number.', + passwordFormTitle: 'Welcome back to the New Expensify! Please set your password.', + passwordNotSet: 'We were unable to set your new password. We have sent you a new password link to try again.', + setPasswordLinkInvalid: 'This set password link is invalid or has expired. A new one is waiting for you in your email inbox!', + validateAccount: 'Verify account', + }, + statusPage: { + status: 'Status', + statusExplanation: "Add an emoji to give your colleagues and friends an easy way to know what's going on. You can optionally add a message too!", + today: 'Today', + clearStatus: 'Clear status', + save: 'Save', + message: 'Message', + timePeriods: { + never: 'Never', + thirtyMinutes: '30 minutes', + oneHour: '1 hour', + afterToday: 'Today', + afterWeek: 'A week', + custom: 'Custom', + }, + untilTomorrow: 'Until tomorrow', + untilTime: ({time}: UntilTimeParams) => `Until ${time}`, + date: 'Date', + time: 'Time', + clearAfter: 'Clear after', + whenClearStatus: 'When should we clear your status?', + }, + stepCounter: ({step, total, text}: StepCounterParams) => { + let result = `Step ${step}`; + + if (total) { + result = `${result} of ${total}`; + } + + if (text) { + result = `${result}: ${text}`; + } + return result; + }, + bankAccount: { + bankInfo: 'Bank info', + confirmBankInfo: 'Confirm bank info', + manuallyAdd: 'Manually add your bank account', + letsDoubleCheck: "Let's double check that everything looks right.", + accountEnding: 'Account ending in', + thisBankAccount: 'This bank account will be used for business payments on your workspace', + accountNumber: 'Account number', + routingNumber: 'Routing number', + chooseAnAccountBelow: 'Choose an account below', + addBankAccount: 'Add bank account', + chooseAnAccount: 'Choose an account', + connectOnlineWithPlaid: 'Connect online with Plaid', + connectManually: 'Connect manually', + desktopConnection: 'Note: To connect with Chase, Wells Fargo, Capital One or Bank of America, please click here to complete this process in a browser.', + yourDataIsSecure: 'Your data is secure', + toGetStarted: 'Add a bank account to reimburse expenses, issue Expensify Cards, collect invoice payments, and pay bills all from one place.', + plaidBodyCopy: 'Give your employees an easier way to pay - and get paid back - for company expenses.', + checkHelpLine: 'Your routing number and account number can be found on a check for the account.', + validateAccountError: { + phrase1: 'Hold up! We need you to validate your account first. To do so, ', + phrase2: 'sign back in with a magic code ', + phrase3: 'or ', + phrase4: 'verify your account here', + }, + hasPhoneLoginError: 'To add a verified bank account please ensure your primary login is a valid email and try again. You can add your phone number as a secondary login.', + hasBeenThrottledError: 'An error occurred while adding your bank account. Please wait a few minutes and try again.', + hasCurrencyError: 'Oops! It appears that your workspace currency is set to a different currency than USD. To proceed, please set it to USD and try again.', + error: { + youNeedToSelectAnOption: 'Please select an option to proceed.', + noBankAccountAvailable: "Sorry, there's no bank account available.", + noBankAccountSelected: 'Please choose an account.', + taxID: 'Please enter a valid tax ID number.', + website: 'Please enter a valid website using lower-case letters.', + zipCode: `Please enter a valid ZIP code using the format: ${CONST.COUNTRY_ZIP_REGEX_DATA.US.samples}.`, + phoneNumber: 'Please enter a valid phone number.', + companyName: 'Please enter a valid business name.', + addressCity: 'Please enter a valid city.', + addressStreet: 'Please enter a valid street address.', + addressState: 'Please select a valid state.', + incorporationDateFuture: "Incorporation date can't be in the future.", + incorporationState: 'Please select a valid state.', + industryCode: 'Please enter a valid industry classification code with six digits.', + restrictedBusiness: "Please confirm the business isn't on the list of restricted businesses.", + routingNumber: 'Please enter a valid routing number.', + accountNumber: 'Please enter a valid account number.', + routingAndAccountNumberCannotBeSame: "Routing and account numbers can't match.", + companyType: 'Please select a valid company type.', + tooManyAttempts: 'Due to a high number of login attempts, this option has been disabled for 24 hours. Please try again later or enter details manually instead.', + address: 'Please enter a valid address.', + dob: 'Please select a valid date of birth.', + age: 'Must be over 18 years old.', + ssnLast4: 'Please enter valid last 4 digits of SSN.', + firstName: 'Please enter a valid first name.', + lastName: 'Please enter a valid last name.', + noDefaultDepositAccountOrDebitCardAvailable: 'Please add a default deposit account or debit card.', + validationAmounts: 'The validation amounts you entered are incorrect. Please double check your bank statement and try again.', + }, + }, + addPersonalBankAccountPage: { + enterPassword: 'Enter Expensify password', + alreadyAdded: 'This account has already been added.', + chooseAccountLabel: 'Account', + successTitle: 'Personal bank account added!', + successMessage: 'Congrats, your bank account is set up and ready to receive reimbursements.', + }, + attachmentView: { + unknownFilename: 'Unknown filename', + passwordRequired: 'Please enter a password', + passwordIncorrect: 'Incorrect password. Please try again.', + failedToLoadPDF: 'Failed to load PDF file.', + pdfPasswordForm: { + title: 'Password protected PDF', + infoText: 'This PDF is password protected.', + beforeLinkText: 'Please', + linkText: 'enter the password', + afterLinkText: 'to view it.', + formLabel: 'View PDF', + }, + }, + messages: { + errorMessageInvalidPhone: `Please enter a valid phone number without brackets or dashes. If you're outside the US, please include your country code (e.g. ${CONST.EXAMPLE_PHONE_NUMBER}).`, + errorMessageInvalidEmail: 'Invalid email', + userIsAlreadyMember: ({login, name}: UserIsAlreadyMemberParams) => `${login} is already a member of ${name}`, + }, + onfidoStep: { + acceptTerms: 'By continuing with the request to activate your Expensify Wallet, you confirm that you have read, understand, and accept', + facialScan: 'Onfido’s Facial Scan Policy and Release', + tryAgain: 'Try again', + verifyIdentity: 'Verify identity', + letsVerifyIdentity: "Let's verify your identity", + butFirst: `But first, the boring stuff. Read up on the legalese in the next step and click "Accept" when you're ready.`, + genericError: 'An error occurred while processing this step. Please try again.', + cameraPermissionsNotGranted: 'Enable camera access', + cameraRequestMessage: 'We need access to your camera to complete bank account verification. Please enable via Settings > New Expensify.', + microphonePermissionsNotGranted: 'Enable microphone access', + microphoneRequestMessage: 'We need access to your microphone to complete bank account verification. Please enable via Settings > New Expensify.', + originalDocumentNeeded: 'Please upload an original image of your ID rather than a screenshot or scanned image.', + documentNeedsBetterQuality: 'Your ID appears to be damaged or has missing security features. Please upload an original image of an undamaged ID that is entirely visible.', + imageNeedsBetterQuality: "There's an issue with the image quality of your ID. Please upload a new image where your entire ID can be seen clearly.", + selfieIssue: "There's an issue with your selfie/video. Please upload a live selfie/video.", + selfieNotMatching: "Your selfie/video doesn't match your ID. Please upload a new selfie/video where your face can be clearly seen.", + selfieNotLive: "Your selfie/video doesn't appear to be a live photo/video. Please upload a live selfie/video.", + }, + additionalDetailsStep: { + headerTitle: 'Additional details', + helpText: 'We need to confirm the following information before you can send and receive money from your wallet.', + helpTextIdologyQuestions: 'We need to ask you just a few more questions to finish validating your identity.', + helpLink: 'Learn more about why we need this.', + legalFirstNameLabel: 'Legal first name', + legalMiddleNameLabel: 'Legal middle name', + legalLastNameLabel: 'Legal last name', + selectAnswer: 'Please select a response to proceed.', + ssnFull9Error: 'Please enter a valid nine-digit SSN.', + needSSNFull9: "We're having trouble verifying your SSN. Please enter the full nine digits of your SSN.", + weCouldNotVerify: "We couldn't verify", + pleaseFixIt: 'Please fix this information before continuing', + failedKYCTextBefore: "We weren't able to verify your identity. Please try again later or reach out to ", + failedKYCTextAfter: ' if you have any questions.', + }, + termsStep: { + headerTitle: 'Terms and fees', + headerTitleRefactor: 'Fees and terms', + haveReadAndAgree: 'I have read and agree to receive ', + electronicDisclosures: 'electronic disclosures', + agreeToThe: 'I agree to the', + walletAgreement: 'Wallet agreement', + enablePayments: 'Enable payments', + monthlyFee: 'Monthly fee', + inactivity: 'Inactivity', + noOverdraftOrCredit: 'No overdraft/credit feature.', + electronicFundsWithdrawal: 'Electronic funds withdrawal', + standard: 'Standard', + reviewTheFees: 'Please review the fees below.', + checkTheBoxes: 'Please check the boxes below.', + agreeToTerms: 'Agree to the terms and you’ll be good to go!', + shortTermsForm: { + expensifyPaymentsAccount: ({walletProgram}: WalletProgramParams) => `The Expensify Wallet is issued by ${walletProgram}.`, + perPurchase: 'Per purchase', + atmWithdrawal: 'ATM withdrawal', + cashReload: 'Cash reload', + inNetwork: 'in-network', + outOfNetwork: 'out-of-network', + atmBalanceInquiry: 'ATM balance inquiry', + inOrOutOfNetwork: '(in-network or out-of-network)', + customerService: 'Customer service', + automatedOrLive: '(automated or live agent)', + afterTwelveMonths: '(after 12 months with no transactions)', + weChargeOneFee: 'We charge one type of fee.', + fdicInsurance: 'Your funds are eligible for FDIC insurance.', + generalInfo: 'For general information about prepaid accounts, visit', + conditionsDetails: 'For details and conditions for all fees and services, visit', + conditionsPhone: 'or calling +1 833-400-0904.', + instant: '(instant)', + electronicFundsInstantFeeMin: ({amount}: TermsParams) => `(min ${amount})`, + }, + longTermsForm: { + listOfAllFees: 'A list of all Expensify Wallet fees', + typeOfFeeHeader: 'Type of fee', + feeAmountHeader: 'Fee amount', + moreDetailsHeader: 'More details', + openingAccountTitle: 'Opening an account', + openingAccountDetails: "There's no fee to open an account.", + monthlyFeeDetails: "There's no monthly fee.", + customerServiceTitle: 'Customer service', + customerServiceDetails: 'There are no customer service fees.', + inactivityDetails: "There's no inactivity fee.", + sendingFundsTitle: 'Sending funds to another account holder', + sendingFundsDetails: "There's no fee to send funds to another account holder using your balance, bank account, or debit card.", + electronicFundsStandardDetails: + "There's no fee to transfer funds from your Expensify Wallet " + + 'to your bank account using the standard option. This transfer usually completes within 1-3 business' + + ' days.', + electronicFundsInstantDetails: ({percentage, amount}: ElectronicFundsParams) => + "There's a fee to transfer funds from your Expensify Wallet to " + + 'your linked debit card using the instant transfer option. This transfer usually completes within ' + + `several minutes. The fee is ${percentage}% of the transfer amount (with a minimum fee of ${amount}).`, + fdicInsuranceBancorp: ({amount}: TermsParams) => + 'Your funds are eligible for FDIC insurance. Your funds will be held at or ' + + `transferred to ${CONST.WALLET.PROGRAM_ISSUERS.BANCORP_BANK}, an FDIC-insured institution. Once there, your funds are insured up ` + + `to ${amount} by the FDIC in the event ${CONST.WALLET.PROGRAM_ISSUERS.BANCORP_BANK} fails. See`, + fdicInsuranceBancorp2: 'for details.', + contactExpensifyPayments: `Contact ${CONST.WALLET.PROGRAM_ISSUERS.EXPENSIFY_PAYMENTS} by calling +1 833-400-0904, by email at`, + contactExpensifyPayments2: 'or sign in at', + generalInformation: 'For general information about prepaid accounts, visit', + generalInformation2: 'If you have a complaint about a prepaid account, call the Consumer Financial Protection Bureau at 1-855-411-2372 or visit', + printerFriendlyView: 'View printer-friendly version', + automated: 'Automated', + liveAgent: 'Live agent', + instant: 'Instant', + electronicFundsInstantFeeMin: ({amount}: TermsParams) => `Min ${amount}`, + }, + }, + activateStep: { + headerTitle: 'Enable payments', + activatedTitle: 'Wallet activated!', + activatedMessage: 'Congrats, your wallet is set up and ready to make payments.', + checkBackLaterTitle: 'Just a minute...', + checkBackLaterMessage: "We're still reviewing your information. Please check back later.", + continueToPayment: 'Continue to payment', + continueToTransfer: 'Continue to transfer', + }, + companyStep: { + headerTitle: 'Company information', + subtitle: 'Almost done! For security purposes, we need to confirm some information:', + legalBusinessName: 'Legal business name', + companyWebsite: 'Company website', + taxIDNumber: 'Tax ID number', + taxIDNumberPlaceholder: '9 digits', + companyType: 'Company type', + incorporationDate: 'Incorporation date', + incorporationState: 'Incorporation state', + industryClassificationCode: 'Industry classification code', + confirmCompanyIsNot: 'I confirm that this company is not on the', + listOfRestrictedBusinesses: 'list of restricted businesses', + incorporationDatePlaceholder: 'Start date (yyyy-mm-dd)', + incorporationTypes: { + LLC: 'LLC', + CORPORATION: 'Corp', + PARTNERSHIP: 'Partnership', + COOPERATIVE: 'Cooperative', + SOLE_PROPRIETORSHIP: 'Sole proprietorship', + OTHER: 'Other', + }, + }, + requestorStep: { + headerTitle: 'Personal information', + learnMore: 'Learn more', + isMyDataSafe: 'Is my data safe?', + }, + personalInfoStep: { + personalInfo: 'Personal info', + enterYourLegalFirstAndLast: "What's your legal name?", + legalFirstName: 'Legal first name', + legalLastName: 'Legal last name', + legalName: 'Legal name', + enterYourDateOfBirth: "What's your date of birth?", + enterTheLast4: 'What are the last four digits of your Social Security Number?', + dontWorry: "Don't worry, we don't do any personal credit checks!", + last4SSN: 'Last 4 of SSN', + enterYourAddress: "What's your address?", + address: 'Address', + letsDoubleCheck: "Let's double check that everything looks right.", + byAddingThisBankAccount: "By adding this bank account, you confirm that you've read, understand, and accept", + whatsYourLegalName: 'What’s your legal name?', + whatsYourDOB: 'What’s your date of birth?', + whatsYourAddress: 'What’s your address?', + noPOBoxesPlease: 'No PO boxes or mail-drop addresses, please.', + whatsYourSSN: 'What are the last four digits of your Social Security Number?', + noPersonalChecks: 'Don’t worry, no personal credit checks here!', + whatsYourPhoneNumber: 'What’s your phone number?', + weNeedThisToVerify: 'We need this to verify your wallet.', + }, + businessInfoStep: { + businessInfo: 'Company info', + enterTheNameOfYourBusiness: "What's the name of your company?", + businessName: 'Legal company name', + enterYourCompanysTaxIdNumber: "What's your company’s Tax ID number?", + taxIDNumber: 'Tax ID number', + taxIDNumberPlaceholder: '9 digits', + enterYourCompanysWebsite: "What's your company’s website?", + companyWebsite: 'Company website', + enterYourCompanysPhoneNumber: "What's your company’s phone number?", + enterYourCompanysAddress: "What's your company’s address?", + selectYourCompanysType: 'What type of company is it?', + companyType: 'Company type', + incorporationType: { + LLC: 'LLC', + CORPORATION: 'Corp', + PARTNERSHIP: 'Partnership', + COOPERATIVE: 'Cooperative', + SOLE_PROPRIETORSHIP: 'Sole proprietorship', + OTHER: 'Other', + }, + selectYourCompanysIncorporationDate: "What's your company’s incorporation date?", + incorporationDate: 'Incorporation date', + incorporationDatePlaceholder: 'Start date (yyyy-mm-dd)', + incorporationState: 'Incorporation state', + pleaseSelectTheStateYourCompanyWasIncorporatedIn: 'Which state was your company incorporated in?', + letsDoubleCheck: "Let's double check that everything looks right.", + companyAddress: 'Company address', + listOfRestrictedBusinesses: 'list of restricted businesses', + confirmCompanyIsNot: 'I confirm that this company is not on the', + }, + beneficialOwnerInfoStep: { + doYouOwn25percent: 'Do you own 25% or more of', + doAnyIndividualOwn25percent: 'Do any individuals own 25% or more of', + areThereMoreIndividualsWhoOwn25percent: 'Are there more individuals who own 25% or more of', + regulationRequiresUsToVerifyTheIdentity: 'Regulation requires us to verify the identity of any individual who owns more than 25% of the business.', + companyOwner: 'Business owner', + enterLegalFirstAndLastName: "What's the owner's legal name?", + legalFirstName: 'Legal first name', + legalLastName: 'Legal last name', + enterTheDateOfBirthOfTheOwner: "What's the owner's date of birth?", + enterTheLast4: 'What are the last 4 digits of the owner’s Social Security Number?', + last4SSN: 'Last 4 of SSN', + dontWorry: "Don't worry, we don't do any personal credit checks!", + enterTheOwnersAddress: "What's the owner's address?", + letsDoubleCheck: 'Let’s double check that everything looks right.', + legalName: 'Legal name', + address: 'Address', + byAddingThisBankAccount: "By adding this bank account, you confirm that you've read, understand, and accept", + owners: 'Owners', + }, + validationStep: { + headerTitle: 'Validate bank account', + buttonText: 'Finish setup', + maxAttemptsReached: 'Validation for this bank account has been disabled due to too many incorrect attempts.', + description: `Within 1-2 business days, we'll send three (3) small transactions to your bank account from a name like "Expensify, Inc. Validation".`, + descriptionCTA: 'Please enter each transaction amount in the fields below. Example: 1.51.', + reviewingInfo: "Thanks! We're reviewing your information, and will be in touch shortly. Please check your chat with Concierge ", + forNextStep: ' for next steps to finish setting up your bank account.', + letsChatCTA: "Yes, let's chat", + letsChatText: 'Almost there! We need your help verifying a few last bits of information over chat. Ready?', + letsChatTitle: "Let's chat!", + enable2FATitle: 'Prevent fraud, enable two-factor authentication (2FA)', + enable2FAText: 'We take your security seriously. Please set up 2FA now to add an extra layer of protection to your account.', + secureYourAccount: 'Secure your account', + }, + beneficialOwnersStep: { + additionalInformation: 'Additional information', + checkAllThatApply: 'Check all that apply, otherwise leave blank.', + iOwnMoreThan25Percent: 'I own more than 25% of ', + someoneOwnsMoreThan25Percent: 'Somebody else owns more than 25% of ', + additionalOwner: 'Additional beneficial owner', + removeOwner: 'Remove this beneficial owner', + addAnotherIndividual: 'Add another individual who owns more than 25% of ', + agreement: 'Agreement:', + termsAndConditions: 'terms and conditions', + certifyTrueAndAccurate: 'I certify that the information provided is true and accurate', + error: { + certify: 'Must certify information is true and accurate.', + }, + }, + completeVerificationStep: { + completeVerification: 'Complete verification', + confirmAgreements: 'Please confirm the agreements below.', + certifyTrueAndAccurate: 'I certify that the information provided is true and accurate', + certifyTrueAndAccurateError: 'Please certify that the information is true and accurate', + isAuthorizedToUseBankAccount: 'I am authorized to use this business bank account for business spend', + isAuthorizedToUseBankAccountError: 'You must be a controlling officer with authorization to operate the business bank account.', + termsAndConditions: 'terms and conditions', + }, + connectBankAccountStep: { + connectBankAccount: 'Connect bank account', + finishButtonText: 'Finish setup', + validateYourBankAccount: 'Validate your bank account', + validateButtonText: 'Validate', + validationInputLabel: 'Transaction', + maxAttemptsReached: 'Validation for this bank account has been disabled due to too many incorrect attempts.', + description: `Within 1-2 business days, we'll send three (3) small transactions to your bank account from a name like "Expensify, Inc. Validation".`, + descriptionCTA: 'Please enter each transaction amount in the fields below. Example: 1.51.', + reviewingInfo: "Thanks! We're reviewing your information and will be in touch shortly. Please check your chat with Concierge ", + forNextSteps: ' for next steps to finish setting up your bank account.', + letsChatCTA: "Yes, let's chat", + letsChatText: 'Almost there! We need your help verifying a few last bits of information over chat. Ready?', + letsChatTitle: "Let's chat!", + enable2FATitle: 'Prevent fraud, enable two-factor authentication (2FA)', + enable2FAText: 'We take your security seriously. Please set up 2FA now to add an extra layer of protection to your account.', + secureYourAccount: 'Secure your account', + }, + reimbursementAccountLoadingAnimation: { + oneMoment: 'One moment', + explanationLine: "We’re taking a look at your information. You'll be able to continue with next steps shortly.", + }, + session: { + offlineMessageRetry: "Looks like you're offline. Please check your connection and try again.", + }, + travel: { + header: 'Book travel', + title: 'Travel smart', + subtitle: 'Use Expensify Travel to get the best travel offers and manage all your business expenses in one place.', + features: { + saveMoney: 'Save money on your bookings', + alerts: 'Get realtime updates and alerts', + }, + bookTravel: 'Book travel', + bookDemo: 'Book demo', + termsAndConditions: { + header: 'Before we continue...', + title: 'Please read the Terms & Conditions for travel', + subtitle: 'To enable travel on your workspace you must agree to our ', + termsconditions: 'terms & conditions', + travelTermsAndConditions: 'terms & conditions', + helpDocIntro: 'Check out this ', + helpDocOutro: 'for more information or reach out to Concierge or your Account Manager.', + helpDoc: 'Help Doc', + agree: 'I agree to the travel ', + error: 'You must accept the Terms & Conditions for travel to continue', + }, + flight: 'Flight', + hotel: 'Hotel', + car: 'Car', + viewTrip: 'View trip', + viewTripDetails: 'View trip details', + trip: 'Trip', + trips: 'Trips', + tripSummary: 'Trip summary', + departs: 'Departs', + errorMessage: 'Something went wrong. Please try again later.', + phoneError: 'To book travel, your default contact method must be a valid email', + }, + workspace: { + common: { + card: 'Cards', + expensifyCard: 'Expensify Card', + companyCards: 'Company cards', + workflows: 'Workflows', + workspace: 'Workspace', + edit: 'Edit workspace', + enabled: 'Enabled', + disabled: 'Disabled', + everyone: 'Everyone', + delete: 'Delete workspace', + settings: 'Settings', + reimburse: 'Reimbursements', + categories: 'Categories', + tags: 'Tags', + reportFields: 'Report fields', + reportField: 'Report field', + taxes: 'Taxes', + bills: 'Bills', + invoices: 'Invoices', + travel: 'Travel', + members: 'Members', + accounting: 'Accounting', + rules: 'Rules', + displayedAs: 'Displayed as', + plan: 'Plan', + profile: 'Profile', + bankAccount: 'Bank account', + connectBankAccount: 'Connect bank account', + testTransactions: 'Test transactions', + issueAndManageCards: 'Issue and manage cards', + reconcileCards: 'Reconcile cards', + selected: ({selectedNumber}) => `${selectedNumber} selected`, + settlementFrequency: 'Settlement frequency', + deleteConfirmation: 'Are you sure you want to delete this workspace?', + unavailable: 'Unavailable workspace', + memberNotFound: 'Member not found. To invite a new member to the workspace, please use the invite button above.', + notAuthorized: `You don't have access to this page. If you're trying to join this workspace, just ask the workspace owner to add you as a member. Something else? Reach out to ${CONST.EMAIL.CONCIERGE}.`, + goToRoom: ({roomName}: GoToRoomParams) => `Go to ${roomName} room`, + workspaceName: 'Workspace name', + workspaceOwner: 'Owner', + workspaceType: 'Workspace type', + workspaceAvatar: 'Workspace avatar', + mustBeOnlineToViewMembers: 'You need to be online in order to view members of this workspace.', + moreFeatures: 'More features', + requested: 'Requested', + distanceRates: 'Distance rates', + welcomeNote: ({workspaceName}: WelcomeNoteParams) => + `You have been invited to ${workspaceName || 'a workspace'}! Download the Expensify mobile app at use.expensify.com/download to start tracking your expenses.`, + subscription: 'Subscription', + markAsExported: 'Mark as manually entered', + exportIntegrationSelected: (connectionName: ConnectionName) => `Export to ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]}`, + letsDoubleCheck: "Let's double check that everything looks right.", + lineItemLevel: 'Line-item level', + reportLevel: 'Report level', + topLevel: 'Top level', + appliedOnExport: 'Not imported into Expensify, applied on export', + shareNote: { + header: 'Easily share your workspace with other members.', + content: { + firstPart: + 'Share this QR code or copy the link below to make it easy for members to request access to your workspace. All requests to join the workspace will show up in the', + secondPart: 'room for your review.', + }, + }, + createNewConnection: 'Create new connection', + reuseExistingConnection: 'Reuse existing connection', + existingConnections: 'Existing connections', + lastSyncDate: (connectionName: string, formattedDate: string) => `${connectionName} - Last synced ${formattedDate}`, + memberAlternateText: 'Members can submit and approve reports.', + adminAlternateText: 'Admins have full edit access to all reports and workspace settings.', + auditorAlternateText: 'Auditors can view and comment on reports.', + roleName: (role?: string): string => { + switch (role) { + case CONST.POLICY.ROLE.ADMIN: + return 'Admin'; + case CONST.POLICY.ROLE.AUDITOR: + return 'Auditor'; + case CONST.POLICY.ROLE.USER: + return 'Member'; + default: + return 'Member'; + } + }, + }, + qbo: { + importDescription: 'Choose which coding configurations to import from QuickBooks Online to Expensify.', + classes: 'Classes', + locations: 'Locations', + customers: 'Customers/projects', + accountsDescription: 'Your Quickbooks Online chart of accounts will import into Expensify as categories.', + accountsSwitchTitle: 'Choose to import new accounts as enabled or disabled categories.', + accountsSwitchDescription: 'Enabled categories will be available for members to select when creating their expenses.', + classesDescription: 'Choose how to handle QuickBooks Online classes in Expensify.', + customersDescription: 'Choose how to handle QuickBooks Online customers/projects in Expensify.', + locationsDescription: 'Choose how to handle QuickBooks Online locations in Expensify.', + taxesDescription: 'Choose how to handle QuickBooks Online taxes in Expensify.', + locationsAdditionalDescription: + 'QuickBooks Online doesn’t support locations on vendor bills or checks. As you have locations enabled on your workspace, these export options are unavailable.', + outOfPocketLocationEnabledDescription: + 'QuickBooks Online doesn’t support locations on vendor bills or checks. As you have locations enabled on your workspace, these export options are unavailable.', + taxesJournalEntrySwitchNote: "QuickBooks Online doesn't support taxes on journal entries. Please change your export option to vendor bill or check.", + exportDescription: 'Configure how Expensify data exports to QuickBooks Online.', + date: 'Export date', + exportInvoices: 'Export invoices to', + exportExpensifyCard: 'Export Expensify Card transactions as', + deepDiveExpensifyCard: 'Expensify Card transactions will automatically export to an "Expensify Card Liability Account" created with', + deepDiveExpensifyCardIntegration: 'our integration.', + exportDate: { + label: 'Export date', + description: 'Use this date when exporting reports to QuickBooks Online.', + values: { + [CONST.QUICKBOOKS_EXPORT_DATE.LAST_EXPENSE]: { + label: 'Date of last expense', + description: 'Date of the most recent expense on the report.', + }, + [CONST.QUICKBOOKS_EXPORT_DATE.REPORT_EXPORTED]: { + label: 'Export date', + description: 'Date the report was exported to QuickBooks Online.', + }, + [CONST.QUICKBOOKS_EXPORT_DATE.REPORT_SUBMITTED]: { + label: 'Submitted date', + description: 'Date the report was submitted for approval.', + }, + }, + }, + receivable: 'Accounts receivable', // This is an account name that will come directly from QBO, so I don't know why we need a translation for it. It should take whatever the name of the account is in QBO. Leaving this note for CS. + archive: 'Accounts receivable archive', // This is an account name that will come directly from QBO, so I don't know why we need a translation for it. It should take whatever the name of the account is in QBO. Leaving this note for CS. + exportInvoicesDescription: 'Use this account when exporting invoices to QuickBooks Online.', + exportCompanyCardsDescription: 'Set how company card purchases export to QuickBooks Online.', + vendor: 'Vendor', + defaultVendorDescription: 'Set a default vendor that will apply to all credit card transactions upon export.', + exportOutOfPocketExpensesDescription: 'Set how out-of-pocket expenses export to QuickBooks Online.', + exportCheckDescription: "We'll create an itemized check for each Expensify report and send it from the bank account below.", + exportJournalEntryDescription: "We'll create an itemized journal entry for each Expensify report and post it to the account below.", + exportVendorBillDescription: + "We'll create an itemized vendor bill for each Expensify report and add it to the account below. If this period is closed, we'll post to the 1st of the next open period.", + account: 'Account', + accountDescription: 'Choose where to post journal entries.', + accountsPayable: 'Accounts payable', + accountsPayableDescription: 'Choose where to create vendor bills.', + bankAccount: 'Bank account', + notConfigured: 'Not configured', + bankAccountDescription: 'Choose where to send checks from.', + creditCardAccount: 'Credit card account', + companyCardsLocationEnabledDescription: + "QuickBooks Online doesn't support locations on vendor bill exports. As you have locations enabled on your workspace, this export option is unavailable.", + outOfPocketTaxEnabledDescription: + "QuickBooks Online doesn't support taxes on journal entry exports. As you have taxes enabled on your workspace, this export option is unavailable.", + outOfPocketTaxEnabledError: 'Journal entries are unavailable when taxes are enabled. Please choose a different export option.', + outOfPocketLocationEnabledError: 'Vendor bills are unavailable when locations are enabled. Please choose a different export option.', + advancedConfig: { + autoSyncDescription: 'Expensify will automatically sync with QuickBooks Online every day.', + inviteEmployees: 'Invite employees', + inviteEmployeesDescription: 'Import Quickbooks Online employee records and invite employees to this workspace.', + createEntities: 'Auto-create entities', + createEntitiesDescription: "Expensify will automatically create vendors in QuickBooks Online if they don't exist already, and auto-create customers when exporting invoices.", + reimbursedReportsDescription: 'Any time a report is paid using Expensify ACH, the corresponding bill payment will be created in the Quickbooks Online account below.', + qboBillPaymentAccount: 'QuickBooks bill payment account', + qboInvoiceCollectionAccount: 'QuickBooks invoice collections account', + accountSelectDescription: "Choose where to pay bills from and we'll create the payment in QuickBooks Online.", + invoiceAccountSelectorDescription: "Choose where to receive invoice payments and we'll create the payment in QuickBooks Online.", + }, + accounts: { + [CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD]: 'Debit card', + [CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD]: 'Credit card', + [CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL]: 'Vendor bill', + [CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY]: 'Journal entry', + [CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.CHECK]: 'Check', + + [`${CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD}Description`]: + "We'll automatically match the merchant name on the debit card transaction to any corresponding vendors in QuickBooks. If no vendors exist, we'll create a 'Debit Card Misc.' vendor for association.", + [`${CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}Description`]: + "We'll automatically match the merchant name on the credit card transaction to any corresponding vendors in QuickBooks. If no vendors exist, we'll create a 'Credit Card Misc.' vendor for association.", + [`${CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL}Description`]: + "We'll create an itemized vendor bill for each Expensify report with the date of the last expense, and add it to the account below. If this period is closed, we'll post to the 1st of the next open period.", + + [`${CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD}AccountDescription`]: 'Choose where to export debit card transactions.', + [`${CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}AccountDescription`]: 'Choose where to export credit card transactions.', + [`${CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL}AccountDescription`]: 'Choose a vendor to apply to all credit card transactions.', + + [`${CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL}Error`]: 'Vendor bills are unavailable when locations are enabled. Please choose a different export option.', + [`${CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.CHECK}Error`]: 'Checks are unavailable when locations are enabled. Please choose a different export option.', + [`${CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY}Error`]: 'Journal entries are unavailable when taxes are enabled. Please choose a different export option.', + }, + noAccountsFound: 'No accounts found', + noAccountsFoundDescription: 'Add the account in Quickbooks Online and sync the connection again.', + }, + xero: { + organization: 'Xero organization', + organizationDescription: "Choose the Xero organization that you'd like to import data from.", + importDescription: 'Choose which coding configurations to import from Xero to Expensify.', + accountsDescription: 'Your Xero chart of accounts will import into Expensify as categories.', + accountsSwitchTitle: 'Choose to import new accounts as enabled or disabled categories.', + accountsSwitchDescription: 'Enabled categories will be available for members to select when creating their expenses.', + trackingCategories: 'Tracking categories', + trackingCategoriesDescription: 'Choose how to handle Xero tracking categories in Expensify.', + mapTrackingCategoryTo: ({categoryName}) => `Map Xero ${categoryName} to`, + mapTrackingCategoryToDescription: ({categoryName}) => `Choose where to map ${categoryName} when exporting to Xero.`, + customers: 'Re-bill customers', + customersDescription: 'Choose whether to re-bill customers in Expensify. Your Xero customer contacts can be tagged to expenses, and will export to Xero as a sales invoice.', + taxesDescription: 'Choose how to handle Xero taxes in Expensify.', + notImported: 'Not imported', + notConfigured: 'Not configured', + trackingCategoriesOptions: { + default: 'Xero contact default', + tag: 'Tags', + }, + exportDescription: 'Configure how Expensify data exports to Xero.', + purchaseBill: 'Purchase bill', + exportDeepDiveCompanyCard: 'Exported expenses will post as bank transactions to the Xero bank account below, and transaction dates will match the dates on your bank statement.', + bankTransactions: 'Bank transactions', + xeroBankAccount: 'Xero bank account', + xeroBankAccountDescription: 'Choose where expenses will post as bank transactions.', + exportExpensesDescription: 'Reports will export as a purchase bill with the date and status selected below.', + purchaseBillDate: 'Purchase bill date', + exportInvoices: 'Export invoices as', + salesInvoice: 'Sales invoice', + exportInvoicesDescription: 'Sales invoices always display the date on which the invoice was sent.', + advancedConfig: { + autoSyncDescription: 'Expensify will automatically sync with Xero every day.', + purchaseBillStatusTitle: 'Purchase bill status', + reimbursedReportsDescription: 'Any time a report is paid using Expensify ACH, the corresponding bill payment will be created in the Xero account below.', + xeroBillPaymentAccount: 'Xero bill payment account', + xeroInvoiceCollectionAccount: 'Xero invoice collections account', + xeroBillPaymentAccountDescription: "Choose where to pay bills from and we'll create the payment in Xero.", + invoiceAccountSelectorDescription: "Choose where to receive invoice payments and we'll create the payment in Xero.", + }, + exportDate: { + label: 'Purchase bill date', + description: 'Use this date when exporting reports to Xero.', + values: { + [CONST.XERO_EXPORT_DATE.LAST_EXPENSE]: { + label: 'Date of last expense', + description: 'Date of the most recent expense on the report.', + }, + [CONST.XERO_EXPORT_DATE.REPORT_EXPORTED]: { + label: 'Export date', + description: 'Date the report was exported to Xero.', + }, + [CONST.XERO_EXPORT_DATE.REPORT_SUBMITTED]: { + label: 'Submitted date', + description: 'Date the report was submitted for approval.', + }, + }, + }, + invoiceStatus: { + label: 'Purchase bill status', + description: 'Use this status when exporting purchase bills to Xero.', + values: { + [CONST.XERO_CONFIG.INVOICE_STATUS.DRAFT]: 'Draft', + [CONST.XERO_CONFIG.INVOICE_STATUS.AWAITING_APPROVAL]: 'Awaiting approval', + [CONST.XERO_CONFIG.INVOICE_STATUS.AWAITING_PAYMENT]: 'Awaiting payment', + }, + }, + noAccountsFound: 'No accounts found', + noAccountsFoundDescription: 'Please add the account in Xero and sync the connection again.', + }, + sageIntacct: { + preferredExporter: 'Preferred exporter', + notConfigured: 'Not configured', + exportDate: { + label: 'Export date', + description: 'Use this date when exporting reports to Sage Intacct.', + values: { + [CONST.SAGE_INTACCT_EXPORT_DATE.LAST_EXPENSE]: { + label: 'Date of last expense', + description: 'Date of the most recent expense on the report.', + }, + [CONST.SAGE_INTACCT_EXPORT_DATE.EXPORTED]: { + label: 'Export date', + description: 'Date the report was exported to Sage Intacct.', + }, + [CONST.SAGE_INTACCT_EXPORT_DATE.SUBMITTED]: { + label: 'Submitted date', + description: 'Date the report was submitted for approval.', + }, + }, + }, + reimbursableExpenses: { + description: 'Set how out-of-pocket expenses export to Sage Intacct.', + values: { + [CONST.SAGE_INTACCT_REIMBURSABLE_EXPENSE_TYPE.EXPENSE_REPORT]: 'Expense reports', + [CONST.SAGE_INTACCT_REIMBURSABLE_EXPENSE_TYPE.VENDOR_BILL]: 'Vendor bills', + }, + }, + nonReimbursableExpenses: { + description: 'Set how company card purchases export to Sage Intacct.', + values: { + [CONST.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSE_TYPE.CREDIT_CARD_CHARGE]: 'Credit cards', + [CONST.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSE_TYPE.VENDOR_BILL]: 'Vendor bills', + }, + }, + creditCardAccount: 'Credit card account', + defaultVendor: 'Default vendor', + defaultVendorDescription: (isReimbursable: boolean): string => + `Set a default vendor that will apply to ${isReimbursable ? '' : 'non-'}reimbursable expenses that don't have a matching vendor in Sage Intacct.`, + exportDescription: 'Configure how Expensify data exports to Sage Intacct.', + exportPreferredExporterNote: + 'The preferred exporter can be any workspace admin, but must also be a Domain Admin if you set different export accounts for individual company cards in Domain Settings.', + exportPreferredExporterSubNote: 'Once set, the preferred exporter will see reports for export in their account.', + noAccountsFound: 'No accounts found', + noAccountsFoundDescription: `Please add the account in Sage Intacct and sync the connection again.`, + autoSync: 'Auto-sync', + autoSyncDescription: 'Expensify will automatically sync with Sage Intacct every day.', + inviteEmployees: 'Invite employees', + inviteEmployeesDescription: + 'Import Sage Intacct employee records and invite employees to this workspace. Your approval workflow will default to manager approval and can be furthered configured on the Members page.', + syncReimbursedReports: 'Sync reimbursed reports', + syncReimbursedReportsDescription: 'Any time a report is paid using Expensify ACH, the corresponding bill payment will be created in the Sage Intacct account below.', + paymentAccount: 'Sage Intacct payment account', + authenticationError: 'Can’t connect to Sage Intacct due to an authentication error. ', + learnMore: 'Learn more.', + }, + netsuite: { + subsidiary: 'Subsidiary', + subsidiarySelectDescription: "Choose the subsidiary in NetSuite that you'd like to import data from.", + exportDescription: 'Configure how Expensify data exports to NetSuite.', + exportInvoices: 'Export invoices to', + journalEntriesTaxPostingAccount: 'Journal entries tax posting account', + journalEntriesProvTaxPostingAccount: 'Journal entries provincial tax posting account', + foreignCurrencyAmount: 'Export foreign currency amount', + exportToNextOpenPeriod: 'Export to next open period', + nonReimbursableJournalPostingAccount: 'Non-reimbursable journal posting account', + reimbursableJournalPostingAccount: 'Reimbursable journal posting account', + journalPostingPreference: { + label: 'Journal entries posting preference', + values: { + [CONST.NETSUITE_JOURNAL_POSTING_PREFERENCE.JOURNALS_POSTING_INDIVIDUAL_LINE]: 'Single, itemized entry for each report', + [CONST.NETSUITE_JOURNAL_POSTING_PREFERENCE.JOURNALS_POSTING_TOTAL_LINE]: 'Single entry for each expense', + }, + }, + invoiceItem: { + label: 'Invoice item', + values: { + [CONST.NETSUITE_INVOICE_ITEM_PREFERENCE.CREATE]: { + label: 'Create one for me', + description: 'We\'ll create an "Expensify invoice line item" for you upon export (if one doesn’t exist already).', + }, + [CONST.NETSUITE_INVOICE_ITEM_PREFERENCE.SELECT]: { + label: 'Select existing', + description: "We'll tie invoices from Expensify to the item selected below.", + }, + }, + }, + exportDate: { + label: 'Export date', + description: 'Use this date when exporting reports to NetSuite.', + values: { + [CONST.NETSUITE_EXPORT_DATE.LAST_EXPENSE]: { + label: 'Date of last expense', + description: 'Date of the most recent expense on the report.', + }, + [CONST.NETSUITE_EXPORT_DATE.EXPORTED]: { + label: 'Export date', + description: 'Date the report was exported to NetSuite.', + }, + [CONST.NETSUITE_EXPORT_DATE.SUBMITTED]: { + label: 'Submitted date', + description: 'Date the report was submitted for approval.', + }, + }, + }, + exportDestination: { + values: { + [CONST.NETSUITE_EXPORT_DESTINATION.EXPENSE_REPORT]: { + label: 'Expense reports', + reimbursableDescription: 'Out-of-pocket expenses will export as expense reports to NetSuite.', + nonReimbursableDescription: 'Company card expenses will export as expense reports to NetSuite.', + }, + [CONST.NETSUITE_EXPORT_DESTINATION.VENDOR_BILL]: { + label: 'Vendor bills', + reimbursableDescription: + 'Out-of-pocket expenses will export as bills payable to the NetSuite vendor specified below.\n' + + '\n' + + 'If you’d like to set a specific vendor for each card, go to *Settings > Domains > Company Cards*.', + nonReimbursableDescription: + 'Company card expenses will export as bills payable to the NetSuite vendor specified below.\n' + + '\n' + + 'If you’d like to set a specific vendor for each card, go to *Settings > Domains > Company Cards*.', + }, + [CONST.NETSUITE_EXPORT_DESTINATION.JOURNAL_ENTRY]: { + label: 'Journal entries', + reimbursableDescription: + 'Out-of-pocket expenses will export as journal entries to the NetSuite account specified below.\n' + + '\n' + + 'If you’d like to set a specific vendor for each card, go to *Settings > Domains > Company Cards*.', + nonReimbursableDescription: + 'Company card expenses will export as journal entries to the NetSuite account specified below.\n' + + '\n' + + 'If you’d like to set a specific vendor for each card, go to *Settings > Domains > Company Cards*.', + }, + }, + }, + advancedConfig: { + autoSyncDescription: 'Expensify will automatically sync with NetSuite every day.', + reimbursedReportsDescription: 'Any time a report is paid using Expensify ACH, the corresponding bill payment will be created in the NetSuite account below.', + reimbursementsAccount: 'Reimbursements account', + reimbursementsAccountDescription: "Choose the bank account you'll use for reimbursements, and we'll create the associated payment in NetSuite.", + collectionsAccount: 'Collections account', + collectionsAccountDescription: 'Once an invoice is marked as paid in Expensify and exported to NetSuite, it’ll appear against the account below.', + approvalAccount: 'A/P approval account', + approvalAccountDescription: + 'Choose the account that transactions will be approved against in NetSuite. If you’re syncing reimbursed reports, this is also the account that bill payments will be created against.', + defaultApprovalAccount: 'NetSuite default', + inviteEmployees: 'Invite employees and set approvals', + inviteEmployeesDescription: + 'Import NetSuite employee records and invite employees to this workspace. Your approval workflow will default to manager approval and can be further configured on the *Members* page.', + autoCreateEntities: 'Auto-create employees/vendors', + enableCategories: 'Enable newly imported categories', + customFormID: 'Custom form ID', + customFormIDDescription: + 'By default, Expensify will create entries using the preferred transaction form set in NetSuite. Alternatively, you can designate a specific transaction form to be used.', + customFormIDReimbursable: 'Out-of-pocket expense', + customFormIDNonReimbursable: 'Company card expense', + exportReportsTo: { + label: 'Expense report approval level', + description: 'Once an expense report is approved in Expensify and exported to NetSuite, you can set an additional level of approval in NetSuite prior to posting.', + values: { + [CONST.NETSUITE_REPORTS_APPROVAL_LEVEL.REPORTS_APPROVED_NONE]: 'NetSuite default preference', + [CONST.NETSUITE_REPORTS_APPROVAL_LEVEL.REPORTS_SUPERVISOR_APPROVED]: 'Only supervisor approved', + [CONST.NETSUITE_REPORTS_APPROVAL_LEVEL.REPORTS_ACCOUNTING_APPROVED]: 'Only accounting approved', + [CONST.NETSUITE_REPORTS_APPROVAL_LEVEL.REPORTS_APPROVED_BOTH]: 'Supervisor and accounting approved', + }, + }, + exportVendorBillsTo: { + label: 'Vendor bill approval level', + description: 'Once a vendor bill is approved in Expensify and exported to NetSuite, you can set an additional level of approval in NetSuite prior to posting.', + values: { + [CONST.NETSUITE_VENDOR_BILLS_APPROVAL_LEVEL.VENDOR_BILLS_APPROVED_NONE]: 'NetSuite default preference', + [CONST.NETSUITE_VENDOR_BILLS_APPROVAL_LEVEL.VENDOR_BILLS_APPROVAL_PENDING]: 'Pending approval', + [CONST.NETSUITE_VENDOR_BILLS_APPROVAL_LEVEL.VENDOR_BILLS_APPROVED]: 'Approved for posting', + }, + }, + exportJournalsTo: { + label: 'Journal entry approval level', + description: 'Once a journal entry is approved in Expensify and exported to NetSuite, you can set an additional level of approval in NetSuite prior to posting.', + values: { + [CONST.NETSUITE_JOURNALS_APPROVAL_LEVEL.JOURNALS_APPROVED_NONE]: 'NetSuite default preference', + [CONST.NETSUITE_JOURNALS_APPROVAL_LEVEL.JOURNALS_APPROVAL_PENDING]: 'Pending approval', + [CONST.NETSUITE_JOURNALS_APPROVAL_LEVEL.JOURNALS_APPROVED]: 'Approved for posting', + }, + }, + error: { + customFormID: 'Please enter a valid numeric custom form ID.', + }, + }, + noAccountsFound: 'No accounts found', + noAccountsFoundDescription: 'Please add the account in NetSuite and sync the connection again.', + noVendorsFound: 'No vendors found', + noVendorsFoundDescription: 'Please add vendors in NetSuite and sync the connection again.', + noItemsFound: 'No invoice items found', + noItemsFoundDescription: 'Please add invoice items in NetSuite and sync the connection again.', + noSubsidiariesFound: 'No subsidiaries found', + noSubsidiariesFoundDescription: 'Please add a subsidiary in NetSuite and sync the connection again.', + tokenInput: { + title: 'NetSuite setup', + formSteps: { + installBundle: { + title: 'Install the Expensify bundle', + description: 'In NetSuite, go to *Customization > SuiteBundler > Search & Install Bundles* > search for "Expensify" > install the bundle.', + }, + enableTokenAuthentication: { + title: 'Enable token-based authentication', + description: 'In NetSuite, go to *Setup > Company > Enable Features > SuiteCloud* > enable *token-based authentication*.', + }, + enableSoapServices: { + title: 'Enable SOAP web services', + description: 'In NetSuite, go to *Setup > Company > Enable Features > SuiteCloud* > enable *SOAP Web Services*.', + }, + createAccessToken: { + title: 'Create an access token', + description: + 'In NetSuite, go to *Setup > Users/Roles > Access Tokens* > create an access token for the "Expensify" app and either the "Expensify Integration" or "Administrator" role.\n\n*Important:* Make sure you save the *Token ID* and *Token Secret* from this step. You\'ll need it for the next step.', + }, + enterCredentials: { + title: 'Enter your NetSuite credentials', + formInputs: { + netSuiteAccountID: 'NetSuite Account ID', + netSuiteTokenID: 'Token ID', + netSuiteTokenSecret: 'Token Secret', + }, + netSuiteAccountIDDescription: 'In NetSuite, go to *Setup > Integration > SOAP Web Services Preferences*.', + }, + }, + }, + import: { + expenseCategories: 'Expense categories', + expenseCategoriesDescription: 'Your NetSuite expense categories will import into Expensify as categories.', + crossSubsidiaryCustomers: 'Cross-subsidiary customers/projects', + importFields: { + departments: { + title: 'Departments', + subtitle: 'Choose how to handle the NetSuite *departments* in Expensify.', + }, + classes: { + title: 'Classes', + subtitle: 'Choose how to handle *classes* in Expensify.', + }, + locations: { + title: 'Locations', + subtitle: 'Choose how to handle *locations* in Expensify.', + }, + }, + customersOrJobs: { + title: 'Customers/projects', + subtitle: 'Choose how to handle NetSuite *customers* and *projects* in Expensify.', + importCustomers: 'Import customers', + importJobs: 'Import projects', + customers: 'customers', + jobs: 'projects', + label: (importFields: string[], importType: string) => `${importFields.join(' and ')}, ${importType}`, + }, + importTaxDescription: 'Import tax groups from NetSuite.', + importCustomFields: { + chooseOptionBelow: 'Choose an option below:', + requiredFieldError: (fieldName: string) => `Please enter the ${fieldName}`, + customSegments: { + title: 'Custom segments/records', + addText: 'Add custom segment/record', + recordTitle: 'Custom segment/record', + helpLink: CONST.NETSUITE_IMPORT.HELP_LINKS.CUSTOM_SEGMENTS, + helpLinkText: 'View detailed instructions', + helpText: ' on configuring custom segments/records.', + emptyTitle: 'Add a custom segment or custom record', + fields: { + segmentName: 'Name', + internalID: 'Internal ID', + scriptID: 'Script ID', + customRecordScriptID: 'Transaction column ID', + mapping: 'Displayed as', + }, + removeTitle: 'Remove custom segment/record', + removePrompt: 'Are you sure you want to remove this custom segment/record?', + addForm: { + customSegmentName: 'custom segment name', + customRecordName: 'custom record name', + segmentTitle: 'Custom segment', + customSegmentAddTitle: 'Add custom segment', + customRecordAddTitle: 'Add custom record', + recordTitle: 'Custom record', + segmentRecordType: 'Do you want to add a custom segment or a custom record?', + customSegmentNameTitle: "What's the custom segment name?", + customRecordNameTitle: "What's the custom record name?", + customSegmentNameFooter: `You can find custom segment names in NetSuite under *Customizations > Links, Records & Fields > Custom Segments* page.\n\n_For more detailed instructions, [visit our help site](${CONST.NETSUITE_IMPORT.HELP_LINKS.CUSTOM_SEGMENTS})_.`, + customRecordNameFooter: `You can find custom record names in NetSuite by entering the "Transaction Column Field" in global search.\n\n_For more detailed instructions, [visit our help site](${CONST.NETSUITE_IMPORT.HELP_LINKS.CUSTOM_SEGMENTS})_.`, + customSegmentInternalIDTitle: "What's the internal ID?", + customSegmentInternalIDFooter: `First, make sure you've enabled internal IDs in NetSuite under *Home > Set Preferences > Show Internal ID.*\n\nYou can find custom segment internal IDs in NetSuite under:\n\n1. *Customization > Lists, Records, & Fields > Custom Segments*.\n2. Click into a custom segment.\n3. Click the hyperlink next to *Custom Record Type*.\n4. Find the internal ID in the table at the bottom.\n\n_For more detailed instructions, [visit our help site](${CONST.NETSUITE_IMPORT.HELP_LINKS.CUSTOM_LISTS})_.`, + customRecordInternalIDFooter: `You can find custom record internal IDs in NetSuite by following these steps:\n\n1. Enter "Transaction Line Fields" in global search.\n2. Click into a custom record.\n3. Find the internal ID on the left-hand side.\n\n_For more detailed instructions, [visit our help site](${CONST.NETSUITE_IMPORT.HELP_LINKS.CUSTOM_SEGMENTS})_.`, + customSegmentScriptIDTitle: "What's the script ID?", + customSegmentScriptIDFooter: `You can find custom segment script IDs in NetSuite under: \n\n1. *Customization > Lists, Records, & Fields > Custom Segments*.\n2. Click into a custom segment.\n3. Click the *Application and Sourcing* tab near the bottom, then:\n a. If you want to display the custom segment as a *tag* (at the line-item level) in Expensify, click the *Transaction Columns* sub-tab and use the *Field ID*.\n b. If you want to display the custom segment as a *report field* (at the report level) in Expensify, click the *Transactions* sub-tab and use the *Field ID*.\n\n_For more detailed instructions, [visit our help site](${CONST.NETSUITE_IMPORT.HELP_LINKS.CUSTOM_LISTS})_.`, + customRecordScriptIDTitle: "What's the transaction column ID?", + customRecordScriptIDFooter: `You can find custom record script IDs in NetSuite under:\n\n1. Enter "Transaction Line Fields" in global search.\n2. Click into a custom record.\n3. Find the script ID on the left-hand side.\n\n_For more detailed instructions, [visit our help site](${CONST.NETSUITE_IMPORT.HELP_LINKS.CUSTOM_SEGMENTS})_.`, + customSegmentMappingTitle: 'How should this custom segment be displayed in Expensify?', + customRecordMappingTitle: 'How should this custom record be displayed in Expensify?', + }, + errors: { + uniqueFieldError: (fieldName: string) => `A custom segment/record with this ${fieldName?.toLowerCase()} already exists.`, + }, + }, + customLists: { + title: 'Custom lists', + addText: 'Add custom list', + recordTitle: 'Custom list', + helpLink: CONST.NETSUITE_IMPORT.HELP_LINKS.CUSTOM_LISTS, + helpLinkText: 'View detailed instructions', + helpText: ' on configuring custom lists.', + emptyTitle: 'Add a custom list', + fields: { + listName: 'Name', + internalID: 'Internal ID', + transactionFieldID: 'Transaction field ID', + mapping: 'Displayed as', + }, + removeTitle: 'Remove custom list', + removePrompt: 'Are you sure you want to remove this custom list?', + addForm: { + listNameTitle: 'Choose a custom list', + transactionFieldIDTitle: "What's the transaction field ID?", + transactionFieldIDFooter: `You can find transaction field IDs in NetSuite by following these steps:\n\n1. Enter "Transaction Line Fields" in global search.\n2. Click into a custom list.\n3. Find the transaction field ID on the left-hand side.\n\n_For more detailed instructions, [visit our help site](${CONST.NETSUITE_IMPORT.HELP_LINKS.CUSTOM_LISTS})_.`, + mappingTitle: 'How should this custom list be displayed in Expensify?', + }, + errors: { + uniqueTransactionFieldIDError: `A custom list with this transaction field ID already exists.`, + }, + }, + }, + importTypes: { + [CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT]: { + label: 'NetSuite employee default', + description: 'Not imported into Expensify, applied on export', + footerContent: (importField: string) => + `If you use ${importField} in NetSuite, we'll apply the default set on the employee record upon export to Expense Report or Journal Entry.`, + }, + [CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG]: { + label: 'Tags', + description: 'Line-item level', + footerContent: (importField: string) => `${startCase(importField)} will be selectable for each individual expense on an employee's report.`, + }, + [CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD]: { + label: 'Report fields', + description: 'Report level', + footerContent: (importField: string) => `${startCase(importField)} selection will apply to all expense on an employee's report.`, + }, + }, + }, + }, + intacct: { + sageIntacctSetup: 'Sage Intacct setup', + prerequisitesTitle: 'Before you connect...', + downloadExpensifyPackage: 'Download the Expensify package for Sage Intacct', + followSteps: 'Follow the steps in our How-to: Connect to Sage Intacct instructions', + enterCredentials: 'Enter your Sage Intacct credentials', + entity: 'Entity', + employeeDefault: 'Sage Intacct employee default', + employeeDefaultDescription: "The employee's default department will be applied to their expenses in Sage Intacct if one exists.", + displayedAsTagDescription: "Department will be selectable for each individual expense on an employee's report.", + displayedAsReportFieldDescription: "Department selection will apply to all expenses on an employee's report.", + toggleImportTitleFirstPart: 'Choose how to handle Sage Intacct ', + toggleImportTitleSecondPart: ' in Expensify.', + expenseTypes: 'Expense types', + expenseTypesDescription: 'Your Sage Intacct expense types will import into Expensify as categories.', + importTaxDescription: 'Import purchase tax rate from Sage Intacct.', + userDefinedDimensions: 'User-defined dimensions', + addUserDefinedDimension: 'Add user-defined dimension', + integrationName: 'Integration name', + dimensionExists: 'A dimension with this name already exists.', + removeDimension: 'Remove user-defined dimension', + removeDimensionPrompt: 'Are you sure you want to remove this user-defined dimension?', + userDefinedDimension: 'User-defined dimension', + addAUserDefinedDimension: 'Add a user-defined dimension', + detailedInstructionsLink: 'View detailed instructions', + detailedInstructionsRestOfSentence: ' on adding user-defined dimensions.', + userDimensionsAdded: (dimensionsCount: number) => `${dimensionsCount} ${Str.pluralize('UDD', `UDDs`, dimensionsCount)} added`, + mappingTitle: (mappingName: SageIntacctMappingName): string => { + switch (mappingName) { + case CONST.SAGE_INTACCT_CONFIG.MAPPINGS.DEPARTMENTS: + return 'departments'; + case CONST.SAGE_INTACCT_CONFIG.MAPPINGS.CLASSES: + return 'classes'; + case CONST.SAGE_INTACCT_CONFIG.MAPPINGS.LOCATIONS: + return 'locations'; + case CONST.SAGE_INTACCT_CONFIG.MAPPINGS.CUSTOMERS: + return 'customers'; + case CONST.SAGE_INTACCT_CONFIG.MAPPINGS.PROJECTS: + return 'projects (jobs)'; + default: + return 'mappings'; + } + }, + }, + type: { + free: 'Free', + control: 'Control', + collect: 'Collect', + }, + companyCards: { + addCompanyCards: 'Add company cards', + selectCardFeed: 'Select card feed', + addCardFeed: 'Add card feed', + addNewCard: { + cardProviders: { + amex: 'American Express Corporate Cards', + mastercard: 'Mastercard Commercial Cards', + visa: 'Visa Commercial Cards', + }, + yourCardProvider: `Who's your card provider?`, + enableFeed: { + title: (provider: string) => `Enable your ${provider} feed`, + heading: 'We have a direct integration with your card issuer and can import your transaction data into Expensify quickly and accurately.\n\nTo get started, simply:', + visa: `1. Visit [this help article](${CONST.COMPANY_CARDS_HELP}) for detailed instructionson how to set up your Visa Commercial Cards.\n\n2. [Contact your bank](${CONST.COMPANY_CARDS_HELP}) to verify they support a custom feed for your program, and ask them toenable it.\n\n3. *Once the feed is enabled and you have its details, continue to the next screen.*`, + amex: `1. Visit [this help article](${CONST.COMPANY_CARDS_HELP}) to find out if American Express can enable a custom feed for your program.\n\n2. Once the feed is enabled, Amex will send you a production letter.\n\n3. *Once you have the feed information, continue to the next screen.*`, + mastercard: `1. Visit [this help article](${CONST.COMPANY_CARDS_HELP}) for detailed instructions on how to set up your Mastercard Commercial Cards.\n\n 2. [Contact your bank](${CONST.COMPANY_CARDS_HELP}) to verify they support a custom feed for your program, and ask them to enable it.\n\n3. *Once the feed is enabled and you have its details, continue to the next screen.*`, + }, + whatBankIssuesCard: 'What bank issues these cards?', + enterNameOfBank: 'Enter name of bank', + feedDetails: { + visa: { + title: 'What are the Visa feed details?', + processorLabel: 'Processor ID', + bankLabel: 'Financial institution (bank) ID', + companyLabel: 'Company ID', + }, + amex: { + title: `What's the Amex delivery file name?`, + fileNameLabel: 'Delivery file name', + }, + mastercard: { + title: `What's the Mastercard distribution ID?`, + distributionLabel: 'Distribution ID', + }, + }, + error: { + pleaseSelectProvider: 'Please select a card provider before continuing.', + }, + }, + assignCard: 'Assign card', + cardNumber: 'Card number', + customFeed: 'Custom feed', + whoNeedsCardAssigned: 'Who needs a card assigned?', + chooseCard: 'Choose a card', + chooseCardFor: ({assignee, feed}: AssignCardParams) => `Choose a card for ${assignee} from the ${feed} cards feed.`, + noActiveCards: 'No active cards on this feed', + somethingMightBeBroken: 'Or something might be broken. Either way, if you have any questions, just', + contactConcierge: 'contact Concierge', + chooseTransactionStartDate: 'Choose a transaction start date', + startDateDescription: 'We will import all transaction from this date onwards. If no date is specified, we’ll go as far back as your bank allows.', + fromTheBeginning: 'From the beginning', + customStartDate: 'Custom start date', + letsDoubleCheck: 'Let’s double check that everything looks right.', + confirmationDescription: 'We’ll begin importing transactions immediately.', + cardholder: 'Cardholder', + card: 'Card', + startTransactionDate: 'Start transaction date', + cardName: 'Card name', + assignedYouCard: (assigner: string) => `${assigner} assigned you a company card! Imported transactions will appear in this chat.`, + }, + expensifyCard: { + issueAndManageCards: 'Issue and manage your Expensify Cards', + getStartedIssuing: 'Get started by issuing your first virtual or physical card.', + disclaimer: + 'The Expensify Visa® Commercial Card is issued by The Bancorp Bank, N.A., Member FDIC, pursuant to a license from Visa U.S.A. Inc. and may not be used at all merchants that accept Visa cards. Apple® and the Apple logo® are trademarks of Apple Inc., registered in the U.S. and other countries. App Store is a service mark of Apple Inc. Google Play and the Google Play logo are trademarks of Google LLC.', + issueCard: 'Issue card', + newCard: 'New card', + name: 'Name', + lastFour: 'Last 4', + limit: 'Limit', + currentBalance: 'Current balance', + currentBalanceDescription: 'Current balance is the sum of all posted Expensify Card transactions that have occurred since the last settlement date.', + cardLimit: 'Card limit', + remainingLimit: 'Remaining limit', + requestLimitIncrease: 'Request limit increase', + remainingLimitDescription: + 'We consider a number of factors when calculating your remaining limit: your tenure as a customer, the business-related information you provided during signup, and the available cash in your business bank account. Your remaining limit can fluctuate on a daily basis.', + earnedCashback: 'Cash back', + earnedCashbackDescription: 'Cash back balance is based on settled monthly Expensify Card spend across your workspace.', + issueNewCard: 'Issue new card', + finishSetup: 'Finish setup', + chooseBankAccount: 'Choose bank account', + chooseExistingBank: 'Choose an existing business bank account to pay your Expensify Card balance, or add a new bank account', + accountEndingIn: 'Account ending in', + addNewBankAccount: 'Add a new bank account', + settlementAccount: 'Settlement account', + settlementAccountDescription: 'Choose an account to pay your Expensify Card balance.', + settlementAccountInfoPt1: 'Make sure this account matches your', + settlementAccountInfoPt2: 'so Continuous Reconciliation works properly.', + reconciliationAccount: 'Reconciliation account', + settlementFrequency: 'Settlement frequency', + settlementFrequencyDescription: 'Choose how often you’ll pay your Expensify Card balance.', + settlementFrequencyInfo: 'If you’d like to switch to monthly settlement, you’ll need to connect your bank account via Plaid and have a positive 90-day balance history.', + frequency: { + daily: 'Daily', + monthly: 'Monthly', + }, + cardDetails: 'Card details', + virtual: 'Virtual', + physical: 'Physical', + deactivate: 'Deactivate card', + changeCardLimit: 'Change card limit', + changeLimit: 'Change limit', + smartLimitWarning: (limit: string) => `If you change this card’s limit to ${limit}, new transactions will be declined until you approve more expenses on the card.`, + monthlyLimitWarning: (limit: string) => `If you change this card’s limit to ${limit}, new transactions will be declined until next month.`, + fixedLimitWarning: (limit: string) => `If you change this card’s limit to ${limit}, new transactions will be declined.`, + changeCardLimitType: 'Change card limit type', + changeLimitType: 'Change limit type', + changeCardSmartLimitTypeWarning: (limit: string) => + `If you change this card's limit type to Smart Limit, new transactions will be declined because the ${limit} unapproved limit has already been reached.`, + changeCardMonthlyLimitTypeWarning: (limit: string) => + `If you change this card's limit type to Monthly, new transactions will be declined because the ${limit} monthly limit has already been reached.`, + addShippingDetails: 'Add shipping details', + issuedCard: (assignee: string) => `issued ${assignee} an Expensify Card! The card will arrive in 2-3 business days.`, + issuedCardNoShippingDetails: (assignee: string) => `issued ${assignee} an Expensify Card! The card will be shipped once shipping details are added.`, + issuedCardVirtual: ({assignee, link}: IssueVirtualCardParams) => `issued ${assignee} a virtual ${link}! The card can be used right away.`, + addedShippingDetails: (assignee: string) => `${assignee} added shipping details. Expensify Card will arrive in 2-3 business days.`, + }, + categories: { + deleteCategories: 'Delete categories', + deleteCategoriesPrompt: 'Are you sure you want to delete these categories?', + deleteCategory: 'Delete category', + deleteCategoryPrompt: 'Are you sure you want to delete this category?', + disableCategories: 'Disable categories', + disableCategory: 'Disable category', + enableCategories: 'Enable categories', + enableCategory: 'Enable category', + defaultSpendCategories: 'Default spend categories', + spendCategoriesDescription: 'Customize how merchant spend is categorized for credit card transactions and scanned receipts.', + deleteFailureMessage: 'An error occurred while deleting the category, please try again.', + categoryName: 'Category name', + requiresCategory: 'Members must categorize all expenses', + needCategoryForExportToIntegration: 'Require a category on every expense in order to export to', + subtitle: 'Get a better overview of where money is being spent. Use our default categories or add your own.', + emptyCategories: { + title: "You haven't created any categories", + subtitle: 'Add a category to organize your spend.', + }, + updateFailureMessage: 'An error occurred while updating the category, please try again.', + createFailureMessage: 'An error occurred while creating the category, please try again.', + addCategory: 'Add category', + editCategory: 'Edit category', + editCategories: 'Edit categories', + categoryRequiredError: 'Category name is required.', + existingCategoryError: 'A category with this name already exists.', + invalidCategoryName: 'Invalid category name.', + importedFromAccountingSoftware: 'The categories below are imported from your', + payrollCode: 'Payroll code', + updatePayrollCodeFailureMessage: 'An error occurred while updating the payroll code, please try again.', + glCode: 'GL code', + updateGLCodeFailureMessage: 'An error occurred while updating the GL code, please try again.', + importCategories: 'Import categories', + }, + moreFeatures: { + subtitle: 'Use the toggles below to enable more features as you grow. Each feature will appear in the navigation menu for further customization.', + spendSection: { + title: 'Spend', + subtitle: 'Enable functionality that helps you scale your team.', + }, + manageSection: { + title: 'Manage', + subtitle: 'Add controls that help keep spend within budget.', + }, + earnSection: { + title: 'Earn', + subtitle: 'Enable optional functionality to streamline your revenue and get paid faster.', + }, + organizeSection: { + title: 'Organize', + subtitle: 'Group and analyze spend, record every tax paid.', + }, + integrateSection: { + title: 'Integrate', + subtitle: 'Connect Expensify to popular financial products.', + }, + distanceRates: { + title: 'Distance rates', + subtitle: 'Add, update, and enforce rates.', + }, + expensifyCard: { + title: 'Expensify Card', + subtitle: 'Gain insights and control over spend.', + disableCardTitle: 'Disable Expensify Card', + disableCardPrompt: 'You can’t disable the Expensify Card because it’s already in use. Reach out to Concierge for next steps.', + disableCardButton: 'Chat with Concierge', + feed: { + title: 'Get the Expensify Card', + subTitle: 'Streamline your business with the Expensify Card.', + features: { + cashBack: 'Cash back on every US purchase', + unlimited: 'Unlimited virtual cards', + spend: 'Spend controls and custom limits', + }, + ctaTitle: 'Issue new card', + }, + }, + companyCards: { + title: 'Company cards', + subtitle: 'Import spend from existing company cards.', + feed: { + title: 'Import company cards', + features: { + support: 'Support for all major card providers', + assignCards: 'Assign cards to the entire team', + automaticImport: 'Automatic transaction import', + }, + ctaTitle: 'Add company cards', + }, + disableCardTitle: 'Disable company cards', + disableCardPrompt: 'You can’t disable company cards because this feature is in use. Reach out to the Concierge for next steps.', + disableCardButton: 'Chat with Concierge', + cardDetails: 'Card details', + cardNumber: 'Card number', + cardholder: 'Cardholder', + cardName: 'Card name', + integrationExport: (integration: string, type: string) => `${integration} ${type} export`, + integrationExportTitleFirstPart: (integration: string) => `Choose the ${integration} account where transactions should be exported. Select a different`, + integrationExportTitleLinkPart: 'export option', + integrationExportTitleSecondPart: 'to change the available accounts.', + lastUpdated: 'Last updated', + transactionStartDate: 'Transaction start date', + updateCard: 'Update card', + unassignCard: 'Unassign card', + unassign: 'Unassign', + unassignCardDescription: 'Unassign this card will remove all transactions on draft reports from the cardholder’s account.', + assignCard: 'Assign card', + cardFeedName: 'Card feed name', + cardFeedNameDescription: 'Give the card feed a unique name so you can tell it apart from the others.', + cardFeedTransaction: 'Delete transactions', + cardFeedTransactionDescription: 'Choose whether cardholders can delete card transactions. New transactions will follow these rules.', + cardFeedRestrictDeletingTransaction: 'Restrict deleting transactions', + cardFeedAllowDeletingTransaction: 'Allow deleting transactions', + removeCardFeed: 'Remove card feed', + removeCardFeedTitle: ({feedName}: CompanyCardFeedNameParams) => `Remove ${feedName} feed`, + removeCardFeedDescription: 'Are you sure you want to remove this card feed? This will unassign all cards.', + error: { + feedNameRequired: 'Card feed name is required.', + }, + corporate: 'Restrict deleting transactions', + personal: 'Allow deleting transactions', + setFeedNameDescription: 'Give the card feed a unique name so you can tell it apart from the others.', + setTransactionLiabilityDescription: 'When enabled, cardholders can delete card transactions. New transactions will follow this rule.', + emptyAddedFeedTitle: 'Assign company cards', + emptyAddedFeedDescription: 'Get started by assigning your first card to a member.', + pendingFeedTitle: `We're reviewing your request...`, + pendingFeedDescription: `We're currently reviewing your feed details. Once that's done we'll reach out to you via`, + giveItNameInstruction: 'Give the card a name that sets it apart from the others.', + updating: 'Updating...', + noAccountsFound: 'No accounts found', + noAccountsFoundDescription: (connection: string) => `Please add the account in ${connection} and sync the connection again.`, + }, + workflows: { + title: 'Workflows', + subtitle: 'Configure how spend is approved and paid.', + }, + invoices: { + title: 'Invoices', + subtitle: 'Send and receive invoices.', + }, + categories: { + title: 'Categories', + subtitle: 'Track and organize spend.', + }, + tags: { + title: 'Tags', + subtitle: 'Classify costs and track billable expenses.', + }, + taxes: { + title: 'Taxes', + subtitle: 'Document and reclaim eligible taxes.', + }, + reportFields: { + title: 'Report fields', + subtitle: 'Set up custom fields for spend.', + }, + connections: { + title: 'Accounting', + subtitle: 'Sync your chart of accounts and more.', + }, + connectionsWarningModal: { + featureEnabledTitle: 'Not so fast...', + featureEnabledText: "To enable or disable this feature, you'll need to change your accounting import settings.", + disconnectText: "To disable accounting, you'll need to disconnect your accounting connection from your workspace.", + manageSettings: 'Manage settings', + }, + rules: { + title: 'Rules', + subtitle: 'Configure when receipts are required, flag high spend, and more.', + }, + }, + reportFields: { + addField: 'Add field', + delete: 'Delete field', + deleteFields: 'Delete fields', + deleteConfirmation: 'Are you sure you want to delete this report field?', + deleteFieldsConfirmation: 'Are you sure you want to delete these report fields?', + emptyReportFields: { + title: "You haven't created any report fields", + subtitle: 'Add a custom field (text, date, or dropdown) that appears on reports.', + }, + subtitle: "Report fields apply to all spend and can be helpful when you'd like to prompt for extra information.", + disableReportFields: 'Disable report fields', + disableReportFieldsConfirmation: 'Are you sure? Text and date fields will be deleted, and lists will be disabled.', + importedFromAccountingSoftware: 'The report fields below are imported from your', + textType: 'Text', + dateType: 'Date', + dropdownType: 'List', + textAlternateText: 'Add a field for free text input.', + dateAlternateText: 'Add a calendar for date selection.', + dropdownAlternateText: 'Add a list of options to choose from.', + nameInputSubtitle: 'Choose a name for the report field.', + typeInputSubtitle: 'Choose what type of report field to use.', + initialValueInputSubtitle: 'Enter a starting value to show in the report field.', + listValuesInputSubtitle: 'These values will appear in your report field dropdown. Enabled values can be selected by members.', + listInputSubtitle: 'These values will appear in your report field list. Enabled values can be selected by members.', + deleteValue: 'Delete value', + deleteValues: 'Delete values', + disableValue: 'Disable value', + disableValues: 'Disable values', + enableValue: 'Enable value', + enableValues: 'Enable values', + emptyReportFieldsValues: { + title: "You haven't created any list values", + subtitle: 'Add custom values to appear on reports.', + }, + deleteValuePrompt: 'Are you sure you want to delete this list value?', + deleteValuesPrompt: 'Are you sure you want to delete these list values?', + listValueRequiredError: 'Please enter a list value name', + existingListValueError: 'A list value with this name already exists', + editValue: 'Edit value', + listValues: 'List values', + addValue: 'Add value', + existingReportFieldNameError: 'A report field with this name already exists', + reportFieldNameRequiredError: 'Please enter a report field name', + reportFieldTypeRequiredError: 'Please choose a report field type', + reportFieldInitialValueRequiredError: 'Please choose a report field initial value', + genericFailureMessage: 'An error occurred while updating the report field. Please try again.', + }, + tags: { + tagName: 'Tag name', + requiresTag: 'Members must tag all expenses', + trackBillable: 'Track billable expenses', + customTagName: 'Custom tag name', + enableTag: 'Enable tag', + enableTags: 'Enable tags', + disableTag: 'Disable tag', + disableTags: 'Disable tags', + addTag: 'Add tag', + editTag: 'Edit tag', + subtitle: 'Tags add more detailed ways to classify costs.', + emptyTags: { + title: "You haven't created any tags", + subtitle: 'Add a tag to track projects, locations, departments, and more.', + }, + deleteTag: 'Delete tag', + deleteTags: 'Delete tags', + deleteTagConfirmation: 'Are you sure that you want to delete this tag?', + deleteTagsConfirmation: 'Are you sure that you want to delete these tags?', + deleteFailureMessage: 'An error occurred while deleting the tag, please try again.', + tagRequiredError: 'Tag name is required.', + existingTagError: 'A tag with this name already exists.', + invalidTagNameError: 'Tag name cannot be 0. Please choose a different value.', + genericFailureMessage: 'An error occurred while updating the tag, please try again.', + importedFromAccountingSoftware: 'The tags below are imported from your', + glCode: 'GL code', + updateGLCodeFailureMessage: 'An error occurred while updating the GL code, please try again.', + tagRules: 'Tag rules', + approverDescription: 'Approver', + importTags: 'Import tags', + importedTagsMessage: (columnCounts: number) => + `We found *${columnCounts} columns* in your spreadsheet. Select *Name* next to the column that contains tags names. You can also select *Enabled* next to the column that sets tags status.`, + }, + taxes: { + subtitle: 'Add tax names, rates, and set defaults.', + addRate: 'Add rate', + workspaceDefault: 'Workspace currency default', + foreignDefault: 'Foreign currency default', + customTaxName: 'Custom tax name', + value: 'Value', + taxReclaimableOn: 'Tax reclaimable on', + taxRate: 'Tax rate', + error: { + taxRateAlreadyExists: 'This tax name is already in use.', + taxCodeAlreadyExists: 'This tax code is already in use.', + valuePercentageRange: 'Please enter a valid percentage between 0 and 100.', + customNameRequired: 'Custom tax name is required.', + deleteFailureMessage: 'An error occurred while deleting the tax rate. Please try again or ask Concierge for help.', + updateFailureMessage: 'An error occurred while updating the tax rate. Please try again or ask Concierge for help.', + createFailureMessage: 'An error occurred while creating the tax rate. Please try again or ask Concierge for help.', + updateTaxClaimableFailureMessage: 'The reclaimable portion must be less than the distance rate amount.', + }, + deleteTaxConfirmation: 'Are you sure you want to delete this tax?', + deleteMultipleTaxConfirmation: ({taxAmount}) => `Are you sure you want to delete ${taxAmount} taxes?`, + actions: { + delete: 'Delete rate', + deleteMultiple: 'Delete rates', + disable: 'Disable rate', + disableMultiple: 'Disable rates', + enable: 'Enable rate', + enableMultiple: 'Enable rates', + }, + importedFromAccountingSoftware: 'The taxes below are imported from your', + taxCode: 'Tax code', + updateTaxCodeFailureMessage: 'An error occurred while updating the tax code, please try again.', + }, + emptyWorkspace: { + title: 'Create a workspace', + subtitle: 'Workspaces are where you’ll chat with your team, reimburse expenses, issue cards, send invoices, pay bills, and more - all in one place.', + createAWorkspaceCTA: 'Get Started', + features: { + trackAndCollect: 'Track and collect receipts', + companyCards: 'Company credit cards', + reimbursements: 'Easy reimbursements', + }, + notFound: 'No workspace found', + description: 'Rooms are a great place to discuss and work with multiple people. To begin collaborating, create or join a workspace', + }, + switcher: { + headerTitle: 'Choose a workspace', + everythingSection: 'Everything', + placeholder: 'Find a workspace', + }, + new: { + newWorkspace: 'New workspace', + getTheExpensifyCardAndMore: 'Get the Expensify Card and more', + }, + people: { + genericFailureMessage: 'An error occurred removing a user from the workspace, please try again.', + removeMembersPrompt: 'Are you sure you want to remove these members?', + removeMembersWarningPrompt: ({memberName, ownerName}: RemoveMembersWarningPrompt) => + `${memberName} is an approver in this workspace. When you unshare this workspace with them, we’ll replace them in the approval workflow with the workspace owner, ${ownerName}`, + removeMembersTitle: 'Remove members', + removeWorkspaceMemberButtonTitle: 'Remove from workspace', + removeGroupMemberButtonTitle: 'Remove from group', + removeRoomMemberButtonTitle: 'Remove from chat', + removeMemberPrompt: ({memberName}: {memberName: string}) => `Are you sure you want to remove ${memberName}?`, + removeMemberTitle: 'Remove member', + transferOwner: 'Transfer owner', + makeMember: 'Make member', + makeAdmin: 'Make admin', + makeAuditor: 'Make auditor', + selectAll: 'Select all', + error: { + genericAdd: 'There was a problem adding this workspace member.', + cannotRemove: "You can't remove yourself or the workspace owner.", + genericRemove: 'There was a problem removing that workspace member.', + }, + addedWithPrimary: 'Some members were added with their primary logins.', + invitedBySecondaryLogin: ({secondaryLogin}) => `Added by secondary login ${secondaryLogin}.`, + membersListTitle: 'Directory of all workspace members.', + }, + card: { + header: 'Unlock free Expensify Cards', + headerWithEcard: 'Cards are ready!', + noVBACopy: 'Connect a bank account to issue Expensify Cards to your workspace members and access exclusive benefits like:', + VBANoECardCopy: 'Add a work email to issue unlimited Expensify Cards to your workspace members and enjoy exclusive benefits like:', + VBAWithECardCopy: 'Access these incredible benefits and more:', + benefit1: 'Cash back on every US purchase', + benefit2: 'Unlimited virtual and physical cards', + benefit3: 'No personal liability', + benefit4: 'Customizable limits and spend controls', + addWorkEmail: 'Add work email address', + checkingDomain: "Hang tight! We're still working on enabling your Expensify Cards. Check back here in a few minutes.", + issueAndManageCards: 'Issue and manage your Expensify Cards', + getStartedIssuing: 'Get started by issuing your first virtual or physical card.', + issueCard: 'Issue card', + issueNewCard: { + whoNeedsCard: 'Who needs a card?', + findMember: 'Find member', + chooseCardType: 'Choose a card type', + physicalCard: 'Physical card', + physicalCardDescription: 'Great for the frequent spender', + virtualCard: 'Virtual card', + virtualCardDescription: 'Instant and flexible', + chooseLimitType: 'Choose a limit type', + smartLimit: 'Smart Limit', + smartLimitDescription: 'Spend up to a certain amount before requiring approval', + monthly: 'Monthly', + monthlyDescription: 'Spend up to a certain amount per month', + fixedAmount: 'Fixed amount', + fixedAmountDescription: 'Spend up to a certain amount once', + setLimit: 'Set a limit', + giveItName: 'Give it a name', + giveItNameInstruction: 'Make it unique enough to tell apart from other cards. Specific use cases are even better!', + cardName: 'Card name', + letsDoubleCheck: 'Let’s double check that everything looks right.', + willBeReady: 'This card will be ready to use immediately.', + cardholder: 'Cardholder', + cardType: 'Card type', + limit: 'Limit', + limitType: 'Limit type', + name: 'Name', + }, + deactivateCardModal: { + deactivate: 'Deactivate', + deactivateCard: 'Deactivate card', + deactivateConfirmation: 'Deactivating this card will decline all future transactions and can’t be undone.', + }, + }, + reimburse: { + captureReceipts: 'Capture receipts', + fastReimbursementsHappyMembers: 'Fast reimbursements = happy members!', + viewAllReceipts: 'View all receipts', + reimburseReceipts: 'Reimburse receipts', + trackDistance: 'Track distance', + trackDistanceCopy: 'Set the per mile/km rate and choose a default unit to track.', + trackDistanceRate: 'Rate', + trackDistanceUnit: 'Unit', + trackDistanceChooseUnit: 'Choose a default unit to track.', + unlockNextDayReimbursements: 'Unlock next-day reimbursements', + captureNoVBACopyBeforeEmail: 'Ask your workspace members to forward receipts to ', + captureNoVBACopyAfterEmail: ' and download the Expensify app to track expenses on the go.', + unlockNoVBACopy: 'Connect a bank account to reimburse your workspace members quickly and easily.', + fastReimbursementsVBACopy: "You're all set to reimburse receipts from your bank account!", + updateCustomUnitError: "Your changes couldn't be saved because the workspace was modified while you were offline. Please try again.", + invalidRateError: 'Please enter a valid rate.', + lowRateError: 'Rate must be greater than 0.', + }, + accounting: { + settings: 'settings', + title: 'Connections', + subtitle: 'Connect to your accounting system to code transactions with your chart of accounts, auto-match payments, and keep your finances in sync.', + qbo: 'Quickbooks Online', + xero: 'Xero', + netsuite: 'NetSuite', + intacct: 'Sage Intacct', + connectionName: (integration: ConnectionName) => { + switch (integration) { + case CONST.POLICY.CONNECTIONS.NAME.QBO: + return 'Quickbooks Online'; + case CONST.POLICY.CONNECTIONS.NAME.XERO: + return 'Xero'; + case CONST.POLICY.CONNECTIONS.NAME.NETSUITE: + return 'NetSuite'; + case CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT: + return 'Sage Intacct'; + default: { + return ''; + } + } + }, + errorODIntegration: "There's an error with a connection that's been set up in Expensify Classic. ", + goToODToFix: 'Go to Expensify Classic to fix this issue.', + setup: 'Connect', + lastSync: (relativeDate: string) => `Last synced ${relativeDate}`, + import: 'Import', + export: 'Export', + advanced: 'Advanced', + other: 'Other integrations', + syncNow: 'Sync now', + disconnect: 'Disconnect', + disconnectTitle: (integration?: ConnectionName): string => { + const integrationName = integration && CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration] ? CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration] : 'integration'; + return `Disconnect ${integrationName}`; + }, + connectTitle: (integrationToConnect: ConnectionName): string => `Connect ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integrationToConnect] ?? 'accounting integration'}`, + + syncError: (integration?: ConnectionName): string => { + switch (integration) { + case CONST.POLICY.CONNECTIONS.NAME.QBO: + return "Can't connect to QuickBooks Online."; + case CONST.POLICY.CONNECTIONS.NAME.XERO: + return "Can't connect to Xero."; + case CONST.POLICY.CONNECTIONS.NAME.NETSUITE: + return "Can't connect to NetSuite."; + default: { + return "Can't connect to integration."; + } + } + }, + accounts: 'Chart of accounts', + taxes: 'Taxes', + imported: 'Imported', + notImported: 'Not imported', + importAsCategory: 'Imported as categories', + importTypes: { + [CONST.INTEGRATION_ENTITY_MAP_TYPES.IMPORTED]: 'Imported', + [CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG]: 'Imported as tags', + [CONST.INTEGRATION_ENTITY_MAP_TYPES.DEFAULT]: 'Imported', + [CONST.INTEGRATION_ENTITY_MAP_TYPES.NOT_IMPORTED]: 'Not imported', + [CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE]: 'Not imported', + [CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD]: 'Imported as report fields', + [CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT]: 'NetSuite employee default', + }, + disconnectPrompt: (currentIntegration?: ConnectionName): string => { + const integrationName = + currentIntegration && CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[currentIntegration] + ? CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[currentIntegration] + : 'this integration'; + return `Are you sure you want to disconnect ${integrationName}?`; + }, + connectPrompt: (integrationToConnect: ConnectionName): string => + `Are you sure you want to connect ${ + CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integrationToConnect] ?? 'this accounting integration' + }? This will remove any existing acounting connections.`, + enterCredentials: 'Enter your credentials', + connections: { + syncStageName: (stage: PolicyConnectionSyncStage) => { + switch (stage) { + case 'quickbooksOnlineImportCustomers': + return 'Importing customers'; + case 'quickbooksOnlineImportEmployees': + case 'netSuiteSyncImportEmployees': + case 'intacctImportEmployees': + return 'Importing employees'; + case 'quickbooksOnlineImportAccounts': + return 'Importing accounts'; + case 'quickbooksOnlineImportClasses': + return 'Importing classes'; + case 'quickbooksOnlineImportLocations': + return 'Importing locations'; + case 'quickbooksOnlineImportProcessing': + return 'Processing imported data'; + case 'quickbooksOnlineSyncBillPayments': + case 'intacctImportSyncBillPayments': + return 'Syncing reimbursed reports and bill payments'; + case 'quickbooksOnlineSyncTaxCodes': + return 'Importing tax codes'; + case 'quickbooksOnlineCheckConnection': + return 'Checking QuickBooks Online connection'; + case 'quickbooksOnlineImportMain': + return 'Importing QuickBooks Online data'; + case 'startingImportXero': + return 'Importing Xero data'; + case 'startingImportQBO': + return 'Importing QuickBooks Online data'; + case 'quickbooksOnlineSyncTitle': + return 'Syncing QuickBooks Online data'; + case 'quickbooksOnlineSyncLoadData': + case 'xeroSyncStep': + case 'intacctImportData': + return 'Loading data'; + case 'quickbooksOnlineSyncApplyCategories': + return 'Updating categories'; + case 'quickbooksOnlineSyncApplyCustomers': + return 'Updating customers/projects'; + case 'quickbooksOnlineSyncApplyEmployees': + return 'Updating people list'; + case 'quickbooksOnlineSyncApplyClassesLocations': + return 'Updating report fields'; + case 'jobDone': + return 'Waiting for imported data to load'; + case 'xeroSyncImportChartOfAccounts': + return 'Syncing chart of accounts'; + case 'xeroSyncImportCategories': + return 'Syncing categories'; + case 'xeroSyncImportCustomers': + return 'Syncing customers'; + case 'xeroSyncXeroReimbursedReports': + return 'Marking Expensify reports as reimbursed'; + case 'xeroSyncExpensifyReimbursedReports': + return 'Marking Xero bills and invoices as paid'; + case 'xeroSyncImportTrackingCategories': + return 'Syncing tracking categories'; + case 'xeroSyncImportBankAccounts': + return 'Syncing bank accounts'; + case 'xeroSyncImportTaxRates': + return 'Syncing tax rates'; + case 'xeroCheckConnection': + return 'Checking Xero connection'; + case 'xeroSyncTitle': + return 'Syncing Xero data'; + case 'netSuiteSyncConnection': + return 'Initializing connection to NetSuite'; + case 'netSuiteSyncCustomers': + return 'Importing customers'; + case 'netSuiteSyncInitData': + return 'Retrieving data from NetSuite'; + case 'netSuiteSyncImportTaxes': + return 'Importing taxes'; + case 'netSuiteSyncImportItems': + return 'Importing items'; + case 'netSuiteSyncData': + return 'Importing data into Expensify'; + case 'netSuiteSyncAccounts': + return 'Syncing accounts'; + case 'netSuiteSyncCurrencies': + return 'Syncing currencies'; + case 'netSuiteSyncCategories': + return 'Syncing categories'; + case 'netSuiteSyncReportFields': + return 'Importing data as Expensify report fields'; + case 'netSuiteSyncTags': + return 'Importing data as Expensify tags'; + case 'netSuiteSyncUpdateConnectionData': + return 'Updating connection info'; + case 'netSuiteSyncNetSuiteReimbursedReports': + return 'Marking Expensify reports as reimbursed'; + case 'netSuiteSyncExpensifyReimbursedReports': + return 'Marking NetSuite bills and invoices as paid'; + case 'netSuiteImportVendorsTitle': + return 'Importing vendors'; + case 'netSuiteImportCustomListsTitle': + return 'Importing custom lists'; + case 'netSuiteSyncImportCustomLists': + return 'Importing custom lists'; + case 'netSuiteSyncImportSubsidiaries': + return 'Importing subsidiaries'; + case 'netSuiteSyncImportVendors': + return 'Importing vendors'; + case 'intacctCheckConnection': + return 'Checking Sage Intacct connection'; + case 'intacctImportDimensions': + return 'Importing Sage Intacct dimensions'; + case 'intacctImportTitle': + return 'Importing Sage Intacct data'; + default: { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + return `Translation missing for stage: ${stage}`; + } + } + }, + }, + preferredExporter: 'Preferred exporter', + exportPreferredExporterNote: + 'The preferred exporter can be any workspace admin, but must also be a Domain Admin if you set different export accounts for individual company cards in Domain Settings.', + exportPreferredExporterSubNote: 'Once set, the preferred exporter will see reports for export in their account.', + exportAs: 'Export as', + exportOutOfPocket: 'Export out-of-pocket expenses as', + exportCompanyCard: 'Export company card expenses as', + exportDate: 'Export date', + defaultVendor: 'Default vendor', + autoSync: 'Auto-sync', + reimbursedReports: 'Sync reimbursed reports', + cardReconciliation: 'Card reconciliation', + reconciliationAccount: 'Reconciliation account', + continuousReconciliation: 'Continuous Reconciliation', + saveHoursOnReconciliation: + 'Save hours on reconciliation each accounting period by having Expensify continuously reconcile Expensify Card statements and settlements on your behalf.', + enableContinuousReconciliation: 'In order to enable Continuous Reconciliation, please enable ', + chooseReconciliationAccount: { + chooseBankAccount: 'Choose the bank account that your Expensify Card payments will be reconciled against.', + accountMatches: 'Make sure this account matches your ', + settlementAccount: 'Expensify Card settlement account ', + reconciliationWorks: (lastFourPAN: string) => `(ending in ${lastFourPAN}) so Continuous Reconciliation works properly.`, + }, + }, + export: { + notReadyHeading: 'Not ready to export', + notReadyDescription: 'Draft or pending expense reports cannot be exported to the accounting system. Please approve or pay these expenses before exporting them.', + }, + bills: { + manageYourBills: 'Manage your bills', + askYourVendorsBeforeEmail: 'Ask your vendors to forward their invoices to ', + askYourVendorsAfterEmail: " and we'll scan them for you to pay.", + viewAllBills: 'View all bills', + unlockOnlineBillPayment: 'Unlock online bill payment', + unlockNoVBACopy: 'Connect your bank account to pay bills online for free!', + hassleFreeBills: 'Hassle-free bills!', + VBACopy: "You're all set to make payments from your bank account!", + }, + invoices: { + invoiceClientsAndCustomers: 'Invoice clients and customers', + invoiceFirstSectionCopy: 'Send beautiful, professional invoices directly to your clients and customers right from the Expensify app.', + viewAllInvoices: 'View all invoices', + unlockOnlineInvoiceCollection: 'Unlock online invoice collection', + unlockNoVBACopy: 'Connect your bank account to accept online invoice payments by ACH or credit card.', + moneyBackInAFlash: 'Money back, in a flash!', + unlockVBACopy: "You're all set to accept payments by ACH or credit card!", + viewUnpaidInvoices: 'View unpaid invoices', + sendInvoice: 'Send invoice', + sendFrom: 'Send from', + invoicingDetails: 'Invoicing details', + invoicingDetailsDescription: 'This info will appear on your invoices.', + companyName: 'Company name', + companyWebsite: 'Company website', + paymentMethods: { + personal: 'Personal', + business: 'Business', + chooseInvoiceMethod: 'Choose a payment method below:', + addBankAccount: 'Add bank account', + payingAsIndividual: 'Paying as an individual', + payingAsBusiness: 'Paying as a business', + }, + }, + travel: { + unlockConciergeBookingTravel: 'Unlock Concierge travel booking', + noVBACopy: 'Connect your bank account to let workspace members book their flights, hotels, and cars by starting a chat with Concierge.', + packYourBags: 'Pack your bags!', + VBACopy: 'Members with the Expensify card can chat with Concierge to book travel!', + bookTravelWithConcierge: 'Book travel with Concierge', + }, + invite: { + member: 'Invite member', + members: 'Invite members', + invitePeople: 'Invite new members', + genericFailureMessage: 'An error occurred while inviting the member to the workspace. Please try again.', + pleaseEnterValidLogin: `Please ensure the email or phone number is valid (e.g. ${CONST.EXAMPLE_PHONE_NUMBER}).`, + user: 'user', + users: 'users', + invited: 'invited', + removed: 'removed', + leftWorkspace: 'left the workspace', + to: 'to', + from: 'from', + }, + inviteMessage: { + inviteMessageTitle: 'Add message', + inviteMessagePrompt: 'Make your invitation extra special by adding a message below', + personalMessagePrompt: 'Message', + genericFailureMessage: 'An error occurred while inviting the member to the workspace. Please try again.', + inviteNoMembersError: 'Please select at least one member to invite.', + }, + distanceRates: { + oopsNotSoFast: 'Oops! Not so fast...', + workspaceNeeds: 'A workspace needs at least one enabled distance rate.', + distance: 'Distance', + centrallyManage: 'Centrally manage rates, track in miles or kilometers, and set a default category.', + rate: 'Rate', + addRate: 'Add rate', + trackTax: 'Track tax', + deleteRates: ({count}: DistanceRateOperationsParams) => `Delete ${Str.pluralize('rate', 'rates', count)}`, + enableRates: ({count}: DistanceRateOperationsParams) => `Enable ${Str.pluralize('rate', 'rates', count)}`, + disableRates: ({count}: DistanceRateOperationsParams) => `Disable ${Str.pluralize('rate', 'rates', count)}`, + enableRate: 'Enable rate', + status: 'Status', + unit: 'Unit', + taxFeatureNotEnabledMessage: 'Taxes must be enabled on the workspace to use this feature. Head over to ', + changePromptMessage: ' to make that change.', + defaultCategory: 'Default category', + deleteDistanceRate: 'Delete distance rate', + areYouSureDelete: ({count}: DistanceRateOperationsParams) => `Are you sure you want to delete ${Str.pluralize('this rate', 'these rates', count)}?`, + }, + editor: { + descriptionInputLabel: 'Description', + nameInputLabel: 'Name', + typeInputLabel: 'Type', + initialValueInputLabel: 'Initial value', + nameInputHelpText: "This is the name you'll see on your workspace.", + nameIsRequiredError: "You'll need to give your workspace a name.", + currencyInputLabel: 'Default currency', + currencyInputHelpText: 'All expenses on this workspace will be converted to this currency.', + currencyInputDisabledText: "The default currency can't be changed because this workspace is linked to a USD bank account.", + save: 'Save', + genericFailureMessage: 'An error occurred while updating the workspace. Please try again.', + avatarUploadFailureMessage: 'An error occurred uploading the avatar. Please try again.', + addressContext: 'A Workspace Address is required to enable Expensify Travel. Please enter an address associated with your business.', + }, + bankAccount: { + continueWithSetup: 'Continue setup', + youreAlmostDone: "You're almost done setting up your bank account, which will let you issue corporate cards, reimburse expenses, collect invoices, and pay bills.", + streamlinePayments: 'Streamline payments', + connectBankAccountNote: "Note: Personal bank accounts can't be used for payments on workspaces.", + oneMoreThing: 'One more thing!', + allSet: "You're all set!", + accountDescriptionWithCards: 'This bank account will be used to issue corporate cards, reimburse expenses, collect invoices, and pay bills.', + letsFinishInChat: "Let's finish in chat!", + almostDone: 'Almost done!', + disconnectBankAccount: 'Disconnect bank account', + noLetsStartOver: "No, let's start over", + startOver: 'Start over', + yesDisconnectMyBankAccount: 'Yes, disconnect my bank account', + yesStartOver: 'Yes, start over', + disconnectYour: 'Disconnect your ', + bankAccountAnyTransactions: ' bank account. Any outstanding transactions for this account will still complete.', + clearProgress: "Starting over will clear the progress you've made so far.", + areYouSure: 'Are you sure?', + workspaceCurrency: 'Workspace currency', + updateCurrencyPrompt: 'It looks like your workspace is currently set to a different currency than USD. Please click the button below to update your currency to USD now.', + updateToUSD: 'Update to USD', + }, + changeOwner: { + changeOwnerPageTitle: 'Transfer owner', + addPaymentCardTitle: 'Enter your payment card to transfer ownership', + addPaymentCardButtonText: 'Accept terms & add payment card', + addPaymentCardReadAndAcceptTextPart1: 'Read and accept', + addPaymentCardReadAndAcceptTextPart2: 'policy to add your card', + addPaymentCardTerms: 'terms', + addPaymentCardPrivacy: 'privacy', + addPaymentCardAnd: '&', + addPaymentCardPciCompliant: 'PCI-DSS compliant', + addPaymentCardBankLevelEncrypt: 'Bank level encryption', + addPaymentCardRedundant: 'Redundant infrastructure', + addPaymentCardLearnMore: 'Learn more about our', + addPaymentCardSecurity: 'security', + amountOwedTitle: 'Outstanding balance', + amountOwedButtonText: 'OK', + amountOwedText: 'This account has an outstanding balance from a previous month.\n\nDo you want to clear the balance and take over billing of this workspace?', + ownerOwesAmountTitle: 'Outstanding balance', + ownerOwesAmountButtonText: 'Transfer balance', + ownerOwesAmountText: ({email, amount}) => + `The account owning this workspace (${email}) has an outstanding balance from a previous month.\n\nDo you want to transfer this amount (${amount}) in order to take over billing for this workspace? Your payment card will be charged immediately.`, + subscriptionTitle: 'Take over annual subscription', + subscriptionButtonText: 'Transfer subscription', + subscriptionText: ({usersCount, finalCount}) => + `Taking over this workspace will merge its annual subscription with your current subscription. This will increase your subscription size by ${usersCount} members making your new subscription size ${finalCount}. Would you like to continue?`, + duplicateSubscriptionTitle: 'Duplicate subscription alert', + duplicateSubscriptionButtonText: 'Continue', + duplicateSubscriptionText: ({email, workspaceName}) => + `It looks like you may be trying to take over billing for ${email}'s workspaces, but to do that, you need to be an admin on all their workspaces first.\n\nClick "Continue" if you only want to take over billing for the workspace ${workspaceName}.\n\nIf you want to take over billing for their entire subscription, please have them add you as an admin to all their workspaces first before taking over billing.`, + hasFailedSettlementsTitle: 'Cannot transfer ownership', + hasFailedSettlementsButtonText: 'Got it', + hasFailedSettlementsText: ({email}) => + `You can't take over billing because ${email} has an overdue expensify Expensify Card settlement. Please ask them to reach out to concierge@expensify.com to resolve the issue. Then, you can take over billing for this workspace.`, + failedToClearBalanceTitle: 'Failed to clear balance', + failedToClearBalanceButtonText: 'OK', + failedToClearBalanceText: 'We were unable to clear the balance. Please try again later.', + successTitle: 'Woohoo! All set.', + successDescription: "You're now the owner of this workspace.", + errorTitle: 'Oops! Not so fast...', + errorDescriptionPartOne: 'There was a problem transferring ownership of this workspace. Try again, or', + errorDescriptionPartTwo: 'reach out to Concierge', + errorDescriptionPartThree: 'for help.', + }, + exportAgainModal: { + title: 'Careful!', + description: (reportName: string, connectionName: ConnectionName) => + `The following reports have already been exported to ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]}:\n\n${reportName}\n\nAre you sure you want to export them again?`, + confirmText: 'Yes, export again', + cancelText: 'Cancel', + }, + upgrade: { + reportFields: { + title: 'Report fields', + description: `Report fields let you specify header-level details, distinct from tags that pertain to expenses on individual line items. These details can encompass specific project names, business trip information, locations, and more.`, + onlyAvailableOnPlan: 'Report fields are only available on the Control plan, starting at ', + }, + [CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: { + title: 'NetSuite', + description: `Enjoy automated syncing and reduce manual entries with the Expensify + NetSuite integration. Gain in-depth, realtime financial insights with native and custom segment support, including project and customer mapping.`, + onlyAvailableOnPlan: 'Our NetSuite integration is only available on the Control plan, starting at ', + }, + [CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT]: { + title: 'Sage Intacct', + description: `Enjoy automated syncing and reduce manual entries with the Expensify + Sage Intacct integration. Gain in-depth, real-time financial insights with user-defined dimensions, as well as expense coding by department, class, location, customer, and project (job).`, + onlyAvailableOnPlan: 'Our Sage Intacct integration is only available on the Control plan, starting at ', + }, + [CONST.UPGRADE_FEATURE_INTRO_MAPPING.approvals.id]: { + title: 'Advanced Approvals', + description: `If you want to add more layers of approval to the mix – or just make sure the largest expenses get another set of eyes – we’ve got you covered. Advanced approvals help you put the right checks in place at every level so you keep your team’s spend under control.`, + onlyAvailableOnPlan: 'Advanced approvals are only available on the Control plan, which starts at ', + }, + glCodes: { + title: 'GL codes', + description: `Add GL codes to your categories and tags for easy export of expenses to your accounting and payroll systems.`, + onlyAvailableOnPlan: 'GL codes are only available on the Control plan, starting at ', + }, + glAndPayrollCodes: { + title: 'GL & Payroll codes', + description: `Add GL & Payroll codes to your categories for easy export of expenses to your accounting and payroll systems.`, + onlyAvailableOnPlan: 'GL & Payroll codes are only available on the Control plan, starting at ', + }, + taxCodes: { + title: 'Tax codes', + description: `Add tax codes to your taxes for easy export of expenses to your accounting and payroll systems.`, + onlyAvailableOnPlan: 'Tax codes are only available on the Control plan, starting at ', + }, + companyCards: { + title: 'Company cards', + description: `Company cards lets you import spend for existing company cards from all major card issuers. You can assign cards to employees, and automatically import transactions.`, + onlyAvailableOnPlan: 'Company cards are only available on the Control plan, starting at ', + }, + rules: { + title: 'Rules', + description: `Rules run in the background and keep your spend under control so you don't have to sweat the small stuff.\n\nRequire expense details like receipts and descriptions, set limits and defaults, and automate approvals and payments – all in one place.`, + onlyAvailableOnPlan: 'Rules are only available on the Control plan, starting at ', + }, + pricing: { + amount: '$9 ', + perActiveMember: 'per active member per month.', + }, + note: { + upgradeWorkspace: 'Upgrade your workspace to access this feature, or', + learnMore: 'learn more', + aboutOurPlans: 'about our plans and pricing.', + }, + upgradeToUnlock: 'Unlock this feature', + completed: { + headline: `You've upgraded your workspace!`, + successMessage: (policyName: string) => `You've successfully upgraded your ${policyName} workspace to the Control plan!`, + viewSubscription: 'View your subscription', + moreDetails: 'for more details.', + gotIt: 'Got it, thanks', + }, + }, + restrictedAction: { + restricted: 'Restricted', + actionsAreCurrentlyRestricted: ({workspaceName}) => `Actions on the ${workspaceName} workspace are currently restricted`, + workspaceOwnerWillNeedToAddOrUpdatePaymentCard: ({workspaceOwnerName}) => + `Workspace owner, ${workspaceOwnerName} will need to add or update the payment card on file to unlock new workspace activity.`, + youWillNeedToAddOrUpdatePaymentCard: "You'll need to add or update the payment card on file to unlock new workspace activity.", + addPaymentCardToUnlock: 'Add a payment card to unlock!', + addPaymentCardToContinueUsingWorkspace: 'Add a payment card to continue using this workspace', + pleaseReachOutToYourWorkspaceAdmin: 'Please reach out to your workspace admin for any questions.', + chatWithYourAdmin: 'Chat with your admin', + chatInAdmins: 'Chat in #admins', + addPaymentCard: 'Add payment card', + }, + rules: { + individualExpenseRules: { + title: 'Expenses', + subtitle: 'Set spend controls and defaults for individual expenses. You can also create rules for', + receiptRequiredAmount: 'Receipt required amount', + receiptRequiredAmountDescription: 'Require receipts when spend exceeds this amount, unless overridden by a category rule.', + maxExpenseAmount: 'Max expense amount', + maxExpenseAmountDescription: 'Flag spend that exceeds this amount, unless overridden by a category rule.', + maxAge: 'Max age', + maxExpenseAge: 'Max expense age', + maxExpenseAgeDescription: 'Flag spend older than a specific number of days.', + maxExpenseAgeDays: (age: number) => `${age} ${Str.pluralize('day', 'days', age)}`, + billableDefault: 'Billable default', + billableDefaultDescription: 'Choose whether cash and credit card expenses should be billable by default. Billable expenses are enabled or disabled in', + billable: 'Billable', + billableDescription: 'Expenses are most often re-billed to clients', + nonBillable: 'Non-billable', + nonBillableDescription: 'Expenses are occasionally re-billed to clients', + eReceipts: 'eReceipts', + eReceiptsHint: 'eReceipts are auto-created', + eReceiptsHintLink: 'for most USD credit transactions', + }, + expenseReportRules: { + examples: 'Examples:', + title: 'Expense reports', + subtitle: 'Automate expense report compliance, approvals, and payment.', + customReportNamesTitle: 'Custom report names', + customReportNamesSubtitle: 'Create custom names using our extensive formulas.', + customNameTitle: 'Custom name', + customNameDescription: 'Choose a custom name for expense reports using our ', + customNameDescriptionLink: 'extensive formulas', + customNameInputLabel: 'Name', + customNameEmailPhoneExample: 'Member’s email or phone: {report:submit:from}', + customNameStartDateExample: 'Report start date: {report:startdate}', + customNameWorkspaceNameExample: 'Workspace name: {report:policyname}', + customNameReportIDExample: 'Report ID: {report:id}', + customNameTotalExample: 'Total: {report:total}.', + preventMembersFromChangingCustomNamesTitle: 'Prevent members from changing custom report names', + preventSelfApprovalsTitle: 'Prevent self-approvals', + preventSelfApprovalsSubtitle: 'Prevent workspace members from approving their own expense reports.', + autoApproveCompliantReportsTitle: 'Auto-approve compliant reports', + autoApproveCompliantReportsSubtitle: 'Configure which expense reports are eligible for auto-approval.', + autoApproveReportsUnderTitle: 'Auto-approve reports under', + autoApproveReportsUnderDescription: 'Fully compliant expense reports under this amount will be automatically approved.', + randomReportAuditTitle: 'Random report audit', + randomReportAuditDescription: 'Require that some reports be manually approved, even if eligible for auto-approval.', + autoPayApprovedReportsTitle: 'Auto-pay approved reports', + autoPayApprovedReportsSubtitle: 'Configure which expense reports are eligible for auto-pay.', + autoPayApprovedReportsLimitError: (currency?: string) => `Please enter an amount less than ${currency ?? ''}20,000`, + autoPayApprovedReportsLockedSubtitle: 'Go to more features and enable workflows, then add payments to unlock this feature.', + autoPayReportsUnderTitle: 'Auto-pay reports under', + autoPayReportsUnderDescription: 'Fully compliant expense reports under this amount will be automatically paid. ', + unlockFeatureGoToSubtitle: 'Go to', + unlockFeatureEnableWorkflowsSubtitle: (featureName: string) => `and enable workflows, then add ${featureName} to unlock this feature.`, + enableFeatureSubtitle: (featureName: string) => `and enable ${featureName} to unlock this feature.`, + }, + categoryRules: { + title: 'Category rules', + approver: 'Approver', + requireDescription: 'Require description', + descriptionHint: 'Description hint', + descriptionHintDescription: (categoryName: string) => + `Remind employees to provide additional information for “${categoryName}” spend. This hint appears in the description field on expenses.`, + descriptionHintLabel: 'Hint', + descriptionHintSubtitle: 'Pro-tip: The shorter the better!', + maxAmount: 'Max amount', + flagAmountsOver: 'Flag amounts over', + flagAmountsOverDescription: (categoryName) => `Applies to the category “${categoryName}”.`, + flagAmountsOverSubtitle: 'This overrides the max amount for all expenses.', + expenseLimitTypes: { + expense: 'Individual expense', + expenseSubtitle: 'Flag expense amounts by category. This rule overrides the general workspace rule for max expense amount.', + daily: 'Category total', + dailySubtitle: 'Flag total category spend per expense report.', + }, + requireReceiptsOver: 'Require receipts over', + requireReceiptsOverList: { + default: (defaultAmount: string) => `${defaultAmount} ${CONST.DOT_SEPARATOR} Default`, + never: 'Never require receipts', + always: 'Always require receipts', + }, + defaultTaxRate: 'Default tax rate', + goTo: 'Go to', + andEnableWorkflows: 'and enable workflows, then add approvals to unlock this feature.', + }, + }, + }, + getAssistancePage: { + title: 'Get assistance', + subtitle: "We're here to clear your path to greatness!", + description: 'Choose from the support options below:', + chatWithConcierge: 'Chat with Concierge', + scheduleSetupCall: 'Schedule a setup call', + questionMarkButtonTooltip: 'Get assistance from our team', + exploreHelpDocs: 'Explore help docs', + }, + emojiPicker: { + skinTonePickerLabel: 'Change default skin tone', + headers: { + frequentlyUsed: 'Frequently Used', + smileysAndEmotion: 'Smileys & Emotion', + peopleAndBody: 'People & Body', + animalsAndNature: 'Animals & Nature', + foodAndDrink: 'Food & Drinks', + travelAndPlaces: 'Travel & Places', + activities: 'Activities', + objects: 'Objects', + symbols: 'Symbols', + flags: 'Flags', + }, + }, + newRoomPage: { + newRoom: 'New room', + roomName: 'Room name', + visibility: 'Visibility', + restrictedDescription: 'People in your workspace can find this room', + privateDescription: 'People invited to this room can find it', + publicDescription: 'Anyone can find this room', + // eslint-disable-next-line @typescript-eslint/naming-convention + public_announceDescription: 'Anyone can find this room', + createRoom: 'Create room', + roomAlreadyExistsError: 'A room with this name already exists.', + roomNameReservedError: ({reservedName}: RoomNameReservedErrorParams) => `${reservedName} is a default room on all workspaces. Please choose another name.`, + roomNameInvalidError: 'Room names can only include lowercase letters, numbers, and hyphens.', + pleaseEnterRoomName: 'Please enter a room name.', + pleaseSelectWorkspace: 'Please select a workspace.', + renamedRoomAction: ({oldName, newName}: RenamedRoomActionParams) => `renamed this room from ${oldName} to ${newName}`, + roomRenamedTo: ({newName}: RoomRenamedToParams) => `Room renamed to ${newName}`, + social: 'social', + selectAWorkspace: 'Select a workspace', + growlMessageOnRenameError: 'Unable to rename workspace room. Please check your connection and try again.', + visibilityOptions: { + restricted: 'Workspace', // the translation for "restricted" visibility is actually workspace. This is so we can display restricted visibility rooms as "workspace" without having to change what's stored. + private: 'Private', + public: 'Public', + // eslint-disable-next-line @typescript-eslint/naming-convention + public_announce: 'Public Announce', + }, + }, + workspaceActions: { + renamedWorkspaceNameAction: ({oldName, newName}) => `updated the name of this workspace from ${oldName} to ${newName}`, + removedFromApprovalWorkflow: ({submittersNames}: {submittersNames: string[]}) => { + let joinedNames = ''; + if (submittersNames.length === 1) { + joinedNames = submittersNames[0]; + } else if (submittersNames.length === 2) { + joinedNames = submittersNames.join(' and '); + } else if (submittersNames.length > 2) { + joinedNames = `${submittersNames.slice(0, submittersNames.length - 1).join(', ')} and ${submittersNames[submittersNames.length - 1]}`; + } + const workflowWord = Str.pluralize('workflow', 'workflows', submittersNames.length); + const chatWord = Str.pluralize('chat', 'chats', submittersNames.length); + return `removed you from ${joinedNames}'s approval ${workflowWord} and workspace ${chatWord}. Previously submitted reports will remain available for approval in your Inbox.`; + }, + }, + roomMembersPage: { + memberNotFound: 'Member not found.', + useInviteButton: 'To invite a new member to the chat, please use the invite button above.', + notAuthorized: `You don't have access to this page. If you're trying to join this room, just ask a room member to add you. Something else? Reach out to ${CONST.EMAIL.CONCIERGE}`, + removeMembersPrompt: 'Are you sure you want to remove the selected members from the room?', + error: { + genericAdd: 'There was a problem adding this room member.', + }, + }, + newTaskPage: { + assignTask: 'Assign task', + assignMe: 'Assign to me', + confirmTask: 'Confirm task', + confirmError: 'Please enter a title and select a share destination.', + descriptionOptional: 'Description (optional)', + pleaseEnterTaskName: 'Please enter a title', + pleaseEnterTaskDestination: 'Please select where you want to share this task.', + }, + task: { + task: 'Task', + title: 'Title', + description: 'Description', + assignee: 'Assignee', + completed: 'Completed', + messages: { + created: ({title}: TaskCreatedActionParams) => `task for ${title}`, + completed: 'marked as complete', + canceled: 'deleted task', + reopened: 'marked as incomplete', + error: "You don't have permission to take the requested action.", + }, + markAsComplete: 'Mark as complete', + markAsIncomplete: 'Mark as incomplete', + assigneeError: 'An error occurred while assigning this task. Please try another assignee.', + genericCreateTaskFailureMessage: 'There was an error creating this task. Please try again later.', + deleteTask: 'Delete task', + deleteConfirmation: 'Are you sure you want to delete this task?', + }, + statementPage: { + title: (year, monthName) => `${monthName} ${year} statement`, + generatingPDF: "We're generating your PDF right now. Please check back soon!", + }, + keyboardShortcutsPage: { + title: 'Keyboard shortcuts', + subtitle: 'Save time with these handy keyboard shortcuts:', + shortcuts: { + openShortcutDialog: 'Opens the keyboard shortcuts dialog', + escape: 'Escape dialogs', + search: 'Open search dialog', + newChat: 'New chat screen', + copy: 'Copy comment', + openDebug: 'Open testing preferences dialog', + }, + }, + guides: { + screenShare: 'Screen share', + screenShareRequest: 'Expensify is inviting you to a screen share', + }, + search: { + resultsAreLimited: 'Search results are limited.', + viewResults: 'View results', + resetFilters: 'Reset filters', + searchResults: { + emptyResults: { + title: 'Nothing to show', + subtitle: 'Try creating something using the green + button.', + }, + emptyTripResults: { + title: 'No trips to display', + subtitle: 'Get started by booking your first trip below.', + buttonText: 'Book a trip', + }, + }, + saveSearch: 'Save search', + saveSearchTooltipText: 'You can rename your saved search', + deleteSavedSearch: 'Delete saved search', + deleteSavedSearchConfirm: 'Are you sure you want to delete this search?', + searchName: 'Search name', + savedSearchesMenuItemTitle: 'Saved', + groupedExpenses: 'grouped expenses', + bulkActions: { + delete: 'Delete', + hold: 'Hold', + unhold: 'Unhold', + noOptionsAvailable: 'No options available for the selected group of expenses.', + }, + filtersHeader: 'Filters', + filters: { + date: { + before: (date?: string) => `Before ${date ?? ''}`, + after: (date?: string) => `After ${date ?? ''}`, + }, + status: 'Status', + keyword: 'Keyword', + hasKeywords: 'Has keywords', + currency: 'Currency', + link: 'Link', + pinned: 'Pinned', + unread: 'Unread', + amount: { + lessThan: (amount?: string) => `Less than ${amount ?? ''}`, + greaterThan: (amount?: string) => `Greater than ${amount ?? ''}`, + between: (greaterThan: string, lessThan: string) => `Between ${greaterThan} and ${lessThan}`, + }, + current: 'Current', + past: 'Past', + }, + expenseType: 'Expense type', + }, + genericErrorPage: { + title: 'Uh-oh, something went wrong!', + body: { + helpTextMobile: 'Please close and reopen the app, or switch to', + helpTextWeb: 'web.', + helpTextConcierge: 'If the problem persists, reach out to', + }, + refresh: 'Refresh', + }, + fileDownload: { + success: { + title: 'Downloaded!', + message: 'Attachment successfully downloaded!', + qrMessage: 'Check your photos or downloads folder for a copy of your QR code. Protip: Add it to a presentation for your audience to scan and connect with you directly.', + }, + generalError: { + title: 'Attachment error', + message: "Attachment can't be downloaded.", + }, + permissionError: { + title: 'Storage access', + message: "Expensify can't save attachments without storage access. Tap settings to update permissions.", + }, + }, + desktopApplicationMenu: { + mainMenu: 'New Expensify', + about: 'About New Expensify', + update: 'Update New Expensify', + checkForUpdates: 'Check for updates', + toggleDevTools: 'Toggle Developer Tools', + viewShortcuts: 'View keyboard shortcuts', + services: 'Services', + hide: 'Hide New Expensify', + hideOthers: 'Hide Others', + showAll: 'Show All', + quit: 'Quit New Expensify', + fileMenu: 'File', + closeWindow: 'Close Window', + editMenu: 'Edit', + undo: 'Undo', + redo: 'Redo', + cut: 'Cut', + copy: 'Copy', + paste: 'Paste', + pasteAndMatchStyle: 'Paste and Match Style', + pasteAsPlainText: 'Paste as Plain Text', + delete: 'Delete', + selectAll: 'Select All', + speechSubmenu: 'Speech', + startSpeaking: 'Start Speaking', + stopSpeaking: 'Stop Speaking', + viewMenu: 'View', + reload: 'Reload', + forceReload: 'Force Reload', + resetZoom: 'Actual Size', + zoomIn: 'Zoom In', + zoomOut: 'Zoom Out', + togglefullscreen: 'Toggle Full Screen', + historyMenu: 'History', + back: 'Back', + forward: 'Forward', + windowMenu: 'Window', + minimize: 'Minimize', + zoom: 'Zoom', + front: 'Bring All to Front', + helpMenu: 'Help', + learnMore: 'Learn more', + documentation: 'Documentation', + communityDiscussions: 'Community Discussions', + searchIssues: 'Search Issues', + }, + historyMenu: { + forward: 'Forward', + back: 'Back', + }, + checkForUpdatesModal: { + available: { + title: 'Update available', + message: ({isSilentUpdating}: {isSilentUpdating: boolean}) => + `The new version will be available shortly.${!isSilentUpdating ? " We'll notify you when we're ready to update." : ''}`, + soundsGood: 'Sounds good', + }, + notAvailable: { + title: 'Update unavailable', + message: "There's no update available right now. Please check back later!", + okay: 'Okay', + }, + error: { + title: 'Update check failed.', + message: "We couldn't check for an update. Please try again in a bit.", + }, + }, + report: { + genericCreateReportFailureMessage: 'Unexpected error creating this chat. Please try again later.', + genericAddCommentFailureMessage: 'Unexpected error posting the comment. Please try again later.', + genericUpdateReportFieldFailureMessage: 'Unexpected error updating the field. Please try again later.', + genericUpdateReporNameEditFailureMessage: 'Unexpected error renaming the report. Please try again later.', + noActivityYet: 'No activity yet', + actions: { + type: { + changeField: ({oldValue, newValue, fieldName}: ChangeFieldParams) => `changed ${fieldName} from ${oldValue} to ${newValue}`, + changeFieldEmpty: ({newValue, fieldName}: ChangeFieldParams) => `changed ${fieldName} to ${newValue}`, + changePolicy: ({fromPolicy, toPolicy}: ChangePolicyParams) => `changed workspace from ${fromPolicy} to ${toPolicy}`, + changeType: ({oldType, newType}: ChangeTypeParams) => `changed type from ${oldType} to ${newType}`, + delegateSubmit: ({delegateUser, originalManager}: DelegateSubmitParams) => `sent this report to ${delegateUser} since ${originalManager} is on vacation`, + exportedToCSV: `exported this report to CSV`, + exportedToIntegration: { + automatic: ({label}: ExportedToIntegrationParams) => `exported this report to ${label}.`, + manual: ({label}: ExportedToIntegrationParams) => `marked this report as manually exported to ${label}.`, + reimburseableLink: 'View out-of-pocket expenses.', + nonReimbursableLink: 'View company card expenses.', + pending: ({label}: ExportedToIntegrationParams) => `started exporting this report to ${label}...`, + }, + integrationsMessage: (errorMessage: string, label: string) => `failed to export this report to ${label} ("${errorMessage}").`, + managerAttachReceipt: `added a receipt`, + managerDetachReceipt: `removed a receipt`, + markedReimbursed: ({amount, currency}: MarkedReimbursedParams) => `paid ${currency}${amount} elsewhere`, + markedReimbursedFromIntegration: ({amount, currency}: MarkReimbursedFromIntegrationParams) => `paid ${currency}${amount} via integration`, + outdatedBankAccount: `couldn’t process the payment due to a problem with the payer’s bank account`, + reimbursementACHBounce: `couldn’t process the payment, as the payer doesn’t have sufficient funds`, + reimbursementACHCancelled: `canceled the payment`, + reimbursementAccountChanged: `couldn’t process the payment, as the payer changed bank accounts`, + reimbursementDelayed: `processed the payment but it’s delayed by 1-2 more business days`, + selectedForRandomAudit: `randomly selected for review`, + selectedForRandomAuditMarkdown: `[randomly selected](https://help.expensify.com/articles/expensify-classic/reports/Set-a-random-report-audit-schedule) for review`, + share: ({to}: ShareParams) => `invited member ${to}`, + unshare: ({to}: UnshareParams) => `removed user ${to}`, + stripePaid: ({amount, currency}: StripePaidParams) => `paid ${currency}${amount}`, + takeControl: `took control`, + unapproved: ({amount, currency}: UnapprovedParams) => `unapproved ${currency}${amount}`, + integrationSyncFailed: (label: string, errorMessage: string) => `failed to sync with ${label} ("${errorMessage}")`, + addEmployee: (email: string, role: string) => `added ${email} as ${role === 'user' ? 'member' : 'admin'}`, + updateRole: (email: string, currentRole: string, newRole: string) => `updated the role of ${email} from ${currentRole} to ${newRole}`, + removeMember: (email: string, role: string) => `removed ${role} ${email}`, + }, + }, + }, + chronos: { + oooEventSummaryFullDay: ({summary, dayCount, date}: OOOEventSummaryFullDayParams) => `${summary} for ${dayCount} ${dayCount === 1 ? 'day' : 'days'} until ${date}`, + oooEventSummaryPartialDay: ({summary, timePeriod, date}: OOOEventSummaryPartialDayParams) => `${summary} from ${timePeriod} on ${date}`, + }, + footer: { + features: 'Features', + expenseManagement: 'Expense Management', + spendManagement: 'Spend Management', + expenseReports: 'Expense Reports', + companyCreditCard: 'Company Credit Card', + receiptScanningApp: 'Receipt Scanning App', + billPay: 'Bill Pay', + invoicing: 'Invoicing', + CPACard: 'CPA Card', + payroll: 'Payroll', + travel: 'Travel', + resources: 'Resources', + expensifyApproved: 'ExpensifyApproved!', + pressKit: 'Press Kit', + support: 'Support', + expensifyHelp: 'ExpensifyHelp', + community: 'Community', + privacy: 'Privacy', + learnMore: 'Learn More', + aboutExpensify: 'About Expensify', + blog: 'Blog', + jobs: 'Jobs', + expensifyOrg: 'Expensify.org', + investorRelations: 'Investor Relations', + getStarted: 'Get Started', + createAccount: 'Create A New Account', + logIn: 'Log In', + }, + allStates: COMMON_CONST.STATES as States, + allCountries: CONST.ALL_COUNTRIES as AllCountries, + accessibilityHints: { + navigateToChatsList: 'Navigate back to chats list', + chatWelcomeMessage: 'Chat welcome message', + navigatesToChat: 'Navigates to a chat', + newMessageLineIndicator: 'New message line indicator', + chatMessage: 'Chat message', + lastChatMessagePreview: 'Last chat message preview', + workspaceName: 'Workspace name', + chatUserDisplayNames: 'Chat user display names', + scrollToNewestMessages: 'Scroll to newest messages', + prestyledText: 'Prestyled text', + viewAttachment: 'View attachment', + }, + parentReportAction: { + deletedReport: '[Deleted report]', + deletedMessage: '[Deleted message]', + deletedExpense: '[Deleted expense]', + reversedTransaction: '[Reversed transaction]', + deletedTask: '[Deleted task]', + hiddenMessage: '[Hidden message]', + }, + threads: { + thread: 'Thread', + replies: 'Replies', + reply: 'Reply', + from: 'From', + in: 'in', + parentNavigationSummary: ({reportName, workspaceName}: ParentNavigationSummaryParams) => `From ${reportName}${workspaceName ? ` in ${workspaceName}` : ''}`, + }, + qrCodes: { + copy: 'Copy URL', + copied: 'Copied!', + }, + moderation: { + flagDescription: 'All flagged messages will be sent to a moderator for review.', + chooseAReason: 'Choose a reason for flagging below:', + spam: 'Spam', + spamDescription: 'Unsolicited off-topic promotion', + inconsiderate: 'Inconsiderate', + inconsiderateDescription: 'Insulting or disrespectful phrasing, with questionable intentions', + intimidation: 'Intimidation', + intimidationDescription: 'Aggressively pursuing an agenda over valid objections', + bullying: 'Bullying', + bullyingDescription: 'Targeting an individual to obtain obedience', + harassment: 'Harassment', + harassmentDescription: 'Racist, misogynistic, or other broadly discriminatory behavior', + assault: 'Assault', + assaultDescription: 'Specifically targeted emotional attack with the intention of harm', + flaggedContent: 'This message has been flagged as violating our community rules and the content has been hidden.', + hideMessage: 'Hide message', + revealMessage: 'Reveal message', + levelOneResult: 'Sends anonymous warning and message is reported for review.', + levelTwoResult: 'Message hidden from channel, plus anonymous warning and message is reported for review.', + levelThreeResult: 'Message removed from channel plus anonymous warning and message is reported for review.', + }, + actionableMentionWhisperOptions: { + invite: 'Invite them', + nothing: 'Do nothing', + }, + actionableMentionJoinWorkspaceOptions: { + accept: 'Accept', + decline: 'Decline', + }, + actionableMentionTrackExpense: { + submit: 'Submit it to someone', + categorize: 'Categorize it', + share: 'Share it with my accountant', + nothing: 'Nothing for now', + }, + teachersUnitePage: { + teachersUnite: 'Teachers Unite', + joinExpensifyOrg: + 'Join Expensify.org in eliminating injustice around the world. The current "Teachers Unite" campaign supports educators everywhere by splitting the costs of essential school supplies.', + iKnowATeacher: 'I know a teacher', + iAmATeacher: 'I am a teacher', + getInTouch: 'Excellent! Please share their information so we can get in touch with them.', + introSchoolPrincipal: 'Intro to your school principal', + schoolPrincipalVerfiyExpense: + 'Expensify.org splits the cost of essential school supplies so that students from low-income households can have a better learning experience. Your principal will be asked to verify your expenses.', + principalFirstName: 'Principal first name', + principalLastName: 'Principal last name', + principalWorkEmail: 'Principal work email', + updateYourEmail: 'Update your email address', + updateEmail: 'Update email address', + contactMethods: 'Contact methods.', + schoolMailAsDefault: 'Before you move forward, please make sure to set your school email as your default contact method. You can do so in Settings > Profile > ', + error: { + enterPhoneEmail: 'Enter a valid email or phone number.', + enterEmail: 'Enter an email.', + enterValidEmail: 'Enter a valid email.', + tryDifferentEmail: 'Please try a different email.', + }, + }, + cardTransactions: { + notActivated: 'Not activated', + outOfPocket: 'Out-of-pocket spend', + companySpend: 'Company spend', + }, + distance: { + addStop: 'Add stop', + deleteWaypoint: 'Delete waypoint', + deleteWaypointConfirmation: 'Are you sure you want to delete this waypoint?', + address: 'Address', + waypointDescription: { + start: 'Start', + stop: 'Stop', + }, + mapPending: { + title: 'Map pending', + subtitle: 'The map will be generated when you go back online', + onlineSubtitle: 'One moment while we set up the map', + }, + error: { + selectSuggestedAddress: 'Please select a suggested address or use current location.', + }, + }, + reportCardLostOrDamaged: { + report: 'Report physical card loss / damage', + screenTitle: 'Report card lost or damaged', + nextButtonLabel: 'Next', + reasonTitle: 'Why do you need a new card?', + cardDamaged: 'My card was damaged', + cardLostOrStolen: 'My card was lost or stolen', + confirmAddressTitle: 'Please confirm the mailing address for your new card.', + cardDamagedInfo: 'Your new card will arrive in 2-3 business days. Your current card will continue to work until you activate your new one.', + cardLostOrStolenInfo: 'Your current card will be permanently deactivated as soon as your order is placed. Most cards arrive in a few business days.', + address: 'Address', + deactivateCardButton: 'Deactivate card', + shipNewCardButton: 'Ship new card', + addressError: 'Address is required', + reasonError: 'Reason is required', + }, + eReceipt: { + guaranteed: 'Guaranteed eReceipt', + transactionDate: 'Transaction date', + }, + referralProgram: { + [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.START_CHAT]: { + buttonText1: 'Start a chat, ', + buttonText2: `get $${CONST.REFERRAL_PROGRAM.REVENUE}.`, + header: `Start a chat, get $${CONST.REFERRAL_PROGRAM.REVENUE}`, + body: `Get paid to talk to your friends! Start a chat with a new Expensify account and get $${CONST.REFERRAL_PROGRAM.REVENUE} when they become a customer.`, + }, + [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SUBMIT_EXPENSE]: { + buttonText1: 'Submit an expense, ', + buttonText2: `get $${CONST.REFERRAL_PROGRAM.REVENUE}.`, + header: `Submit an expense, get $${CONST.REFERRAL_PROGRAM.REVENUE}`, + body: `It pays to get paid! Submit an expense to a new Expensify account and get $${CONST.REFERRAL_PROGRAM.REVENUE} when they become a customer.`, + }, + [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.PAY_SOMEONE]: { + buttonText1: 'Pay someone, ', + buttonText2: `get $${CONST.REFERRAL_PROGRAM.REVENUE}.`, + header: `Pay someone, get $${CONST.REFERRAL_PROGRAM.REVENUE}`, + body: `You gotta spend money to make money! Pay someone with Expensify and get $${CONST.REFERRAL_PROGRAM.REVENUE} when they become a customer.`, + }, + [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND]: { + buttonText1: 'Invite a friend, ', + buttonText2: `get $${CONST.REFERRAL_PROGRAM.REVENUE}.`, + header: `Get $${CONST.REFERRAL_PROGRAM.REVENUE}`, + body: `Chat, pay, submit, or split an expense with a friend and get $${CONST.REFERRAL_PROGRAM.REVENUE} when they become a customer. Otherwise, just share your invite link!`, + }, + [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SHARE_CODE]: { + buttonText1: `Get $${CONST.REFERRAL_PROGRAM.REVENUE}`, + header: `Get $${CONST.REFERRAL_PROGRAM.REVENUE}`, + body: `Chat, pay, submit, or split an expense with a friend and get $${CONST.REFERRAL_PROGRAM.REVENUE} when they become a customer. Otherwise, just share your invite link!`, + }, + copyReferralLink: 'Copy invite link', + }, + systemChatFooterMessage: { + [CONST.INTRO_CHOICES.MANAGE_TEAM]: { + phrase1: 'Chat with your setup specialist in ', + phrase2: ' for help', + }, + default: { + phrase1: 'Message ', + phrase2: ' for help with setup', + }, + }, + violations: { + allTagLevelsRequired: 'All tags required', + autoReportedRejectedExpense: ({rejectReason, rejectedBy}: ViolationsAutoReportedRejectedExpenseParams) => `${rejectedBy} rejected this expense with the comment "${rejectReason}"`, + billableExpense: 'Billable no longer valid', + cashExpenseWithNoReceipt: ({formattedLimit}: ViolationsCashExpenseWithNoReceiptParams) => `Receipt required${formattedLimit ? ` over ${formattedLimit}` : ''}`, + categoryOutOfPolicy: 'Category no longer valid', + conversionSurcharge: ({surcharge}: ViolationsConversionSurchargeParams) => `Applied ${surcharge}% conversion surcharge`, + customUnitOutOfPolicy: 'Rate not valid for this workspace', + duplicatedTransaction: 'Duplicate', + fieldRequired: 'Report fields are required', + futureDate: 'Future date not allowed', + invoiceMarkup: ({invoiceMarkup}: ViolationsInvoiceMarkupParams) => `Marked up by ${invoiceMarkup}%`, + maxAge: ({maxAge}: ViolationsMaxAgeParams) => `Date older than ${maxAge} days`, + missingCategory: 'Missing category', + missingComment: 'Description required for selected category', + missingTag: ({tagName}: ViolationsMissingTagParams) => `Missing ${tagName ?? 'tag'}`, + modifiedAmount: ({type, displayPercentVariance}: ViolationsModifiedAmountParams): string => { + switch (type) { + case 'distance': + return 'Amount differs from calculated distance'; + case 'card': + return 'Amount greater than card transaction'; + default: + if (displayPercentVariance) { + return `Amount ${displayPercentVariance}% greater than scanned receipt`; + } + return 'Amount greater than scanned receipt'; + } + }, + modifiedDate: 'Date differs from scanned receipt', + nonExpensiworksExpense: 'Non-Expensiworks expense', + overAutoApprovalLimit: ({formattedLimit}: ViolationsOverLimitParams) => `Expense exceeds auto approval limit of ${formattedLimit}`, + overCategoryLimit: ({formattedLimit}: ViolationsOverCategoryLimitParams) => `Amount over ${formattedLimit}/person category limit`, + overLimit: ({formattedLimit}: ViolationsOverLimitParams) => `Amount over ${formattedLimit}/person limit`, + overLimitAttendee: ({formattedLimit}: ViolationsOverLimitParams) => `Amount over ${formattedLimit}/person limit`, + perDayLimit: ({formattedLimit}: ViolationsPerDayLimitParams) => `Amount over daily ${formattedLimit}/person category limit`, + receiptNotSmartScanned: 'Receipt not verified. Please confirm accuracy.', + receiptRequired: ({formattedLimit, category}: ViolationsReceiptRequiredParams) => { + let message = 'Receipt required'; + if (formattedLimit ?? category) { + message += ' over'; + if (formattedLimit) { + message += ` ${formattedLimit}`; + } + if (category) { + message += ' category limit'; + } + } + return message; + }, + reviewRequired: 'Review required', + rter: ({brokenBankConnection, email, isAdmin, isTransactionOlderThan7Days, member}: ViolationsRterParams) => { + if (brokenBankConnection) { + return isAdmin + ? `Can't auto-match receipt due to broken bank connection which ${email} needs to fix` + : "Can't auto-match receipt due to broken bank connection which you need to fix"; + } + if (!isTransactionOlderThan7Days) { + return isAdmin ? `Ask ${member} to mark as a cash or wait 7 days and try again` : 'Awaiting merge with card transaction.'; + } + + return ''; + }, + smartscanFailed: 'Receipt scanning failed. Enter details manually.', + someTagLevelsRequired: ({tagName}: ViolationsTagOutOfPolicyParams) => `Missing ${tagName ?? 'Tag'}`, + tagOutOfPolicy: ({tagName}: ViolationsTagOutOfPolicyParams) => `${tagName ?? 'Tag'} no longer valid`, + taxAmountChanged: 'Tax amount was modified', + taxOutOfPolicy: ({taxName}: ViolationsTaxOutOfPolicyParams) => `${taxName ?? 'Tax'} no longer valid`, + taxRateChanged: 'Tax rate was modified', + taxRequired: 'Missing tax rate', + none: 'None', + taxCodeToKeep: 'Choose which tax code to keep', + tagToKeep: 'Choose which tag to keep', + isTransactionReimbursable: 'Choose if transaction is reimbursable', + merchantToKeep: 'Choose which merchant to keep', + descriptionToKeep: 'Choose which description to keep', + categoryToKeep: 'Choose which category to keep', + isTransactionBillable: 'Choose if transaction is billable', + keepThisOne: 'Keep this one', + confirmDetails: `Confirm the details you're keeping`, + confirmDuplicatesInfo: `The duplicate requests you don't keep will be held for the member to delete`, + hold: 'Hold', + }, + reportViolations: { + [CONST.REPORT_VIOLATIONS.FIELD_REQUIRED]: (fieldName: string) => `${fieldName} is required`, + }, + violationDismissal: { + rter: { + manual: 'marked this receipt as cash.', + }, + duplicatedTransaction: { + manual: 'resolved the duplicate', + }, + }, + videoPlayer: { + play: 'Play', + pause: 'Pause', + fullscreen: 'Fullscreen', + playbackSpeed: 'Playback speed', + expand: 'Expand', + mute: 'Mute', + unmute: 'Unmute', + normal: 'Normal', + }, + exitSurvey: { + header: 'Before you go', + reasonPage: { + title: "Please tell us why you're leaving", + subtitle: 'Before you go, please tell us why you’d like to switch to Expensify Classic.', + }, + reasons: { + [CONST.EXIT_SURVEY.REASONS.FEATURE_NOT_AVAILABLE]: "I need a feature that's only available in Expensify Classic.", + [CONST.EXIT_SURVEY.REASONS.DONT_UNDERSTAND]: "I don't understand how to use New Expensify.", + [CONST.EXIT_SURVEY.REASONS.PREFER_CLASSIC]: 'I understand how to use New Expensify, but I prefer Expensify Classic.', + }, + prompts: { + [CONST.EXIT_SURVEY.REASONS.FEATURE_NOT_AVAILABLE]: "What feature do you need that isn't available in New Expensify?", + [CONST.EXIT_SURVEY.REASONS.DONT_UNDERSTAND]: 'What are you trying to do?', + [CONST.EXIT_SURVEY.REASONS.PREFER_CLASSIC]: 'Why do you prefer Expensify Classic?', + }, + responsePlaceholder: 'Your response', + thankYou: 'Thanks for the feedback!', + thankYouSubtitle: 'Your responses will help us build a better product to get stuff done. Thank you so much!', + goToExpensifyClassic: 'Switch to Expensify Classic', + offlineTitle: "Looks like you're stuck here...", + offline: + "You appear to be offline. Unfortunately, Expensify Classic doesn't work offline, but New Expensify does. If you prefer to use Expensify Classic, try again when you have an internet connection.", + }, + listBoundary: { + errorMessage: 'An error occurred while loading more messages.', + tryAgain: 'Try again', + }, + systemMessage: { + mergedWithCashTransaction: 'matched a receipt to this transaction.', + }, + subscription: { + authenticatePaymentCard: 'Authenticate payment card', + mobileReducedFunctionalityMessage: 'You can’t make changes to your subscription in the mobile app.', + badge: { + freeTrial: ({numOfDays}) => `Free trial: ${numOfDays} ${numOfDays === 1 ? 'day' : 'days'} left`, + }, + billingBanner: { + policyOwnerAmountOwed: { + title: 'Your payment info is outdated', + subtitle: ({date}) => `Update your payment card by ${date} to continue using all of your favorite features.`, + }, + policyOwnerAmountOwedOverdue: { + title: 'Your payment info is outdated', + subtitle: 'Please update your payment information.', + }, + policyOwnerUnderInvoicing: { + title: 'Your payment info is outdated', + subtitle: ({date}) => `Your payment is past due. Please pay your invoice by ${date} to avoid service interruption.`, + }, + policyOwnerUnderInvoicingOverdue: { + title: 'Your payment info is outdated', + subtitle: 'Your payment is past due. Please pay your invoice.', + }, + billingDisputePending: { + title: 'Your card couldn’t be charged', + subtitle: ({amountOwed, cardEnding}) => + `You disputed the ${amountOwed} charge on the card ending in ${cardEnding}. Your account will be locked until the dispute is resolved with your bank.`, + }, + cardAuthenticationRequired: { + title: 'Your card couldn’t be charged', + subtitle: ({cardEnding}) => + `Your payment card hasn’t been fully authenticated. Please complete the authentication process to activate your payment card ending in ${cardEnding}.`, + }, + insufficientFunds: { + title: 'Your card couldn’t be charged', + subtitle: ({amountOwed}) => + `Your payment card was declined due to insufficient funds. Please retry or add a new payment card to clear your ${amountOwed} outstanding balance.`, + }, + cardExpired: { + title: 'Your card couldn’t be charged', + subtitle: ({amountOwed}) => `Your payment card expired. Please add a new payment card to clear your ${amountOwed} outstanding balance.`, + }, + cardExpireSoon: { + title: 'Your card is expiring soon', + subtitle: 'Your payment card will expire at the end of this month. Click the three-dot menu below to update it and continue using all your favorite features.', + }, + retryBillingSuccess: { + title: 'Success!', + subtitle: 'Your card has been billed successfully.', + }, + retryBillingError: { + title: 'Your card couldn’t be charged', + subtitle: 'Before retrying, please call your bank directly to authorize Expensify charges and remove any holds. Otherwise, try adding a different payment card.', + }, + cardOnDispute: ({amountOwed, cardEnding}) => + `You disputed the ${amountOwed} charge on the card ending in ${cardEnding}. Your account will be locked until the dispute is resolved with your bank.`, + preTrial: { + title: 'Start a free trial', + subtitleStart: 'As a next step, ', + subtitleLink: 'complete your setup checklist ', + subtitleEnd: 'so your team can start expensing.', + }, + trialStarted: { + title: ({numOfDays}) => `Free trial: ${numOfDays} ${numOfDays === 1 ? 'day' : 'days'} left!`, + subtitle: 'Add a payment card to continue using all of your favorite features.', + }, + trialEnded: { + title: 'Your free trial has ended', + subtitle: 'Add a payment card to continue using all of your favorite features.', + }, + }, + cardSection: { + title: 'Payment', + subtitle: 'Add a card to pay for your Expensify subscription.', + addCardButton: 'Add payment card', + cardNextPayment: ({nextPaymentDate}) => `Your next payment date is ${nextPaymentDate}.`, + cardEnding: ({cardNumber}) => `Card ending in ${cardNumber}`, + cardInfo: ({name, expiration, currency}) => `Name: ${name}, Expiration: ${expiration}, Currency: ${currency}`, + changeCard: 'Change payment card', + changeCurrency: 'Change payment currency', + cardNotFound: 'No payment card added', + retryPaymentButton: 'Retry payment', + authenticatePayment: 'Authenticate payment', + requestRefund: 'Request refund', + requestRefundModal: { + phrase1: 'Getting a refund is easy, just downgrade your account before your next billing date and you’ll receive a refund.', + phrase2: + 'Heads up: Downgrading your account means your workspace(s) will be deleted. This action can’t be undone, but you can always create a new workspace if you change your mind.', + confirm: 'Delete workspace(s) and downgrade', + }, + viewPaymentHistory: 'View payment history', + }, + yourPlan: { + title: 'Your plan', + collect: { + title: 'Collect', + priceAnnual: ({lower, upper}) => `From ${lower}/active member with the Expensify Card, ${upper}/active member without the Expensify Card.`, + pricePayPerUse: ({lower, upper}) => `From ${lower}/active member with the Expensify Card, ${upper}/active member without the Expensify Card.`, + benefit1: 'Unlimited SmartScans and distance tracking', + benefit2: 'Expensify Cards with Smart Limits', + benefit3: 'Bill pay and invoicing', + benefit4: 'Expense approvals', + benefit5: 'ACH reimbursement', + benefit6: 'QuickBooks and Xero integrations', + benefit7: 'Custom insights and reporting', + }, + control: { + title: 'Control', + priceAnnual: ({lower, upper}) => `From ${lower}/active member with the Expensify Card, ${upper}/active member without the Expensify Card.`, + pricePayPerUse: ({lower, upper}) => `From ${lower}/active member with the Expensify Card, ${upper}/active member without the Expensify Card.`, + benefit1: 'Everything in Collect, plus:', + benefit2: 'NetSuite and Sage Intacct integrations', + benefit3: 'Certinia and Workday sync', + benefit4: 'Multiple expense approvers', + benefit5: 'SAML/SSO', + benefit6: 'Budgeting', + }, + saveWithExpensifyTitle: 'Save with the Expensify Card', + saveWithExpensifyDescription: 'Use our savings calculator to see how cash back from the Expensify Card can reduce your Expensify bill.', + saveWithExpensifyButton: 'Learn more', + }, + details: { + title: 'Subscription details', + annual: 'Annual subscription', + taxExempt: 'Request tax exempt status', + taxExemptEnabled: 'Tax exempt', + payPerUse: 'Pay-per-use', + subscriptionSize: 'Subscription size', + headsUp: + "Heads up: If you don’t set your subscription size now, we’ll set it automatically to your first month's active member count. You’ll then be committed to paying for at least this number of members for the next 12 months. You can increase your subscription size at any time, but you can’t decrease it until your subscription is over.", + zeroCommitment: 'Zero commitment at the discounted annual subscription rate', + }, + subscriptionSize: { + title: 'Subscription size', + yourSize: 'Your subscription size is the number of open seats that can be filled by any active member in a given month.', + eachMonth: + 'Each month, your subscription covers up to the number of active members set above. Any time you increase your subscription size, you’ll start a new 12-month subscription at that new size.', + note: 'Note: An active member is anyone who has created, edited, submitted, approved, reimbursed, or exported expense data tied to your company workspace.', + confirmDetails: 'Confirm your new annual subscription details:', + subscriptionSize: 'Subscription size', + activeMembers: ({size}) => `${size} active members/month`, + subscriptionRenews: 'Subscription renews', + youCantDowngrade: 'You can’t downgrade during your annual subscription.', + youAlreadyCommitted: ({size, date}) => + `You already committed to an annual subscription size of ${size} active members per month until ${date}. You can switch to a pay-per-use subscription on ${date} by disabling auto-renew.`, + error: { + size: 'Please enter a valid subscription size.', + sameSize: 'Please enter a number different than your current subscription size.', + }, + }, + paymentCard: { + addPaymentCard: 'Add payment card', + enterPaymentCardDetails: 'Enter your payment card details', + security: 'Expensify is PCI-DSS compliant, uses bank-level encryption, and utilizes redundant infrastructure to protect your data.', + learnMoreAboutSecurity: 'Learn more about our security.', + }, + subscriptionSettings: { + title: 'Subscription settings', + autoRenew: 'Auto-renew', + autoIncrease: 'Auto-increase annual seats', + saveUpTo: ({amountWithCurrency}) => `Save up to ${amountWithCurrency}/month per active member`, + automaticallyIncrease: + 'Automatically increase your annual seats to accommodate for active members that exceed your subscription size. Note: This will extend your annual subscription end date.', + disableAutoRenew: 'Disable auto-renew', + helpUsImprove: 'Help us improve Expensify', + whatsMainReason: "What's the main reason you're disabling auto-renew?", + renewsOn: ({date}) => `Renews on ${date}.`, + }, + requestEarlyCancellation: { + title: 'Request early cancellation', + subtitle: 'What’s the main reason you’re requesting early cancellation?', + subscriptionCanceled: { + title: 'Subscription canceled', + subtitle: 'Your annual subscription has been canceled.', + info: 'If you want to keep using your workspace(s) on a pay-per-use basis, you’re all set.', + preventFutureActivity: { + part1: 'If you’d like to prevent future activity and charges, you must ', + link: 'delete your workspace(s)', + part2: '. Note that when you delete your workspace(s), you’ll be charged for any outstanding activity that was incurred during the current calendar month.', + }, + }, + requestSubmitted: { + title: 'Request submitted', + subtitle: { + part1: 'Thanks for letting us know you’re interested in canceling your subscription. We’re reviewing your request and will be in touch soon via your chat with ', + link: 'Concierge', + part2: '.', + }, + }, + acknowledgement: { + part1: 'By requesting early cancellation, I acknowledge and agree that Expensify has no obligation to grant such request under the Expensify ', + link: 'Terms of Service', + part2: ' or other applicable services agreement between me and Expensify and that Expensify retains sole discretion with regard to granting any such request.', + }, + }, + }, + feedbackSurvey: { + tooLimited: 'Functionality needs improvement', + tooExpensive: 'Too expensive', + inadequateSupport: 'Inadequate customer support', + businessClosing: 'Company closing, downsizing, or acquired', + additionalInfoTitle: 'What software are you moving to and why?', + additionalInfoInputLabel: 'Your response', + }, + roomChangeLog: { + updateRoomDescription: 'set the room description to:', + clearRoomDescription: 'cleared the room description', + }, + delegate: { + switchAccount: 'Switch accounts:', + copilotDelegatedAccess: 'Copilot: Delegated access', + copilotDelegatedAccessDescription: 'Allow other members to access your account.', + addCopilot: 'Add copilot', + membersCanAccessYourAccount: 'These members can access your account:', + youCanAccessTheseAccounts: 'You can access these accounts via the account switcher:', + role: (role?: string): string => { + switch (role) { + case CONST.DELEGATE_ROLE.ALL: + return 'Full'; + case CONST.DELEGATE_ROLE.SUBMITTER: + return 'Limited'; + default: + return ''; + } + }, + genericError: 'Oops, something went wrong. Please try again.', + accessLevel: 'Access level', + confirmCopilot: 'Confirm your copilot below.', + accessLevelDescription: 'Choose an access level below. Both Full and Limited access allow copilots to view all conversations and expenses.', + roleDescription: (role?: string): string => { + switch (role) { + case CONST.DELEGATE_ROLE.ALL: + return 'Allow another member to take all actions in your account, on your behalf. Includes chat, submissions, approvals, payments, settings updates, and more.'; + case CONST.DELEGATE_ROLE.SUBMITTER: + return 'Allow another member to take most actions in your account, on your behalf. Excludes approvals, payments, rejections, and holds.'; + default: + return ''; + } + }, + makeSureItIsYou: "Let's make sure it's you", + enterMagicCode: ({contactMethod}: EnterMagicCodeParams) => `Please enter the magic code sent to ${contactMethod} to add a copilot.`, + notAllowed: 'Not so fast...', + notAllowedMessageStart: ({accountOwnerEmail}: AccountOwnerParams) => `You don't have permission to take this action for ${accountOwnerEmail} as a`, + notAllowedMessageHyperLinked: ' limited access', + notAllowedMessageEnd: ' copilot', + }, + debug: { + debug: 'Debug', + details: 'Details', + JSON: 'JSON', + reportActions: 'Actions', + reportActionPreview: 'Preview', + nothingToPreview: 'Nothing to preview', + editJson: 'Edit JSON:', + preview: 'Preview:', + missingProperty: ({propertyName}) => `Missing ${propertyName}`, + invalidProperty: ({propertyName, expectedType}) => `Invalid property: ${propertyName} - Expected: ${expectedType}`, + invalidValue: ({expectedValues}) => `Invalid value - Expected: ${expectedValues}`, + missingValue: 'Missing value', + createReportAction: 'Create Report Action', + reportAction: 'Report Action', + report: 'Report', + hint: "Data changes won't be sent to the backend", + textFields: 'Text fields', + numberFields: 'Number fields', + booleanFields: 'Boolean fields', + constantFields: 'Constant fields', + dateTimeFields: 'DateTime fields', + date: 'Date', + time: 'Time', + none: 'None', + }, +} satisfies TranslationBase; diff --git a/src/languages/es.ts b/src/languages/es.ts index 2f11de46faed..26a7c92f557f 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -4002,11 +4002,11 @@ export default { removedFromApprovalWorkflow: ({submittersNames}: {submittersNames: string[]}) => { let joinedNames = ''; if (submittersNames.length === 1) { - joinedNames = submittersNames[0]; + joinedNames = submittersNames.at(0) ?? ''; } else if (submittersNames.length === 2) { joinedNames = submittersNames.join(' y '); } else if (submittersNames.length > 2) { - joinedNames = `${submittersNames.slice(0, submittersNames.length - 1).join(', ')} y ${submittersNames[submittersNames.length - 1]}`; + joinedNames = `${submittersNames.slice(0, submittersNames.length - 1).join(', ')} y ${submittersNames.at(submittersNames.length - 1)}`; } const workflowWord = Str.pluralize('del flujo', 'de los flujos', submittersNames.length); const chatWord = Str.pluralize('del chat', 'de los chats', submittersNames.length); diff --git a/src/languages/es.ts.orig b/src/languages/es.ts.orig new file mode 100644 index 000000000000..2f11de46faed --- /dev/null +++ b/src/languages/es.ts.orig @@ -0,0 +1,5348 @@ +import {Str} from 'expensify-common'; +import CONST from '@src/CONST'; +import type {ConnectionName, PolicyConnectionSyncStage, SageIntacctMappingName} from '@src/types/onyx/Policy'; +import type { + AccountOwnerParams, + AddressLineParams, + AdminCanceledRequestParams, + AlreadySignedInParams, + ApprovalWorkflowErrorParams, + ApprovedAmountParams, + AssignCardParams, + BeginningOfChatHistoryAdminRoomPartOneParams, + BeginningOfChatHistoryAnnounceRoomPartOneParams, + BeginningOfChatHistoryAnnounceRoomPartTwo, + BeginningOfChatHistoryDomainRoomPartOneParams, + CanceledRequestParams, + ChangeFieldParams, + ChangePolicyParams, + ChangeTypeParams, + CharacterLimitParams, + CompanyCardFeedNameParams, + ConfirmHoldExpenseParams, + ConfirmThatParams, + DateShouldBeAfterParams, + DateShouldBeBeforeParams, + DelegateSubmitParams, + DeleteActionParams, + DeleteConfirmationParams, + DeleteExpenseTranslationParams, + DidSplitAmountMessageParams, + DistanceRateOperationsParams, + EditActionParams, + ElectronicFundsParams, + EnglishTranslation, + EnterMagicCodeParams, + ExportedToIntegrationParams, + FormattedMaxLengthParams, + ForwardedAmountParams, + GoBackMessageParams, + GoToRoomParams, + InstantSummaryParams, + IssueVirtualCardParams, + LocalTimeParams, + LoggedInAsParams, + LogSizeParams, + ManagerApprovedAmountParams, + ManagerApprovedParams, + MarkedReimbursedParams, + MarkReimbursedFromIntegrationParams, + NoLongerHaveAccessParams, + NotAllowedExtensionParams, + NotYouParams, + OOOEventSummaryFullDayParams, + OOOEventSummaryPartialDayParams, + OurEmailProviderParams, + PaidElsewhereWithAmountParams, + PaidWithExpensifyWithAmountParams, + ParentNavigationSummaryParams, + PayerOwesAmountParams, + PayerOwesParams, + PayerPaidAmountParams, + PayerPaidParams, + PayerSettledParams, + PaySomeoneParams, + ReimbursementRateParams, + RemovedTheRequestParams, + RemoveMembersWarningPrompt, + RenamedRoomActionParams, + ReportArchiveReasonsClosedParams, + ReportArchiveReasonsInvoiceReceiverPolicyDeletedParams, + ReportArchiveReasonsMergedParams, + ReportArchiveReasonsPolicyDeletedParams, + ReportArchiveReasonsRemovedFromPolicyParams, + RequestAmountParams, + RequestCountParams, + RequestedAmountMessageParams, + ResolutionConstraintsParams, + RoomNameReservedErrorParams, + RoomRenamedToParams, + SetTheDistanceMerchantParams, + SetTheRequestParams, + SettledAfterAddedBankAccountParams, + SettleExpensifyCardParams, + ShareParams, + SignUpNewFaceCodeParams, + SizeExceededParams, + SplitAmountParams, + StepCounterParams, + StripePaidParams, + TaskCreatedActionParams, + TermsParams, + ThreadRequestReportNameParams, + ThreadSentMoneyReportNameParams, + ToValidateLoginParams, + TransferParams, + UnapprovedParams, + UnshareParams, + UntilTimeParams, + UpdatedTheDistanceMerchantParams, + UpdatedTheRequestParams, + UsePlusButtonParams, + UserIsAlreadyMemberParams, + UserSplitParams, + ViolationsAutoReportedRejectedExpenseParams, + ViolationsCashExpenseWithNoReceiptParams, + ViolationsConversionSurchargeParams, + ViolationsInvoiceMarkupParams, + ViolationsMaxAgeParams, + ViolationsMissingTagParams, + ViolationsModifiedAmountParams, + ViolationsOverAutoApprovalLimitParams, + ViolationsOverCategoryLimitParams, + ViolationsOverLimitParams, + ViolationsPerDayLimitParams, + ViolationsReceiptRequiredParams, + ViolationsRterParams, + ViolationsTagOutOfPolicyParams, + ViolationsTaxOutOfPolicyParams, + WaitingOnBankAccountParams, + WalletProgramParams, + WelcomeEnterMagicCodeParams, + WelcomeNoteParams, + WelcomeToRoomParams, + WeSentYouMagicSignInLinkParams, + ZipCodeExampleFormatParams, +} from './types'; + +/* eslint-disable max-len */ +export default { + common: { + cancel: 'Cancelar', + dismiss: 'Descartar', + yes: 'Sí', + no: 'No', + ok: 'OK', + notNow: 'Ahora no', + learnMore: 'Más información', + buttonConfirm: 'Ok, entendido', + name: 'Nombre', + attachment: 'Archivo adjunto', + attachments: 'Archivos adjuntos', + from: 'De', + to: 'A', + in: 'En', + optional: 'Opcional', + new: 'Nuevo', + center: 'Centrar', + search: 'Buscar', + find: 'Encontrar', + searchWithThreeDots: 'Buscar...', + select: 'Seleccionar', + selectMultiple: 'Seleccionar varios', + next: 'Siguiente', + create: 'Crear', + previous: 'Anterior', + goBack: 'Volver', + add: 'Añadir', + resend: 'Reenviar', + save: 'Guardar', + saveChanges: 'Guardar cambios', + submit: 'Enviar', + rotate: 'Rotar', + zoom: 'Zoom', + password: 'Contraseña', + magicCode: 'Código mágico', + twoFactorCode: 'Autenticación de dos factores', + workspaces: 'Espacios de trabajo', + inbox: 'Recibidos', + group: 'Grupo', + profile: 'Perfil', + referral: 'Remisión', + payments: 'Pagos', + approvals: 'Aprobaciones', + wallet: 'Billetera', + preferences: 'Preferencias', + view: 'Ver', + review: 'Revisar', + not: 'No', + privacyPolicy: 'la Política de Privacidad de Expensify', + addCardTermsOfService: 'Términos de Servicio', + signIn: 'Conectarse', + signInWithGoogle: 'Iniciar sesión con Google', + signInWithApple: 'Iniciar sesión con Apple', + signInWith: 'Iniciar sesión con', + continue: 'Continuar', + firstName: 'Nombre', + lastName: 'Apellidos', + phone: 'Teléfono', + phoneNumber: 'Número de teléfono', + phoneNumberPlaceholder: '(xxx) xxx-xxxx', + email: 'Email', + and: 'y', + details: 'Detalles', + privacy: 'Privacidad', + hidden: 'Oculto', + visible: 'Visible', + delete: 'Eliminar', + archived: 'archivado', + contacts: 'Contactos', + recents: 'Recientes', + close: 'Cerrar', + download: 'Descargar', + downloading: 'Descargando', + pin: 'Fijar', + unPin: 'Desfijar', + back: 'Volver', + saveAndContinue: 'Guardar y continuar', + settings: 'Configuración', + termsOfService: 'Términos de Servicio', + members: 'Miembros', + invite: 'Invitar', + here: 'aquí', + date: 'Fecha', + dob: 'Fecha de nacimiento', + currentYear: 'Año actual', + currentMonth: 'Mes actual', + ssnLast4: 'Últimos 4 dígitos de tu SSN', + ssnFull9: 'Los 9 dígitos del SSN', + addressLine: ({lineNumber}: AddressLineParams) => `Dirección línea ${lineNumber}`, + personalAddress: 'Dirección física personal', + companyAddress: 'Dirección física de la empresa', + noPO: 'No se aceptan apartados ni direcciones postales', + city: 'Ciudad', + state: 'Estado', + streetAddress: 'Dirección', + stateOrProvince: 'Estado / Provincia', + country: 'País', + zip: 'Código postal', + zipPostCode: 'Código postal', + whatThis: '¿Qué es esto?', + iAcceptThe: 'Acepto los ', + remove: 'Eliminar', + admin: 'Administrador', + owner: 'Dueño', + dateFormat: 'AAAA-MM-DD', + send: 'Enviar', + notifications: 'Notificaciones', + na: 'N/A', + noResultsFound: 'No se han encontrado resultados', + recentDestinations: 'Destinos recientes', + timePrefix: 'Son las', + conjunctionFor: 'para', + todayAt: 'Hoy a las', + tomorrowAt: 'Mañana a las', + yesterdayAt: 'Ayer a las', + conjunctionAt: 'a', + conjunctionTo: 'a', + genericErrorMessage: 'Ups... algo no ha ido bien y la acción no se ha podido completar. Por favor, inténtalo más tarde.', + percentage: 'Porcentaje', + error: { + invalidAmount: 'Importe no válido.', + acceptTerms: 'Debes aceptar los Términos de Servicio para continuar.', + phoneNumber: `Introduce un teléfono válido, incluyendo el código del país (p. ej. ${CONST.EXAMPLE_PHONE_NUMBER})`, + fieldRequired: 'Este campo es obligatorio.', + requestModified: 'Esta solicitud está siendo modificada por otro miembro.', + characterLimit: ({limit}: CharacterLimitParams) => `Supera el límite de ${limit} caracteres`, + characterLimitExceedCounter: ({length, limit}) => `Se superó el límite de caracteres (${length}/${limit})`, + dateInvalid: 'Por favor, selecciona una fecha válida.', + invalidDateShouldBeFuture: 'Por favor, elige una fecha igual o posterior a hoy.', + invalidTimeShouldBeFuture: 'Por favor, elige una hora al menos un minuto en el futuro.', + invalidCharacter: 'Carácter invalido.', + enterMerchant: 'Introduce un comerciante.', + enterAmount: 'Introduce un importe.', + enterDate: 'Introduce una fecha.', + invalidTimeRange: 'Por favor, introduce una hora entre 1 y 12 (por ejemplo, 2:30 PM).', + pleaseCompleteForm: 'Por favor complete el formulario de arriba para continuar.', + pleaseSelectOne: 'Seleccione una de las opciones.', + }, + comma: 'la coma', + semicolon: 'el punto y coma', + please: 'Por favor', + rename: 'Renombrar', + contactUs: 'contáctenos', + pleaseEnterEmailOrPhoneNumber: 'Por favor, escribe un email o número de teléfono', + fixTheErrors: 'corrige los errores', + inTheFormBeforeContinuing: 'en el formulario antes de continuar', + confirm: 'Confirmar', + reset: 'Restablecer', + done: 'Listo', + more: 'Más', + debitCard: 'Tarjeta de débito', + bankAccount: 'Cuenta bancaria', + personalBankAccount: 'Cuenta bancaria personal', + businessBankAccount: 'Cuenta bancaria comercial', + join: 'Unirse', + leave: 'Salir', + decline: 'Rechazar', + transferBalance: 'Transferencia de saldo', + cantFindAddress: '¿No encuentras tu dirección? ', + enterManually: 'Introducir manualmente', + message: 'Chatear con ', + leaveThread: 'Salir del hilo', + you: 'Tú', + youAfterPreposition: 'ti', + your: 'tu', + conciergeHelp: 'Por favor, contacta con Concierge para obtener ayuda.', + youAppearToBeOffline: 'Parece que estás desconectado.', + thisFeatureRequiresInternet: 'Esta función requiere una conexión a Internet activa.', + attachementWillBeAvailableOnceBackOnline: 'El archivo adjunto estará disponible cuando vuelvas a estar en línea.', + areYouSure: '¿Estás seguro?', + verify: 'Verifique', + yesContinue: 'Sí, continuar', + websiteExample: 'p. ej. https://www.expensify.com', + zipCodeExampleFormat: ({zipSampleFormat}: ZipCodeExampleFormatParams) => (zipSampleFormat ? `p. ej. ${zipSampleFormat}` : ''), + description: 'Descripción', + with: 'con', + shareCode: 'Compartir código', + share: 'Compartir', + per: 'por', + mi: 'milla', + km: 'kilómetro', + copied: '¡Copiado!', + someone: 'Alguien', + total: 'Total', + edit: 'Editar', + letsDoThis: '¡Hagámoslo!', + letsStart: 'Empecemos', + showMore: 'Mostrar más', + merchant: 'Comerciante', + category: 'Categoría', + billable: 'Facturable', + nonBillable: 'No facturable', + tag: 'Etiqueta', + receipt: 'Recibo', + verified: 'Verificado', + replace: 'Sustituir', + distance: 'Distancia', + mile: 'milla', + miles: 'millas', + kilometer: 'kilómetro', + kilometers: 'kilómetros', + recent: 'Reciente', + all: 'Todo', + am: 'AM', + pm: 'PM', + tbd: 'Por determinar', + selectCurrency: 'Selecciona una moneda', + card: 'Tarjeta', + whyDoWeAskForThis: '¿Por qué pedimos esto?', + required: 'Obligatorio', + showing: 'Mostrando', + of: 'de', + default: 'Predeterminado', + update: 'Actualizar', + member: 'Miembro', + auditor: 'Auditor', + role: 'Role', + currency: 'Divisa', + rate: 'Tarifa', + emptyLHN: { + title: 'Woohoo! Todo al día.', + subtitleText1: 'Encuentra un chat usando el botón', + subtitleText2: 'o crea algo usando el botón', + subtitleText3: '.', + }, + businessName: 'Nombre de la empresa', + clear: 'Borrar', + type: 'Tipo', + action: 'Acción', + expenses: 'Gastos', + tax: 'Impuesto', + shared: 'Compartidos', + drafts: 'Borradores', + finished: 'Finalizados', + upgrade: 'Mejora', + companyID: 'Empresa ID', + userID: 'Usuario ID', + disable: 'Deshabilitar', + export: 'Exportar', + initialValue: 'Valor inicial', + currentDate: 'Fecha actual', + value: 'Valor', + downloadFailedTitle: 'Error en la descarga', + downloadFailedDescription: 'No se pudo completar la descarga. Por favor, inténtalo más tarde.', + filterLogs: 'Registros de filtrado', + network: 'La red', + reportID: 'ID del informe', + chooseFile: 'Elegir archivo', + dropTitle: 'Suéltalo', + dropMessage: 'Suelta tu archivo aquí', + enabled: 'Habilitado', + ignore: 'Ignorar', + import: 'Importar', + offlinePrompt: 'No puedes realizar esta acción ahora mismo.', + outstanding: 'Pendiente', + chats: 'Chats', + unread: 'No leído', + sent: 'Enviado', + links: 'Enlaces', + days: 'días', + }, + connectionComplete: { + title: 'Conexión completa', + supportingText: 'Ya puedes cerrar esta página y volver a la App de Expensify.', + }, + location: { + useCurrent: 'Usar ubicación actual', + notFound: 'No pudimos encontrar tu ubicación. Inténtalo de nuevo o introduce una dirección manualmente.', + permissionDenied: 'Parece que has denegado el permiso a tu ubicación.', + please: 'Por favor,', + allowPermission: 'habilita el permiso de ubicación en la configuración', + tryAgain: 'e inténtalo de nuevo.', + }, + anonymousReportFooter: { + logoTagline: 'Únete a la discusión.', + }, + attachmentPicker: { + cameraPermissionRequired: 'Permiso para acceder a la cámara', + expensifyDoesntHaveAccessToCamera: 'Expensify no puede tomar fotos sin acceso a la cámara. Haz click en configuración para actualizar los permisos.', + attachmentError: 'Error al adjuntar archivo', + errorWhileSelectingAttachment: 'Se ha producido un error al seleccionar un archivo adjunto. Por favor, inténtalo de nuevo.', + errorWhileSelectingCorruptedAttachment: 'Se ha producido un error al seleccionar un archivo adjunto corrupto. Por favor, inténtalo con otro archivo.', + takePhoto: 'Hacer una foto', + chooseFromGallery: 'Elegir de la galería', + chooseDocument: 'Elegir un archivo', + attachmentTooLarge: 'Archivo adjunto demasiado grande', + sizeExceeded: 'El archivo adjunto supera el límite de 24 MB.', + attachmentTooSmall: 'Archivo adjunto demasiado pequeño', + sizeNotMet: 'El archivo adjunto debe ser más grande que 240 bytes.', + wrongFileType: 'Tipo de archivo inválido', + notAllowedExtension: 'Este tipo de archivo no es compatible', + folderNotAllowedMessage: 'Subir una carpeta no está permitido. Prueba con otro archivo.', + protectedPDFNotSupported: 'Los PDFs con contraseña no son compatibles', + attachmentImageResized: 'Se ha cambiado el tamaño de esta imagen para obtener una vista previa. Descargar para resolución completa.', + attachmentImageTooLarge: 'Esta imagen es demasiado grande para obtener una vista previa antes de subirla.', + }, + filePicker: { + fileError: 'Error de archivo', + errorWhileSelectingFile: 'An error occurred while selecting an file. Please try again.', + }, + avatarCropModal: { + title: 'Editar foto', + description: 'Arrastra, haz zoom y rota tu imagen para que quede como te gusta.', + }, + composer: { + noExtensionFoundForMimeType: 'No se encontró una extension para este tipo de contenido', + problemGettingImageYouPasted: 'Ha ocurrido un problema al obtener la imagen que has pegado', + commentExceededMaxLength: ({formattedMaxLength}: FormattedMaxLengthParams) => `El comentario debe tener máximo ${formattedMaxLength} caracteres.`, + }, + baseUpdateAppModal: { + updateApp: 'Actualizar app', + updatePrompt: 'Existe una nueva versión de esta aplicación.\nActualiza ahora or reinicia la aplicación más tarde para recibir la última versión.', + }, + deeplinkWrapper: { + launching: 'Cargando Expensify', + expired: 'Tu sesión ha expirado.', + signIn: 'Por favor, inicia sesión de nuevo.', + redirectedToDesktopApp: 'Te hemos redirigido a la aplicación de escritorio.', + youCanAlso: 'También puedes', + openLinkInBrowser: 'abrir este enlace en tu navegador', + loggedInAs: ({email}: LoggedInAsParams) => + `Has iniciado sesión como ${email}. Haz clic en "Abrir enlace" en el aviso para iniciar sesión en la aplicación de escritorio con esta cuenta.`, + doNotSeePrompt: '¿No ves el aviso?', + tryAgain: 'Inténtalo de nuevo', + or: ', o', + continueInWeb: 'continuar en la web', + }, + validateCodeModal: { + successfulSignInTitle: 'Abracadabra,\n¡sesión iniciada!', + successfulSignInDescription: 'Vuelve a la pestaña original para continuar.', + title: 'Aquí está tu código mágico', + or: ', ¡o', + description: 'Por favor, introduce el código utilizando el dispositivo\nen el que se solicitó originalmente', + signInHere: 'simplemente inicia sesión aquí', + expiredCodeTitle: 'Código mágico caducado', + expiredCodeDescription: 'Vuelve al dispositivo original y solicita un código nuevo', + successfulNewCodeRequest: 'Código solicitado. Por favor, comprueba tu dispositivo.', + tfaRequiredTitle: 'Se requiere autenticación\nde dos factores', + tfaRequiredDescription: 'Por favor, introduce el código de autenticación de dos factores\ndonde estás intentando iniciar sesión.', + requestOneHere: 'solicite uno aquí.', + }, + moneyRequestConfirmationList: { + paidBy: 'Pagado por', + whatsItFor: '¿Para qué es?', + }, + selectionList: { + nameEmailOrPhoneNumber: 'Nombre, email o número de teléfono', + findMember: 'Encuentra un miembro', + }, + emptyList: { + [CONST.IOU.TYPE.SUBMIT]: { + title: 'Presentar un gasto', + subtitleText1: 'Presente un gasto a alguien y ', + subtitleText2: `recibe ${CONST.REFERRAL_PROGRAM.REVENUE} dólares`, + subtitleText3: ' cuando se convierta en client.', + }, + [CONST.IOU.TYPE.SPLIT]: { + title: 'Dividir un gasto', + subtitleText1: 'Divide con un amigo y ', + subtitleText2: `recibe ${CONST.REFERRAL_PROGRAM.REVENUE} dólares`, + subtitleText3: ' cuando se convierta en client.', + }, + [CONST.IOU.TYPE.PAY]: { + title: 'Pagar a alguien', + subtitleText1: 'Paga a quien quieras y ', + subtitleText2: `recibe ${CONST.REFERRAL_PROGRAM.REVENUE} dólares`, + subtitleText3: ' cuando se convierta en client.', + }, + }, + videoChatButtonAndMenu: { + tooltip: 'Programar una llamada', + }, + hello: 'Hola', + phoneCountryCode: '34', + welcomeText: { + getStarted: 'Comience a continuación.', + anotherLoginPageIsOpen: 'Otra página de inicio de sesión está abierta.', + anotherLoginPageIsOpenExplanation: 'Ha abierto la página de inicio de sesión en una pestaña separada. Inicie sesión desde esa pestaña específica.', + welcome: '¡Bienvenido!', + welcomeWithoutExclamation: 'Bienvenido', + phrase2: 'El dinero habla. Y ahora que chat y pagos están en un mismo lugar, es también fácil.', + phrase3: 'Tus pagos llegan tan rápido como tus mensajes.', + enterPassword: 'Por favor, introduce tu contraseña', + welcomeNewFace: ({login}: SignUpNewFaceCodeParams) => `${login}, siempre es genial ver una cara nueva por aquí!`, + welcomeEnterMagicCode: ({login}: WelcomeEnterMagicCodeParams) => `Por favor, introduce el código mágico enviado a ${login}. Debería llegar en un par de minutos.`, + }, + login: { + hero: { + header: 'Gestiona, divide gastos y chatea con tu equipo.', + body: 'Bienvenido al futuro de Expensify, tu nuevo lugar de referencia para la colaboración financiera con amigos y compañeros de equipo por igual.', + }, + }, + thirdPartySignIn: { + alreadySignedIn: ({email}: AlreadySignedInParams) => `Ya has iniciado sesión con ${email}.`, + goBackMessage: ({provider}: GoBackMessageParams) => `No quieres iniciar sesión con ${provider}?`, + continueWithMyCurrentSession: 'Continuar con mi sesión actual', + redirectToDesktopMessage: 'Lo redirigiremos a la aplicación de escritorio una vez que termine de iniciar sesión.', + signInAgreementMessage: 'Al iniciar sesión, aceptas las', + termsOfService: 'Términos de servicio', + privacy: 'Privacidad', + }, + samlSignIn: { + welcomeSAMLEnabled: 'Continua iniciando sesión con el inicio de sesión único:', + orContinueWithMagicCode: 'También puedes iniciar sesión con un código mágico', + useSingleSignOn: 'Usar el inicio de sesión único', + useMagicCode: 'Usar código mágico', + launching: 'Cargando...', + oneMoment: 'Un momento mientras te redirigimos al portal de inicio de sesión único de tu empresa.', + }, + reportActionCompose: { + dropToUpload: 'Suelta el archivo aquí para compartirlo', + sendAttachment: 'Enviar adjunto', + addAttachment: 'Añadir archivo adjunto', + writeSomething: 'Escribe algo...', + blockedFromConcierge: 'Comunicación no permitida', + fileUploadFailed: 'Subida fallida. El archivo no es compatible.', + localTime: ({user, time}: LocalTimeParams) => `Son las ${time} para ${user}`, + edited: '(editado)', + emoji: 'Emoji', + collapse: 'Colapsar', + expand: 'Expandir', + tooltip: { + title: '¡Empecemos!', + subtitle: ' Presenta tu primer gasto', + }, + }, + reportActionContextMenu: { + copyToClipboard: 'Copiar al portapapeles', + copied: '¡Copiado!', + copyLink: 'Copiar enlace', + copyURLToClipboard: 'Copiar URL al portapapeles', + copyEmailToClipboard: 'Copiar email al portapapeles', + markAsUnread: 'Marcar como no leído', + markAsRead: 'Marcar como leído', + editAction: ({action}: EditActionParams) => `Editar ${action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? 'gastos' : 'comentario'}`, + deleteAction: ({action}: DeleteActionParams) => `Eliminar ${action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? 'gastos' : 'comentario'}`, + deleteConfirmation: ({action}: DeleteConfirmationParams) => + `¿Estás seguro de que quieres eliminar este ${action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? 'gasto' : 'comentario'}`, + onlyVisible: 'Visible sólo para', + replyInThread: 'Responder en el hilo', + joinThread: 'Unirse al hilo', + leaveThread: 'Dejar hilo', + copyOnyxData: 'Copiar datos de Onyx', + flagAsOffensive: 'Marcar como ofensivo', + menu: 'Menú', + }, + emojiReactions: { + addReactionTooltip: 'Añadir una reacción', + reactedWith: 'reaccionó con', + }, + reportActionsView: { + beginningOfArchivedRoomPartOne: 'Te perdiste la fiesta en ', + beginningOfArchivedRoomPartTwo: ', no hay nada que ver aquí.', + beginningOfChatHistoryDomainRoomPartOne: ({domainRoom}: BeginningOfChatHistoryDomainRoomPartOneParams) => `¡Colabora aquí con todos los participantes de ${domainRoom}! 🎉\nUtiliza `, + beginningOfChatHistoryDomainRoomPartTwo: ' para chatear con compañeros, compartir consejos o hacer una pregunta.', + beginningOfChatHistoryAdminRoomPartOne: ({workspaceName}: BeginningOfChatHistoryAdminRoomPartOneParams) => + `¡Este es el lugar para que los administradores de ${workspaceName} colaboren! 🎉\nUsa `, + beginningOfChatHistoryAdminRoomPartTwo: ' para chatear sobre temas como la configuración del espacio de trabajo y mas.', + beginningOfChatHistoryAnnounceRoomPartOne: ({workspaceName}: BeginningOfChatHistoryAnnounceRoomPartOneParams) => + `¡Este es el lugar para que todos los miembros de ${workspaceName} colaboren! 🎉\nUsa `, + beginningOfChatHistoryAnnounceRoomPartTwo: ({workspaceName}: BeginningOfChatHistoryAnnounceRoomPartTwo) => ` para chatear sobre cualquier cosa relacionada con ${workspaceName}.`, + beginningOfChatHistoryUserRoomPartOne: '¡Este es el lugar para colaborar! 🎉\nUsa este espacio para chatear sobre cualquier cosa relacionada con ', + beginningOfChatHistoryUserRoomPartTwo: '.', + beginningOfChatHistoryInvoiceRoom: '¡Este es el lugar para colaborar! 🎉 Utilice esta sala para ver, discutir y pagar facturas.', + beginningOfChatHistory: 'Aquí comienzan tus conversaciones con ', + beginningOfChatHistoryPolicyExpenseChatPartOne: '¡La colaboración entre ', + beginningOfChatHistoryPolicyExpenseChatPartTwo: ' y ', + beginningOfChatHistoryPolicyExpenseChatPartThree: ' empieza aquí! 🎉 Este es el lugar donde chatear y presentar o pagar gastos.', + beginningOfChatHistorySelfDM: 'Este es tu espacio personal. Úsalo para notas, tareas, borradores y recordatorios.', + beginningOfChatHistorySystemDM: '¡Bienvenido! Vamos a configurar tu cuenta.', + chatWithAccountManager: 'Chatea con tu gestor de cuenta aquí', + sayHello: '¡Saluda!', + yourSpace: 'Tu espacio', + welcomeToRoom: ({roomName}: WelcomeToRoomParams) => `¡Bienvenido a ${roomName}!`, + usePlusButton: ({additionalText}: UsePlusButtonParams) => `\n¡También puedes usar el botón + de abajo para ${additionalText}, o asignar una tarea!`, + iouTypes: { + pay: 'pagar gastos', + split: 'dividir un gasto', + submit: 'presentar un gasto', + track: 'rastrear un gasto', + invoice: 'facturar un gasto', + }, + }, + adminOnlyCanPost: 'Solo los administradores pueden enviar mensajes en esta sala.', + reportAction: { + asCopilot: 'como copiloto de', + }, + mentionSuggestions: { + hereAlternateText: 'Notificar a todos en esta conversación', + }, + newMessages: 'Mensajes nuevos', + youHaveBeenBanned: 'Nota: Se te ha prohibido comunicarte en este canal', + reportTypingIndicator: { + isTyping: 'está escribiendo...', + areTyping: 'están escribiendo...', + multipleUsers: 'Varios miembros', + }, + reportArchiveReasons: { + [CONST.REPORT.ARCHIVE_REASON.DEFAULT]: 'Esta sala de chat ha sido eliminada.', + [CONST.REPORT.ARCHIVE_REASON.ACCOUNT_CLOSED]: ({displayName}: ReportArchiveReasonsClosedParams) => `Este chat está desactivado porque ${displayName} ha cerrado tu cuenta.`, + [CONST.REPORT.ARCHIVE_REASON.ACCOUNT_MERGED]: ({displayName, oldDisplayName}: ReportArchiveReasonsMergedParams) => + `Este chat está desactivado porque ${oldDisplayName} ha combinado tu cuenta con ${displayName}`, + [CONST.REPORT.ARCHIVE_REASON.REMOVED_FROM_POLICY]: ({displayName, policyName, shouldUseYou = false}: ReportArchiveReasonsRemovedFromPolicyParams) => + shouldUseYou + ? `Este chat ya no está activo porque tu ya no eres miembro del espacio de trabajo ${policyName}.` + : `Este chat está desactivado porque ${displayName} ha dejado de ser miembro del espacio de trabajo ${policyName}.`, + [CONST.REPORT.ARCHIVE_REASON.POLICY_DELETED]: ({policyName}: ReportArchiveReasonsPolicyDeletedParams) => + `Este chat está desactivado porque el espacio de trabajo ${policyName} se ha eliminado.`, + [CONST.REPORT.ARCHIVE_REASON.INVOICE_RECEIVER_POLICY_DELETED]: ({policyName}: ReportArchiveReasonsInvoiceReceiverPolicyDeletedParams) => + `Este chat está desactivado porque el espacio de trabajo ${policyName} se ha eliminado.`, + [CONST.REPORT.ARCHIVE_REASON.BOOKING_END_DATE_HAS_PASSED]: 'Esta reserva está archivada.', + }, + writeCapabilityPage: { + label: 'Quién puede postear', + writeCapability: { + all: 'Todos los miembros', + admins: 'Solo administradores', + }, + }, + sidebarScreen: { + buttonFind: 'Encuentre algo...', + buttonMySettings: 'Mi configuración', + fabNewChat: 'Iniciar chat', + fabNewChatExplained: 'Iniciar chat (Acción flotante)', + chatPinned: 'Chat fijado', + draftedMessage: 'Mensaje borrador', + listOfChatMessages: 'Lista de mensajes del chat', + listOfChats: 'lista de chats', + saveTheWorld: 'Salvar el mundo', + tooltip: '¡Comienza aquí!', + }, + allSettingsScreen: { + subscription: 'Suscripcion', + domains: 'Dominios', + }, + tabSelector: { + chat: 'Chat', + room: 'Sala', + distance: 'Distancia', + manual: 'Manual', + scan: 'Escanear', + }, + spreadsheet: { + upload: 'Importar', + dragAndDrop: 'Arrastra y suelta un archivo de hoja de cálculo aquí', + chooseSpreadsheet: 'Subir', + fileContainsHeader: 'El archivo contiene encabezados', + column: (name: string) => `Columna ${name}`, + fieldNotMapped: (fieldName: string) => `¡Vaya! Un campo obligatorio ("${fieldName}") no ha sido mapeado. Por favor, revisa e inténtalo de nuevo.`, + singleFieldMultipleColumns: (fieldName: string) => `¡Vaya! Has mapeado un solo campo ("${fieldName}") a varias columnas. Por favor, revisa e inténtalo de nuevo.`, + importFailedTitle: 'Fallo en la importación', + importFailedDescription: 'Por favor, asegúrate de que todos los campos estén llenos correctamente e inténtalo de nuevo. Si el problema persiste, por favor contacta a Concierge.', + importCategoriesSuccessfullDescription: (categories: number) => (categories > 1 ? `Se han agregado ${categories} categorías.` : 'Se ha agregado 1 categoría.'), + importTagsSuccessfullDescription: (tags: number) => (tags > 1 ? `Se han agregado ${tags} etiquetas.` : 'Se ha agregado 1 etiqueta.'), + importSuccessfullTitle: 'Importar categorías', + importDescription: 'Elige qué campos mapear desde tu hoja de cálculo haciendo clic en el menú desplegable junto a cada columna importada a continuación.', + sizeNotMet: 'El archivo adjunto debe ser más grande que 0 bytes.', + invalidFileMessage: + 'El archivo que subiste está vacío o contiene datos no válidos. Asegúrate de que el archivo esté correctamente formateado y contenga la información necesaria antes de volver a subirlo.', + importSpreadsheet: 'Importar hoja de cálculo', + downloadCSV: 'Descargar CSV', + }, + receipt: { + upload: 'Subir recibo', + dragReceiptBeforeEmail: 'Arrastra un recibo a esta página, reenvíalo a ', + dragReceiptAfterEmail: ' o elije un archivo para subir a continuación.', + chooseReceipt: 'Elige un recibo para subir o reenvía un recibo a ', + takePhoto: 'Haz una foto', + cameraAccess: 'Se requiere acceso a la cámara para hacer fotos de los recibos.', + cameraErrorTitle: 'Error en la cámara', + locationAccessTitle: 'Permitir acceso a la ubicación', + locationAccessMessage: 'El acceso a la ubicación nos ayuda a mantener tu zona horaria y moneda precisas dondequiera que vayas.', + locationErrorTitle: 'Permitir acceso a la ubicación', + locationErrorMessage: 'El acceso a la ubicación nos ayuda a mantener tu zona horaria y moneda precisas dondequiera que vayas.', + cameraErrorMessage: 'Se ha producido un error al hacer una foto. Por favor, inténtalo de nuevo.', + dropTitle: 'Suéltalo', + dropMessage: 'Suelta tu archivo aquí', + flash: 'flash', + shutter: 'obturador', + gallery: 'galería', + deleteReceipt: 'Eliminar recibo', + deleteConfirmation: '¿Estás seguro de que quieres borrar este recibo?', + addReceipt: 'Añadir recibo', + }, + quickAction: { + scanReceipt: 'Escanear recibo', + recordDistance: 'Grabar distancia', + requestMoney: 'Presentar gasto', + splitBill: 'Dividir gasto', + splitScan: 'Dividir recibo', + splitDistance: 'Dividir distancia', + paySomeone: (name: string) => `Pagar a ${name ?? 'alguien'}`, + assignTask: 'Assignar tarea', + header: 'Acción rápida', + trackManual: 'Crear gasto', + trackScan: 'Crear gasto por recibo', + trackDistance: 'Crear gasto por desplazamiento', + noLongerHaveReportAccess: 'Ya no tienes acceso al destino previo de esta acción rápida. Escoge uno nuevo a continuación.', + updateDestination: 'Actualiza el destino', + tooltip: { + title: '¡Acción rápida! ', + subtitle: 'A un click.', + }, + }, + iou: { + amount: 'Importe', + taxAmount: 'Importe del impuesto', + taxRate: 'Tasa de impuesto', + approve: 'Aprobar', + approved: 'Aprobado', + cash: 'Efectivo', + card: 'Tarjeta', + original: 'Original', + split: 'Dividir', + splitExpense: 'Dividir gasto', + expense: 'Gasto', + categorize: 'Categorizar', + share: 'Compartir', + participants: 'Participantes', + submitExpense: 'Presentar gasto', + paySomeone: ({name}: PaySomeoneParams) => `Pagar a ${name ?? 'alguien'}`, + trackExpense: 'Seguimiento de gastos', + pay: 'Pagar', + cancelPayment: 'Cancelar el pago', + cancelPaymentConfirmation: '¿Estás seguro de que quieres cancelar este pago?', + viewDetails: 'Ver detalles', + pending: 'Pendiente', + canceled: 'Canceló', + posted: 'Contabilizado', + deleteReceipt: 'Eliminar recibo', + pendingMatchWithCreditCard: 'Recibo pendiente de adjuntar con la transacción de la tarjeta', + pendingMatchWithCreditCardDescription: 'Recibo pendiente de adjuntar con la transacción de la tarjeta. Márcalo como efectivo para cancelar.', + markAsCash: 'Marcar como efectivo', + routePending: 'Ruta pendiente...', + receiptIssuesFound: (count: number) => `${count === 1 ? 'Problema encontrado' : 'Problemas encontrados'}`, + fieldPending: 'Pendiente...', + receiptScanning: 'Escaneando recibo...', + receiptScanInProgress: 'Escaneado de recibo en proceso', + receiptScanInProgressDescription: 'Escaneado de recibo en proceso. Vuelve a comprobarlo más tarde o introduce los detalles ahora.', + defaultRate: 'Tasa predeterminada', + receiptMissingDetails: 'Recibo con campos vacíos', + missingAmount: 'Falta importe', + missingMerchant: 'Falta comerciante', + receiptStatusTitle: 'Escaneando…', + receiptStatusText: 'Solo tú puedes ver este recibo cuando se está escaneando. Vuelve más tarde o introduce los detalles ahora.', + receiptScanningFailed: 'El escaneo de recibo ha fallado. Introduce los detalles manualmente.', + transactionPendingDescription: 'Transacción pendiente. Puede tardar unos días en contabilizarse.', + companyInfo: 'Información de la empresa', + companyInfoDescription: 'Necesitamos algunos detalles más antes de que pueda enviar su primera factura.', + yourCompanyName: 'Nombre de su empresa', + yourCompanyWebsite: 'Sitio web de su empresa', + yourCompanyWebsiteNote: 'Si no tiene un sitio web, puede proporcionar el perfil de LinkedIn o de las redes sociales de su empresa.', + invalidDomainError: 'Ha introducido un dominio no válido. Para continuar, introduzca un dominio válido.', + publicDomainError: 'Ha introducido un dominio público. Para continuar, introduzca un dominio privado.', + expenseCount: ({count, scanningReceipts = 0, pendingReceipts = 0}: RequestCountParams) => { + const expenseText = `${count} ${Str.pluralize('gasto', 'gastos', count)}`; + const statusText = []; + if (scanningReceipts > 0) { + statusText.push(`${scanningReceipts} escaneando`); + } + if (pendingReceipts > 0) { + statusText.push(`${pendingReceipts} pendiente`); + } + return statusText.length > 0 ? `${expenseText} (${statusText.join(', ')})` : expenseText; + }, + deleteExpense: ({count}: DeleteExpenseTranslationParams = {count: 1}) => `Eliminar ${Str.pluralize('gasto', 'gastos', count)}`, + deleteConfirmation: ({count}: DeleteExpenseTranslationParams = {count: 1}) => `¿Estás seguro de que quieres eliminar ${Str.pluralize('esta solicitud', 'estas solicitudes', count)}?`, + settledExpensify: 'Pagado', + settledElsewhere: 'Pagado de otra forma', + individual: 'Individual', + business: 'Empresa', + settleExpensify: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pagar ${formattedAmount} con Expensify` : `Pagar con Expensify`), + settlePersonal: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pago ${formattedAmount} como individuo` : `Pago individual`), + settlePayment: ({formattedAmount}: SettleExpensifyCardParams) => `Pagar ${formattedAmount}`, + settleBusiness: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pagar ${formattedAmount} como negocio` : `Pagar como empresa`), + payElsewhere: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pagar ${formattedAmount} de otra forma` : `Pagar de otra forma`), + nextStep: 'Pasos siguientes', + finished: 'Finalizado', + sendInvoice: ({amount}: RequestAmountParams) => `Enviar factura de ${amount}`, + submitAmount: ({amount}: RequestAmountParams) => `solicitar ${amount}`, + submittedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `solicitó ${formattedAmount}${comment ? ` para ${comment}` : ''}`, + trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `realizó un seguimiento de ${formattedAmount}${comment ? ` para ${comment}` : ''}`, + splitAmount: ({amount}: SplitAmountParams) => `dividir ${amount}`, + didSplitAmount: ({formattedAmount, comment}: DidSplitAmountMessageParams) => `dividió ${formattedAmount}${comment ? ` para ${comment}` : ''}`, + yourSplit: ({amount}: UserSplitParams) => `Tu parte ${amount}`, + payerOwesAmount: ({payer, amount, comment}: PayerOwesAmountParams) => `${payer} debe ${amount}${comment ? ` para ${comment}` : ''}`, + payerOwes: ({payer}: PayerOwesParams) => `${payer} debe: `, + payerPaidAmount: ({payer, amount}: PayerPaidAmountParams) => `${payer ? `${payer} ` : ''}pagó ${amount}`, + payerPaid: ({payer}: PayerPaidParams) => `${payer} pagó: `, + payerSpentAmount: ({payer, amount}: PayerPaidAmountParams): string => `${payer} gastó ${amount}`, + payerSpent: ({payer}: PayerPaidParams) => `${payer} gastó: `, + managerApproved: ({manager}: ManagerApprovedParams) => `${manager} aprobó:`, + managerApprovedAmount: ({manager, amount}: ManagerApprovedAmountParams) => `${manager} aprobó ${amount}`, + payerSettled: ({amount}: PayerSettledParams) => `pagó ${amount}`, + payerSettledWithMissingBankAccount: ({amount}: PayerSettledParams) => `pagó ${amount}. Agrega una cuenta bancaria para recibir tu pago.`, + approvedAmount: ({amount}: ApprovedAmountParams) => `aprobó ${amount}`, + forwardedAmount: ({amount}: ForwardedAmountParams) => `aprobó ${amount}`, + rejectedThisReport: 'rechazó este informe', + waitingOnBankAccount: ({submitterDisplayName}: WaitingOnBankAccountParams) => `inició el pago, pero no se procesará hasta que ${submitterDisplayName} añada una cuenta bancaria`, + adminCanceledRequest: ({manager, amount}: AdminCanceledRequestParams) => `${manager ? `${manager}: ` : ''}canceló el pago de ${amount}.`, + canceledRequest: ({amount, submitterDisplayName}: CanceledRequestParams) => + `canceló el pago ${amount}, porque ${submitterDisplayName} no habilitó tu Billetera Expensify en un plazo de 30 días.`, + settledAfterAddedBankAccount: ({submitterDisplayName, amount}: SettledAfterAddedBankAccountParams) => + `${submitterDisplayName} añadió una cuenta bancaria. El pago de ${amount} se ha realizado.`, + paidElsewhereWithAmount: ({payer, amount}: PaidElsewhereWithAmountParams) => `${payer ? `${payer} ` : ''}pagó ${amount} de otra forma`, + paidWithExpensifyWithAmount: ({payer, amount}: PaidWithExpensifyWithAmountParams) => `${payer ? `${payer} ` : ''}pagó ${amount} con Expensify`, + noReimbursableExpenses: 'El importe de este informe no es válido', + pendingConversionMessage: 'El total se actualizará cuando estés online', + changedTheExpense: 'cambió el gasto', + setTheRequest: ({valueName, newValueToDisplay}: SetTheRequestParams) => `${valueName === 'comerciante' ? 'el' : 'la'} ${valueName} a ${newValueToDisplay}`, + setTheDistanceMerchant: ({translatedChangedField, newMerchant, newAmountToDisplay}: SetTheDistanceMerchantParams) => + `estableció la ${translatedChangedField} a ${newMerchant}, lo que estableció el importe a ${newAmountToDisplay}`, + removedTheRequest: ({valueName, oldValueToDisplay}: RemovedTheRequestParams) => `${valueName === 'comerciante' ? 'el' : 'la'} ${valueName} (previamente ${oldValueToDisplay})`, + updatedTheRequest: ({valueName, newValueToDisplay, oldValueToDisplay}: UpdatedTheRequestParams) => + `${valueName === 'comerciante' || valueName === 'importe' || valueName === 'gasto' ? 'el' : 'la'} ${valueName} a ${newValueToDisplay} (previamente ${oldValueToDisplay})`, + updatedTheDistanceMerchant: ({translatedChangedField, newMerchant, oldMerchant, newAmountToDisplay, oldAmountToDisplay}: UpdatedTheDistanceMerchantParams) => + `cambió la ${translatedChangedField} a ${newMerchant} (previamente ${oldMerchant}), lo que cambió el importe a ${newAmountToDisplay} (previamente ${oldAmountToDisplay})`, + threadExpenseReportName: ({formattedAmount, comment}: ThreadRequestReportNameParams) => `${comment ? `${formattedAmount} para ${comment}` : `Gasto de ${formattedAmount}`}`, + threadTrackReportName: ({formattedAmount, comment}: ThreadRequestReportNameParams) => `Seguimiento ${formattedAmount} ${comment ? `para ${comment}` : ''}`, + threadPaySomeoneReportName: ({formattedAmount, comment}: ThreadSentMoneyReportNameParams) => `${formattedAmount} enviado${comment ? ` para ${comment}` : ''}`, + tagSelection: 'Selecciona una etiqueta para organizar mejor tus gastos.', + categorySelection: 'Selecciona una categoría para organizar mejor tus gastos.', + error: { + invalidCategoryLength: 'La longitud de la categoría escogida excede el máximo permitido (255). Por favor, escoge otra categoría o acorta la categoría primero.', + invalidAmount: 'Por favor, ingresa un importe válido antes de continuar.', + invalidTaxAmount: ({amount}: RequestAmountParams) => `El importe máximo del impuesto es ${amount}`, + invalidSplit: 'La suma de las partes debe ser igual al importe total.', + invalidSplitParticipants: 'Introduce un importe superior a cero para al menos dos participantes.', + invalidSplitYourself: 'Por favor, introduce una cantidad diferente de cero para tu parte.', + other: 'Error inesperado. Por favor, inténtalo más tarde.', + genericHoldExpenseFailureMessage: 'Error inesperado al bloquear el gasto. Por favor, inténtalo de nuevo más tarde.', + genericUnholdExpenseFailureMessage: 'Error inesperado al desbloquear el gasto. Por favor, inténtalo de nuevo más tarde.', + genericCreateFailureMessage: 'Error inesperado al enviar este gasto. Por favor, inténtalo más tarde.', + genericCreateInvoiceFailureMessage: 'Error inesperado al enviar la factura. Por favor, inténtalo de nuevo más tarde.', + receiptDeleteFailureError: 'Error inesperado al borrar este recibo. Por favor, vuelve a intentarlo más tarde.', + receiptFailureMessage: 'El recibo no se subió.', + // eslint-disable-next-line rulesdir/use-periods-for-error-messages + saveFileMessage: 'Guarda el archivo ', + loseFileMessage: 'o descarta este error y piérdelo.', + genericDeleteFailureMessage: 'Error inesperado al eliminar este gasto. Por favor, inténtalo más tarde.', + genericEditFailureMessage: 'Error inesperado al editar este gasto. Por favor, inténtalo más tarde.', + genericSmartscanFailureMessage: 'La transacción tiene campos vacíos.', + duplicateWaypointsErrorMessage: 'Por favor, elimina los puntos de ruta duplicados.', + atLeastTwoDifferentWaypoints: 'Por favor, introduce al menos dos direcciones diferentes.', + splitExpenseMultipleParticipantsErrorMessage: 'Solo puedes dividir un gasto entre un único espacio de trabajo o con miembros individuales. Por favor, actualiza tu selección.', + invalidMerchant: 'Por favor, introduce un comerciante correcto.', + }, + waitingOnEnabledWallet: ({submitterDisplayName}: WaitingOnBankAccountParams) => `inició el pago, pero no se procesará hasta que ${submitterDisplayName} active su billetera`, + enableWallet: 'Habilitar billetera', + holdExpense: 'Bloquear gasto', + unholdExpense: 'Desbloquear gasto', + heldExpense: 'bloqueó este gasto', + unheldExpense: 'desbloqueó este gasto', + explainHold: 'Explica la razón para bloquear esta solicitud.', + reason: 'Razón', + holdReasonRequired: 'Se requiere una razón para bloquear.', + expenseOnHold: 'Este gasto está bloqueado. Revisa los comentarios para saber como proceder.', + expensesOnHold: 'Todos los gastos quedaron bloqueados. Revisa los comentarios para saber como proceder.', + expenseDuplicate: 'Esta solicitud tiene los mismos detalles que otra. Revisa los duplicados para eliminar el bloqueo.', + someDuplicatesArePaid: 'Algunos de estos duplicados ya han sido aprobados o pagados.', + reviewDuplicates: 'Revisar duplicados', + keepAll: 'Mantener todos', + confirmApprove: 'Confirmar importe a aprobar', + confirmApprovalAmount: 'Aprueba sólo los gastos conformes, o aprueba todo el informe.', + confirmApprovalAllHoldAmount: ({transactionCount}: ConfirmHoldExpenseParams) => + `${Str.pluralize('Este gasto está bloqueado', 'Estos gastos están bloqueados', transactionCount)}. ¿Quieres ${Str.pluralize( + 'aprobar', + 'aprobarlos', + transactionCount, + )} de todos modos?`, + confirmPay: 'Confirmar importe de pago', + confirmPayAmount: 'Paga lo que no está bloqueado, o paga el informe completo.', + confirmPayAllHoldAmount: ({transactionCount}: ConfirmHoldExpenseParams) => + `${Str.pluralize('Este gasto está bloqueado', 'Estos gastos están bloqueados', transactionCount)}. ¿Quieres ${Str.pluralize( + 'pagar', + 'pagarlo', + transactionCount, + )} de todos modos?`, + payOnly: 'Solo pagar', + approveOnly: 'Solo aprobar', + hold: 'Bloquear', + unhold: 'Desbloquear', + holdEducationalTitle: 'Este gasto está', + whatIsHoldTitle: '¿Qué es Bloquear?', + whatIsHoldExplain: 'Bloquear es nuestra forma de agilizar la colaboración financiera. ¡"Rechazar" es tan duro!', + holdIsTemporaryTitle: 'Bloquear suele ser temporal', + holdIsTemporaryExplain: 'Se utiliza bloquear para aclarar confusión o aclarar un detalle importante antes del pago, no es permanente.', + deleteHoldTitle: 'Eliminar lo que no se pagará', + deleteHoldExplain: 'En el raro caso de que algo se bloquee y no se pague, la persona que solicita el pago debe eliminarlo.', + set: 'estableció', + changed: 'cambió', + removed: 'eliminó', + transactionPending: 'Transacción pendiente.', + chooseARate: ({unit}: ReimbursementRateParams) => `Selecciona una tasa de reembolso por ${unit} del espacio de trabajo`, + unapprove: 'Desaprobar', + unapproveReport: 'Anular la aprobación del informe', + headsUp: 'Atención!', + unapproveWithIntegrationWarning: (accountingIntegration: string) => + `Este informe ya se ha exportado a ${accountingIntegration}. Los cambios realizados en este informe en Expensify pueden provocar discrepancias en los datos y problemas de conciliación de la tarjeta Expensify. ¿Está seguro de que desea anular la aprobación de este informe?`, + reimbursable: 'reembolsable', + nonReimbursable: 'no reembolsable', + bookingPending: 'Esta reserva está pendiente', + bookingPendingDescription: 'Esta reserva está pendiente porque aún no se ha pagado.', + bookingArchived: 'Esta reserva está archivada', + bookingArchivedDescription: 'Esta reserva está archivada porque la fecha del viaje ha pasado. Agregue un gasto por el monto final si es necesario.', + }, + notificationPreferencesPage: { + header: 'Preferencias de avisos', + label: 'Avisar sobre nuevos mensajes', + notificationPreferences: { + always: 'Inmediatamente', + daily: 'Cada día', + mute: 'Nunca', + hidden: 'Oculto', + }, + }, + loginField: { + numberHasNotBeenValidated: 'El número no está validado todavía. Haz click en el botón para reenviar el enlace de confirmación via SMS.', + emailHasNotBeenValidated: 'El email no está validado todavía. Haz click en el botón para reenviar el enlace de confirmación via email.', + }, + avatarWithImagePicker: { + uploadPhoto: 'Subir foto', + removePhoto: 'Eliminar foto', + editImage: 'Editar foto', + viewPhoto: 'Ver foto', + imageUploadFailed: 'Error al cargar la imagen', + deleteWorkspaceError: 'Lo sentimos, hubo un problema eliminando el avatar de tu espacio de trabajo', + sizeExceeded: ({maxUploadSizeInMB}: SizeExceededParams) => `La imagen supera el tamaño máximo de ${maxUploadSizeInMB}MB.`, + resolutionConstraints: ({minHeightInPx, minWidthInPx, maxHeightInPx, maxWidthInPx}: ResolutionConstraintsParams) => + `Por favor, elige una imagen más grande que ${minHeightInPx}x${minWidthInPx} píxeles y más pequeña que ${maxHeightInPx}x${maxWidthInPx} píxeles.`, + notAllowedExtension: ({allowedExtensions}: NotAllowedExtensionParams) => `La foto de perfil debe ser de uno de los siguientes tipos: ${allowedExtensions.join(', ')}.`, + }, + profilePage: { + profile: 'Perfil', + preferredPronouns: 'Pronombres preferidos', + selectYourPronouns: 'Selecciona tus pronombres', + selfSelectYourPronoun: 'Auto-selecciona tu pronombre', + emailAddress: 'Dirección de email', + setMyTimezoneAutomatically: 'Configura mi zona horaria automáticamente', + timezone: 'Zona horaria', + invalidFileMessage: 'Archivo inválido. Pruebe con una imagen diferente.', + avatarUploadFailureMessage: 'No se pudo subir el avatar. Por favor, inténtalo de nuevo.', + online: 'En línea', + offline: 'Desconectado', + syncing: 'Sincronizando', + profileAvatar: 'Perfil avatar', + publicSection: { + title: 'Público', + subtitle: 'Estos detalles se muestran en tu perfil público, a disposición de los demás.', + }, + privateSection: { + title: 'Privado', + subtitle: 'Estos detalles se utilizan para viajes y pagos. Nunca se mostrarán en tu perfil público.', + }, + }, + securityPage: { + title: 'Opciones de seguridad', + subtitle: 'Activa la autenticación de dos factores para mantener tu cuenta segura.', + }, + shareCodePage: { + title: 'Tu código', + subtitle: 'Invita a miembros a Expensify compartiendo tu código QR personal o enlace de invitación.', + }, + pronounsPage: { + pronouns: 'Pronombres', + isShownOnProfile: 'Tus pronombres se muestran en tu perfil.', + placeholderText: 'Buscar para ver opciones', + }, + contacts: { + contactMethod: 'Método de contacto', + contactMethods: 'Métodos de contacto', + helpTextBeforeEmail: 'Añade más formas de que la gente te encuentre y reenvía los recibos a ', + helpTextAfterEmail: ' desde varias direcciones de correo electrónico.', + pleaseVerify: 'Por favor, verifica este método de contacto', + getInTouch: 'Utilizaremos este método de contacto cuando necesitemos contactarte.', + enterMagicCode: ({contactMethod}: EnterMagicCodeParams) => `Por favor, introduce el código mágico enviado a ${contactMethod}`, + setAsDefault: 'Establecer como predeterminado', + yourDefaultContactMethod: + 'Este es tu método de contacto predeterminado. Antes de poder eliminarlo, tendrás que elegir otro método de contacto y haz clic en "Establecer como predeterminado".', + removeContactMethod: 'Eliminar método de contacto', + removeAreYouSure: '¿Estás seguro de que quieres eliminar este método de contacto? Esta acción no se puede deshacer.', + failedNewContact: 'Se ha producido un error al añadir este método de contacto.', + genericFailureMessages: { + requestContactMethodValidateCode: 'No se ha podido enviar un nuevo código mágico. Espera un rato y vuelve a intentarlo.', + validateSecondaryLogin: 'Código mágico incorrecto o no válido. Inténtalo de nuevo o solicita otro código.', + deleteContactMethod: 'No se ha podido eliminar este método de contacto. Por favor, contacta con Concierge para obtener ayuda.', + setDefaultContactMethod: 'No se pudo establecer un nuevo método de contacto predeterminado. Por favor contacta con Concierge para obtener ayuda.', + addContactMethod: 'Se ha producido un error al añadir este método de contacto. Por favor, contacta con Concierge para obtener ayuda.', + enteredMethodIsAlreadySubmited: 'El método de contacto ingresado ya existe.', + passwordRequired: 'Se requiere contraseña', + contactMethodRequired: 'Se requiere método de contacto.', + invalidContactMethod: 'Método de contacto no válido', + }, + newContactMethod: 'Nuevo método de contacto', + goBackContactMethods: 'Volver a métodos de contacto', + }, + pronouns: { + coCos: 'Co / Cos', + eEyEmEir: 'E / Ey / Em / Eir', + faeFaer: 'Fae / Faer', + heHimHis: 'Él', + heHimHisTheyThemTheirs: 'Él / Ellos', + sheHerHers: 'Ella', + sheHerHersTheyThemTheirs: 'Ella / Ellos', + merMers: 'Mer / Mers', + neNirNirs: 'Ne / Nir / Nirs', + neeNerNers: 'Nee / Ner / Ners', + perPers: 'Per / Pers', + theyThemTheirs: 'Ellos', + thonThons: 'Thon / Thons', + veVerVis: 'Ve / Ver / Vis', + viVir: 'Vi / Vir', + xeXemXyr: 'Xe / Xem / Xyr', + zeZieZirHir: 'Ze / Zie / Zir / Hir', + zeHirHirs: 'Ze / Hir', + callMeByMyName: 'Llámame por mi nombre', + }, + displayNamePage: { + headerTitle: 'Nombre', + isShownOnProfile: 'Este nombre es visible en tu perfil.', + }, + timezonePage: { + timezone: 'Zona horaria', + isShownOnProfile: 'Tu zona horaria se muestra en tu perfil.', + getLocationAutomatically: 'Detecta tu ubicación automáticamente', + }, + updateRequiredView: { + updateRequired: 'Actualización requerida', + pleaseInstall: 'Por favor, actualiza a la última versión de Nuevo Expensify', + toGetLatestChanges: 'Para móvil o escritorio, descarga e instala la última versión. Para la web, actualiza tu navegador.', + }, + initialSettingsPage: { + about: 'Acerca de', + aboutPage: { + description: 'La Nueva Expensify está creada por una comunidad de desarrolladores de código abierto de todo el mundo. Ayúdanos a construir el futuro de Expensify.', + appDownloadLinks: 'Enlaces para descargar la App', + viewKeyboardShortcuts: 'Ver atajos de teclado', + viewTheCode: 'Ver código', + viewOpenJobs: 'Ver trabajos disponibles', + reportABug: 'Reportar un error', + troubleshoot: 'Solución de problemas', + }, + appDownloadLinks: { + android: { + label: 'Android', + }, + ios: { + label: 'iOS', + }, + desktop: { + label: 'macOS', + }, + }, + troubleshoot: { + clearCacheAndRestart: 'Borrar caché y reiniciar', + viewConsole: 'Ver la consola de depuración', + debugConsole: 'Consola de depuración', + description: 'Utilice las herramientas que aparecen a continuación para solucionar los problemas de Expensify. Si tiene algún problema, por favor', + submitBug: 'envíe un informe de error', + confirmResetDescription: 'Todos los borradores no enviados se perderán, pero el resto de tus datos estarán a salvo.', + resetAndRefresh: 'Restablecer y actualizar', + clientSideLogging: 'Registro a nivel cliente', + noLogsToShare: 'No hay logs que compartir', + useProfiling: 'Usar el trazado', + profileTrace: 'Traza de ejecución', + releaseOptions: 'Opciones de publicación', + testingPreferences: 'Preferencias para Tests', + useStagingServer: 'Usar servidor “staging”', + forceOffline: 'Forzar desconexión', + simulatFailingNetworkRequests: 'Simular fallos en solicitudes de red', + authenticationStatus: 'Estado de autenticación', + deviceCredentials: 'Credenciales del dispositivo', + invalidate: 'Invalidar', + destroy: 'Destruir', + maskExportOnyxStateData: 'Enmascare los datos frágiles del usuario mientras exporta el estado Onyx', + exportOnyxState: 'Exportar estado Onyx', + testCrash: 'Prueba de fallo', + debugMode: 'Modo depuración', + }, + debugConsole: { + saveLog: 'Guardar registro', + shareLog: 'Compartir registro', + enterCommand: 'Introducir comando', + execute: 'Ejecutar', + noLogsAvailable: 'No hay registros disponibles', + logSizeTooLarge: ({size}: LogSizeParams) => `El tamaño del registro excede el límite de ${size} MB. Utilice "Guardar registro" para descargar el archivo de registro.`, + logs: 'Logs', + viewConsole: 'Ver consola', + }, + security: 'Seguridad', + restoreStashed: 'Restablecer login guardado', + signOut: 'Desconectar', + signOutConfirmationText: 'Si cierras sesión perderás los cambios hechos mientras estabas desconectado', + versionLetter: 'v', + readTheTermsAndPrivacy: { + phrase1: 'Leer los', + phrase2: 'Términos de Servicio', + phrase3: 'y', + phrase4: 'Privacidad', + }, + returnToClassic: 'Volver a Expensify Clásico', + help: 'Ayuda', + accountSettings: 'Configuración de la cuenta', + account: 'Cuenta', + general: 'General', + }, + closeAccountPage: { + closeAccount: 'Cerrar cuenta', + reasonForLeavingPrompt: '¡Lamentamos verte partir! ¿Serías tan amable de decirnos por qué, para que podamos mejorar?', + enterMessageHere: 'Escribe aquí tu mensaje', + closeAccountWarning: 'Una vez cerrada tu cuenta no se puede revertir.', + closeAccountPermanentlyDeleteData: '¿Estás seguro de que quieres eliminar tu cuenta? Esta acción eliminará permanentemente toda la información de cualquier gasto pendiente.', + enterDefaultContactToConfirm: 'Por favor, escribe tu método de contacto predeterminado para confirmar que deseas eliminar tu cuenta. Tu método de contacto predeterminado es:', + enterDefaultContact: 'Tu método de contacto predeterminado', + defaultContact: 'Método de contacto predeterminado:', + enterYourDefaultContactMethod: 'Por favor, introduce tu método de contacto predeterminado para cerrar tu cuenta.', + }, + passwordPage: { + changePassword: 'Cambiar contraseña', + changingYourPasswordPrompt: 'El cambio de contraseña va a afectar tanto a la cuenta de Expensify.com como la de Nuevo Expensify.', + currentPassword: 'Contraseña actual', + newPassword: 'Nueva contraseña', + newPasswordPrompt: 'La nueva contraseña debe ser diferente de la antigua y contener al menos 8 caracteres, 1 letra mayúscula, 1 letra minúscula y 1 número.', + }, + twoFactorAuth: { + headerTitle: 'Autenticación de dos factores', + twoFactorAuthEnabled: 'Autenticación de dos factores habilitada', + whatIsTwoFactorAuth: + 'La autenticación de dos factores (2FA) ayuda a mantener tu cuenta segura. Al iniciar sesión, deberás ingresar un código generado por tu aplicación de autenticación preferida.', + disableTwoFactorAuth: 'Deshabilitar la autenticación de dos factores', + explainProcessToRemove: 'Para deshabilitar la autenticación de dos factores (2FA), por favor introduce un código válido de tu aplicación de autenticación.', + disabled: 'La autenticación de dos factores está ahora deshabilitada', + noAuthenticatorApp: 'Ya no necesitarás una aplicación de autenticación para iniciar sesión en Expensify.', + stepCodes: 'Códigos de recuperación', + keepCodesSafe: '¡Guarda los códigos de recuperación en un lugar seguro!', + codesLoseAccess: + 'Si pierdes el acceso a tu aplicación de autenticación y no tienes estos códigos, perderás el acceso a tu cuenta. \n\nNota: Configurar la autenticación de dos factores cerrará la sesión de todas las demás sesiones activas.', + errorStepCodes: 'Copia o descarga los códigos antes de continuar.', + stepVerify: 'Verificar', + scanCode: 'Escanea el código QR usando tu', + authenticatorApp: 'aplicación de autenticación', + addKey: 'O añade esta clave secreta a tu aplicación de autenticación:', + enterCode: 'Luego introduce el código de seis dígitos generado por tu aplicación de autenticación.', + stepSuccess: 'Finalizado', + enabled: '¡La autenticación de dos factores está ahora habilitada!', + congrats: 'Felicidades, ahora tienes esa seguridad adicional.', + copy: 'Copiar', + disable: 'Deshabilitar', + enableTwoFactorAuth: 'Activar la autenticación de dos factores', + pleaseEnableTwoFactorAuth: 'Activa la autenticación de dos factores.', + twoFactorAuthIsRequiredDescription: 'La autenticación de dos factores es necesaria para conectarse a Xero. Activa la autenticación de dos factores para continuar.', + twoFactorAuthIsRequiredForAdminsDescription: + 'La autenticación de dos factores es necesaria para los administradores del área de trabajo de Xero. Activa la autenticación de dos factores para continuar.', + }, + recoveryCodeForm: { + error: { + pleaseFillRecoveryCode: 'Por favor, introduce tu código de recuperación.', + incorrectRecoveryCode: 'Código de recuperación incorrecto. Por favor, inténtalo de nuevo.', + }, + useRecoveryCode: 'Usar código de recuperación', + recoveryCode: 'Código de recuperación', + use2fa: 'Usar autenticación de dos factores', + }, + twoFactorAuthForm: { + error: { + pleaseFillTwoFactorAuth: 'Por favor, introduce tu código de autenticación de dos factores.', + incorrect2fa: 'Código de autenticación de dos factores incorrecto. Por favor, inténtalo de nuevo.', + }, + }, + passwordConfirmationScreen: { + passwordUpdated: '¡Contraseña actualizada!', + allSet: 'Todo está listo. Guarda tu contraseña en un lugar seguro.', + }, + privateNotes: { + title: 'Notas privadas', + personalNoteMessage: 'Guarda notas sobre este chat aquí. Usted es la única persona que puede añadir, editar o ver estas notas.', + sharedNoteMessage: 'Guarda notas sobre este chat aquí. Los empleados de Expensify y otros miembros del dominio team.expensify.com pueden ver estas notas.', + composerLabel: 'Notas', + myNote: 'Mi nota', + error: { + genericFailureMessage: 'Las notas privadas no han podido ser guardadas.', + }, + }, + billingCurrency: { + error: { + securityCode: 'Por favor, introduce un código de seguridad válido.', + }, + securityCode: 'Código de seguridad', + changePaymentCurrency: 'Cambiar moneda de facturación', + changeBillingCurrency: 'Cambiar la moneda de pago', + paymentCurrency: 'Moneda de pago', + note: 'Nota: Cambiar tu moneda de pago puede afectar cuánto pagarás por Expensify. Consulta nuestra', + noteLink: 'página de precios', + noteDetails: 'para conocer todos los detalles.', + }, + addDebitCardPage: { + addADebitCard: 'Añadir una tarjeta de débito', + nameOnCard: 'Nombre en la tarjeta', + debitCardNumber: 'Número de la tarjeta de débito', + expiration: 'Fecha de vencimiento', + expirationDate: 'MMAA', + cvv: 'CVV', + billingAddress: 'Dirección de envio', + growlMessageOnSave: 'Tu tarteja de débito se añadió correctamente', + expensifyPassword: 'Contraseña de Expensify', + error: { + invalidName: 'El nombre sólo puede incluir letras.', + addressZipCode: 'Por favor, introduce un código postal válido.', + debitCardNumber: 'Por favor, introduce un número de tarjeta de débito válido.', + expirationDate: 'Por favor, selecciona una fecha de vencimiento válida.', + securityCode: 'Por favor, introduce un código de seguridad válido.', + addressStreet: 'Por favor, introduce una dirección de facturación válida que no sea un apartado postal.', + addressState: 'Por favor, selecciona un estado.', + addressCity: 'Por favor, introduce una ciudad.', + genericFailureMessage: 'Se ha producido un error al añadir tu tarjeta. Por favor, vuelva a intentarlo.', + password: 'Por favor, introduce tu contraseña de Expensify.', + }, + }, + addPaymentCardPage: { + addAPaymentCard: 'Añade tarjeta de pago', + nameOnCard: 'Nombre en la tarjeta', + paymentCardNumber: 'Número de la tarjeta', + expiration: 'Fecha de vencimiento', + expirationDate: 'MMAA', + cvv: 'CVV', + billingAddress: 'Dirección de envio', + growlMessageOnSave: 'Tu tarjeta de pago se añadió correctamente', + expensifyPassword: 'Contraseña de Expensify', + error: { + invalidName: 'El nombre sólo puede incluir letras.', + addressZipCode: 'Por favor, introduce un código postal válido.', + paymentCardNumber: 'Por favor, introduce un número de tarjeta de pago válido.', + expirationDate: 'Por favor, selecciona una fecha de vencimiento válida.', + securityCode: 'Por favor, introduce un código de seguridad válido.', + addressStreet: 'Por favor, introduce una dirección de facturación válida que no sea un apartado postal.', + addressState: 'Por favor, selecciona un estado.', + addressCity: 'Por favor, introduce una ciudad.', + genericFailureMessage: 'Se ha producido un error al añadir tu tarjeta. Por favor, vuelva a intentarlo.', + password: 'Por favor, introduce tu contraseña de Expensify.', + }, + }, + walletPage: { + balance: 'Saldo', + paymentMethodsTitle: 'Métodos de pago', + setDefaultConfirmation: 'Marcar como método de pago predeterminado', + setDefaultSuccess: 'Método de pago configurado', + deleteAccount: 'Eliminar cuenta', + deleteConfirmation: '¿Estás seguro de que quieres eliminar esta cuenta?', + error: { + notOwnerOfBankAccount: 'Se ha producido un error al establecer esta cuenta bancaria como método de pago predeterminado.', + invalidBankAccount: 'Esta cuenta bancaria está temporalmente suspendida.', + notOwnerOfFund: 'Se ha producido un error al establecer esta tarjeta de crédito como método de pago predeterminado.', + setDefaultFailure: 'No se ha podido configurar el método de pago.', + }, + addBankAccountFailure: 'Ocurrió un error inesperado al intentar añadir la cuenta bancaria. Inténtalo de nuevo.', + getPaidFaster: 'Cobra más rápido', + addPaymentMethod: 'Añade un método de pago para enviar y recibir pagos directamente en la aplicación.', + getPaidBackFaster: 'Recibe tus pagos más rápido', + secureAccessToYourMoney: 'Acceso seguro a tu dinero', + receiveMoney: 'Recibe dinero en tu moneda local', + expensifyWallet: 'Billetera Expensify', + sendAndReceiveMoney: 'Envía y recibe dinero desde tu Billetera Expensify', + enableWalletToSendAndReceiveMoney: 'Habilita tu Billetera Expensify para comenzar a enviar y recibir dinero con amigos.', + walletEnabledToSendAndReceiveMoney: 'Tu billetera ha sido habilitada para enviar y recibir dinero con amigos.', + enableWallet: 'Habilitar billetera', + bankAccounts: 'Cuentas bancarias', + addBankAccountToSendAndReceive: 'Agregar una cuenta bancaria te permite recibir reembolsos por los gastos que envíes a un espacio de trabajo.', + addBankAccount: 'Añadir cuenta bancaria', + assignedCards: 'Tarjetas asignadas', + assignedCardsDescription: 'Son tarjetas asignadas por un administrador del espacio de trabajo para gestionar los gastos de la empresa.', + expensifyCard: 'Tarjeta Expensify', + walletActivationPending: 'Estamos revisando tu información. Por favor, vuelve en unos minutos.', + walletActivationFailed: 'Lamentablemente, no podemos activar tu billetera en este momento. Chatea con Concierge para obtener más ayuda.', + addYourBankAccount: 'Añadir tu cuenta bancaria', + addBankAccountBody: 'Conectemos tu cuenta bancaria a Expensify para que sea más fácil que nunca enviar y recibir pagos directamente en la aplicación.', + chooseYourBankAccount: 'Elige tu cuenta bancaria', + chooseAccountBody: 'Asegúrese de elegir el adecuado.', + confirmYourBankAccount: 'Confirma tu cuenta bancaria', + }, + cardPage: { + expensifyCard: 'Tarjeta Expensify', + availableSpend: 'Límite restante', + smartLimit: { + name: 'Límite inteligente', + title: (formattedLimit: string) => `Puedes gastar hasta ${formattedLimit} en esta tarjeta al mes. El límite se restablecerá el primer día del mes.`, + }, + fixedLimit: { + name: 'Límite fijo', + title: (formattedLimit: string) => `Puedes gastar hasta ${formattedLimit} en esta tarjeta, luego se desactivará.`, + }, + monthlyLimit: { + name: 'Límite mensual', + title: (formattedLimit: string) => `Puedes gastar hasta ${formattedLimit} en esta tarjeta y el límite se restablecerá a medida que se aprueben tus gastos.`, + }, + virtualCardNumber: 'Número de la tarjeta virtual', + physicalCardNumber: 'Número de la tarjeta física', + getPhysicalCard: 'Obtener tarjeta física', + reportFraud: 'Reportar fraude con la tarjeta virtual', + reviewTransaction: 'Revisar transacción', + suspiciousBannerTitle: 'Transacción sospechosa', + suspiciousBannerDescription: 'Hemos detectado una transacción sospechosa en la tarjeta. Haz click abajo para revisarla.', + cardLocked: 'La tarjeta está temporalmente bloqueada mientras nuestro equipo revisa la cuenta de tu empresa.', + cardDetails: { + cardNumber: 'Número de tarjeta virtual', + expiration: 'Expiración', + cvv: 'CVV', + address: 'Dirección', + revealDetails: 'Revelar detalles', + copyCardNumber: 'Copiar número de la tarjeta', + updateAddress: 'Actualizar dirección', + }, + cardDetailsLoadingFailure: 'Se ha producido un error al cargar los datos de la tarjeta. Comprueba tu conexión a Internet e inténtalo de nuevo.', + validateCardTitle: 'Asegurémonos de que eres tú', + enterMagicCode: ({contactMethod}: EnterMagicCodeParams) => `Introduzca el código mágico enviado a ${contactMethod} para ver los datos de su tarjeta`, + }, + workflowsPage: { + workflowTitle: 'Gasto', + workflowDescription: 'Configure un flujo de trabajo desde el momento en que se produce el gasto, incluida la aprobación y el pago', + delaySubmissionTitle: 'Retrasar envíos', + delaySubmissionDescription: 'Retrasa la presentación de gastos en base a un calendario personalizado, o mantén esta opción desactivada para seguir viendo los gastos en tiempo real.', + submissionFrequency: 'Frecuencia de envíos', + submissionFrequencyDateOfMonth: 'Fecha del mes', + addApprovalsTitle: 'Aprobaciones', + addApprovalButton: 'Añadir flujo de aprobación', + addApprovalTip: 'Este flujo de trabajo por defecto se aplica a todos los miembros, a menos que exista un flujo de trabajo más específico', + approver: 'Aprobador', + connectBankAccount: 'Conectar cuenta bancaria', + addApprovalsDescription: 'Requiere una aprobación adicional antes de autorizar un pago.', + makeOrTrackPaymentsTitle: 'Realizar o seguir pagos', + makeOrTrackPaymentsDescription: 'Añade un pagador autorizado para los pagos realizados en Expensify, o simplemente realiza un seguimiento de los pagos realizados en otro lugar.', + editor: { + submissionFrequency: 'Elige cuánto tiempo Expensify debe esperar antes de compartir los gastos sin errores.', + }, + frequencyDescription: 'Elige la frecuencia de presentación automática de gastos, o preséntalos manualmente', + frequencies: { + weekly: 'Semanal', + monthly: 'Mensual', + twiceAMonth: 'Dos veces al mes', + byTrip: 'Por viaje', + manually: 'Manualmente', + daily: 'Diaria', + lastDayOfMonth: 'Último día del mes', + lastBusinessDayOfMonth: 'Último día hábil del mes', + ordinals: { + one: '.º', + two: '.º', + few: '.º', + other: '.º', + /* eslint-disable @typescript-eslint/naming-convention */ + '1': 'Primero', + '2': 'Segundo', + '3': 'Tercero', + '4': 'Cuarto', + '5': 'Quinto', + '6': 'Sexto', + '7': 'Séptimo', + '8': 'Octavo', + '9': 'Noveno', + '10': 'Décimo', + /* eslint-enable @typescript-eslint/naming-convention */ + }, + }, + approverInMultipleWorkflows: 'Este miembro ya pertenece a otro flujo de aprobación. Cualquier actualización aquí se reflejará allí también.', + approverCircularReference: ({name1, name2}: ApprovalWorkflowErrorParams) => + `${name1} ya aprueba informes a ${name2}. Por favor, elige un aprobador diferente para evitar un flujo de trabajo circular.`, + emptyContent: { + title: 'No hay miembros para mostrar', + expensesFromSubtitle: 'Todos los miembros del espacio de trabajo ya pertenecen a un flujo de aprobación existente.', + approverSubtitle: 'Todos los aprobadores pertenecen a un flujo de trabajo existente.', + }, + }, + workflowsDelayedSubmissionPage: { + autoReportingErrorMessage: 'El parámetro de envío retrasado no pudo ser cambiado. Por favor, inténtelo de nuevo o contacte al soporte.', + autoReportingFrequencyErrorMessage: 'La frecuencia de envío no pudo ser cambiada. Por favor, inténtelo de nuevo o contacte al soporte.', + monthlyOffsetErrorMessage: 'La frecuencia mensual no pudo ser cambiada. Por favor, inténtelo de nuevo o contacte al soporte.', + }, + workflowsCreateApprovalsPage: { + title: 'Confirmar', + header: 'Agrega más aprobadores y confirma.', + additionalApprover: 'Añadir aprobador', + submitButton: 'Añadir flujo de trabajo', + }, + workflowsEditApprovalsPage: { + title: 'Edicion flujo de aprobación', + deleteTitle: 'Eliminar flujo de trabajo de aprobación', + deletePrompt: '¿Estás seguro de que quieres eliminar este flujo de trabajo de aprobación? Todos los miembros pasarán a usar el flujo de trabajo predeterminado.', + }, + workflowsExpensesFromPage: { + title: 'Gastos de', + header: 'Cuando los siguientes miembros presenten gastos:', + }, + workflowsApproverPage: { + genericErrorMessage: 'El aprobador no pudo ser cambiado. Por favor, inténtelo de nuevo o contacte al soporte.', + header: 'Enviar a este miembro para su aprobación:', + }, + workflowsPayerPage: { + title: 'Pagador autorizado', + genericErrorMessage: 'El pagador autorizado no se pudo cambiar. Por favor, inténtalo mas tarde.', + admins: 'Administradores', + payer: 'Pagador', + paymentAccount: 'Cuenta de pago', + }, + reportFraudPage: { + title: 'Reportar fraude con la tarjeta virtual', + description: + 'Si los datos de tu tarjeta virtual han sido robados o se han visto comprometidos, desactivaremos permanentemente la tarjeta actual y le proporcionaremos una tarjeta virtual y un número nuevo.', + deactivateCard: 'Desactivar tarjeta', + reportVirtualCardFraud: 'Reportar fraude con la tarjeta virtual', + }, + activateCardPage: { + activateCard: 'Activar tarjeta', + pleaseEnterLastFour: 'Introduce los cuatro últimos dígitos de la tarjeta.', + activatePhysicalCard: 'Activar tarjeta física', + error: { + thatDidntMatch: 'Los 4 últimos dígitos de tu tarjeta no coinciden. Por favor, inténtalo de nuevo.', + throttled: + 'Has introducido incorrectamente los 4 últimos dígitos de tu tarjeta Expensify demasiadas veces. Si estás seguro de que los números son correctos, ponte en contacto con Conserjería para solucionarlo. De lo contrario, inténtalo de nuevo más tarde.', + }, + }, + // TODO: add translation + getPhysicalCard: { + header: 'Obtener tarjeta física', + nameMessage: 'Introduce tu nombre y apellido como aparecerá en tu tarjeta.', + legalName: 'Nombre completo', + legalFirstName: 'Nombre legal', + legalLastName: 'Apellidos legales', + phoneMessage: 'Introduce tu número de teléfono.', + phoneNumber: 'Número de teléfono', + address: 'Dirección', + addressMessage: 'Introduce tu dirección de envío.', + streetAddress: 'Calle de dirección', + city: 'Ciudad', + state: 'Estado', + zipPostcode: 'Código postal', + country: 'País', + confirmMessage: 'Por favor confirma tus datos.', + estimatedDeliveryMessage: 'Tu tarjeta física llegará en 2-3 días laborales.', + next: 'Siguiente', + getPhysicalCard: 'Obtener tarjeta física', + shipCard: 'Enviar tarjeta', + }, + transferAmountPage: { + transfer: ({amount}: TransferParams) => `Transferir${amount ? ` ${amount}` : ''}`, + instant: 'Instante', + instantSummary: ({rate, minAmount}: InstantSummaryParams) => `Tarifa del ${rate}% (${minAmount} mínimo)`, + ach: '1-3 días laborales', + achSummary: 'Sin cargo', + whichAccount: '¿Qué cuenta?', + fee: 'Tarifa', + transferSuccess: '¡Transferencia exitosa!', + transferDetailBankAccount: 'Tu dinero debería llegar en 1-3 días laborables.', + transferDetailDebitCard: 'Tu dinero debería llegar de inmediato.', + failedTransfer: 'Tu saldo no se ha acreditado completamente. Por favor, transfiere los fondos a una cuenta bancaria.', + notHereSubTitle: 'Por favor, transfiere el saldo desde la página de billetera', + goToWallet: 'Ir a billetera', + }, + chooseTransferAccountPage: { + chooseAccount: 'Elegir cuenta', + }, + paymentMethodList: { + addPaymentMethod: 'Añadir método de pago', + addNewDebitCard: 'Añadir nueva tarjeta de débito', + addNewBankAccount: 'Añadir nueva cuenta de banco', + accountLastFour: 'Terminada en', + cardLastFour: 'Tarjeta terminada en', + addFirstPaymentMethod: 'Añade un método de pago para enviar y recibir pagos directamente desde la aplicación.', + defaultPaymentMethod: 'Predeterminado', + }, + preferencesPage: { + appSection: { + title: 'Preferencias de la aplicación', + subtitle: 'Personaliza tu cuenta de Expensify.', + }, + testSection: { + title: 'Preferencias para tests', + subtitle: 'Ajustes para ayudar a depurar y probar la aplicación en “staging”.', + }, + receiveRelevantFeatureUpdatesAndExpensifyNews: 'Recibir noticias sobre Expensify y actualizaciones del producto', + muteAllSounds: 'Silenciar todos los sonidos de Expensify', + }, + priorityModePage: { + priorityMode: 'Modo prioridad', + explainerText: + 'Elige #concentración si deseas enfocarte sólo en los chats no leídos y en los anclados, o mostrarlo todo con los chats más recientes y los anclados en la parte superior.', + priorityModes: { + default: { + label: 'Más recientes', + description: 'Mostrar todos los chats ordenados desde el más reciente', + }, + gsd: { + label: '#concentración', + description: 'Mostrar sólo los no leídos ordenados alfabéticamente', + }, + }, + }, + reportDetailsPage: { + inWorkspace: ({policyName}) => `en ${policyName}`, + }, + reportDescriptionPage: { + roomDescription: 'Descripción de la sala de chat', + roomDescriptionOptional: 'Descripción de la sala de chat (opcional)', + explainerText: 'Establece una descripción personalizada para la sala de chat.', + }, + groupConfirmPage: { + groupName: 'Nombre del grupo', + }, + groupChat: { + lastMemberTitle: '¡Atención!', + lastMemberWarning: 'Ya que eres la última persona aquí, si te vas, este chat quedará inaccesible para todos los miembros. ¿Estás seguro de que quieres salir del chat?', + defaultReportName: ({displayName}: {displayName: string}) => `Chat de grupo de ${displayName}`, + }, + languagePage: { + language: 'Idioma', + languages: { + en: { + label: 'Inglés', + }, + es: { + label: 'Español', + }, + }, + }, + themePage: { + theme: 'Tema', + themes: { + dark: { + label: 'Oscuro', + }, + light: { + label: 'Claro', + }, + system: { + label: 'Utiliza los ajustes del dispositivo', + }, + }, + chooseThemeBelowOrSync: 'Elige un tema a continuación o sincronízalo con los ajustes de tu dispositivo.', + }, + termsOfUse: { + phrase1: 'Al iniciar sesión, estás accediendo a los', + phrase2: 'Términos de Servicio', + phrase3: 'y', + phrase4: 'Privacidad', + phrase5: `El envío de dinero es brindado por ${CONST.WALLET.PROGRAM_ISSUERS.EXPENSIFY_PAYMENTS} (NMLS ID:2017010) de conformidad con sus`, + phrase6: 'licencias', + }, + validateCodeForm: { + magicCodeNotReceived: '¿No recibiste un código mágico?', + enterAuthenticatorCode: 'Por favor, introduce el código de autenticador', + enterRecoveryCode: 'Por favor, introduce tu código de recuperación', + requiredWhen2FAEnabled: 'Obligatorio cuando A2F está habilitado', + requestNewCode: 'Pedir un código nuevo en ', + requestNewCodeAfterErrorOccurred: 'Solicitar un nuevo código', + error: { + pleaseFillMagicCode: 'Por favor, introduce el código mágico.', + incorrectMagicCode: 'Código mágico incorrecto.', + pleaseFillTwoFactorAuth: 'Por favor, introduce tu código de autenticación de dos factores.', + }, + }, + passwordForm: { + pleaseFillOutAllFields: 'Por favor, completa todos los campos', + pleaseFillPassword: 'Por favor, introduce tu contraseña', + pleaseFillTwoFactorAuth: 'Por favor, introduce tu código 2 factores', + enterYourTwoFactorAuthenticationCodeToContinue: 'Introduce el código de autenticación de dos factores para continuar', + forgot: '¿Has olvidado la contraseña?', + requiredWhen2FAEnabled: 'Obligatorio cuando A2F está habilitado', + error: { + incorrectPassword: 'Contraseña incorrecta. Por favor, inténtalo de nuevo.', + incorrectLoginOrPassword: 'Usuario o contraseña incorrectos. Por favor, inténtalo de nuevo.', + incorrect2fa: 'Código de autenticación de dos factores incorrecto. Por favor, inténtalo de nuevo.', + twoFactorAuthenticationEnabled: 'Tienes autenticación de 2 factores activada en esta cuenta. Por favor, conéctate usando tu email o número de teléfono.', + invalidLoginOrPassword: 'Usuario o clave incorrectos. Por favor, inténtalo de nuevo o restablece la contraseña.', + unableToResetPassword: + 'No se pudo cambiar tu clave. Probablemente porque el enlace para restablecer la contrasenña ha expirado. Te hemos enviado un nuevo enlace. Comprueba tu bandeja de entrada y carpeta de Spam.', + noAccess: 'No tienes acceso a esta aplicación. Por favor, añade tu usuario de GitHub para acceder.', + accountLocked: 'Tu cuenta ha sido bloqueada tras varios intentos fallidos. Por favor, inténtalo de nuevo dentro de una hora.', + fallback: 'Ha ocurrido un error. Por favor, inténtalo mas tarde.', + }, + }, + loginForm: { + phoneOrEmail: 'Número de teléfono o email', + error: { + invalidFormatEmailLogin: 'El email introducido no es válido. Corrígelo e inténtalo de nuevo.', + }, + cannotGetAccountDetails: 'No se pudieron cargar los detalles de tu cuenta. Por favor, intenta iniciar sesión de nuevo.', + loginForm: 'Formulario de inicio de sesión', + notYou: ({user}: NotYouParams) => `¿No eres ${user}?`, + }, + onboarding: { + welcome: '¡Bienvenido!', + explanationModal: { + title: 'Bienvenido a Expensify', + description: 'Una aplicación para gestionar en un chat todos los gastos de tu empresa y personales. Inténtalo y dinos qué te parece. ¡Hay mucho más por venir!', + secondaryDescription: 'Para volver a Expensify Classic, simplemente haz click en tu foto de perfil > Ir a Expensify Classic.', + }, + welcomeVideo: { + title: 'Bienvenido a Expensify', + description: 'Una aplicación para gestionar todos tus gastos de empresa y personales en un chat. Pensada para tu empresa, tu equipo y tus amigos.', + }, + getStarted: 'Comenzar', + whatsYourName: '¿Cómo te llamas?', + whereYouWork: '¿Dónde trabajas?', + purpose: { + title: '¿Qué quieres hacer hoy?', + errorSelection: 'Por favor selecciona una opción para continuar.', + errorContinue: 'Por favor, haz click en continuar para configurar tu cuenta.', + errorBackButton: 'Por favor, finaliza las preguntas de configuración para empezar a utilizar la aplicación.', + [CONST.ONBOARDING_CHOICES.EMPLOYER]: 'Cobrar de mi empresa', + [CONST.ONBOARDING_CHOICES.MANAGE_TEAM]: 'Gestionar los gastos de mi equipo', + [CONST.ONBOARDING_CHOICES.PERSONAL_SPEND]: 'Controlar y presupuestar gastos', + [CONST.ONBOARDING_CHOICES.CHAT_SPLIT]: 'Chatea y divide gastos con tus amigos', + [CONST.ONBOARDING_CHOICES.LOOKING_AROUND]: 'Algo más', + }, + error: { + requiredFirstName: 'Introduce tu nombre para continuar.', + }, + }, + featureTraining: { + doNotShowAgain: 'No muestres esto otra vez', + }, + personalDetails: { + error: { + containsReservedWord: 'El nombre no puede contener las palabras Expensify o Concierge.', + hasInvalidCharacter: 'El nombre no puede contener una coma o un punto y coma.', + requiredFirstName: 'El nombre no puede estar vacío.', + }, + }, + privatePersonalDetails: { + enterLegalName: '¿Cuál es tu nombre legal?', + enterDateOfBirth: '¿Cuál es tu fecha de nacimiento?', + enterAddress: '¿Cuál es tu dirección?', + enterPhoneNumber: '¿Cuál es tu número de teléfono?', + personalDetails: 'Datos personales', + privateDataMessage: 'Estos detalles se utilizan para viajes y pagos. Nunca se mostrarán en tu perfil público.', + legalName: 'Nombre completo', + legalFirstName: 'Nombre legal', + legalLastName: 'Apellidos legales', + address: 'Dirección', + error: { + dateShouldBeBefore: ({dateString}: DateShouldBeBeforeParams) => `La fecha debe ser anterior a ${dateString}.`, + dateShouldBeAfter: ({dateString}: DateShouldBeAfterParams) => `La fecha debe ser posterior a ${dateString}.`, + incorrectZipFormat: (zipFormat?: string) => `Formato de código postal incorrecto.${zipFormat ? ` Formato aceptable: ${zipFormat}` : ''}`, + hasInvalidCharacter: 'El nombre sólo puede incluir caracteres latinos.', + }, + }, + resendValidationForm: { + linkHasBeenResent: 'El enlace se ha reenviado', + weSentYouMagicSignInLink: ({login, loginType}: WeSentYouMagicSignInLinkParams) => + `Te he enviado un hiperenlace mágico para iniciar sesión a ${login}. Por favor, revisa tu ${loginType}`, + resendLink: 'Reenviar enlace', + }, + unlinkLoginForm: { + toValidateLogin: ({primaryLogin, secondaryLogin}: ToValidateLoginParams) => + `Para validar ${secondaryLogin}, reenvía el código mágico desde la Configuración de la cuenta de ${primaryLogin}.`, + noLongerHaveAccess: ({primaryLogin}: NoLongerHaveAccessParams) => `Si ya no tienes acceso a ${primaryLogin} por favor, desvincula las cuentas.`, + unlink: 'Desvincular', + linkSent: '¡Enlace enviado!', + succesfullyUnlinkedLogin: '¡Nombre de usuario secundario desvinculado correctamente!', + }, + emailDeliveryFailurePage: { + ourEmailProvider: ({login}: OurEmailProviderParams) => + `Nuestro proveedor de correo electrónico ha suspendido temporalmente los correos electrónicos a ${login} debido a problemas de entrega. Para desbloquear el inicio de sesión, sigue estos pasos:`, + confirmThat: ({login}: ConfirmThatParams) => `Confirma que ${login} está escrito correctamente y que es una dirección de correo electrónico real que puede recibir correos. `, + emailAliases: + 'Los alias de correo electrónico como "expenses@domain.com" deben tener acceso a tu propia bandeja de entrada de correo electrónico para que sea un inicio de sesión válido de Expensify.', + ensureYourEmailClient: 'Asegúrese de que tu cliente de correo electrónico permita correos electrónicos de expensify.com. ', + youCanFindDirections: 'Puedes encontrar instrucciones sobre cómo completar este paso ', + helpConfigure: ', pero es posible que necesites que el departamento de informática te ayude a configurar los ajustes de correo electrónico.', + onceTheAbove: 'Una vez completados los pasos anteriores, ponte en contacto con ', + toUnblock: ' para desbloquear el inicio de sesión.', + }, + welcomeSignUpForm: { + join: 'Unirse', + }, + detailsPage: { + localTime: 'Hora local', + }, + newChatPage: { + startGroup: 'Crear grupo', + addToGroup: 'Añadir al grupo', + }, + yearPickerPage: { + year: 'Año', + selectYear: 'Por favor, selecciona un año', + }, + focusModeUpdateModal: { + title: '¡Bienvenido al modo #concentración!', + prompt: 'Mantente al tanto de todo viendo sólo los chats no leídos o los que necesitan tu atención. No te preocupes, puedes cambiar el ajuste en cualquier momento desde la ', + settings: 'configuración', + }, + notFound: { + chatYouLookingForCannotBeFound: 'El chat que estás buscando no se pudo encontrar.', + getMeOutOfHere: 'Sácame de aquí', + iouReportNotFound: 'Los detalles del pago que estás buscando no se pudieron encontrar.', + notHere: 'Hmm… no está aquí', + pageNotFound: 'Ups, no deberías estar aquí', + noAccess: 'No tienes acceso a este chat', + goBackHome: 'Volver a la página principal', + }, + setPasswordPage: { + enterPassword: 'Escribe una contraseña', + setPassword: 'Configura tu contraseña', + newPasswordPrompt: 'La contraseña debe tener al menos 8 caracteres, 1 letra mayúscula, 1 letra minúscula y 1 número.', + passwordFormTitle: '¡Bienvenido de vuelta al Nuevo Expensify! Por favor, elige una contraseña.', + passwordNotSet: 'No se pudo cambiar tu clave. Te hemos enviado un nuevo enlace para que intentes cambiar la clave nuevamente.', + setPasswordLinkInvalid: 'El enlace para configurar tu contraseña ha expirado. Te hemos enviado un nuevo enlace a tu correo.', + validateAccount: 'Verificar cuenta', + }, + statusPage: { + status: 'Estado', + statusExplanation: 'Añade un emoji para que tus colegas y amigos puedan saber fácilmente qué está pasando. ¡También puedes añadir un mensaje opcionalmente!', + today: 'Hoy', + clearStatus: 'Borrar estado', + save: 'Guardar', + message: 'Mensaje', + timePeriods: { + never: 'Nunca', + thirtyMinutes: '30 minutos', + oneHour: '1 hora', + afterToday: 'Hoy', + afterWeek: 'Una semana', + custom: 'Personalizado', + }, + untilTomorrow: 'Hasta mañana', + untilTime: ({time}: UntilTimeParams) => { + // Check for HH:MM AM/PM format and starts with '01:' + if (CONST.REGEX.TIME_STARTS_01.test(time)) { + return `Hasta la ${time}`; + } + // Check for any HH:MM AM/PM format not starting with '01:' + if (CONST.REGEX.TIME_FORMAT.test(time)) { + return `Hasta las ${time}`; + } + // Check for date-time format like "06-29 11:30 AM" + if (CONST.REGEX.DATE_TIME_FORMAT.test(time)) { + return `Hasta el día ${time}`; + } + // Default case + return `Hasta ${time}`; + }, + date: 'Fecha', + time: 'Hora', + clearAfter: 'Borrar después', + whenClearStatus: '¿Cuándo deberíamos borrar tu estado?', + }, + stepCounter: ({step, total, text}: StepCounterParams) => { + let result = `Paso ${step}`; + + if (total) { + result = `${result} de ${total}`; + } + + if (text) { + result = `${result}: ${text}`; + } + return result; + }, + bankAccount: { + bankInfo: 'Información bancaria', + confirmBankInfo: 'Confirmar información bancaria', + manuallyAdd: '¿Cuáles son los detalles de tu cuenta bancaria comercial?', + letsDoubleCheck: 'Verifiquemos que todo esté correcto.', + accountEnding: 'Cuenta terminada en', + thisBankAccount: 'Esta cuenta bancaria se utilizará para pagos comerciales en tu espacio de trabajo', + accountNumber: 'Número de cuenta', + routingNumber: 'Número de ruta', + chooseAnAccountBelow: 'Elige una cuenta a continuación', + addBankAccount: 'Añadir cuenta bancaria', + chooseAnAccount: 'Elige una cuenta', + connectOnlineWithPlaid: 'Conectar con Plaid', + connectManually: 'Conectar manualmente', + desktopConnection: 'Para conectarse con Chase, Wells Fargo, Capital One o Bank of America, haz clic aquí para completar este proceso en un navegador.', + yourDataIsSecure: 'Tus datos están seguros', + toGetStarted: 'Conecta una cuenta bancaria para reembolsar gastos, emitir Tarjetas Expensify, y cobrar y pagar facturas todo desde un mismo lugar.', + plaidBodyCopy: 'Ofrezca a sus empleados una forma más sencilla de pagar - y recuperar - los gastos de la empresa.', + checkHelpLine: 'Tus números de ruta y de cuenta se pueden encontrar en un cheque de la cuenta bancaria.', + validateAccountError: { + phrase1: '¡Un momento! Primero necesitas validar tu cuenta. Para hacerlo, ', + phrase2: 'vuelve a iniciar sesión con un código mágico', + phrase3: 'o', + phrase4: 'verifica aquí tu cuenta', + }, + hasPhoneLoginError: + 'Para añadir una cuenta bancaria verificada, asegúrate de que tu nombre de usuario principal sea un correo electrónico válido y vuelve a intentarlo. Puedes añadir tu número de teléfono como nombre de usuario secundario.', + hasBeenThrottledError: 'Se ha producido un error al intentar añadir tu cuenta bancaria. Por favor, espera unos minutos e inténtalo de nuevo.', + hasCurrencyError: '¡Ups! Parece que la moneda de tu espacio de trabajo no está configurada en USD. Por favor, configúrala en USD e inténtalo nuevamente.', + error: { + youNeedToSelectAnOption: 'Debes seleccionar una opción para continuar.', + noBankAccountAvailable: 'Lo sentimos, no hay ninguna cuenta bancaria disponible.', + noBankAccountSelected: 'Por favor, elige una cuenta bancaria.', + taxID: 'Por favor, introduce un número de identificación fiscal válido.', + website: 'Por favor, introduce un sitio web válido. El sitio web debe estar en minúsculas.', + zipCode: `Formato de código postal incorrecto. Formato aceptable: ${CONST.COUNTRY_ZIP_REGEX_DATA.US.samples}.`, + phoneNumber: 'Por favor, introduce un teléfono válido.', + companyName: 'Por favor, introduce un nombre comercial legal válido.', + addressCity: 'Por favor, introduce una ciudad válida.', + addressStreet: 'Por favor, introduce una dirección válida que no sea un apartado postal.', + addressState: 'Por favor, selecciona un estado.', + incorporationDateFuture: 'La fecha de incorporación no puede ser futura.', + incorporationState: 'Por favor, selecciona una estado válido.', + industryCode: 'Por favor, introduce un código de clasificación de industria válido.', + restrictedBusiness: 'Por favor, confirma que la empresa no está en la lista de negocios restringidos.', + routingNumber: 'Por favor, introduce un número de ruta válido.', + accountNumber: 'Por favor, introduce un número de cuenta válido.', + routingAndAccountNumberCannotBeSame: 'Los números de ruta y de cuenta no pueden ser iguales.', + companyType: 'Por favor, selecciona un tipo de compañía válido.', + tooManyAttempts: + 'Debido a la gran cantidad de intentos de inicio de sesión, esta opción ha sido desactivada temporalmente durante 24 horas. Por favor, inténtalo de nuevo más tarde.', + address: 'Por favor, introduce una dirección válida.', + dob: 'Por favor, selecciona una fecha de nacimiento válida.', + age: 'Debe ser mayor de 18 años.', + ssnLast4: 'Por favor, introduce los últimos 4 dígitos del número de seguridad social.', + firstName: 'Por favor, introduce el nombre.', + lastName: 'Por favor, introduce los apellidos.', + noDefaultDepositAccountOrDebitCardAvailable: 'Por favor, añade una cuenta bancaria para depósitos o una tarjeta de débito.', + validationAmounts: 'Los importes de validación que introduciste son incorrectos. Por favor, comprueba tu cuenta bancaria e inténtalo de nuevo.', + }, + }, + addPersonalBankAccountPage: { + enterPassword: 'Escribe tu contraseña de Expensify', + alreadyAdded: 'Esta cuenta ya ha sido añadida.', + chooseAccountLabel: 'Cuenta', + successTitle: '¡Cuenta bancaria personal añadida!', + successMessage: 'Enhorabuena, tu cuenta bancaria está lista para recibir reembolsos.', + }, + attachmentView: { + unknownFilename: 'Archivo desconocido', + passwordRequired: 'Por favor, introduce tu contraseña', + passwordIncorrect: 'Contraseña incorrecta. Por favor, inténtalo de nuevo.', + failedToLoadPDF: 'Se ha producido un error al intentar cargar el PDF.', + pdfPasswordForm: { + title: 'PDF protegido con contraseña', + infoText: 'Este PDF esta protegido con contraseña.', + beforeLinkText: 'Por favor', + linkText: 'introduce la contraseña', + afterLinkText: 'para verlo.', + formLabel: 'Ver PDF', + }, + }, + messages: { + errorMessageInvalidPhone: `Por favor, introduce un número de teléfono válido sin paréntesis o guiones. Si reside fuera de Estados Unidos, por favor incluye el prefijo internacional (p. ej. ${CONST.EXAMPLE_PHONE_NUMBER}).`, + errorMessageInvalidEmail: 'Email inválido', + userIsAlreadyMember: ({login, name}: UserIsAlreadyMemberParams) => `${login} ya es miembro de ${name}`, + }, + onfidoStep: { + acceptTerms: 'Al continuar con la solicitud para activar tu Billetera Expensify, confirma que ha leído, comprende y acepta ', + facialScan: 'Política y lanzamiento de la exploración facial de Onfido', + tryAgain: 'Intentar otra vez', + verifyIdentity: 'Verificar identidad', + letsVerifyIdentity: '¡Vamos a verificar tu identidad!', + butFirst: 'Pero primero, lo aburrido. Lee la jerga legal en el siguiente paso y haz clic en "Aceptar" cuando estés listo.', + genericError: 'Se ha producido un error al procesar este paso. Inténtalo de nuevo.', + cameraPermissionsNotGranted: 'Permiso para acceder a la cámara', + cameraRequestMessage: 'Necesitamos acceso a tu cámara para completar la verificación de tu cuenta de banco. Por favor habilita los permisos en Configuración > Nuevo Expensify.', + microphonePermissionsNotGranted: 'Permiso para acceder al micrófono', + microphoneRequestMessage: + 'Necesitamos acceso a tu micrófono para completar la verificación de tu cuenta de banco. Por favor habilita los permisos en Configuración > Nuevo Expensify.', + originalDocumentNeeded: 'Por favor, sube una imagen original de tu identificación en lugar de una captura de pantalla o imagen escaneada.', + documentNeedsBetterQuality: + 'Parece que tu identificación esta dañado o le faltan características de seguridad. Por favor, sube una imagen de tu documento sin daños y que se vea completamente.', + imageNeedsBetterQuality: 'Hay un problema con la calidad de la imagen de tu identificación. Por favor, sube una nueva imagen donde el identificación se vea con claridad.', + selfieIssue: 'Hay un problema con tu selfie/video. Por favor, sube un nuevo selfie/video grabado en el momento', + selfieNotMatching: 'Tu selfie/video no concuerda con tu identificación. Por favor, sube un nuevo selfie/video donde se vea tu cara con claridad.', + selfieNotLive: 'Tu selfie/video no parece ser un selfie/video en vivo. Por favor, sube un selfie/video a tiempo real.', + }, + additionalDetailsStep: { + headerTitle: 'Detalles adicionales', + helpText: 'Necesitamos confirmar la siguiente información antes de que puedas enviar y recibir dinero desde tu billetera.', + helpTextIdologyQuestions: 'Tenemos que preguntarte unas preguntas más para terminar de verificar tu identidad', + helpLink: 'Obtén más información sobre por qué necesitamos esto.', + legalFirstNameLabel: 'Primer nombre legal', + legalMiddleNameLabel: 'Segundo nombre legal', + legalLastNameLabel: 'Apellidos legales', + selectAnswer: 'Selecciona una respuesta.', + ssnFull9Error: 'Por favor, introduce los 9 dígitos de un número de seguridad social válido.', + needSSNFull9: 'Estamos teniendo problemas para verificar tu número de seguridad social. Introduce los 9 dígitos del número de seguridad social.', + weCouldNotVerify: 'No se pudo verificar', + pleaseFixIt: 'Corrige esta información antes de continuar.', + failedKYCTextBefore: 'No se ha podido verificar correctamente tu identidad. Vuelve a intentarlo más tarde o comunicate con ', + failedKYCTextAfter: ' si tienes alguna pregunta.', + }, + termsStep: { + headerTitle: 'Condiciones y tarifas', + headerTitleRefactor: 'Tarifas y condiciones', + haveReadAndAgree: 'He leído y acepto recibir ', + electronicDisclosures: 'divulgaciones electrónicas', + agreeToThe: 'Estoy de acuerdo con el ', + walletAgreement: 'Acuerdo de la billetera', + enablePayments: 'Habilitar pagos', + monthlyFee: 'Cuota mensual', + inactivity: 'Inactividad', + noOverdraftOrCredit: 'Sin función de sobregiro/crédito', + electronicFundsWithdrawal: 'Retiro electrónico de fondos', + standard: 'Estándar', + reviewTheFees: 'Por favor, revisa las siguientes tarifas.', + checkTheBoxes: 'Por favor, marca las siguientes casillas.', + agreeToTerms: 'Debes aceptar los términos y condiciones para continuar.', + shortTermsForm: { + expensifyPaymentsAccount: ({walletProgram}: WalletProgramParams) => `La Billetera Expensify es emitida por ${walletProgram}.`, + perPurchase: 'Por compra', + atmWithdrawal: 'Retiro en cajeros automáticos', + cashReload: 'Recarga de efectivo', + inNetwork: 'en la red', + outOfNetwork: 'fuera de la red', + atmBalanceInquiry: 'Consulta de saldo en cajeros automáticos', + inOrOutOfNetwork: '(dentro o fuera de la red)', + customerService: 'Servicio al cliente', + automatedOrLive: '(agente automatizado o en vivo)', + afterTwelveMonths: '(después de 12 meses sin transacciones)', + weChargeOneFee: 'Cobramos un tipo de tarifa.', + fdicInsurance: 'Tus fondos pueden acogerse al seguro de la FDIC.', + generalInfo: 'Para obtener información general sobre cuentas de prepago, visite', + conditionsDetails: 'Encuentra detalles y condiciones para todas las tarifas y servicios visitando', + conditionsPhone: 'o llamando al +1 833-400-0904.', + instant: '(instantáneo)', + electronicFundsInstantFeeMin: ({amount}: TermsParams) => `(mínimo ${amount})`, + }, + longTermsForm: { + listOfAllFees: 'Una lista de todas las tarifas de la Billetera Expensify', + typeOfFeeHeader: 'Tipo de tarifa', + feeAmountHeader: 'Importe de la tarifa', + moreDetailsHeader: 'Más detalles', + openingAccountTitle: 'Abrir una cuenta', + openingAccountDetails: 'No hay tarifa para abrir una cuenta.', + monthlyFeeDetails: 'No hay tarifa mensual.', + customerServiceTitle: 'Servicio al cliente', + customerServiceDetails: 'No hay tarifas de servicio al cliente.', + inactivityDetails: 'No hay tarifa de inactividad.', + sendingFundsTitle: 'Enviar fondos a otro titular de cuenta', + sendingFundsDetails: 'No se aplica ningún cargo por enviar fondos a otro titular de cuenta utilizando tu saldo cuenta bancaria o tarjeta de débito', + electronicFundsStandardDetails: + 'No hay cargo por transferir fondos desde tu Billetera Expensify ' + + 'a tu cuenta bancaria utilizando la opción estándar. Esta transferencia generalmente se completa en' + + '1-3 días laborables.', + electronicFundsInstantDetails: ({percentage, amount}: ElectronicFundsParams) => + 'Hay una tarifa para transferir fondos desde tu Billetera Expensify a ' + + 'la tarjeta de débito vinculada utilizando la opción de transferencia instantánea. Esta transferencia ' + + `generalmente se completa dentro de varios minutos. La tarifa es el ${percentage}% del importe de la ` + + `transferencia (con una tarifa mínima de ${amount}). `, + fdicInsuranceBancorp: ({amount}: TermsParams) => + 'Tus fondos pueden acogerse al seguro de la FDIC. Tus fondos se mantendrán o serán ' + + `transferidos a ${CONST.WALLET.PROGRAM_ISSUERS.BANCORP_BANK}, una institución asegurada por la FDIC. Una vez allí, tus fondos ` + + `están asegurados hasta ${amount} por la FDIC en caso de que ${CONST.WALLET.PROGRAM_ISSUERS.BANCORP_BANK} quiebre. Ver`, + fdicInsuranceBancorp2: 'para más detalles.', + contactExpensifyPayments: `Comunícate con ${CONST.WALLET.PROGRAM_ISSUERS.EXPENSIFY_PAYMENTS} llamando al + 1833-400-0904, o por correo electrónico a`, + contactExpensifyPayments2: 'o inicie sesión en', + generalInformation: 'Para obtener información general sobre cuentas de prepago, visite', + generalInformation2: 'Si tienes alguna queja sobre una cuenta de prepago, llama al Consumer Financial Oficina de Protección al 1-855-411-2372 o visita', + printerFriendlyView: 'Ver versión para imprimir', + automated: 'Automatizado', + liveAgent: 'Agente en vivo', + instant: 'Instantáneo', + electronicFundsInstantFeeMin: ({amount}: TermsParams) => `Mínimo ${amount}`, + }, + }, + activateStep: { + headerTitle: 'Habilitar pagos', + activatedTitle: '¡Billetera activada!', + activatedMessage: 'Felicidades, tu billetera está configurada y lista para hacer pagos.', + checkBackLaterTitle: 'Un momento...', + checkBackLaterMessage: 'Todavía estamos revisando tu información. Por favor, vuelve más tarde.', + continueToPayment: 'Continuar al pago', + continueToTransfer: 'Continuar a la transferencia', + }, + companyStep: { + headerTitle: 'Información de la empresa', + subtitle: '¡Ya casi estamos! Por motivos de seguridad, necesitamos confirmar la siguiente información:', + legalBusinessName: 'Nombre comercial legal', + companyWebsite: 'Página web de la empresa', + taxIDNumber: 'Número de identificación fiscal', + taxIDNumberPlaceholder: '9 dígitos', + companyType: 'Tipo de empresa', + incorporationDate: 'Fecha de incorporación', + incorporationState: 'Estado de incorporación', + industryClassificationCode: 'Código de clasificación industrial', + confirmCompanyIsNot: 'Confirmo que esta empresa no está en el', + listOfRestrictedBusinesses: 'lista de negocios restringidos', + incorporationDatePlaceholder: 'Fecha de inicio (aaaa-mm-dd)', + incorporationTypes: { + LLC: 'LLC', + CORPORATION: 'Corp', + PARTNERSHIP: 'Sociedad', + COOPERATIVE: 'Cooperativa', + SOLE_PROPRIETORSHIP: 'Propietario único', + OTHER: 'Otra', + }, + }, + requestorStep: { + headerTitle: 'Información personal', + learnMore: 'Más información', + isMyDataSafe: '¿Están seguros mis datos?', + }, + personalInfoStep: { + personalInfo: 'Información Personal', + enterYourLegalFirstAndLast: '¿Cuál es tu nombre legal?', + legalFirstName: 'Nombre', + legalLastName: 'Apellidos', + legalName: 'Nombre legal', + enterYourDateOfBirth: '¿Cuál es tu fecha de nacimiento?', + enterTheLast4: '¿Cuáles son los últimos 4 dígitos de tu número de la seguridad social?', + dontWorry: 'No te preocupes, no hacemos verificaciones de crédito personales.', + last4SSN: 'Últimos 4 dígitos de tu número de la seguridad social', + enterYourAddress: '¿Cuál es tu dirección?', + address: 'Dirección', + letsDoubleCheck: 'Revisemos que todo esté bien', + byAddingThisBankAccount: 'Añadiendo esta cuenta bancaria, confirmas que has leído, entendido y aceptado', + whatsYourLegalName: '¿Cuál es tu nombre legal?', + whatsYourDOB: '¿Cuál es tu fecha de nacimiento?', + whatsYourAddress: '¿Cuál es tu dirección?', + noPOBoxesPlease: 'Nada de apartados de correos ni direcciones de envío, por favor.', + whatsYourSSN: '¿Cuáles son los últimos 4 dígitos de tu número de la seguridad social?', + noPersonalChecks: 'No te preocupes, no hacemos verificaciones de crédito personales.', + whatsYourPhoneNumber: '¿Cuál es tu número de teléfono?', + weNeedThisToVerify: 'Necesitamos esto para verificar tu billetera.', + }, + businessInfoStep: { + businessInfo: 'Información de la empresa', + enterTheNameOfYourBusiness: '¿Cuál es el nombre de tu empresa?', + businessName: 'Nombre de la empresa', + enterYourCompanysTaxIdNumber: '¿Cuál es el número de identificación fiscal?', + taxIDNumber: 'Número de identificación fiscal', + taxIDNumberPlaceholder: '9 dígitos', + enterYourCompanysWebsite: '¿Cuál es la página web de tu empresa?', + companyWebsite: 'Página web de la empresa', + enterYourCompanysPhoneNumber: '¿Cuál es el número de teléfono de tu empresa?', + enterYourCompanysAddress: '¿Cuál es la dirección de tu empresa?', + selectYourCompanysType: '¿Cuál es el tipo de empresa?', + companyType: 'Tipo de empresa', + incorporationType: { + LLC: 'SRL', + CORPORATION: 'Corporación', + PARTNERSHIP: 'Sociedad', + COOPERATIVE: 'Cooperativa', + SOLE_PROPRIETORSHIP: 'Empresa individual', + OTHER: 'Otros', + }, + selectYourCompanysIncorporationDate: '¿Cuál es la fecha de constitución de la empresa?', + incorporationDate: 'Fecha de constitución', + incorporationDatePlaceholder: 'Fecha de inicio (yyyy-mm-dd)', + incorporationState: 'Estado en el que se constituyó', + pleaseSelectTheStateYourCompanyWasIncorporatedIn: '¿Cuál es el estado en el que se constituyó la empresa?', + letsDoubleCheck: 'Verifiquemos que todo esté correcto', + companyAddress: 'Dirección de la empresa', + listOfRestrictedBusinesses: 'lista de negocios restringidos', + confirmCompanyIsNot: 'Confirmo que esta empresa no está en la', + }, + beneficialOwnerInfoStep: { + doYouOwn25percent: '¿Posees el 25% o más de', + doAnyIndividualOwn25percent: '¿Alguna persona posee el 25% o más de', + areThereMoreIndividualsWhoOwn25percent: '¿Hay más personas que posean el 25% o más de', + regulationRequiresUsToVerifyTheIdentity: 'La ley nos exige verificar la identidad de cualquier persona que posea más del 25% de la empresa.', + companyOwner: 'Dueño de la empresa', + enterLegalFirstAndLastName: '¿Cuál es el nombre legal del dueño?', + legalFirstName: 'Nombre legal', + legalLastName: 'Apellidos legales', + enterTheDateOfBirthOfTheOwner: '¿Cuál es la fecha de nacimiento del dueño?', + enterTheLast4: '¿Cuáles son los últimos 4 dígitos del número de la seguridad social del dueño?', + last4SSN: 'Últimos 4 dígitos del número de la seguridad social', + dontWorry: 'No te preocupes, ¡no realizamos verificaciones de crédito personales!', + enterTheOwnersAddress: '¿Cuál es la dirección del dueño?', + letsDoubleCheck: 'Vamos a verificar que todo esté correcto.', + legalName: 'Nombre legal', + address: 'Dirección', + byAddingThisBankAccount: 'Al añadir esta cuenta bancaria, confirmas que has leído, comprendido y aceptado', + owners: 'Dueños', + }, + validationStep: { + headerTitle: 'Validar cuenta bancaria', + buttonText: 'Finalizar configuración', + maxAttemptsReached: 'Se ha inhabilitado la validación de esta cuenta bancaria debido a demasiados intentos incorrectos.', + description: 'Enviaremos tres (3) pequeñas transacciones a tu cuenta bancaria a nombre de "Expensify, Inc. Validation" dentro de los próximos 1-2 días laborables.', + descriptionCTA: 'Introduce el importe de cada transacción en los campos siguientes. Ejemplo: 1.51.', + reviewingInfo: '¡Gracias! Estamos revisando tu información y nos comunicaremos contigo en breve. Consulta el chat con Concierge ', + forNextStep: ' para conocer los próximos pasos para terminar de configurar tu cuenta bancaria.', + letsChatCTA: 'Sí, vamos a chatear', + letsChatText: '¡Ya casi estamos! Necesitamos tu ayuda para verificar unos últimos datos a través del chat. ¿Estás listo?', + letsChatTitle: '¡Vamos a chatear!', + enable2FATitle: 'Evita fraudes, activa la autenticación de dos factores!', + enable2FAText: 'Tu seguridad es importante para nosotros. Por favor, configura ahora la autenticación de dos factores para añadir una capa adicional de protección a tu cuenta.', + secureYourAccount: 'Asegura tu cuenta', + }, + beneficialOwnersStep: { + additionalInformation: 'Información adicional', + checkAllThatApply: 'Marca todos los que apliquen, en caso de que ninguno aplique dejar en blanco.', + iOwnMoreThan25Percent: 'Soy dueño de mas de 25% de ', + someoneOwnsMoreThan25Percent: 'Otra persona es dueña de mas de 25% de ', + additionalOwner: 'Beneficiario efectivo adicional', + removeOwner: 'Eliminar este beneficiario efectivo', + addAnotherIndividual: 'Añadir otra persona que es dueña de mas de 25% de ', + agreement: 'Acuerdo:', + termsAndConditions: 'Términos y condiciones', + certifyTrueAndAccurate: 'Certifico que la información dada es correcta', + error: { + certify: 'Debe certificar que la información es verdadera y precisa.', + }, + }, + completeVerificationStep: { + completeVerification: 'Completar la verificación', + confirmAgreements: 'Por favor, confirma los acuerdos siguientes.', + certifyTrueAndAccurate: 'Certifico que la información dada es verdadera y precisa', + certifyTrueAndAccurateError: 'Por favor, certifica que la información es verdadera y exacta', + isAuthorizedToUseBankAccount: 'Estoy autorizado para usar la cuenta bancaria de mi empresa para gastos de empresa', + isAuthorizedToUseBankAccountError: 'Debes ser el responsable oficial con autorización para operar la cuenta bancaria de la empresa.', + termsAndConditions: 'Términos y Condiciones', + }, + connectBankAccountStep: { + connectBankAccount: 'Conectar cuenta bancaria', + finishButtonText: 'Finalizar configuración', + validateYourBankAccount: 'Valida tu cuenta bancaria', + validateButtonText: 'Validar', + validationInputLabel: 'Transacción', + maxAttemptsReached: 'La validación de esta cuenta bancaria se ha desactivado debido a demasiados intentos incorrectos.', + description: 'Enviaremos tres (3) pequeñas transacciones a tu cuenta bancaria a nombre de "Expensify, Inc. Validation" dentro de los próximos 1-2 días laborables.', + descriptionCTA: 'Introduce el importe de cada transacción en los campos siguientes. Ejemplo: 1.51.', + reviewingInfo: '¡Gracias! Estamos revisando tu información y nos comunicaremos contigo en breve. Consulta el chat con Concierge ', + forNextSteps: ' para conocer los próximos pasos para terminar de configurar tu cuenta bancaria.', + letsChatCTA: 'Sí, vamos a chatear', + letsChatText: '¡Ya casi estamos! Necesitamos tu ayuda para verificar unos últimos datos a través del chat. ¿Estás listo?', + letsChatTitle: '¡Vamos a chatear!', + enable2FATitle: '¡Evita fraudes, activa la autenticación de dos factores!', + enable2FAText: 'Tu seguridad es importante para nosotros. Por favor, configura ahora la autenticación de dos factores para añadir una capa adicional de protección a tu cuenta.', + secureYourAccount: 'Asegura tu cuenta', + }, + reimbursementAccountLoadingAnimation: { + oneMoment: 'Un momento', + explanationLine: 'Estamos verificando tu información y podrás continuar con los siguientes pasos en unos momentos.', + }, + session: { + offlineMessageRetry: 'Parece que estás desconectado. Por favor, comprueba tu conexión e inténtalo de nuevo.', + }, + travel: { + header: 'Reservar viajes', + title: 'Viaja de forma inteligente', + subtitle: 'Utiliza Expensify Travel para obtener las mejores ofertas de viaje y gestionar todos los gastos de tu negocio en un solo lugar.', + features: { + saveMoney: 'Ahorra dinero en tus reservas', + alerts: 'Obtén actualizaciones y alertas en tiempo real', + }, + bookTravel: 'Reservar viajes', + bookDemo: 'Pedir demostración', + termsAndConditions: { + header: 'Antes de continuar...', + title: 'Por favor, lee los Términos y condiciones para reservar viajes', + subtitle: 'Para permitir la opción de reservar viajes en tu espacio de trabajo debe aceptar nuestros ', + termsconditions: 'términos y condiciones', + travelTermsAndConditions: 'términos y condiciones de viaje', + helpDocIntro: 'Consulta este ', + helpDocOutro: 'para obtener más información o comunícate con Concierge o tu gestor de cuentas.', + helpDoc: 'documento de ayuda', + agree: 'Acepto los ', + error: 'Debes aceptar los Términos y condiciones para que el viaje continúe', + }, + flight: 'Vuelo', + hotel: 'Hotel', + car: 'Auto', + viewTrip: 'Ver viaje', + viewTripDetails: 'Ver detalles del viaje', + trip: 'Viaje', + trips: 'Viajes', + tripSummary: 'Resumen del viaje', + departs: 'Sale', + errorMessage: 'Ha ocurrido un error. Por favor, inténtalo mas tarde.', + phoneError: 'Para reservar viajes, tu método de contacto predeterminado debe ser un correo electrónico válido', + }, + workspace: { + common: { + card: 'Tarjetas', + expensifyCard: 'Tarjeta Expensify', + companyCards: 'Tarjetas de empresa', + workflows: 'Flujos de trabajo', + workspace: 'Espacio de trabajo', + edit: 'Editar espacio de trabajo', + enabled: 'Activada', + disabled: 'Desactivada', + everyone: 'Todos', + delete: 'Eliminar espacio de trabajo', + settings: 'Configuración', + reimburse: 'Reembolsos', + categories: 'Categorías', + tags: 'Etiquetas', + reportFields: 'Campos de informe', + taxes: 'Impuestos', + bills: 'Pagar facturas', + invoices: 'Facturas', + travel: 'Viajes', + members: 'Miembros', + accounting: 'Contabilidad', + rules: 'Reglas', + plan: 'Plan', + profile: 'Perfil', + bankAccount: 'Cuenta bancaria', + displayedAs: 'Mostrado como', + connectBankAccount: 'Conectar cuenta bancaria', + testTransactions: 'Transacciones de prueba', + issueAndManageCards: 'Emitir y gestionar tarjetas', + reconcileCards: 'Reconciliar tarjetas', + selected: ({selectedNumber}) => `${selectedNumber} seleccionados`, + settlementFrequency: 'Frecuencia de liquidación', + deleteConfirmation: '¿Estás seguro de que quieres eliminar este espacio de trabajo?', + unavailable: 'Espacio de trabajo no disponible', + memberNotFound: 'Miembro no encontrado. Para invitar a un nuevo miembro al espacio de trabajo, por favor, utiliza el botón invitar que está arriba.', + notAuthorized: `No tienes acceso a esta página. Si estás intentando unirte a este espacio de trabajo, pide al dueño del espacio de trabajo que te añada como miembro. ¿Necesitas algo más? Comunícate con ${CONST.EMAIL.CONCIERGE}`, + goToRoom: ({roomName}: GoToRoomParams) => `Ir a la sala ${roomName}`, + workspaceName: 'Nombre del espacio de trabajo', + workspaceOwner: 'Dueño', + workspaceType: 'Tipo de espacio de trabajo', + workspaceAvatar: 'Espacio de trabajo avatar', + mustBeOnlineToViewMembers: 'Debes estar en línea para poder ver los miembros de este espacio de trabajo.', + moreFeatures: 'Más características', + requested: 'Solicitado', + distanceRates: 'Tasas de distancia', + welcomeNote: ({workspaceName}: WelcomeNoteParams) => + `¡Has sido invitado a ${workspaceName}! Descargue la aplicación móvil Expensify en use.expensify.com/download para comenzar a rastrear sus gastos.`, + subscription: 'Suscripción', + markAsExported: 'Marcar como introducido manualmente', + exportIntegrationSelected: (connectionName: ConnectionName) => `Exportar a ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]}`, + letsDoubleCheck: 'Verifiquemos que todo esté correcto', + reportField: 'Campo del informe', + lineItemLevel: 'Nivel de partida', + reportLevel: 'Nivel de informe', + appliedOnExport: 'No se importa en Expensify, se aplica en la exportación', + shareNote: { + header: 'Comparte fácilmente tu espacio de trabajo con otros miembros.', + content: { + firstPart: + 'Comparte este código QR o copia el enlace de abajo para facilitar que los miembros soliciten acceso a tu espacio de trabajo. Todas las solicitudes para unirse al espacio de trabajo aparecerán en la sala', + secondPart: 'para tu revisión.', + }, + }, + createNewConnection: 'Crear una nueva conexión', + reuseExistingConnection: 'Reutilizar la conexión existente', + existingConnections: 'Conexiones existentes', + lastSyncDate: (connectionName: string, formattedDate: string) => `${connectionName} - Última sincronización ${formattedDate}`, + topLevel: 'Nivel superior', + memberAlternateText: 'Los miembros pueden presentar y aprobar informes.', + adminAlternateText: 'Los administradores tienen acceso total para editar todos los informes y la configuración del área de trabajo.', + auditorAlternateText: 'Los auditores pueden ver y comentar los informes.', + roleName: (role?: string): string => { + switch (role) { + case CONST.POLICY.ROLE.ADMIN: + return 'Administrador'; + case CONST.POLICY.ROLE.AUDITOR: + return 'Auditor'; + case CONST.POLICY.ROLE.USER: + return 'Miembro'; + default: + return 'Miembro'; + } + }, + }, + qbo: { + importDescription: 'Elige que configuraciónes de codificación son importadas desde QuickBooks Online a Expensify.', + classes: 'Clases', + locations: 'Lugares', + customers: 'Clientes/proyectos', + accountsDescription: 'Tu plan de cuentas de Quickbooks Online se importará a Expensify como categorías.', + accountsSwitchTitle: 'Elige importar cuentas nuevas como categorías activadas o desactivadas.', + accountsSwitchDescription: 'Las categorías activas estarán disponibles para ser escogidas cuando se crea un gasto.', + classesDescription: 'Elige cómo gestionar las clases de QuickBooks Online en Expensify.', + customersDescription: 'Elige cómo gestionar los clientes/proyectos de QuickBooks Online en Expensify.', + locationsDescription: 'Elige cómo gestionar los lugares de QuickBooks Online en Expensify.', + taxesDescription: 'Elige cómo gestionar los impuestos de QuickBooks Online en Expensify.', + taxesJournalEntrySwitchNote: 'QuickBooks Online no permite impuestos en los asientos contables. Por favor, cambia la opción de exportación a factura de proveedor o cheque.', + locationsAdditionalDescription: + 'QuickBooks Online no permite lugares en facturas de proveedores o cheques. Como tienes activadas los lugares en tu espacio de trabajo, estas opciones de exportación no están disponibles.', + exportInvoices: 'Exportar facturas a', + exportDescription: 'Configura cómo se exportan los datos de Expensify a QuickBooks Online.', + date: 'Fecha de exportación', + deepDiveExpensifyCard: 'Las transacciones de la Tarjeta Expensify se exportan automáticamente a una "Cuenta de Responsabilidad de la Tarjeta Expensify" creada con', + deepDiveExpensifyCardIntegration: 'nuestra integración.', + exportExpensifyCard: 'Exportar las transacciones de las tarjetas Expensify como', + exportDate: { + label: 'Fecha de exportación', + description: 'Usa esta fecha al exportar informe a QuickBooks Online.', + values: { + [CONST.QUICKBOOKS_EXPORT_DATE.LAST_EXPENSE]: { + label: 'Fecha del último gasto', + description: 'Fecha del gasto mas reciente en el informe.', + }, + [CONST.QUICKBOOKS_EXPORT_DATE.REPORT_EXPORTED]: { + label: 'Fecha de exportación', + description: 'Fecha de exportación del informe a QuickBooks Online.', + }, + [CONST.QUICKBOOKS_EXPORT_DATE.REPORT_SUBMITTED]: { + label: 'Fecha de envío', + description: 'Fecha en la que el informe se envió para tu aprobación.', + }, + }, + }, + receivable: 'Cuentas por cobrar', // This is an account name that will come directly from QBO, so I don't know why we need a translation for it. It should take whatever the name of the account is in QBO. Leaving this note for CS. + archive: 'Archivo de cuentas por cobrar', // This is an account name that will come directly from QBO, so I don't know why we need a translation for it. It should take whatever the name of the account is in QBO. Leaving this note for CS. + exportInvoicesDescription: 'Usa esta cuenta al exportar facturas a QuickBooks Online.', + exportCompanyCardsDescription: 'Establece cómo se exportan las compras con tarjeta de empresa a QuickBooks Online.', + account: 'Cuenta', + accountDescription: 'Elige dónde contabilizar los asientos contables.', + vendor: 'Proveedor', + defaultVendorDescription: 'Establece un proveedor predeterminado que se aplicará a todas las transacciones con tarjeta de crédito al momento de exportarlas.', + accountsPayable: 'Cuentas por pagar', + accountsPayableDescription: 'Elige dónde crear las facturas de proveedores.', + bankAccount: 'Cuenta bancaria', + notConfigured: 'No configurado', + bankAccountDescription: 'Elige desde dónde enviar los cheques.', + creditCardAccount: 'Cuenta de la tarjeta de crédito', + companyCardsLocationEnabledDescription: + 'QuickBooks Online no permite lugares en las exportaciones de facturas de proveedores. Como tienes activadas los lugares en tu espacio de trabajo, esta opción de exportación no está disponible.', + exportOutOfPocketExpensesDescription: 'Establezca cómo se exportan los gastos de bolsillo a QuickBooks Online.', + exportCheckDescription: 'Crearemos un cheque desglosado para cada informe de Expensify y lo enviaremos desde la cuenta bancaria a continuación.', + exportJournalEntryDescription: 'Crearemos una entrada contable desglosada para cada informe de Expensify y lo contabilizaremos en la cuenta a continuación.', + exportVendorBillDescription: + 'Crearemos una factura de proveedor desglosada para cada informe de Expensify y la añadiremos a la cuenta a continuación. Si este periodo está cerrado, lo contabilizaremos en el día 1 del siguiente periodo abierto.', + outOfPocketTaxEnabledDescription: + 'QuickBooks Online no permite impuestos en las exportaciones de entradas a los asientos contables. Como tienes los impuestos activados en tu espacio de trabajo, esta opción de exportación no está disponible.', + outOfPocketTaxEnabledError: 'La anotacion en el diario no está disponible cuando los impuestos están activados. Por favor, selecciona otra opción de exportación diferente.', + outOfPocketLocationEnabledError: + 'Las facturas de proveedores no están disponibles cuando las ubicaciones están activadas. Por favor, selecciona otra opción de exportación diferente.', + outOfPocketLocationEnabledDescription: + 'QuickBooks Online no permite lugares en facturas de proveedores o cheques. Como tienes activadas los lugares en tu espacio de trabajo, estas opciones de exportación no están disponibles.', + + advancedConfig: { + autoSyncDescription: 'Expensify se sincronizará automáticamente con QuickBooks Online todos los días.', + inviteEmployees: 'Invitar empleados', + inviteEmployeesDescription: 'Importe los registros de los empleados de Quickbooks Online e invítelos a este espacio de trabajo.', + createEntities: 'Crear entidades automáticamente', + createEntitiesDescription: 'Expensify creará automáticamente proveedores en QuickBooks Online si aún no existen, y creará automáticamente clientes al exportar facturas.', + reimbursedReportsDescription: + 'Cada vez que se pague un informe utilizando Expensify ACH, se creará el correspondiente pago de la factura en la cuenta de Quickbooks Online indicadas a continuación.', + qboBillPaymentAccount: 'Cuenta de pago de las facturas de QuickBooks', + qboInvoiceCollectionAccount: 'Cuenta de cobro de las facturas QuickBooks', + accountSelectDescription: 'Elige desde dónde pagar las facturas y crearemos el pago en QuickBooks Online.', + invoiceAccountSelectorDescription: 'Elige dónde recibir los pagos de facturas y crearemos el pago en QuickBooks Online.', + }, + accounts: { + [CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD]: 'Tarjeta de débito', + [CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD]: 'Tarjeta de crédito', + [CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL]: 'Factura del proveedor', + [CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY]: 'Asiento contable', + [CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.CHECK]: 'Cheque', + + [`${CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD}Description`]: + "Automáticamente relacionaremos el nombre del comerciante de la transacción con tarjeta de débito con cualquier proveedor correspondiente en QuickBooks. Si no existen proveedores, crearemos un proveedor asociado 'Debit Card Misc.'.", + [`${CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}Description`]: + "Automáticamente relacionaremos el nombre del comerciante de la transacción con tarjeta de crédito con cualquier proveedor correspondiente en QuickBooks. Si no existen proveedores, crearemos un proveedor asociado 'Credit Card Misc.'.", + [`${CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL}Description`]: + 'Crearemos una factura de proveedor desglosada para cada informe de Expensify con la fecha del último gasto, y la añadiremos a la cuenta a continuación. Si este periodo está cerrado, lo contabilizaremos en el día 1 del siguiente periodo abierto.', + + [`${CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD}AccountDescription`]: 'Elige dónde exportar las transacciones con tarjeta de débito.', + [`${CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}AccountDescription`]: 'Elige dónde exportar las transacciones con tarjeta de crédito.', + [`${CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL}AccountDescription`]: 'Selecciona el proveedor que se aplicará a todas las transacciones con tarjeta de crédito.', + + [`${CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL}Error`]: + 'Las facturas de proveedores no están disponibles cuando las ubicaciones están habilitadas. Por favor, selecciona otra opción de exportación diferente.', + [`${CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.CHECK}Error`]: + 'La verificación no está disponible cuando las ubicaciones están habilitadas. Por favor, selecciona otra opción de exportación diferente.', + [`${CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY}Error`]: + 'El asiento de diario no está disponible cuando los impuestos están habilitados. Por favor, selecciona otra opción de exportación diferente.', + }, + noAccountsFound: 'No se ha encontrado ninguna cuenta', + noAccountsFoundDescription: 'Añade la cuenta en Quickbooks Online y sincroniza de nuevo la conexión.', + }, + xero: { + organization: 'Organización Xero', + organizationDescription: 'Seleccione la organización en Xero desde la que está importando los datos.', + importDescription: 'Elija qué configuraciones de codificación se importan de Xero a Expensify.', + accountsDescription: 'Tu plan de cuentas de Xero se importará a Expensify como categorías.', + accountsSwitchTitle: 'Elige importar cuentas nuevas como categorías activadas o desactivadas.', + accountsSwitchDescription: 'Las categorías activas estarán disponibles para ser escogidas cuando se crea un gasto.', + trackingCategories: 'Categorías de seguimiento', + trackingCategoriesDescription: 'Elige cómo gestionar categorías de seguimiento de Xero en Expensify.', + mapTrackingCategoryTo: ({categoryName}) => `Asignar ${categoryName} de Xero a`, + mapTrackingCategoryToDescription: ({categoryName}) => `Elige dónde mapear ${categoryName} al exportar a Xero.`, + customers: 'Volver a facturar a los clientes', + customersDescription: + 'Elige si quieres volver a facturar a los clientes en Expensify. Tus contactos de clientes de Xero se pueden etiquetar como gastos, y se exportarán a Xero como una factura de venta.', + taxesDescription: 'Elige cómo gestionar los impuestos de Xero en Expensify.', + notImported: 'No importado', + notConfigured: 'No configurado', + trackingCategoriesOptions: { + default: 'Contacto de Xero por defecto', + tag: 'Etiquetas', + }, + exportDescription: 'Configura cómo se exportan los datos de Expensify a Xero.', + purchaseBill: 'Factura de compra', + exportDeepDiveCompanyCard: + 'Cada gasto exportado se contabiliza como una transacción bancaria en la cuenta bancaria de Xero que selecciones a continuación. Las fechas de las transacciones coincidirán con las fechas de el extracto bancario.', + bankTransactions: 'Transacciones bancarias', + xeroBankAccount: 'Cuenta bancaria de Xero', + xeroBankAccountDescription: 'Elige dónde se contabilizarán los gastos como transacciones bancarias.', + exportExpensesDescription: 'Los informes se exportarán como una factura de compra utilizando la fecha y el estado que seleccione a continuación', + purchaseBillDate: 'Fecha de la factura de compra', + exportInvoices: 'Exportar facturas como', + salesInvoice: 'Factura de venta', + exportInvoicesDescription: 'Las facturas de venta siempre muestran la fecha en la que se envió la factura.', + advancedConfig: { + autoSyncDescription: 'Expensify se sincronizará automáticamente con Xero todos los días.', + purchaseBillStatusTitle: 'Estado de la factura de compra', + reimbursedReportsDescription: + 'Cada vez que se pague un informe utilizando Expensify ACH, se creará el correspondiente pago de la factura en la cuenta de Xero indicadas a continuación.', + xeroBillPaymentAccount: 'Cuenta de pago de las facturas de Xero', + xeroInvoiceCollectionAccount: 'Cuenta de cobro de las facturas Xero', + xeroBillPaymentAccountDescription: 'Elige desde dónde pagar las facturas y crearemos el pago en Xero.', + invoiceAccountSelectorDescription: 'Elige dónde recibir los pagos de facturas y crearemos el pago en Xero.', + }, + exportDate: { + label: 'Fecha de la factura de compra', + description: 'Usa esta fecha al exportar el informe a Xero.', + values: { + [CONST.XERO_EXPORT_DATE.LAST_EXPENSE]: { + label: 'Fecha del último gasto', + description: 'Fecha del gasto mas reciente en el informe.', + }, + [CONST.XERO_EXPORT_DATE.REPORT_EXPORTED]: { + label: 'Fecha de exportación', + description: 'Fecha de exportación del informe a Xero.', + }, + [CONST.XERO_EXPORT_DATE.REPORT_SUBMITTED]: { + label: 'Fecha de envío', + description: 'Fecha en la que el informe se envió para su aprobación.', + }, + }, + }, + invoiceStatus: { + label: 'Estado de la factura de compra', + description: 'Usa este estado al exportar facturas de compra a Xero.', + values: { + [CONST.XERO_CONFIG.INVOICE_STATUS.DRAFT]: 'Borrador', + [CONST.XERO_CONFIG.INVOICE_STATUS.AWAITING_APPROVAL]: 'Pendiente de aprobación', + [CONST.XERO_CONFIG.INVOICE_STATUS.AWAITING_PAYMENT]: 'Pendiente de pago', + }, + }, + noAccountsFound: 'No se ha encontrado ninguna cuenta', + noAccountsFoundDescription: 'Añade la cuenta en Xero y sincroniza de nuevo la conexión.', + }, + + sageIntacct: { + preferredExporter: 'Exportador preferido', + notConfigured: 'No configurado', + exportDate: { + label: 'Fecha de exportación', + description: 'Utilice esta fecha cuando exporte informes a Sage Intacct.', + values: { + [CONST.SAGE_INTACCT_EXPORT_DATE.LAST_EXPENSE]: { + label: 'Fecha del último gasto', + description: 'Fecha del gasto más reciente del informe.', + }, + [CONST.SAGE_INTACCT_EXPORT_DATE.EXPORTED]: { + label: 'Fecha de exportación', + description: 'Fecha en la que se exportó el informe a Sage Intacct.', + }, + [CONST.SAGE_INTACCT_EXPORT_DATE.SUBMITTED]: { + label: 'Fecha de envío', + description: 'Fecha de presentación del informe para su aprobación.', + }, + }, + }, + reimbursableExpenses: { + description: 'Establece cómo se exportan los gastos por cuenta propia a Sage Intacct.', + values: { + [CONST.SAGE_INTACCT_REIMBURSABLE_EXPENSE_TYPE.EXPENSE_REPORT]: 'Informes de gastos', + [CONST.SAGE_INTACCT_REIMBURSABLE_EXPENSE_TYPE.VENDOR_BILL]: 'Facturas de proveedores', + }, + }, + nonReimbursableExpenses: { + description: 'Establece cómo se exportan las compras con tarjeta de empresa a Sage Intacct.', + values: { + [CONST.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSE_TYPE.CREDIT_CARD_CHARGE]: 'Tarjetas de crédito', + [CONST.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSE_TYPE.VENDOR_BILL]: 'Facturas de proveedores', + }, + }, + creditCardAccount: 'Cuenta de tarjeta de crédito', + defaultVendor: 'Proveedor por defecto', + defaultVendorDescription: (isReimbursable: boolean): string => + `Establezca un proveedor predeterminado que se aplicará a los gastos ${isReimbursable ? '' : 'no '}reembolsables que no tienen un proveedor coincidente en Sage Intacct.`, + exportDescription: 'Configure cómo se exportan los datos de Expensify a Sage Intacct.', + exportPreferredExporterNote: + 'El exportador preferido puede ser cualquier administrador del área de trabajo, pero también debe ser un administrador del dominio si establece diferentes cuentas de exportación para tarjetas de empresa individuales en Configuración del dominio.', + exportPreferredExporterSubNote: 'Una vez configurado, el exportador preferido verá los informes para exportar en su cuenta.', + noAccountsFound: 'No se ha encontrado ninguna cuenta', + noAccountsFoundDescription: 'Añade la cuenta en Sage Intacct y sincroniza de nuevo la conexión.', + autoSync: 'Sincronización automática', + autoSyncDescription: 'Sincronice Sage Intacct y Expensify automáticamente, todos los días.', + inviteEmployees: 'Invitar a los empleados', + inviteEmployeesDescription: + 'Importe los registros de empleados de Sage Intacct e invite a los empleados a este espacio de trabajo. Su flujo de trabajo de aprobación será por defecto la aprobación del gerente y se puede configurar aún más en la página Miembros.', + syncReimbursedReports: 'Sincronizar informes reembolsados', + syncReimbursedReportsDescription: + 'Cuando un informe se reembolsa utilizando Expensify ACH, la factura de compra correspondiente se creará en la cuenta de Sage Intacct a continuación.', + paymentAccount: 'Cuenta de pago Sage Intacct', + authenticationError: 'No se puede conectar a Sage Intacct debido a un error de autenticación. ', + learnMore: 'Más información.', + }, + netsuite: { + subsidiary: 'Subsidiaria', + subsidiarySelectDescription: 'Elige la subsidiaria de NetSuite de la que deseas importar datos.', + exportDescription: 'Configura cómo se exportan los datos de Expensify a NetSuite.', + exportInvoices: 'Exportar facturas a', + journalEntriesTaxPostingAccount: 'Cuenta de registro de impuestos de asientos contables', + journalEntriesProvTaxPostingAccount: 'Cuenta de registro de impuestos provinciales de asientos contables', + foreignCurrencyAmount: 'Exportar importe en moneda extranjera', + exportToNextOpenPeriod: 'Exportar al siguiente período abierto', + nonReimbursableJournalPostingAccount: 'Cuenta de registro de diario no reembolsable', + reimbursableJournalPostingAccount: 'Cuenta de registro de diario reembolsable', + journalPostingPreference: { + label: 'Preferencia de registro de asientos contables', + values: { + [CONST.NETSUITE_JOURNAL_POSTING_PREFERENCE.JOURNALS_POSTING_INDIVIDUAL_LINE]: 'Entrada única y detallada para cada informe', + [CONST.NETSUITE_JOURNAL_POSTING_PREFERENCE.JOURNALS_POSTING_TOTAL_LINE]: 'Entrada única para cada gasto individual', + }, + }, + invoiceItem: { + label: 'Artículo de la factura', + values: { + [CONST.NETSUITE_INVOICE_ITEM_PREFERENCE.CREATE]: { + label: 'Crear uno para mí', + description: "Crearemos un 'Artículo de línea de factura de Expensify' para ti al exportar (si aún no existe).", + }, + [CONST.NETSUITE_INVOICE_ITEM_PREFERENCE.SELECT]: { + label: 'Seleccionar existente', + description: 'Asociaremos las facturas de Expensify al artículo seleccionado a continuación.', + }, + }, + }, + exportDate: { + label: 'Fecha de exportación', + description: 'Usa esta fecha al exportar informe a NetSuite.', + values: { + [CONST.NETSUITE_EXPORT_DATE.LAST_EXPENSE]: { + label: 'Fecha del último gasto', + description: 'Fecha del gasto mas reciente en el informe.', + }, + [CONST.NETSUITE_EXPORT_DATE.EXPORTED]: { + label: 'Fecha de exportación', + description: 'Fecha de exportación del informe a NetSuite.', + }, + [CONST.NETSUITE_EXPORT_DATE.SUBMITTED]: { + label: 'Fecha de envío', + description: 'Fecha en la que el informe se envió para su aprobación.', + }, + }, + }, + exportDestination: { + values: { + [CONST.NETSUITE_EXPORT_DESTINATION.EXPENSE_REPORT]: { + label: 'Informes de gastos', + reimbursableDescription: 'Los gastos reembolsables se exportarán como informes de gastos a NetSuite.', + nonReimbursableDescription: 'Los gastos no reembolsables se exportarán como informes de gastos a NetSuite.', + }, + [CONST.NETSUITE_EXPORT_DESTINATION.VENDOR_BILL]: { + label: 'Facturas de proveedores', + reimbursableDescription: + 'Los gastos reembolsables se exportarán como facturas pagaderas al proveedor especificado en NetSuite.\n' + + '\n' + + 'Si deseas establecer un proveedor específico para cada tarjeta, ve a *Configuraciones > Dominios > Tarjetas de Empresa*.', + nonReimbursableDescription: + 'Los gastos no reembolsables se exportarán como facturas pagaderas al proveedor especificado en NetSuite.\n' + + '\n' + + 'Si deseas establecer un proveedor específico para cada tarjeta, ve a *Configuraciones > Dominios > Tarjetas de Empresa*.', + }, + [CONST.NETSUITE_EXPORT_DESTINATION.JOURNAL_ENTRY]: { + label: 'Asientos contables', + reimbursableDescription: + 'Los gastos reembolsables se exportarán como asientos contables a la cuenta especificada en NetSuite.\n' + + '\n' + + 'Si deseas establecer un proveedor específico para cada tarjeta, ve a *Configuraciones > Dominios > Tarjetas de Empresa*.', + nonReimbursableDescription: + 'Los gastos no reembolsables se exportarán como asientos contables a la cuenta especificada en NetSuite.\n' + + '\n' + + 'Si deseas establecer un proveedor específico para cada tarjeta, ve a *Configuraciones > Dominios > Tarjetas de Empresa*.', + }, + }, + }, + advancedConfig: { + autoSyncDescription: 'Expensify se sincronizará automáticamente con NetSuite todos los días.', + reimbursedReportsDescription: + 'Cada vez que se pague un informe utilizando Expensify ACH, se creará el correspondiente pago de la factura en la cuenta de NetSuite indicadas a continuación.', + reimbursementsAccount: 'Cuenta de reembolsos', + reimbursementsAccountDescription: 'Elija la cuenta bancaria que utilizará para los reembolsos y crearemos el pago asociado en NetSuite.', + collectionsAccount: 'Cuenta de cobros', + collectionsAccountDescription: 'Una vez que una factura se marca como pagada en Expensify y se exporta a NetSuite, aparecerá contra la cuenta de abajo.', + approvalAccount: 'Cuenta de aprobación de cuentas por pagar', + approvalAccountDescription: + 'Elija la cuenta con la que se aprobarán las transacciones en NetSuite. Si está sincronizando informes reembolsados, esta es también la cuenta con la que se crearán los pagos de facturas.', + defaultApprovalAccount: 'Preferencia predeterminada de NetSuite', + inviteEmployees: 'Invitar empleados y establecer aprobaciones', + inviteEmployeesDescription: + 'Importar registros de empleados de NetSuite e invitar a empleados a este espacio de trabajo. Su flujo de trabajo de aprobación será por defecto la aprobación del gerente y se puede configurar más en la página *Miembros*.', + autoCreateEntities: 'Crear automáticamente empleados/proveedores', + enableCategories: 'Activar categorías recién importadas', + customFormID: 'ID de formulario personalizado', + customFormIDDescription: + 'Por defecto, Expensify creará entradas utilizando el formulario de transacción preferido configurado en NetSuite. Alternativamente, tienes la opción de designar un formulario de transacción específico para ser utilizado.', + customFormIDReimbursable: 'Gasto reembolsable', + customFormIDNonReimbursable: 'Gasto no reembolsable', + exportReportsTo: { + label: 'Nivel de aprobación del informe de gastos', + description: + 'Una vez aprobado un informe de gastos en Expensify y exportado a NetSuite, puede establecer un nivel adicional de aprobación en NetSuite antes de su contabilización.', + values: { + [CONST.NETSUITE_REPORTS_APPROVAL_LEVEL.REPORTS_APPROVED_NONE]: 'Preferencia predeterminada de NetSuite', + [CONST.NETSUITE_REPORTS_APPROVAL_LEVEL.REPORTS_SUPERVISOR_APPROVED]: 'Solo aprobado por el supervisor', + [CONST.NETSUITE_REPORTS_APPROVAL_LEVEL.REPORTS_ACCOUNTING_APPROVED]: 'Solo aprobado por contabilidad', + [CONST.NETSUITE_REPORTS_APPROVAL_LEVEL.REPORTS_APPROVED_BOTH]: 'Aprobado por supervisor y contabilidad', + }, + }, + exportVendorBillsTo: { + label: 'Nivel de aprobación de facturas de proveedores', + description: + 'Una vez aprobada una factura de proveedor en Expensify y exportada a NetSuite, puede establecer un nivel adicional de aprobación en NetSuite antes de su contabilización.', + values: { + [CONST.NETSUITE_VENDOR_BILLS_APPROVAL_LEVEL.VENDOR_BILLS_APPROVED_NONE]: 'Preferencia predeterminada de NetSuite', + [CONST.NETSUITE_VENDOR_BILLS_APPROVAL_LEVEL.VENDOR_BILLS_APPROVAL_PENDING]: 'Aprobación pendiente', + [CONST.NETSUITE_VENDOR_BILLS_APPROVAL_LEVEL.VENDOR_BILLS_APPROVED]: 'Aprobado para publicación', + }, + }, + exportJournalsTo: { + label: 'Nivel de aprobación de asientos contables', + description: 'Una vez aprobado un asiento en Expensify y exportado a NetSuite, puede establecer un nivel adicional de aprobación en NetSuite antes de contabilizarlo.', + values: { + [CONST.NETSUITE_JOURNALS_APPROVAL_LEVEL.JOURNALS_APPROVED_NONE]: 'Preferencia predeterminada de NetSuite', + [CONST.NETSUITE_JOURNALS_APPROVAL_LEVEL.JOURNALS_APPROVAL_PENDING]: 'Aprobación pendiente', + [CONST.NETSUITE_JOURNALS_APPROVAL_LEVEL.JOURNALS_APPROVED]: 'Aprobado para publicación', + }, + }, + error: { + customFormID: 'Introduzca un ID numérico válido para el formulario personalizado.', + }, + }, + noAccountsFound: 'No se han encontrado cuentas', + noAccountsFoundDescription: 'Añade la cuenta en NetSuite y sincroniza la conexión de nuevo.', + noVendorsFound: 'No se han encontrado proveedores', + noVendorsFoundDescription: 'Añade proveedores en NetSuite y sincroniza la conexión de nuevo.', + noItemsFound: 'No se han encontrado artículos de factura', + noItemsFoundDescription: 'Añade artículos de factura en NetSuite y sincroniza la conexión de nuevo.', + noSubsidiariesFound: 'No se ha encontrado subsidiarias', + noSubsidiariesFoundDescription: 'Añade la subsidiaria en NetSuite y sincroniza de nuevo la conexión.', + tokenInput: { + title: 'Netsuite configuración', + formSteps: { + installBundle: { + title: 'Instala el paquete de Expensify', + description: 'En NetSuite, ir a *Personalización > SuiteBundler > Buscar e Instalar Paquetes* > busca "Expensify" > instala el paquete.', + }, + enableTokenAuthentication: { + title: 'Habilitar la autenticación basada en token', + description: 'En NetSuite, ir a *Configuración > Empresa > Habilitar Funciones > SuiteCloud* > activar *autenticación basada en token*.', + }, + enableSoapServices: { + title: 'Habilitar servicios web SOAP', + description: 'En NetSuite, ir a *Configuración > Empresa > Habilitar funciones > SuiteCloud* > habilitar *Servicios Web SOAP*.', + }, + createAccessToken: { + title: 'Crear un token de acceso', + description: + 'En NetSuite, ir a *Configuración > Usuarios/Roles > Tokens de Acceso* > crear un token de acceso para la aplicación "Expensify" y tambiém para el rol de "Integración Expensify" o "Administrador".\n\n*Importante:* Asegúrese de guardar el ID y el secreto del Token en este paso. Los necesitará para el siguiente paso.', + }, + enterCredentials: { + title: 'Ingresa tus credenciales de NetSuite', + formInputs: { + netSuiteAccountID: 'ID de Cuenta NetSuite', + netSuiteTokenID: 'ID de Token', + netSuiteTokenSecret: 'Secreto de Token', + }, + netSuiteAccountIDDescription: 'En NetSuite, ir a *Configuración > Integración > Preferencias de Servicios Web SOAP*.', + }, + }, + }, + import: { + expenseCategories: 'Categorías de gastos', + expenseCategoriesDescription: 'Las categorías de gastos de NetSuite se importan a Expensify como categorías.', + crossSubsidiaryCustomers: 'Clientes/proyectos entre subsidiaria', + importFields: { + departments: { + title: 'Departamentos', + subtitle: 'Elige cómo manejar los *departamentos* de NetSuite en Expensify.', + }, + classes: { + title: 'Clases', + subtitle: 'Elige cómo manejar las *clases* en Expensify.', + }, + locations: { + title: 'Ubicaciones', + subtitle: 'Elija cómo manejar *ubicaciones* en Expensify.', + }, + }, + customersOrJobs: { + title: 'Clientes/proyectos', + subtitle: 'Elija cómo manejar los *clientes* y *proyectos* de NetSuite en Expensify.', + importCustomers: 'Importar clientes', + importJobs: 'Importar proyectos', + customers: 'clientes', + jobs: 'proyectos', + label: (importFields: string[], importType: string) => `${importFields.join(' y ')}, ${importType}`, + }, + importTaxDescription: 'Importar grupos de impuestos desde NetSuite.', + importCustomFields: { + chooseOptionBelow: 'Elija una de las opciones siguientes:', + requiredFieldError: (fieldName: string) => `Por favor, introduzca el ${fieldName}`, + customSegments: { + title: 'Segmentos/registros personalizados', + addText: 'Añadir segmento/registro personalizado', + recordTitle: 'Segmento/registro personalizado', + helpLink: CONST.NETSUITE_IMPORT.HELP_LINKS.CUSTOM_SEGMENTS, + helpLinkText: 'Ver instrucciones detalladas', + helpText: ' sobre la configuración de segmentos/registros personalizado.', + emptyTitle: 'Añadir un segmento personalizado o un registro personalizado', + fields: { + segmentName: 'Name', + internalID: 'Identificación interna', + scriptID: 'ID de guión', + mapping: 'Mostrado como', + customRecordScriptID: 'ID de columna de transacción', + }, + removeTitle: 'Eliminar segmento/registro personalizado', + removePrompt: '¿Está seguro de que desea eliminar este segmento/registro personalizado?', + addForm: { + customSegmentName: 'nombre de segmento personalizado', + customRecordName: 'nombre de registro personalizado', + segmentTitle: 'Segmento personalizado', + customSegmentAddTitle: 'Añadir segmento personalizado', + customRecordAddTitle: 'Añadir registro personalizado', + recordTitle: 'Registro personalizado', + segmentRecordType: '¿Desea añadir un segmento personalizado o un registro personalizado?', + customSegmentNameTitle: '¿Cuál es el nombre del segmento personalizado?', + customRecordNameTitle: '¿Cuál es el nombre del registro personalizado?', + customSegmentNameFooter: `Puede encontrar los nombres de los segmentos personalizados en NetSuite en la página *Personalizaciones > Vínculos, registros y campos > Segmentos personalizados*.\nn_Para obtener instrucciones más detalladas, [visite nuestro sitio de ayuda](${CONST.NETSUITE_IMPORT.HELP_LINKS.CUSTOM_SEGMENTS})_.`, + customRecordNameFooter: `Puede encontrar nombres de registros personalizados en NetSuite introduciendo el "Campo de columna de transacción" en la búsqueda global.\nn_Para obtener instrucciones más detalladas, [visite nuestro sitio de ayuda](${CONST.NETSUITE_IMPORT.HELP_LINKS.CUSTOM_SEGMENTS})_.`, + customSegmentInternalIDTitle: '¿Cuál es la identificación interna?', + customSegmentInternalIDFooter: `En primer lugar, asegúrese de que ha habilitado los ID internos en NetSuite en *Inicio > Establecer preferencias > Mostrar ID interno*. *Personalización > Listas, registros y campos > Segmentos personalizados*.\n2. Haga clic en un segmento personalizado. Haga clic en un segmento personalizado. Haga clic en el hipervínculo situado junto a *Tipo de registro personalizado*.\n4. Para obtener instrucciones más detalladas, [visite nuestro sitio de ayuda](${CONST.NETSUITE_IMPORT.HELP_LINKS.CUSTOM_LISTS})_.`, + customRecordInternalIDFooter: `Puede encontrar IDs internos de registros personalizados en NetSuite siguiendo estos pasos:\n\n1. Introduzca "Campos de línea de transacción" en la búsqueda global. Haga clic en un registro personalizado. Para obtener instrucciones más detalladas, [visite nuestro sitio de ayuda](${CONST.NETSUITE_IMPORT.HELP_LINKS.CUSTOM_SEGMENTS})_.`, + customSegmentScriptIDTitle: '¿Cuál es el ID del guión?', + customSegmentScriptIDFooter: `Puede encontrar IDs de script de segmentos personalizados en NetSuite en: \n\n1. *Personalización > Listas, Registros y Campos > Segmentos Personalizados*.\n2. Haga clic en un segmento personalizado. a. Si desea mostrar el segmento personalizado como una *etiqueta* (a nivel de partida) en Expensify, haga clic en la subpestaña *Columnas de transacción* y utilice el *ID de campo*. b. Si desea mostrar el segmento personalizado como una *etiqueta* (a nivel de partida) en Expensify, haga clic en la subpestaña *Columnas de transacción* y utilice el *ID de campo*. Si desea mostrar el segmento personalizado como un *campo de informe* (a nivel de informe) en Expensify, haga clic en la subpestaña *Transacciones* y utilice el *ID de campo*. Para obtener instrucciones más detalladas, [visite nuestro sitio de ayuda](${CONST.NETSUITE_IMPORT.HELP_LINKS.CUSTOM_LISTS})_.`, + customRecordScriptIDTitle: '¿Cuál es el ID de columna de la transacción?', + customRecordScriptIDFooter: `Puede encontrar IDs de script de registro personalizados en NetSuite en:\n\n1. Introduzca "Campos de línea de transacción" en la búsqueda global.\n2. Haga clic en un registro personalizado.\n3. Para obtener instrucciones más detalladas, [visite nuestro sitio de ayuda](${CONST.NETSUITE_IMPORT.HELP_LINKS.CUSTOM_SEGMENTS})_.`, + customSegmentMappingTitle: '¿Cómo debería mostrarse este segmento personalizado en Expensify?', + customRecordMappingTitle: '¿Cómo debería mostrarse este registro de segmento personalizado en Expensify?', + }, + errors: { + uniqueFieldError: (fieldName: string) => `Ya existe un segmento/registro personalizado con este ${fieldName?.toLowerCase()}.`, + }, + }, + customLists: { + title: 'Listas personalizadas', + addText: 'Añadir lista personalizada', + recordTitle: 'Lista personalizado', + helpLink: CONST.NETSUITE_IMPORT.HELP_LINKS.CUSTOM_LISTS, + helpLinkText: 'Ver instrucciones detalladas', + helpText: ' sobre cómo configurar listas personalizada.', + emptyTitle: 'Añadir una lista personalizado', + fields: { + listName: 'Nombre', + internalID: 'Identificación interna', + transactionFieldID: 'ID del campo de transacción', + mapping: 'Mostrado como', + }, + removeTitle: 'Eliminar lista personalizado', + removePrompt: '¿Está seguro de que desea eliminar esta lista personalizado?', + addForm: { + listNameTitle: 'Elija una lista personalizada', + transactionFieldIDTitle: '¿Cuál es el ID del campo de transacción?', + transactionFieldIDFooter: `Puede encontrar los ID de campo de transacción en NetSuite siguiendo estos pasos:\n\n1. Introduzca "Campos de línea de transacción" en búsqueda global. Introduzca "Campos de línea de transacción" en la búsqueda global.\n2. Haga clic en una lista personalizada.\n3. Para obtener instrucciones más detalladas, [visite nuestro sitio de ayuda](${CONST.NETSUITE_IMPORT.HELP_LINKS.CUSTOM_LISTS})_.`, + mappingTitle: '¿Cómo debería mostrarse esta lista personalizada en Expensify?', + }, + errors: { + uniqueTransactionFieldIDError: `Ya existe una lista personalizada con este ID de campo de transacción.`, + }, + }, + }, + importTypes: { + [CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT]: { + label: 'Predeterminado del empleado NetSuite', + description: 'No importado a Expensify, aplicado en exportación', + footerContent: (importField: string) => + `Si usa ${importField} en NetSuite, aplicaremos el conjunto predeterminado en el registro del empleado al exportarlo a Informe de gastos o Entrada de diario.`, + }, + [CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG]: { + label: 'Etiquetas', + description: 'Nivel de línea de pedido', + footerContent: (importField: string) => `Se podrán seleccionar ${importField} para cada gasto individual en el informe de un empleado.`, + }, + [CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD]: { + label: 'Campos de informe', + description: 'Nivel de informe', + footerContent: (importField: string) => `La selección de ${importField} se aplicará a todos los gastos en el informe de un empleado.`, + }, + }, + }, + }, + intacct: { + sageIntacctSetup: 'Sage Intacct configuración', + prerequisitesTitle: 'Antes de conectar...', + downloadExpensifyPackage: 'Descargar el paquete Expensify para Sage Intacct', + followSteps: 'Siga los pasos de nuestras instrucciones Cómo: Instrucciones para conectarse a Sage Intacct', + enterCredentials: 'Introduzca sus credenciales de Sage Intacct', + entity: 'Entidad', + employeeDefault: 'Sage Intacct empleado por defecto', + employeeDefaultDescription: 'El departamento por defecto del empleado se aplicará a sus gastos en Sage Intacct si existe.', + displayedAsTagDescription: 'Se podrá seleccionar el departamento para cada gasto individual en el informe de un empleado.', + displayedAsReportFieldDescription: 'La selección de departamento se aplicará a todos los gastos que figuren en el informe de un empleado.', + toggleImportTitleFirstPart: 'Elija cómo gestionar Sage Intacct ', + toggleImportTitleSecondPart: ' en Expensify.', + expenseTypes: 'Tipos de gastos', + expenseTypesDescription: 'Los tipos de gastos de Sage Intacct se importan a Expensify como categorías.', + importTaxDescription: 'Importar el tipo impositivo de compra desde Sage Intacct.', + userDefinedDimensions: 'Dimensiones definidas por el usuario', + addUserDefinedDimension: 'Añadir dimensión definida por el usuario', + integrationName: 'Nombre de la integración', + dimensionExists: 'Ya existe una dimensión con ese nombre.', + removeDimension: 'Eliminar dimensión definida por el usuario', + removeDimensionPrompt: 'Está seguro de que desea eliminar esta dimensión definida por el usuario?', + userDefinedDimension: 'Dimensión definida por el usuario', + addAUserDefinedDimension: 'Añadir una dimensión definida por el usuario', + detailedInstructionsLink: 'Ver instrucciones detalladas', + detailedInstructionsRestOfSentence: ' para añadir dimensiones definidas por el usuario.', + userDimensionsAdded: (dimensionsCount: number) => `${dimensionsCount} ${Str.pluralize('UDD', `UDDs`, dimensionsCount)} añadido`, + mappingTitle: (mappingName: SageIntacctMappingName): string => { + switch (mappingName) { + case CONST.SAGE_INTACCT_CONFIG.MAPPINGS.DEPARTMENTS: + return 'departamentos'; + case CONST.SAGE_INTACCT_CONFIG.MAPPINGS.CLASSES: + return 'clases'; + case CONST.SAGE_INTACCT_CONFIG.MAPPINGS.LOCATIONS: + return 'lugares'; + case CONST.SAGE_INTACCT_CONFIG.MAPPINGS.CUSTOMERS: + return 'clientes'; + case CONST.SAGE_INTACCT_CONFIG.MAPPINGS.PROJECTS: + return 'proyectos (empleos)'; + default: + return 'asignaciones'; + } + }, + }, + type: { + free: 'Gratis', + control: 'Control', + collect: 'Recolectar', + }, + companyCards: { + addCompanyCards: 'Agregar tarjetas de empresa', + selectCardFeed: 'Seleccionar feed de tarjetas', + addCardFeed: 'Añadir alimentación de tarjeta', + addNewCard: { + cardProviders: { + amex: 'Tarjetas de empresa American Express', + mastercard: 'Tarjetas comerciales Mastercard', + visa: 'Tarjetas comerciales Visa', + }, + yourCardProvider: `¿Quién es su proveedor de tarjetas?`, + enableFeed: { + title: (provider: string) => `Habilita tu feed ${provider}`, + heading: + 'Tenemos una integración directa con el emisor de su tarjeta y podemos importar los datos de sus transacciones a Expensify de forma rápida y precisa.\n\nPara empezar, simplemente:', + visa: `1. Visite [este artículo de ayuda](${CONST.COMPANY_CARDS_HELP}) para obtener instrucciones detalladas sobre cómo configurar sus tarjetas comerciales Visa.\n\n2. [Póngase en contacto con su banco](${CONST.COMPANY_CARDS_HELP}) para comprobar que admiten un feed personalizado para su programa, y pídales que lo activen.\n\n3. *Una vez que el feed esté habilitado y tengas sus datos, pasa a la siguiente pantalla.*`, + amex: `1. Visite [este artículo de ayuda](${CONST.COMPANY_CARDS_HELP}) para saber si American Express puede habilitar un feed personalizado para su programa.\n\n2. Una vez activada la alimentación, Amex le enviará una carta de producción.\n\n3. *Una vez que tenga la información de alimentación, continúe con la siguiente pantalla.*`, + mastercard: `1. Visite [este artículo de ayuda](${CONST.NETSUITE_IMPORT.HELP_LINKS.CUSTOM_SEGMENTS}) para obtener instrucciones detalladas sobre cómo configurar sus tarjetas comerciales Mastercard.\n\n 2. [Póngase en contacto con su banco](${CONST.COMPANY_CARDS_HELP}) para verificar que admiten un feed personalizado para su programa, y pídales que lo habiliten.\n\n3. *Una vez que el feed esté habilitado y tengas sus datos, pasa a la siguiente pantalla.*`, + }, + whatBankIssuesCard: '¿Qué banco emite estas tarjetas?', + enterNameOfBank: 'Introduzca el nombre del banco', + feedDetails: { + visa: { + title: '¿Cuáles son los datos de alimentación de Visa?', + processorLabel: 'ID del procesador', + bankLabel: 'Identificación de la institución financiera (banco)', + companyLabel: 'Empresa ID', + }, + amex: { + title: `¿Cuál es el nombre del archivo de entrega de Amex?`, + fileNameLabel: 'Nombre del archivo de entrega', + }, + mastercard: { + title: `¿Cuál es el identificador de distribución de Mastercard?`, + distributionLabel: 'ID de distribución', + }, + }, + error: { + pleaseSelectProvider: 'Seleccione un proveedor de tarjetas antes de continuar.', + }, + }, + assignCard: 'Asignar tarjeta', + cardNumber: 'Número de la tarjeta', + customFeed: 'Fuente personalizada', + whoNeedsCardAssigned: '¿Quién necesita una tarjeta?', + chooseCard: 'Elige una tarjeta', + chooseCardFor: ({assignee, feed}: AssignCardParams) => `Elige una tarjeta para ${assignee} del feed de tarjetas ${feed}.`, + noActiveCards: 'No hay tarjetas activas en este feed', + somethingMightBeBroken: 'O algo podría estar roto. De cualquier manera, si tienes alguna pregunta,', + contactConcierge: 'contacta a Concierge', + chooseTransactionStartDate: 'Elige una fecha de inicio de transacciones', + startDateDescription: 'Importaremos todas las transacciones desde esta fecha en adelante. Si no se especifica una fecha, iremos tan atrás como lo permita tu banco.', + fromTheBeginning: 'Desde el principio', + customStartDate: 'Fecha de inicio personalizada', + letsDoubleCheck: 'Verifiquemos que todo esté bien.', + confirmationDescription: 'Comenzaremos a importar transacciones inmediatamente.', + cardholder: 'Titular de la tarjeta', + card: 'Tarjeta', + startTransactionDate: 'Fecha de inicio de transacciones', + cardName: 'Nombre de la tarjeta', + assignedYouCard: (assigner: string) => `¡${assigner} te ha asignado una tarjeta de empresa! Las transacciones importadas aparecerán en este chat.`, + }, + expensifyCard: { + issueAndManageCards: 'Emitir y gestionar Tarjetas Expensify', + getStartedIssuing: 'Empieza emitiendo tu primera tarjeta virtual o física.', + disclaimer: + 'La tarjeta comercial Expensify Visa® es emitida por The Bancorp Bank, N.A., miembro de la FDIC, en virtud de una licencia de Visa U.S.A. Inc. y no puede utilizarse en todos los comercios que aceptan tarjetas Visa. Apple® y el logotipo de Apple® son marcas comerciales de Apple Inc. registradas en EE.UU. y otros países. App Store es una marca de servicio de Apple Inc. Google Play y el logotipo de Google Play son marcas comerciales de Google LLC.', + issueCard: 'Emitir tarjeta', + newCard: 'Nueva tarjeta', + name: 'Nombre', + lastFour: '4 últimos', + limit: 'Limite', + currentBalance: 'Saldo actual', + currentBalanceDescription: + 'El saldo actual es la suma de todas las transacciones contabilizadas con la Tarjeta Expensify que se han producido desde la última fecha de liquidación.', + cardLimit: 'Límite de la tarjeta', + remainingLimit: 'Límite restante', + requestLimitIncrease: 'Solicitar aumento de límite', + remainingLimitDescription: + 'A la hora de calcular tu límite restante, tenemos en cuenta una serie de factores: su antigüedad como cliente, la información relacionada con tu negocio que nos facilitaste al darte de alta y el efectivo disponible en tu cuenta bancaria comercial. Tu límite restante puede fluctuar a diario.', + earnedCashback: 'Reembolso', + earnedCashbackDescription: 'El saldo de devolución se basa en el gasto mensual realizado con la tarjeta Expensify en tu espacio de trabajo.', + issueNewCard: 'Emitir nueva tarjeta', + finishSetup: 'Terminar configuración', + chooseBankAccount: 'Elegir cuenta bancaria', + chooseExistingBank: 'Elige una cuenta bancaria comercial existente para pagar el saldo de su Tarjeta Expensify o añade una nueva cuenta bancaria.', + accountEndingIn: 'Cuenta terminada en', + addNewBankAccount: 'Añadir nueva cuenta bancaria', + settlementAccount: 'Cuenta de liquidación', + settlementAccountDescription: 'Elige una cuenta para pagar el saldo de tu Tarjeta Expensify.', + settlementAccountInfoPt1: 'Asegúrate de que esta cuenta coincide con tu', + settlementAccountInfoPt2: 'para que Reconciliación Continua funcione correctamente.', + reconciliationAccount: 'Cuenta de conciliación', + settlementFrequency: 'Frecuencia de liquidación', + settlementFrequencyDescription: 'Elige con qué frecuencia pagarás el saldo de tu Tarjeta Expensify', + settlementFrequencyInfo: + 'Si deseas cambiar a la liquidación mensual, deberás conectar tu cuenta bancaria a través de Plaid y tener un historial de saldo positivo en los últimos 90 días.', + frequency: { + daily: 'Cada día', + monthly: 'Mensual', + }, + cardDetails: 'Datos de la tarjeta', + virtual: 'Virtual', + physical: 'Física', + deactivate: 'Desactivar tarjeta', + changeCardLimit: 'Modificar el límite de la tarjeta', + changeLimit: 'Modificar límite', + smartLimitWarning: (limit: string) => + `Si cambias el límite de esta tarjeta a ${limit}, las nuevas transacciones serán rechazadas hasta que apruebes antiguos gastos de la tarjeta.`, + monthlyLimitWarning: (limit: string) => `Si cambias el límite de esta tarjeta a ${limit}, las nuevas transacciones serán rechazadas hasta el próximo mes.`, + fixedLimitWarning: (limit: string) => `Si cambias el límite de esta tarjeta a ${limit}, se rechazarán las nuevas transacciones.`, + changeCardLimitType: 'Modificar el tipo de límite de la tarjeta', + changeLimitType: 'Modificar el tipo de límite', + changeCardSmartLimitTypeWarning: (limit: string) => + `Si cambias el tipo de límite de esta tarjeta a Límite inteligente, las nuevas transacciones serán rechazadas porque ya se ha alcanzado el límite de ${limit} no aprobado.`, + changeCardMonthlyLimitTypeWarning: (limit: string) => + `Si cambias el tipo de límite de esta tarjeta a Mensual, las nuevas transacciones serán rechazadas porque ya se ha alcanzado el límite de ${limit} mensual.`, + addShippingDetails: 'Añadir detalles de envío', + issuedCard: (assignee: string) => `¡emitió a ${assignee} una Tarjeta Expensify! La tarjeta llegará en 2-3 días laborables.`, + issuedCardNoShippingDetails: (assignee: string) => `¡emitió a ${assignee} una Tarjeta Expensify! La tarjeta se enviará una vez que se agreguen los detalles de envío.`, + issuedCardVirtual: ({assignee, link}: IssueVirtualCardParams) => `¡emitió a ${assignee} una ${link} virtual! La tarjeta puede utilizarse inmediatamente.`, + addedShippingDetails: (assignee: string) => `${assignee} agregó los detalles de envío. La Tarjeta Expensify llegará en 2-3 días hábiles.`, + }, + categories: { + deleteCategories: 'Eliminar categorías', + deleteCategoriesPrompt: '¿Estás seguro de que quieres eliminar estas categorías?', + deleteCategory: 'Eliminar categoría', + deleteCategoryPrompt: '¿Estás seguro de que quieres eliminar esta categoría?', + disableCategories: 'Desactivar categorías', + disableCategory: 'Desactivar categoría', + enableCategories: 'Activar categorías', + enableCategory: 'Activar categoría', + defaultSpendCategories: 'Categorías de gasto predeterminadas', + spendCategoriesDescription: 'Personaliza cómo se categorizan los gastos de los comerciantes para las transacciones con tarjeta de crédito y los recibos escaneados.', + deleteFailureMessage: 'Se ha producido un error al intentar eliminar la categoría. Por favor, inténtalo más tarde.', + categoryName: 'Nombre de la categoría', + requiresCategory: 'Los miembros deben clasificar todos los gastos', + needCategoryForExportToIntegration: 'Se requiere una categoría en cada gasto para poder exportarlo a', + subtitle: 'Obtén una visión general de dónde te gastas el dinero. Utiliza las categorías predeterminadas o añade las tuyas propias.', + emptyCategories: { + title: 'No has creado ninguna categoría', + subtitle: 'Añade una categoría para organizar tu gasto.', + }, + updateFailureMessage: 'Se ha producido un error al intentar eliminar la categoría. Por favor, inténtalo más tarde.', + createFailureMessage: 'Se ha producido un error al intentar crear la categoría. Por favor, inténtalo más tarde.', + addCategory: 'Añadir categoría', + editCategory: 'Editar categoría', + editCategories: 'Editar categorías', + categoryRequiredError: 'Lo nombre de la categoría es obligatorio.', + existingCategoryError: 'Ya existe una categoría con este nombre.', + invalidCategoryName: 'Lo nombre de la categoría es invalido.', + importedFromAccountingSoftware: 'Categorías importadas desde', + payrollCode: 'Código de nómina', + updatePayrollCodeFailureMessage: 'Se produjo un error al actualizar el código de nómina, por favor intente nuevamente.', + glCode: 'Código de Libro Mayor', + updateGLCodeFailureMessage: 'Se produjo un error al actualizar el código de Libro Mayor. Inténtelo nuevamente.', + importCategories: 'Importar categorías', + }, + moreFeatures: { + subtitle: 'Utiliza los botones de abajo para activar más funciones a medida que creces. Cada función aparecerá en el menú de navegación para una mayor personalización.', + spendSection: { + title: 'Gasto', + subtitle: 'Habilita otras funcionalidades que ayudan a aumentar tu equipo.', + }, + manageSection: { + title: 'Gestionar', + subtitle: 'Añade controles que ayudan a mantener los gastos dentro del presupuesto.', + }, + earnSection: { + title: 'Gane', + subtitle: 'Habilita funciones opcionales para agilizar tus ingresos y recibir pagos más rápido.', + }, + organizeSection: { + title: 'Organizar', + subtitle: 'Agrupa y analiza el gasto, registra cada impuesto pagado.', + }, + integrateSection: { + title: 'Integrar', + subtitle: 'Conecta Expensify a otros productos financieros populares.', + }, + distanceRates: { + title: 'Tasas de distancia', + subtitle: 'Añade, actualiza y haz cumplir las tasas.', + }, + expensifyCard: { + title: 'Tarjeta Expensify', + subtitle: 'Obtén información y control sobre tus gastos.', + disableCardTitle: 'Deshabilitar la Tarjeta Expensify', + disableCardPrompt: 'No puedes deshabilitar la Tarjeta Expensify porque ya está en uso. Por favor, contacta con Concierge para conocer los pasos a seguir.', + disableCardButton: 'Chatear con Concierge', + feed: { + title: 'Consigue la Tarjeta Expensify', + subTitle: 'Optimiza tu negocio con la Tarjeta Expensify.', + features: { + cashBack: 'Devolución de dinero en cada compra en Estados Unidos', + unlimited: 'Un número ilimitado de tarjetas virtuales', + spend: 'Controles de gastos y límites personalizados', + }, + ctaTitle: 'Emitir nueva tarjeta', + }, + }, + companyCards: { + title: 'Tarjetas de empresa', + subtitle: 'Importar gastos de las tarjetas de empresa existentes.', + feed: { + title: 'Importar tarjetas de empresa', + features: { + support: 'Compatibilidad con los principales proveedores de tarjetas', + assignCards: 'Asignar tarjetas a todo el equipo', + automaticImport: 'Importación automática de transacciones', + }, + ctaTitle: 'Añadir tarjetas de empresa', + }, + disableCardTitle: 'Deshabilitar tarjetas de empresa', + disableCardPrompt: 'No puedes deshabilitar las tarjetas de empresa porque esta función está en uso. Por favor, contacta a Concierge para los próximos pasos.', + disableCardButton: 'Chatear con Concierge', + cardDetails: 'Datos de la tarjeta', + cardNumber: 'Número de la tarjeta', + cardholder: 'Titular de la tarjeta', + cardName: 'Nombre de la tarjeta', + integrationExport: (integration: string, type: string) => `Exportación a ${integration} ${type}`, + integrationExportTitleFirstPart: (integration: string) => `Seleccione la cuenta ${integration} donde se deben exportar las transacciones. Seleccione una cuenta diferente`, + integrationExportTitleLinkPart: 'opción de exportación', + integrationExportTitleSecondPart: 'para cambiar las cuentas disponibles.', + lastUpdated: 'Última actualización', + transactionStartDate: 'Fecha de inicio de transacciones', + updateCard: 'Actualizar tarjeta', + unassignCard: 'Desasignar tarjeta', + unassign: 'Desasignar', + unassignCardDescription: 'Desasignar esta tarjeta eliminará todas las transacciones en informes en borrador de la cuenta del titular.', + assignCard: 'Asignar tarjeta', + cardFeedName: 'Nombre del feed de tarjeta', + cardFeedNameDescription: 'Dale al feed de tarjeta un nombre único para que puedas distinguirlo de los demás.', + cardFeedTransaction: 'Eliminar transacciones', + cardFeedTransactionDescription: 'Elige si los titulares de tarjetas pueden eliminar transacciones de tarjetas. Las nuevas transacciones seguirán estas reglas.', + cardFeedRestrictDeletingTransaction: 'Restringir eliminación de transacciones', + cardFeedAllowDeletingTransaction: 'Permitir eliminación de transacciones', + removeCardFeed: 'Quitar la alimentación de tarjetas', + removeCardFeedTitle: ({feedName}: CompanyCardFeedNameParams) => `Eliminar el feed de ${feedName}`, + removeCardFeedDescription: '¿Estás seguro de que deseas eliminar esta fuente de tarjetas? Esto anulará la asignación de todas las tarjetas.', + error: { + feedNameRequired: 'Se requiere el nombre de la fuente de la tarjeta.', + }, + corporate: 'Restringir eliminación de transacciones', + personal: 'Permitir eliminación de transacciones', + setFeedNameDescription: 'Dale al feed de tarjeta un nombre único para que puedas distinguirlo de los demás.', + setTransactionLiabilityDescription: + 'Cuando está habilitada, los titulares de tarjetas pueden eliminar transacciones con tarjeta. Las transacciones nuevas seguirán esta regla.', + emptyAddedFeedTitle: 'Asignar tarjetas de empresa', + emptyAddedFeedDescription: 'Comienza asignando tu primera tarjeta a un miembro.', + pendingFeedTitle: `Estamos revisando tu solicitud...`, + pendingFeedDescription: `Actualmente estamos revisando los detalles de tu feed. Una vez hecho esto, nos pondremos en contacto contigo a través de`, + giveItNameInstruction: 'Nombra la tarjeta para distingirla de las demás.', + updating: 'Actualizando...', + noAccountsFound: 'No se han encontrado cuentas', + noAccountsFoundDescription: (connection: string) => `Añade la cuenta en ${connection} y sincroniza la conexión de nuevo.`, + }, + workflows: { + title: 'Flujos de trabajo', + subtitle: 'Configura cómo se aprueba y paga los gastos.', + }, + invoices: { + title: 'Facturas', + subtitle: 'Enviar y recibir facturas.', + }, + categories: { + title: 'Categorías', + subtitle: 'Monitoriza y organiza los gastos.', + }, + tags: { + title: 'Etiquetas', + subtitle: 'Clasifica costes y rastrea gastos facturables.', + }, + taxes: { + title: 'Impuestos', + subtitle: 'Documenta y reclama los impuestos aplicables.', + }, + reportFields: { + title: 'Campos de informes', + subtitle: 'Configura campos personalizados para los gastos.', + }, + connections: { + title: 'Contabilidad', + subtitle: 'Sincroniza tu plan de cuentas y otras opciones.', + }, + connectionsWarningModal: { + featureEnabledTitle: 'No tan rápido...', + featureEnabledText: 'Para activar o desactivar esta función, cambia la configuración de importación contable.', + disconnectText: 'Para desactivar la contabilidad, desconecta tu conexión contable del espacio de trabajo.', + manageSettings: 'Gestionar la configuración', + }, + rules: { + title: 'Reglas', + subtitle: 'Configura cuándo se exigen los recibos, marca los gastos elevados y mucho más.', + }, + }, + reportFields: { + addField: 'Añadir campo', + delete: 'Eliminar campo', + deleteFields: 'Eliminar campos', + deleteConfirmation: '¿Está seguro de que desea eliminar este campo del informe?', + deleteFieldsConfirmation: '¿Está seguro de que desea eliminar estos campos del informe?', + emptyReportFields: { + title: 'No has creado ningún campo de informe', + subtitle: 'Añade un campo personalizado (texto, fecha o desplegable) que aparezca en los informes.', + }, + subtitle: 'Los campos de informe se aplican a todos los gastos y pueden ser útiles cuando quieras solicitar información adicional.', + disableReportFields: 'Desactivar campos de informe', + disableReportFieldsConfirmation: 'Estás seguro? Se eliminarán los campos de texto y fecha y se desactivarán las listas.', + importedFromAccountingSoftware: 'Campos de informes importadas desde', + textType: 'Texto', + dateType: 'Fecha', + dropdownType: 'Lista', + textAlternateText: 'Añade un campo para introducir texto libre.', + dateAlternateText: 'Añade un calendario para la selección de fechas.', + dropdownAlternateText: 'Añade una lista de opciones para elegir.', + nameInputSubtitle: 'Elige un nombre para el campo del informe.', + typeInputSubtitle: 'Elige qué tipo de campo de informe utilizar.', + initialValueInputSubtitle: 'Ingresa un valor inicial para mostrar en el campo del informe.', + listValuesInputSubtitle: 'Estos valores aparecerán en el desplegable del campo de tu informe. Los miembros pueden seleccionar los valores habilitados.', + listInputSubtitle: 'Estos valores aparecerán en la lista de campos de tu informe. Los miembros pueden seleccionar los valores habilitados.', + deleteValue: 'Eliminar valor', + deleteValues: 'Eliminar valores', + disableValue: 'Desactivar valor', + disableValues: 'Desactivar valores', + enableValue: 'Habilitar valor', + enableValues: 'Habilitar valores', + emptyReportFieldsValues: { + title: 'No has creado ningún valor en la lista', + subtitle: 'Añade valores personalizados para que aparezcan en los informes.', + }, + deleteValuePrompt: '¿Estás seguro de que quieres eliminar este valor de la lista?', + deleteValuesPrompt: '¿Estás seguro de que quieres eliminar estos valores de la lista?', + listValueRequiredError: 'Ingresa un nombre para el valor de la lista', + existingListValueError: 'Ya existe un valor en la lista con este nombre', + editValue: 'Editar valor', + listValues: 'Valores de la lista', + addValue: 'Añade valor', + existingReportFieldNameError: 'Ya existe un campo de informe con este nombre', + reportFieldNameRequiredError: 'Ingresa un nombre de campo de informe', + reportFieldTypeRequiredError: 'Elige un tipo de campo de informe', + reportFieldInitialValueRequiredError: 'Elige un valor inicial de campo de informe', + genericFailureMessage: 'Se ha producido un error al actualizar el campo de informe. Por favor, inténtalo de nuevo.', + }, + tags: { + tagName: 'Nombre de etiqueta', + requiresTag: 'Los miembros deben etiquetar todos los gastos', + trackBillable: 'Permitir marcar gastos como facturables', + customTagName: 'Nombre de etiqueta personalizada', + enableTag: 'Habilitar etiqueta', + enableTags: 'Habilitar etiquetas', + disableTag: 'Desactivar etiqueta', + disableTags: 'Desactivar etiquetas', + addTag: 'Añadir etiqueta', + editTag: 'Editar etiqueta', + subtitle: 'Las etiquetas añaden formas más detalladas de clasificar los costos.', + emptyTags: { + title: 'No has creado ninguna etiqueta', + subtitle: 'Añade una etiqueta para realizar el seguimiento de proyectos, ubicaciones, departamentos y otros.', + }, + deleteTag: 'Eliminar etiqueta', + deleteTags: 'Eliminar etiquetas', + deleteTagConfirmation: '¿Estás seguro de que quieres eliminar esta etiqueta?', + deleteTagsConfirmation: '¿Estás seguro de que quieres eliminar estas etiquetas?', + deleteFailureMessage: 'Se ha producido un error al intentar eliminar la etiqueta. Por favor, inténtalo más tarde.', + tagRequiredError: 'Lo nombre de la etiqueta es obligatorio.', + existingTagError: 'Ya existe una etiqueta con este nombre.', + invalidTagNameError: 'El nombre de la etiqueta no puede ser 0. Por favor, elige un valor diferente.', + genericFailureMessage: 'Se ha producido un error al actualizar la etiqueta. Por favor, inténtelo nuevamente.', + importedFromAccountingSoftware: 'Etiquetas importadas desde', + glCode: 'Código de Libro Mayor', + updateGLCodeFailureMessage: 'Se produjo un error al actualizar el código de Libro Mayor. Por favor, inténtelo nuevamente.', + tagRules: 'Reglas de etiquetas', + approverDescription: 'Aprobador', + importTags: 'Importar categorías', + importedTagsMessage: (columnCounts: number) => + `Hemos encontrado *${columnCounts} columnas* en su hoja de cálculo. Seleccione *Nombre* junto a la columna que contiene los nombres de las etiquetas. También puede seleccionar *Habilitado* junto a la columna que establece el estado de la etiqueta.`, + }, + taxes: { + subtitle: 'Añade nombres, tasas y establezca valores por defecto para los impuestos.', + addRate: 'Añadir tasa', + workspaceDefault: 'Moneda por defecto del espacio de trabajo', + foreignDefault: 'Moneda extranjera por defecto', + customTaxName: 'Nombre del impuesto', + value: 'Valor', + taxRate: 'Tasa de impuesto', + taxReclaimableOn: 'Impuesto recuperable en', + error: { + taxRateAlreadyExists: 'Ya existe un impuesto con este nombre.', + taxCodeAlreadyExists: 'Ya existe un código de impuesto con este nombre.', + customNameRequired: 'El nombre del impuesto es obligatorio.', + valuePercentageRange: 'Por favor, introduce un porcentaje entre 0 y 100.', + deleteFailureMessage: 'Se ha producido un error al intentar eliminar la tasa de impuesto. Por favor, inténtalo más tarde.', + updateFailureMessage: 'Se ha producido un error al intentar modificar la tasa de impuesto. Por favor, inténtalo más tarde.', + createFailureMessage: 'Se ha producido un error al intentar crear la tasa de impuesto. Por favor, inténtalo más tarde.', + updateTaxClaimableFailureMessage: 'La porción recuperable debe ser menor al monto del importe por distancia.', + }, + deleteTaxConfirmation: '¿Estás seguro de que quieres eliminar este impuesto?', + deleteMultipleTaxConfirmation: ({taxAmount}) => `¿Estás seguro de que quieres eliminar ${taxAmount} impuestos?`, + actions: { + delete: 'Eliminar tasa', + deleteMultiple: 'Eliminar tasas', + disable: 'Desactivar tasa', + disableMultiple: 'Desactivar tasas', + enable: 'Activar tasa', + enableMultiple: 'Activar tasas', + }, + importedFromAccountingSoftware: 'Impuestos importadas desde', + taxCode: 'Código de impuesto', + updateTaxCodeFailureMessage: 'Se produjo un error al actualizar el código tributario, inténtelo nuevamente.', + }, + emptyWorkspace: { + title: 'Crea un espacio de trabajo', + subtitle: 'En los espacios de trabajo podrás chatear con tu equipo, reembolsar gastos, emitir tarjetas, enviar y pagar facturas, y mucho más - todo en un mismo lugar.', + createAWorkspaceCTA: 'Comenzar', + features: { + trackAndCollect: 'Organiza recibos', + companyCards: 'Tarjetas de crédito corporativas', + reimbursements: 'Reembolsos fáciles', + }, + notFound: 'No se encontró ningún espacio de trabajo', + description: 'Las salas son un gran lugar para discutir y trabajar con varias personas. Para comenzar a colaborar, cree o únase a un espacio de trabajo', + }, + switcher: { + headerTitle: 'Elige un espacio de trabajo', + everythingSection: 'Todo', + placeholder: 'Encuentra un espacio de trabajo', + }, + new: { + newWorkspace: 'Nuevo espacio de trabajo', + getTheExpensifyCardAndMore: 'Consigue la Tarjeta Expensify y más', + }, + people: { + genericFailureMessage: 'Se ha producido un error al intentar eliminar a un miembro del espacio de trabajo. Por favor, inténtalo más tarde.', + removeMembersPrompt: '¿Estás seguro de que deseas eliminar a estos miembros?', + removeMembersWarningPrompt: ({memberName, ownerName}: RemoveMembersWarningPrompt) => + `${memberName} es un aprobador en este espacio de trabajo. Cuando lo elimine de este espacio de trabajo, los sustituiremos en el flujo de trabajo de aprobación por el propietario del espacio de trabajo, ${ownerName}`, + removeMembersTitle: 'Eliminar miembros', + removeWorkspaceMemberButtonTitle: 'Eliminar del espacio de trabajo', + removeGroupMemberButtonTitle: 'Eliminar del grupo', + removeRoomMemberButtonTitle: 'Eliminar del chat', + removeMemberPrompt: ({memberName}: {memberName: string}) => `¿Estás seguro de que deseas eliminar a ${memberName}?`, + removeMemberTitle: 'Eliminar miembro', + transferOwner: 'Transferir la propiedad', + makeMember: 'Hacer miembro', + makeAdmin: 'Hacer administrador', + makeAuditor: 'Hacer auditor', + selectAll: 'Seleccionar todo', + error: { + genericAdd: 'Ha ocurrido un problema al añadir el miembro al espacio de trabajo.', + cannotRemove: 'No puedes eliminarte ni a ti mismo ni al dueño del espacio de trabajo.', + genericRemove: 'Ha ocurrido un problema al eliminar al miembro del espacio de trabajo.', + }, + addedWithPrimary: 'Se agregaron algunos miembros con sus nombres de usuario principales.', + invitedBySecondaryLogin: ({secondaryLogin}) => `Agregado por nombre de usuario secundario ${secondaryLogin}.`, + membersListTitle: 'Directorio de todos los miembros del espacio de trabajo.', + }, + accounting: { + settings: 'configuración', + title: 'Conexiones', + subtitle: 'Conecta a tu sistema de contabilidad para codificar transacciones con tu plan de cuentas, auto-cotejar pagos, y mantener tus finanzas sincronizadas.', + qbo: 'Quickbooks Online', + xero: 'Xero', + netsuite: 'NetSuite', + intacct: 'Sage Intacct', + connectionName: (integration: ConnectionName) => { + switch (integration) { + case CONST.POLICY.CONNECTIONS.NAME.QBO: + return 'Quickbooks Online'; + case CONST.POLICY.CONNECTIONS.NAME.XERO: + return 'Xero'; + case CONST.POLICY.CONNECTIONS.NAME.NETSUITE: + return 'NetSuite'; + case CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT: + return 'Sage Intacct'; + default: { + return ''; + } + } + }, + errorODIntegration: 'Hay un error con una conexión que se ha configurado en Expensify Classic. ', + goToODToFix: 'Ve a Expensify Classic para solucionar este problema.', + setup: 'Configurar', + lastSync: (relativeDate: string) => `Recién sincronizado ${relativeDate}`, + import: 'Importar', + export: 'Exportar', + advanced: 'Avanzado', + other: 'Otras integraciones', + syncNow: 'Sincronizar ahora', + disconnect: 'Desconectar', + disconnectTitle: (integration?: ConnectionName): string => { + const integrationName = integration && CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration] ? CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration] : 'integración'; + return `Desconectar ${integrationName}`; + }, + connectTitle: (integrationToConnect: ConnectionName): string => `Conectar ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integrationToConnect] ?? 'accounting integration'}`, + syncError: (integration?: ConnectionName): string => { + switch (integration) { + case CONST.POLICY.CONNECTIONS.NAME.QBO: + return 'No se puede conectar a QuickBooks Online.'; + case CONST.POLICY.CONNECTIONS.NAME.XERO: + return 'No se puede conectar a Xero.'; + case CONST.POLICY.CONNECTIONS.NAME.NETSUITE: + return 'No se puede conectar a NetSuite.'; + default: { + return 'No se ha podido conectar a la integración.'; + } + } + }, + accounts: 'Plan de cuentas', + taxes: 'Impuestos', + imported: 'Importado', + notImported: 'No importado', + importAsCategory: 'Importado como categorías', + importTypes: { + [CONST.INTEGRATION_ENTITY_MAP_TYPES.IMPORTED]: 'Importado', + [CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG]: 'Importado como etiquetas', + [CONST.INTEGRATION_ENTITY_MAP_TYPES.DEFAULT]: 'Importado', + [CONST.INTEGRATION_ENTITY_MAP_TYPES.NOT_IMPORTED]: 'No importado', + [CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE]: 'No importado', + [CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD]: 'Importado como campos de informe', + [CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT]: 'Predeterminado del empleado NetSuite', + }, + disconnectPrompt: (currentIntegration?: ConnectionName): string => { + const integrationName = + currentIntegration && CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[currentIntegration] ? CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[currentIntegration] : 'integración'; + return `¿Estás seguro de que quieres desconectar ${integrationName}?`; + }, + connectPrompt: (integrationToConnect: ConnectionName): string => + `¿Estás seguro de que quieres conectar a ${ + CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integrationToConnect] ?? 'esta integración contable' + }? Esto eliminará cualquier conexión contable existente.`, + enterCredentials: 'Ingresa tus credenciales', + connections: { + syncStageName: (stage: PolicyConnectionSyncStage) => { + switch (stage) { + case 'quickbooksOnlineImportCustomers': + return 'Importando clientes'; + case 'quickbooksOnlineImportEmployees': + case 'netSuiteSyncImportEmployees': + case 'intacctImportEmployees': + return 'Importando empleados'; + case 'quickbooksOnlineImportAccounts': + return 'Importando cuentas'; + case 'quickbooksOnlineImportClasses': + return 'Importando clases'; + case 'quickbooksOnlineImportLocations': + return 'Importando localidades'; + case 'quickbooksOnlineImportProcessing': + return 'Procesando datos importados'; + case 'quickbooksOnlineSyncBillPayments': + case 'intacctImportSyncBillPayments': + return 'Sincronizando reportes reembolsados y facturas pagadas'; + case 'quickbooksOnlineSyncTaxCodes': + return 'Importando tipos de impuestos'; + case 'quickbooksOnlineCheckConnection': + return 'Revisando conexión a QuickBooks Online'; + case 'quickbooksOnlineImportMain': + return 'Importando datos desde QuickBooks Online'; + case 'startingImportXero': + return 'Importando datos desde Xero'; + case 'startingImportQBO': + return 'Importando datos desde QuickBooks Online'; + case 'quickbooksOnlineSyncTitle': + return 'Sincronizando datos desde QuickBooks Online'; + case 'quickbooksOnlineSyncLoadData': + case 'xeroSyncStep': + case 'intacctImportData': + return 'Cargando datos'; + case 'quickbooksOnlineSyncApplyCategories': + return 'Actualizando categorías'; + case 'quickbooksOnlineSyncApplyCustomers': + return 'Actualizando clientes/proyectos'; + case 'quickbooksOnlineSyncApplyEmployees': + return 'Actualizando empleados'; + case 'quickbooksOnlineSyncApplyClassesLocations': + return 'Actualizando clases'; + case 'jobDone': + return 'Esperando a que se carguen los datos importados'; + case 'xeroSyncImportChartOfAccounts': + return 'Sincronizando plan de cuentas'; + case 'xeroSyncImportCategories': + return 'Sincronizando categorias'; + case 'xeroSyncImportCustomers': + return 'Sincronizando clientes'; + case 'xeroSyncXeroReimbursedReports': + return 'Marcar los informes de Expensify como reembolsados'; + case 'xeroSyncExpensifyReimbursedReports': + return 'Marcar facturas y recibos de Xero como pagados'; + case 'xeroSyncImportTrackingCategories': + return 'Sincronizando categorías de seguimiento'; + case 'xeroSyncImportBankAccounts': + return 'Sincronizando cuentas bancarias'; + case 'xeroSyncImportTaxRates': + return 'Sincronizando las tasas de impuesto'; + case 'xeroCheckConnection': + return 'Comprobando la conexión a Xero'; + case 'xeroSyncTitle': + return 'Sincronizando los datos de Xero'; + case 'netSuiteSyncConnection': + return 'Iniciando conexión a NetSuite'; + case 'netSuiteSyncCustomers': + return 'Importando clientes'; + case 'netSuiteSyncInitData': + return 'Recuperando datos de NetSuite'; + case 'netSuiteSyncImportTaxes': + return 'Importando impuestos'; + case 'netSuiteSyncImportItems': + return 'Importando artículos'; + case 'netSuiteSyncData': + return 'Importando datos a Expensify'; + case 'netSuiteSyncAccounts': + return 'Sincronizando cuentas'; + case 'netSuiteSyncCurrencies': + return 'Sincronizando divisas'; + case 'netSuiteSyncCategories': + return 'Sincronizando categorías'; + case 'netSuiteSyncReportFields': + return 'Importando datos como campos de informe de Expensify'; + case 'netSuiteSyncTags': + return 'Importando datos como etiquetas de Expensify'; + case 'netSuiteSyncUpdateConnectionData': + return 'Actualizando información de conexión'; + case 'netSuiteSyncNetSuiteReimbursedReports': + return 'Marcando informes de Expensify como reembolsados'; + case 'netSuiteSyncImportCustomLists': + return 'Importando listas personalizadas'; + case 'netSuiteSyncImportSubsidiaries': + return 'Importando subsidiarias'; + case 'netSuiteSyncImportVendors': + return 'Importando proveedores'; + case 'netSuiteSyncExpensifyReimbursedReports': + return 'Marcando facturas y recibos de NetSuite como pagados'; + case 'netSuiteImportVendorsTitle': + return 'Importando proveedores'; + case 'netSuiteImportCustomListsTitle': + return 'Importando listas personalizadas'; + case 'intacctCheckConnection': + return 'Comprobando la conexión a Sage Intacct'; + case 'intacctImportDimensions': + return 'Importando dimensiones'; + case 'intacctImportTitle': + return 'Importando datos desde Sage Intacct'; + default: { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + return `Translation missing for stage: ${stage}`; + } + } + }, + }, + preferredExporter: 'Exportador preferido', + exportPreferredExporterNote: + 'Puede ser cualquier administrador del espacio de trabajo, pero debe ser un administrador de dominio si configura diferentes cuentas de exportación para tarjetas de empresa individuales en la configuración del dominio.', + exportPreferredExporterSubNote: 'Una vez configurado, el exportador preferido verá los informes para exportar en tu cuenta.', + exportAs: 'Exportar cómo', + exportOutOfPocket: ' Exportar gastos por cuenta propia como', + exportCompanyCard: 'Exportar gastos de la tarjeta de empresa como', + exportDate: 'Fecha de exportación', + defaultVendor: 'Proveedor predeterminado', + autoSync: 'Autosincronización', + reimbursedReports: 'Sincronizar informes reembolsados', + cardReconciliation: 'Conciliación de tarjetas', + reconciliationAccount: 'Cuenta de conciliación', + continuousReconciliation: 'Conciliación continua', + saveHoursOnReconciliation: + 'Ahorra horas de conciliación en cada período contable haciendo que Expensify concilie continuamente los extractos y liquidaciones de la Tarjeta Expensify en tu nombre.', + enableContinuousReconciliation: 'Para activar la Conciliación Continua, activa la ', + chooseReconciliationAccount: { + chooseBankAccount: 'Elige la cuenta bancaria con la que se conciliarán los pagos de tu Tarjeta Expensify.', + accountMatches: 'Asegúrate de que esta cuenta coincide con ', + settlementAccount: 'la cuenta de liquidación de tu Tarjeta Expensify ', + reconciliationWorks: (lastFourPAN: string) => `(que termina en ${lastFourPAN}) para que la conciliación continua funcione correctamente.`, + }, + }, + card: { + header: 'Desbloquea Tarjetas Expensify gratis', + headerWithEcard: '¡Tus tarjetas están listas!', + noVBACopy: 'Conecta una cuenta bancaria para emitir Tarjetas Expensify a los miembros de tu espacio de trabajo y acceder a estos increíbles beneficios y más:', + VBANoECardCopy: + 'Añade tu correo electrónico de trabajo para emitir Tarjetas Expensify ilimitadas a los miembros de tu espacio de trabajo y acceder a todas estas increíbles ventajas:', + VBAWithECardCopy: 'Acceda a estos increíbles beneficios y más:', + benefit1: 'Devolución de dinero en cada compra en Estados Unidos', + benefit2: 'Tarjetas virtuales y físicas ilimitadas', + benefit3: 'Sin responsabilidad personal', + benefit4: 'Límites personalizables', + addWorkEmail: 'Añadir correo electrónico de trabajo', + checkingDomain: '¡Un momento! Estamos todavía trabajando para habilitar tu Tarjeta Expensify. Vuelve aquí en unos minutos.', + issueCard: 'Emitir tarjeta', + issueAndManageCards: 'Emitir y gestionar Tarjetas Expensify', + getStartedIssuing: 'Empieza emitiendo tu primera tarjeta virtual o física.', + issueNewCard: { + whoNeedsCard: '¿Quién necesita una tarjeta?', + findMember: 'Buscar miembro', + chooseCardType: 'Elegir un tipo de tarjeta', + physicalCard: 'Tarjeta física', + physicalCardDescription: 'Ideal para los consumidores habituales', + virtualCard: 'Tarjeta virtual', + virtualCardDescription: 'Instantáneo y flexible', + chooseLimitType: 'Elegir un tipo de límite', + smartLimit: 'Límite inteligente', + smartLimitDescription: 'Gasta hasta una determinada cantidad antes de requerir aprobación', + monthly: 'Mensual', + monthlyDescription: 'Gasta hasta una determinada cantidad al mes', + fixedAmount: 'Cantidad fija', + fixedAmountDescription: 'Gasta hasta una determinada cantidad una vez', + setLimit: 'Establecer un límite', + giveItName: 'Dale un nombre', + giveItNameInstruction: 'Hazlo lo suficientemente único para distinguirla de otras tarjetas. ¡Los casos de uso específicos son aún mejores!', + cardName: 'Nombre de la tarjeta', + letsDoubleCheck: 'Vuelve a comprobar que todo parece correcto. ', + willBeReady: 'Esta tarjeta estará lista para su uso inmediato.', + cardholder: 'Titular de la tarjeta', + cardType: 'Tipo de tarjeta', + limit: 'Limite', + limitType: 'Tipo de limite', + name: 'Nombre', + }, + deactivateCardModal: { + deactivate: 'Desactivar', + deactivateCard: 'Desactivar tarjeta', + deactivateConfirmation: 'Al desactivar esta tarjeta, se rechazarán todas las transacciones futuras y no se podrá deshacer.', + }, + }, + reimburse: { + captureReceipts: 'Captura recibos', + fastReimbursementsHappyMembers: '¡Reembolsos rápidos = miembros felices!', + viewAllReceipts: 'Ver todos los recibos', + reimburseReceipts: 'Reembolsar recibos', + trackDistance: 'Medir distancia', + trackDistanceCopy: 'Configura la tarifa y unidad usadas para medir distancias.', + trackDistanceRate: 'Tarifa', + trackDistanceUnit: 'Unidad', + trackDistanceChooseUnit: 'Elige una unidad predeterminada de medida.', + unlockNextDayReimbursements: 'Desbloquea reembolsos diarios', + captureNoVBACopyBeforeEmail: 'Pide a los miembros de tu espacio de trabajo que envíen recibos a ', + captureNoVBACopyAfterEmail: ' y descarga la app de Expensify para controlar tus gastos en efectivo sobre la marcha.', + unlockNoVBACopy: 'Conecta una cuenta bancaria para reembolsar online a los miembros de tu espacio de trabajo.', + fastReimbursementsVBACopy: '¡Todo listo para reembolsar recibos desde tu cuenta bancaria!', + updateCustomUnitError: 'Los cambios no han podido ser guardados. El espacio de trabajo ha sido modificado mientras estabas desconectado. Por favor, inténtalo de nuevo.', + invalidRateError: 'Por favor, introduce una tarifa válida.', + lowRateError: 'La tarifa debe ser mayor que 0.', + }, + export: { + notReadyHeading: 'No está listo para exportar', + notReadyDescription: + 'Los borradores o informes de gastos pendientes no se pueden exportar al sistema contabilidad. Por favor, apruebe o pague estos gastos antes de exportarlos.', + }, + bills: { + manageYourBills: 'Gestiona tus facturas', + askYourVendorsBeforeEmail: 'Pide a tus proveedores que envíen sus facturas a ', + askYourVendorsAfterEmail: ' y las escanearemos para que las pagues.', + viewAllBills: 'Ver facturas recibidas', + unlockOnlineBillPayment: 'Desbloquea el pago de facturas online', + unlockNoVBACopy: '¡Conecta tu cuenta bancaria para pagar tus facturas online de manera gratuita!', + hassleFreeBills: '¡Facturas sin complicaciones!', + VBACopy: '¡Todo listo para realizar pagos desde tu cuenta bancaria!', + }, + invoices: { + invoiceClientsAndCustomers: 'Emite facturas a tus clientes', + invoiceFirstSectionCopy: 'Envía facturas detalladas y profesionales directamente a tus clientes desde la app de Expensify.', + viewAllInvoices: 'Ver facturas emitidas', + unlockOnlineInvoiceCollection: 'Desbloquea el cobro de facturas online', + unlockNoVBACopy: 'Conecta tu cuenta bancaria para recibir pagos de facturas online por transferencia o con tarjeta.', + moneyBackInAFlash: '¡Tu dinero de vuelta en un momento!', + unlockVBACopy: '¡Todo listo para recibir pagos por transferencia o con tarjeta!', + viewUnpaidInvoices: 'Ver facturas emitidas pendientes', + sendInvoice: 'Enviar factura', + sendFrom: 'Enviar desde', + invoicingDetails: 'Detalles de facturación', + invoicingDetailsDescription: 'Esta información aparecerá en tus facturas.', + companyName: 'Nombre de la empresa', + companyWebsite: 'Sitio web de la empresa', + paymentMethods: { + personal: 'Personal', + business: 'Empresas', + chooseInvoiceMethod: 'Elija un método de pago:', + addBankAccount: 'Añadir cuenta bancaria', + payingAsIndividual: 'Pago individual', + payingAsBusiness: 'Pagar como una empresa', + }, + }, + travel: { + unlockConciergeBookingTravel: 'Desbloquea la reserva de viajes con Concierge', + noVBACopy: 'Conecta tu cuenta bancaria para permitir a los miembros de tu espacio de trabajo reservar sus vuelos, hoteles y coches empezando una conversación con Concierge.', + packYourBags: '¡Haz las maletas!', + VBACopy: '¡Miembros con la tarjeta Expensify pueden hablar con Concierge para reservar viajes!', + bookTravelWithConcierge: 'Reserva viajes con Concierge', + }, + invite: { + member: 'Invitar miembros', + members: 'Invitar miembros', + invitePeople: 'Invitar nuevos miembros', + genericFailureMessage: 'Se ha producido un error al invitar al miembro al espacio de trabajo. Vuelva a intentarlo.', + pleaseEnterValidLogin: `Asegúrese de que el correo electrónico o el número de teléfono sean válidos (p. ej. ${CONST.EXAMPLE_PHONE_NUMBER}).`, + user: 'miembro', + users: 'miembros', + invited: 'invitó', + removed: 'eliminó', + leftWorkspace: 'salió del espacio de trabajo', + to: 'a', + from: 'de', + }, + inviteMessage: { + inviteMessageTitle: 'Añadir un mensaje', + inviteMessagePrompt: 'Añadir un mensaje para hacer tu invitación destacar', + personalMessagePrompt: 'Mensaje', + inviteNoMembersError: 'Por favor, selecciona al menos un miembro a invitar.', + genericFailureMessage: 'Se ha producido un error al invitar al miembro al espacio de trabajo. Por favor, vuelva a intentarlo.', + }, + distanceRates: { + oopsNotSoFast: 'Ups! No tan rápido...', + workspaceNeeds: 'Un espacio de trabajo necesita al menos una tasa de distancia activa.', + distance: 'Distancia', + centrallyManage: 'Gestiona centralizadamente las tasas, elige si contabilizar en millas o kilómetros, y define una categoría por defecto', + rate: 'Tasa', + addRate: 'Agregar tasa', + trackTax: 'Impuesto de seguimiento', + deleteRates: ({count}: DistanceRateOperationsParams) => `Eliminar ${Str.pluralize('tasa', 'tasas', count)}`, + enableRates: ({count}: DistanceRateOperationsParams) => `Activar ${Str.pluralize('tasa', 'tasas', count)}`, + disableRates: ({count}: DistanceRateOperationsParams) => `Desactivar ${Str.pluralize('tasa', 'tasas', count)}`, + enableRate: 'Activar tasa', + status: 'Estado', + unit: 'Unidad', + taxFeatureNotEnabledMessage: 'Los impuestos deben estar activados en el área de trabajo para poder utilizar esta función. Dirígete a ', + changePromptMessage: ' para hacer ese cambio.', + defaultCategory: 'Categoría predeterminada', + deleteDistanceRate: 'Eliminar tasa de distancia', + areYouSureDelete: ({count}: DistanceRateOperationsParams) => `¿Estás seguro de que quieres eliminar ${Str.pluralize('esta tasa', 'estas tasas', count)}?`, + }, + editor: { + nameInputLabel: 'Nombre', + descriptionInputLabel: 'Descripción', + typeInputLabel: 'Tipo', + initialValueInputLabel: 'Valor inicial', + nameInputHelpText: 'Este es el nombre que verás en tu espacio de trabajo.', + nameIsRequiredError: 'Debes definir un nombre para tu espacio de trabajo.', + currencyInputLabel: 'Moneda por defecto', + currencyInputHelpText: 'Todas los gastos en este espacio de trabajo serán convertidos a esta moneda.', + currencyInputDisabledText: 'La moneda predeterminada no se puede cambiar porque este espacio de trabajo está vinculado a una cuenta bancaria en USD.', + save: 'Guardar', + genericFailureMessage: 'Se ha producido un error al guardar el espacio de trabajo. Por favor, inténtalo de nuevo.', + avatarUploadFailureMessage: 'No se pudo subir el avatar. Por favor, inténtalo de nuevo.', + addressContext: 'Se requiere una dirección para habilitar Expensify Travel. Por favor, introduce una dirección asociada con tu negocio.', + }, + bankAccount: { + continueWithSetup: 'Continuar con la configuración', + youreAlmostDone: 'Casi has acabado de configurar tu cuenta bancaria, que te permitirá emitir tarjetas corporativas, reembolsar gastos y cobrar pagar facturas.', + streamlinePayments: 'Optimiza pagos', + connectBankAccountNote: 'Nota: No se pueden usar cuentas bancarias personales para realizar pagos en los espacios de trabajo.', + oneMoreThing: '¡Una cosa más!', + allSet: '¡Todo listo!', + accountDescriptionWithCards: 'Esta cuenta bancaria se utilizará para emitir tarjetas corporativas, reembolsar gastos y cobrar y pagar facturas.', + letsFinishInChat: '¡Continuemos en el chat!', + almostDone: '¡Casi listo!', + disconnectBankAccount: 'Desconectar cuenta bancaria', + noLetsStartOver: 'No, empecemos de nuevo', + startOver: 'Empezar de nuevo', + yesDisconnectMyBankAccount: 'Sí, desconecta mi cuenta bancaria', + yesStartOver: 'Sí, empezar de nuevo', + disconnectYour: 'Desconecta tu cuenta bancaria de ', + bankAccountAnyTransactions: '. Los reembolsos pendientes serán completados sin problemas.', + clearProgress: 'Empezar de nuevo descartará lo completado hasta ahora.', + areYouSure: '¿Estás seguro?', + workspaceCurrency: 'Moneda del espacio de trabajo', + updateCurrencyPrompt: + 'Parece que tu espacio de trabajo está configurado actualmente en una moneda diferente a USD. Por favor, haz clic en el botón de abajo para actualizar tu moneda a USD ahora.', + updateToUSD: 'Actualizar a USD', + }, + changeOwner: { + changeOwnerPageTitle: 'Transferir la propiedad', + addPaymentCardTitle: 'Ingrese tu tarjeta de pago para transferir la propiedad', + addPaymentCardButtonText: 'Aceptar términos y agregar tarjeta de pago', + addPaymentCardReadAndAcceptTextPart1: 'Lea y acepte', + addPaymentCardReadAndAcceptTextPart2: 'para agregar tu tarjeta', + addPaymentCardTerms: 'los términos', + addPaymentCardPrivacy: 'la política de privacidad', + addPaymentCardAnd: 'y', + addPaymentCardPciCompliant: 'PCI-DSS obediente', + addPaymentCardBankLevelEncrypt: 'Cifrado a nivel bancario', + addPaymentCardRedundant: 'Infraestructura redundante', + addPaymentCardLearnMore: 'Conozca más sobre nuestra', + addPaymentCardSecurity: 'seguridad', + amountOwedTitle: 'Saldo pendiente', + amountOwedButtonText: 'OK', + amountOwedText: 'Esta cuenta tiene un saldo pendiente de un mes anterior.\n\n¿Quiere liquidar el saldo y hacerse cargo de la facturación de este espacio de trabajo?', + ownerOwesAmountTitle: 'Saldo pendiente', + ownerOwesAmountButtonText: 'Transferir saldo', + ownerOwesAmountText: ({email, amount}) => + `La cuenta propietaria de este espacio de trabajo (${email}) tiene un saldo pendiente de un mes anterior.\n\n¿Desea transferir este monto (${amount}) para hacerse cargo de la facturación de este espacio de trabajo? tu tarjeta de pago se cargará inmediatamente.`, + subscriptionTitle: 'Asumir la suscripción anual', + subscriptionButtonText: 'Transferir suscripción', + subscriptionText: ({usersCount, finalCount}) => + `Al hacerse cargo de este espacio de trabajo se fusionará tu suscripción anual asociada con tu suscripción actual. Esto aumentará el tamaño de tu suscripción en ${usersCount} miembros, lo que hará que tu nuevo tamaño de suscripción sea ${finalCount}. ¿Te gustaria continuar?`, + duplicateSubscriptionTitle: 'Alerta de suscripción duplicada', + duplicateSubscriptionButtonText: 'Continuar', + duplicateSubscriptionText: ({email, workspaceName}) => + `Parece que estás intentando hacerte cargo de la facturación de los espacios de trabajo de ${email}, pero para hacerlo, primero debes ser administrador de todos sus espacios de trabajo.\n\nHaz clic en "Continuar" si solo quieres tomar sobrefacturación para el espacio de trabajo ${workspaceName}.\n\nSi desea hacerse cargo de la facturación de toda tu suscripción, pídales que lo agreguen como administrador a todos sus espacios de trabajo antes de hacerse cargo de la facturación.`, + hasFailedSettlementsTitle: 'No se puede transferir la propiedad', + hasFailedSettlementsButtonText: 'Entiendo', + hasFailedSettlementsText: ({email}) => + `No puede hacerse cargo de la facturación porque ${email} tiene una liquidación vencida de la tarjeta Expensify. Avíseles que se comuniquen con concierge@expensify.com para resolver el problema. Luego, podrá hacerse cargo de la facturación de este espacio de trabajo.`, + failedToClearBalanceTitle: 'Fallo al liquidar el saldo', + failedToClearBalanceButtonText: 'OK', + failedToClearBalanceText: 'No hemos podido liquidar el saldo, por favor, inténtalo más tarde.', + successTitle: '¡Guau! Todo listo.', + successDescription: 'Ahora eres el propietario de este espacio de trabajo.', + errorTitle: '¡Ups! No tan rapido...', + errorDescriptionPartOne: 'Hubo un problema al transferir la propiedad de este espacio de trabajo. Inténtalo de nuevo, o', + errorDescriptionPartTwo: 'contacta con el conserje', + errorDescriptionPartThree: 'por ayuda.', + }, + + exportAgainModal: { + title: '¡Cuidado!', + description: (reportName: string, connectionName: ConnectionName) => + `Los siguientes informes ya se han exportado a ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]}:\n\n${reportName}\n\n¿Estás seguro de que deseas exportarlos de nuevo?`, + confirmText: 'Sí, exportar de nuevo', + cancelText: 'Cancelar', + }, + upgrade: { + reportFields: { + title: 'Los campos', + description: `Los campos de informe permiten especificar detalles a nivel de cabecera, distintos de las etiquetas que pertenecen a los gastos en partidas individuales. Estos detalles pueden incluir nombres de proyectos específicos, información sobre viajes de negocios, ubicaciones, etc.`, + onlyAvailableOnPlan: 'Los campos de informe sólo están disponibles en el plan Control, a partir de ', + }, + [CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: { + title: 'NetSuite', + description: `Disfruta de la sincronización automática y reduce las entradas manuales con la integración Expensify + NetSuite. Obtén información financiera en profundidad y en tiempo real con la compatibilidad nativa y personalizada con segmentos, incluida la asignación de proyectos y clientes.`, + onlyAvailableOnPlan: 'Nuestra integración NetSuite sólo está disponible en el plan Control, a partir de ', + }, + [CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT]: { + title: 'Sage Intacct', + description: `Disfruta de una sincronización automatizada y reduce las entradas manuales con la integración Expensify + Sage Intacct. Obtén información financiera en profundidad y en tiempo real con dimensiones definidas por el usuario, así como codificación de gastos por departamento, clase, ubicación, cliente y proyecto (trabajo).`, + onlyAvailableOnPlan: 'Nuestra integración Sage Intacct sólo está disponible en el plan Control, a partir de ', + }, + [CONST.UPGRADE_FEATURE_INTRO_MAPPING.approvals.id]: { + title: 'Aprobaciones anticipadas', + description: `Si quieres añadir más niveles de aprobación, o simplemente asegurarte de que los gastos más importantes reciben otro vistazo, no hay problema. Las aprobaciones avanzadas ayudan a realizar las comprobaciones adecuadas a cada nivel para mantener los gastos de tu equipo bajo control.`, + onlyAvailableOnPlan: 'Las aprobaciones avanzadas sólo están disponibles en el plan Control, con precios desde ', + }, + glCodes: { + title: 'Códigos de libro mayor', + description: `Añada códigos de libro mayor a sus categorías para exportar fácilmente los gastos a sus sistemas de contabilidad y nómina.`, + onlyAvailableOnPlan: 'Los códigos de libro mayor solo están disponibles en el plan Control, a partir de ', + }, + glAndPayrollCodes: { + title: 'Códigos de libro mayor y nómina', + description: `Añada códigos de libro mayor y nómina a sus categorías para exportar fácilmente los gastos a sus sistemas de contabilidad y nómina.`, + onlyAvailableOnPlan: 'Los códigos de libro mayor y nómina solo están disponibles en el plan Control, a partir de ', + }, + taxCodes: { + title: 'Código de impuesto', + description: `Añada código de impuesto mayor a sus categorías para exportar fácilmente los gastos a sus sistemas de contabilidad y nómina.`, + onlyAvailableOnPlan: 'Los código de impuesto mayor solo están disponibles en el plan Control, a partir de ', + }, + companyCards: { + title: 'Tarjetas de empresa', + description: `Las tarjetas de empresa le permiten importar los gastos de las tarjetas de empresa existentes de todos los principales emisores de tarjetas. Puede asignar tarjetas a empleados e importar transacciones automáticamente.`, + onlyAvailableOnPlan: 'Las tarjetas de empresa solo están disponibles en el plan Control, a partir de ', + }, + rules: { + title: 'Reglas', + description: `Las reglas se ejecutan en segundo plano y mantienen tus gastos bajo control para que no tengas que preocuparte por los detalles pequeños.\n\nExige detalles de los gastos, como recibos y descripciones, establece límites y valores predeterminados, y automatiza las aprobaciones y los pagos, todo en un mismo lugar.`, + onlyAvailableOnPlan: 'Las reglas están disponibles solo en el plan Control, que comienza en ', + }, + note: { + upgradeWorkspace: 'Mejore su espacio de trabajo para acceder a esta función, o', + learnMore: 'más información', + aboutOurPlans: 'sobre nuestros planes y precios.', + }, + pricing: { + amount: '$9 ', + perActiveMember: 'por miembro activo al mes.', + }, + upgradeToUnlock: 'Desbloquear esta función', + completed: { + headline: 'Has mejorado tu espacio de trabajo.', + successMessage: (policyName: string) => `Ha mejorado correctamente su espacio de trabajo ${policyName} al plan Control.`, + viewSubscription: 'Ver su suscripción', + moreDetails: 'para obtener más información.', + gotIt: 'Entendido, gracias.', + }, + }, + restrictedAction: { + restricted: 'Restringido', + actionsAreCurrentlyRestricted: ({workspaceName}) => `Las acciones en el espacio de trabajo ${workspaceName} están actualmente restringidas`, + workspaceOwnerWillNeedToAddOrUpdatePaymentCard: ({workspaceOwnerName}) => + `El propietario del espacio de trabajo, ${workspaceOwnerName} tendrá que añadir o actualizar la tarjeta de pago registrada para desbloquear nueva actividad en el espacio de trabajo.`, + youWillNeedToAddOrUpdatePaymentCard: 'Debes añadir o actualizar la tarjeta de pago registrada para desbloquear nueva actividad en el espacio de trabajo.', + addPaymentCardToUnlock: 'Añade una tarjeta para desbloquearlo!', + addPaymentCardToContinueUsingWorkspace: 'Añade una tarjeta de pago para seguir utilizando este espacio de trabajo', + pleaseReachOutToYourWorkspaceAdmin: 'Si tienes alguna pregunta, ponte en contacto con el administrador de su espacio de trabajo.', + chatWithYourAdmin: 'Chatea con tu administrador', + chatInAdmins: 'Chatea en #admins', + addPaymentCard: 'Agregar tarjeta de pago', + }, + rules: { + individualExpenseRules: { + title: 'Gastos', + subtitle: 'Establece controles y valores predeterminados para gastos individuales. También puedes crear reglas para', + receiptRequiredAmount: 'Cantidad requerida para los recibos', + receiptRequiredAmountDescription: 'Exige recibos cuando los gastos superen este importe, a menos que lo anule una regla de categoría.', + maxExpenseAmount: 'Importe máximo del gasto', + maxExpenseAmountDescription: 'Marca los gastos que superen este importe, a menos que una regla de categoría lo anule.', + maxAge: 'Antigüedad máxima', + maxExpenseAge: 'Antigüedad máxima de los gastos', + maxExpenseAgeDescription: 'Marca los gastos de más de un número determinado de días.', + maxExpenseAgeDays: (age: number) => `${age} ${Str.pluralize('día', 'días', age)}`, + billableDefault: 'Valor predeterminado facturable', + billableDefaultDescription: 'Elige si los gastos en efectivo y con tarjeta de crédito deben ser facturables por defecto. Los gastos facturables se activan o desactivan en', + billable: 'Facturable', + billableDescription: 'Los gastos se vuelven a facturar a los clientes en la mayoría de los casos', + nonBillable: 'No facturable', + nonBillableDescription: 'Los gastos se vuelven a facturar a los clientes en ocasiones', + eReceipts: 'Recibos electrónicos', + eReceiptsHint: 'Los recibos electrónicos se crean automáticamente', + eReceiptsHintLink: 'para la mayoría de las transacciones en USD', + }, + expenseReportRules: { + examples: 'Ejemplos:', + title: 'Informes de gastos', + subtitle: 'Automatiza el cumplimiento, la aprobación y el pago de los informes de gastos.', + customReportNamesTitle: 'Nombres personalizados de informes', + customReportNamesSubtitle: 'Crea nombres personalizados usando nuestras fórmulas variadas.', + customNameTitle: 'Nombre personalizado', + customNameDescription: 'Elige un nombre personalizado para los informes de gastos usando nuestras ', + customNameDescriptionLink: 'fórmulas variadas', + customNameInputLabel: 'Nombre', + customNameEmailPhoneExample: 'Correo electrónico o teléfono del miembro: {report:submit:from}', + customNameStartDateExample: 'Fecha de inicio del informe: {report:startdate}', + customNameWorkspaceNameExample: 'Nombre del espacio de trabajo: {report:policyname}', + customNameReportIDExample: 'ID del informe: {report:id}', + customNameTotalExample: 'Total: {report:total}.', + preventMembersFromChangingCustomNamesTitle: 'Evitar que los miembros cambien los nombres personalizados de los informes', + preventSelfApprovalsTitle: 'Evitar autoaprobaciones', + preventSelfApprovalsSubtitle: 'Evita que los miembros del espacio de trabajo aprueben sus propios informes de gastos.', + autoApproveCompliantReportsTitle: 'Aprobación automática de informes conformes', + autoApproveCompliantReportsSubtitle: 'Configura qué informes de gastos pueden aprobarse de forma automática.', + autoApproveReportsUnderTitle: 'Aprobar automáticamente informes por debajo de', + autoApproveReportsUnderDescription: 'Los informes de gastos totalmente conformes por debajo de esta cantidad se aprobarán automáticamente.', + randomReportAuditTitle: 'Auditoría aleatoria de informes', + randomReportAuditDescription: 'Requiere que algunos informes sean aprobados manualmente, incluso si son elegibles para la aprobación automática.', + autoPayApprovedReportsTitle: 'Pago automático de informes aprobados', + autoPayApprovedReportsSubtitle: 'Configura qué informes de gastos pueden pagarse de forma automática.', + autoPayApprovedReportsLimitError: (currency?: string) => `Por favor, introduce un monto menor a ${currency ?? ''}20,000`, + autoPayApprovedReportsLockedSubtitle: 'Ve a más funciones y habilita flujos de trabajo, luego agrega pagos para desbloquear esta función.', + autoPayReportsUnderTitle: 'Pagar automáticamente informes por debajo de', + autoPayReportsUnderDescription: 'Los informes de gastos totalmente conformes por debajo de esta cantidad se pagarán automáticamente.', + unlockFeatureGoToSubtitle: 'Ir a', + unlockFeatureEnableWorkflowsSubtitle: (featureName: string) => `y habilita flujos de trabajo, luego agrega ${featureName} para desbloquear esta función.`, + enableFeatureSubtitle: (featureName: string) => `y habilita ${featureName} para desbloquear esta función.`, + }, + categoryRules: { + title: 'Reglas de categoría', + approver: 'Aprobador', + requireDescription: 'Requerir descripción', + descriptionHint: 'Sugerencia de descripción', + descriptionHintDescription: (categoryName: string) => + `Recuerda a los empleados que deben proporcionar información adicional para los gastos de “${categoryName}”. Esta sugerencia aparece en el campo de descripción en los gastos.`, + descriptionHintLabel: 'Sugerencia', + descriptionHintSubtitle: 'Consejo: ¡Cuanto más corta, mejor!', + maxAmount: 'Importe máximo', + flagAmountsOver: 'Señala importes superiores a', + flagAmountsOverDescription: (categoryName: string) => `Aplica a la categoría “${categoryName}”.`, + flagAmountsOverSubtitle: 'Esto anula el importe máximo para todos los gastos.', + expenseLimitTypes: { + expense: 'Gasto individual', + expenseSubtitle: 'Señala importes de gastos por categoría. Esta regla anula la regla general del espacio de trabajo para el importe máximo de gastos.', + daily: 'Total por categoría', + dailySubtitle: 'Marcar el gasto total por categoría en cada informe de gastos.', + }, + requireReceiptsOver: 'Requerir recibos para importes superiores a', + requireReceiptsOverList: { + default: (defaultAmount: string) => `${defaultAmount} ${CONST.DOT_SEPARATOR} Predeterminado`, + never: 'Nunca requerir recibos', + always: 'Requerir recibos siempre', + }, + defaultTaxRate: 'Tasa de impuesto predeterminada', + goTo: 'Ve a', + andEnableWorkflows: 'y habilita los flujos de trabajo, luego añade aprobaciones para desbloquear esta función.', + }, + }, + }, + getAssistancePage: { + title: 'Obtener ayuda', + subtitle: '¡Estamos aquí para ayudarte!', + description: 'Elige una de las siguientes opciones:', + chatWithConcierge: 'Chatear con Concierge', + scheduleSetupCall: 'Concertar una llamada', + questionMarkButtonTooltip: 'Obtén ayuda de nuestro equipo', + exploreHelpDocs: 'Explorar la documentación de ayuda', + }, + emojiPicker: { + skinTonePickerLabel: 'Elige el tono de piel por defecto', + headers: { + frequentlyUsed: 'Usado frecuentemente', + smileysAndEmotion: 'Emoticonos y emociones', + peopleAndBody: 'Personas y Cuerpo', + animalsAndNature: 'Animales y naturaleza', + foodAndDrink: 'Alimentos y bebidas', + travelAndPlaces: 'Viajes y lugares', + activities: 'Actividades', + objects: 'Objetos', + symbols: 'Símbolos', + flags: 'Banderas', + }, + }, + newRoomPage: { + newRoom: 'Nueva sala de chat', + roomName: 'Nombre de la sala', + visibility: 'Visibilidad', + restrictedDescription: 'Sólo las personas en tu espacio de trabajo pueden encontrar esta sala', + privateDescription: 'Sólo las personas que están invitadas a esta sala pueden encontrarla', + publicDescription: 'Cualquier persona puede unirse a esta sala', + // eslint-disable-next-line @typescript-eslint/naming-convention + public_announceDescription: 'Cualquier persona puede unirse a esta sala', + createRoom: 'Crea una sala de chat', + roomAlreadyExistsError: 'Ya existe una sala con este nombre.', + roomNameReservedError: ({reservedName}: RoomNameReservedErrorParams) => + `${reservedName} es el nombre una sala por defecto de todos los espacios de trabajo. Por favor, elige otro nombre.`, + roomNameInvalidError: 'Los nombres de las salas solo pueden contener minúsculas, números y guiones.', + pleaseEnterRoomName: 'Por favor, escribe el nombre de una sala.', + pleaseSelectWorkspace: 'Por favor, selecciona un espacio de trabajo.', + renamedRoomAction: ({oldName, newName}: RenamedRoomActionParams) => `cambió el nombre de la sala de ${oldName} a ${newName}`, + roomRenamedTo: ({newName}: RoomRenamedToParams) => `Sala renombrada a ${newName}`, + social: 'social', + selectAWorkspace: 'Seleccionar un espacio de trabajo', + growlMessageOnRenameError: 'No se ha podido cambiar el nombre del espacio de trabajo. Por favor, comprueba tu conexión e inténtalo de nuevo.', + visibilityOptions: { + restricted: 'Espacio de trabajo', // the translation for "restricted" visibility is actually workspace. This is so we can display restricted visibility rooms as "workspace" without having to change what's stored. + private: 'Privada', + public: 'Público', + // eslint-disable-next-line @typescript-eslint/naming-convention + public_announce: 'Anuncio Público', + }, + }, + workspaceActions: { + renamedWorkspaceNameAction: ({oldName, newName}) => `actualizó el nombre de este espacio de trabajo de ${oldName} a ${newName}`, + removedFromApprovalWorkflow: ({submittersNames}: {submittersNames: string[]}) => { + let joinedNames = ''; + if (submittersNames.length === 1) { + joinedNames = submittersNames[0]; + } else if (submittersNames.length === 2) { + joinedNames = submittersNames.join(' y '); + } else if (submittersNames.length > 2) { + joinedNames = `${submittersNames.slice(0, submittersNames.length - 1).join(', ')} y ${submittersNames[submittersNames.length - 1]}`; + } + const workflowWord = Str.pluralize('del flujo', 'de los flujos', submittersNames.length); + const chatWord = Str.pluralize('del chat', 'de los chats', submittersNames.length); + return `te eliminó ${workflowWord} de trabajo de aprobaciones y ${chatWord} del espacio de trabajo de ${joinedNames}. Los informes enviados anteriormente seguirán estando disponibles para su aprobación en tu bandeja de entrada.`; + }, + }, + roomMembersPage: { + memberNotFound: 'Miembro no encontrado.', + useInviteButton: 'Para invitar a un nuevo miembro al chat, por favor, utiliza el botón invitar que está más arriba.', + notAuthorized: `No tienes acceso a esta página. Si estás intentando unirte a esta sala, pide a un miembro de la sala que te añada. ¿Necesitas algo más? Comunícate con ${CONST.EMAIL.CONCIERGE}`, + removeMembersPrompt: '¿Estás seguro de que quieres eliminar a los miembros seleccionados de la sala de chat?', + error: { + genericAdd: 'Hubo un problema al añadir este miembro a la sala de chat.', + }, + }, + newTaskPage: { + assignTask: 'Asignar tarea', + assignMe: 'Asignar a mí mismo', + confirmTask: 'Confirmar tarea', + confirmError: 'Por favor, introduce un título y selecciona un destino de tarea.', + descriptionOptional: 'Descripción (opcional)', + pleaseEnterTaskName: 'Por favor, introduce un título', + pleaseEnterTaskDestination: 'Por favor, selecciona dónde deseas compartir esta tarea.', + }, + task: { + task: 'Tarea', + title: 'Título', + description: 'Descripción', + assignee: 'Miembro asignado', + completed: 'Completada', + messages: { + created: ({title}: TaskCreatedActionParams) => `tarea para ${title}`, + completed: 'marcada como completa', + canceled: 'tarea eliminada', + reopened: 'marcada como incompleta', + error: 'No tiene permiso para realizar la acción solicitada.', + }, + markAsComplete: 'Marcar como completada', + markAsIncomplete: 'Marcar como incompleta', + assigneeError: 'Se ha producido un error al asignar esta tarea. Por favor, inténtalo con otro miembro.', + genericCreateTaskFailureMessage: 'Error inesperado al crear la tarea. Por favor, inténtalo más tarde.', + deleteTask: 'Eliminar tarea', + deleteConfirmation: '¿Estás seguro de que quieres eliminar esta tarea?', + }, + statementPage: { + title: (year, monthName) => `Estado de cuenta de ${monthName} ${year}`, + generatingPDF: 'Estamos generando tu PDF ahora mismo. ¡Por favor, vuelve más tarde!', + }, + keyboardShortcutsPage: { + title: 'Atajos de teclado', + subtitle: 'Ahorra tiempo con estos atajos de teclado:', + shortcuts: { + openShortcutDialog: 'Abre el cuadro de diálogo de métodos abreviados de teclado', + escape: 'Diálogos de escape', + search: 'Abrir diálogo de búsqueda', + newChat: 'Nueva pantalla de chat', + copy: 'Copiar comentario', + openDebug: 'Abrir el diálogo de preferencias de pruebas', + }, + }, + guides: { + screenShare: 'Compartir pantalla', + screenShareRequest: 'Expensify te está invitando a compartir la pantalla', + }, + search: { + resultsAreLimited: 'Los resultados de búsqueda están limitados.', + viewResults: 'Ver resultados', + resetFilters: 'Restablecer filtros', + searchResults: { + emptyResults: { + title: 'No hay nada que ver aquí', + subtitle: 'Por favor intenta crear algo usando el botón verde.', + }, + emptyTripResults: { + title: 'No tienes viajes', + subtitle: 'Reserva tu primer viaje a continuación.', + buttonText: 'Reserva un viaje', + }, + }, + saveSearch: 'Guardar búsqueda', + saveSearchTooltipText: 'Puedes cambiar el nombre de tu búsqueda guardada', + savedSearchesMenuItemTitle: 'Guardadas', + searchName: 'Nombre de la búsqueda', + deleteSavedSearch: 'Eliminar búsqueda guardada', + deleteSavedSearchConfirm: '¿Estás seguro de que quieres eliminar esta búsqueda?', + groupedExpenses: 'gastos agrupados', + bulkActions: { + delete: 'Eliminar', + hold: 'Bloquear', + unhold: 'Desbloquear', + noOptionsAvailable: 'No hay opciones disponibles para el grupo de gastos seleccionado.', + }, + filtersHeader: 'Filtros', + filters: { + date: { + before: (date?: string) => `Antes de ${date ?? ''}`, + after: (date?: string) => `Después de ${date ?? ''}`, + }, + status: 'Estado', + keyword: 'Palabra clave', + hasKeywords: 'Tiene palabras clave', + currency: 'Divisa', + link: 'Enlace', + pinned: 'Fijado', + unread: 'No leído', + amount: { + lessThan: (amount?: string) => `Menos de ${amount ?? ''}`, + greaterThan: (amount?: string) => `Más que ${amount ?? ''}`, + between: (greaterThan: string, lessThan: string) => `Entre ${greaterThan} y ${lessThan}`, + }, + current: 'Actual', + past: 'Anterior', + }, + expenseType: 'Tipo de gasto', + }, + genericErrorPage: { + title: '¡Oh-oh, algo salió mal!', + body: { + helpTextMobile: 'Intenta cerrar y volver a abrir la aplicación o cambiar a la', + helpTextWeb: 'web.', + helpTextConcierge: 'Si el problema persiste, comunícate con', + }, + refresh: 'Actualizar', + }, + fileDownload: { + success: { + title: '!Descargado!', + message: 'Archivo descargado correctamente', + qrMessage: + 'Busca la copia de tu código QR en la carpeta de fotos o descargas. Consejo: Añádelo a una presentación para que el público pueda escanearlo y conectar contigo directamente.', + }, + generalError: { + title: 'Error en la descarga', + message: 'No se puede descargar el archivo adjunto.', + }, + permissionError: { + title: 'Permiso para acceder al almacenamiento', + message: 'Expensify no puede guardar los archivos adjuntos sin permiso para acceder al almacenamiento. Haz click en configuración para actualizar los permisos.', + }, + }, + desktopApplicationMenu: { + mainMenu: 'Nuevo Expensify', + about: 'Sobre Nuevo Expensify', + update: 'Actualizar Nuevo Expensify', + checkForUpdates: 'Buscar actualizaciones', + toggleDevTools: 'Ver herramientas de desarrollo', + viewShortcuts: 'Ver atajos de teclado', + services: 'Servicios', + hide: 'Ocultar Nuevo Expensify', + hideOthers: 'Ocultar otros', + showAll: 'Mostrar todos', + quit: 'Salir de Nuevo Expensify', + fileMenu: 'Archivo', + closeWindow: 'Cerrar ventana', + editMenu: 'Editar', + undo: 'Deshacer', + redo: 'Rehacer', + cut: 'Cortar', + copy: 'Copiar', + paste: 'Pegar', + pasteAndMatchStyle: 'Pegar adaptando el estilo', + pasteAsPlainText: 'Pegar como texto sin formato', + delete: 'Eliminar', + selectAll: 'Seleccionar todo', + speechSubmenu: 'Voz', + startSpeaking: 'Empezar a hablar', + stopSpeaking: 'Dejar de Hablar', + viewMenu: 'Ver', + reload: 'Cargar de nuevo', + forceReload: 'Forzar recarga', + resetZoom: 'Tamaño real', + zoomIn: 'Acercar', + zoomOut: 'Alejar', + togglefullscreen: 'Alternar pantalla completa', + historyMenu: 'Historial', + back: 'Atrás', + forward: 'Adelante', + windowMenu: 'Ventana', + minimize: 'Minimizar', + zoom: 'Zoom', + front: 'Traer todo al frente', + helpMenu: 'Ayuda', + learnMore: 'Más información', + documentation: 'Documentación', + communityDiscussions: 'Debates de la comunidad', + searchIssues: 'Buscar problemas', + }, + historyMenu: { + forward: 'Adelante', + back: 'Atrás', + }, + checkForUpdatesModal: { + available: { + title: 'Actualización disponible', + message: ({isSilentUpdating}: {isSilentUpdating: boolean}) => + `La nueva versión estará disponible dentro de poco.${isSilentUpdating ? ' Te notificaremos cuando esté lista.' : ''}`, + soundsGood: 'Suena bien', + }, + notAvailable: { + title: 'Actualización no disponible', + message: '¡No existe ninguna actualización disponible! Inténtalo de nuevo más tarde.', + okay: 'Vale', + }, + error: { + title: 'Comprobación fallida.', + message: 'No hemos podido comprobar si existe una actualización. ¡Inténtalo de nuevo más tarde!.', + }, + }, + report: { + genericCreateReportFailureMessage: 'Error inesperado al crear el chat. Por favor, inténtalo más tarde.', + genericAddCommentFailureMessage: 'Error inesperado al añadir el comentario. Por favor, inténtalo más tarde.', + genericUpdateReportFieldFailureMessage: 'Error inesperado al actualizar el campo. Por favor, inténtalo más tarde.', + genericUpdateReporNameEditFailureMessage: 'Error inesperado al cambiar el nombre del informe. Por favor, intentarlo más tarde.', + noActivityYet: 'Sin actividad todavía', + actions: { + type: { + changeField: ({oldValue, newValue, fieldName}: ChangeFieldParams) => `cambió ${fieldName} de ${oldValue} a ${newValue}`, + changeFieldEmpty: ({newValue, fieldName}: ChangeFieldParams) => `cambió ${fieldName} a ${newValue}`, + changePolicy: ({fromPolicy, toPolicy}: ChangePolicyParams) => `cambió el espacio de trabajo de ${fromPolicy} a ${toPolicy}`, + changeType: ({oldType, newType}: ChangeTypeParams) => `cambió type de ${oldType} a ${newType}`, + delegateSubmit: ({delegateUser, originalManager}: DelegateSubmitParams) => `envié este informe a ${delegateUser} ya que ${originalManager} está de vacaciones`, + exportedToCSV: `exportó este informe a CSV`, + exportedToIntegration: { + automatic: ({label}: ExportedToIntegrationParams) => `exportó este informe a ${label}.`, + manual: ({label}: ExportedToIntegrationParams) => `marcó este informe como exportado manualmente a ${label}.`, + reimburseableLink: 'Ver los gastos por cuenta propia.', + nonReimbursableLink: 'Ver los gastos de la tarjeta de empresa.', + pending: ({label}: ExportedToIntegrationParams) => `comenzó a exportar este informe a ${label}...`, + }, + integrationsMessage: (errorMessage: string, label: string) => `no se pudo exportar este informe a ${label} ("${errorMessage}").`, + managerAttachReceipt: `agregó un recibo`, + managerDetachReceipt: `quitó un recibo`, + markedReimbursed: ({amount, currency}: MarkedReimbursedParams) => `pagó ${currency}${amount} en otro lugar`, + markedReimbursedFromIntegration: ({amount, currency}: MarkReimbursedFromIntegrationParams) => `pagó ${currency}${amount} mediante integración`, + outdatedBankAccount: `no se pudo procesar el pago debido a un problema con la cuenta bancaria del pagador`, + reimbursementACHBounce: `no se pudo procesar el pago porque el pagador no tiene fondos suficientes`, + reimbursementACHCancelled: `canceled the payment`, + reimbursementAccountChanged: `no se pudo procesar el pago porque el pagador cambió de cuenta bancaria`, + reimbursementDelayed: `procesó el pago pero se retrasó entre 1 y 2 días hábiles más`, + selectedForRandomAudit: `seleccionado al azar para revisión`, + selectedForRandomAuditMarkdown: `[seleccionado al azar](https://help.expensify.com/articles/expensify-classic/reports/Set-a-random-report-audit-schedule) para revisión`, + share: ({to}: ShareParams) => `miembro invitado ${to}`, + unshare: ({to}: UnshareParams) => `usuario eliminado ${to}`, + stripePaid: ({amount, currency}: StripePaidParams) => `pagado ${currency}${amount}`, + takeControl: `tomó el control`, + unapproved: ({amount, currency}: UnapprovedParams) => `no aprobado ${currency}${amount}`, + integrationSyncFailed: (label: string, errorMessage: string) => `no se pudo sincronizar con ${label} ("${errorMessage}")`, + addEmployee: (email: string, role: string) => `agregó a ${email} como ${role === 'user' ? 'miembro' : 'administrador'}`, + updateRole: (email: string, currentRole: string, newRole: string) => + `actualicé el rol ${email} de ${currentRole === 'user' ? 'miembro' : 'administrador'} a ${newRole === 'user' ? 'miembro' : 'administrador'}`, + removeMember: (email: string, role: string) => `eliminado ${role === 'user' ? 'miembro' : 'administrador'} ${email}`, + }, + }, + }, + chronos: { + oooEventSummaryFullDay: ({summary, dayCount, date}: OOOEventSummaryFullDayParams) => `${summary} por ${dayCount} ${dayCount === 1 ? 'día' : 'días'} hasta el ${date}`, + oooEventSummaryPartialDay: ({summary, timePeriod, date}: OOOEventSummaryPartialDayParams) => `${summary} de ${timePeriod} del ${date}`, + }, + footer: { + features: 'Características', + expenseManagement: 'Gestión de Gastos', + spendManagement: 'Control de Gastos', + expenseReports: 'Informes de Gastos', + companyCreditCard: 'Tarjeta de Crédito Corporativa', + receiptScanningApp: 'Aplicación de Escaneado de Recibos', + billPay: 'Pago de Facturas', + invoicing: 'Facturación', + CPACard: 'Tarjeta Para Contables', + payroll: 'Nómina', + travel: 'Viajes', + resources: 'Recursos', + expensifyApproved: 'ExpensifyApproved!', + pressKit: 'Kit de Prensa', + support: 'Soporte', + expensifyHelp: 'ExpensifyHelp', + community: 'Comunidad', + privacy: 'Privacidad', + learnMore: 'Más Información', + aboutExpensify: 'Acerca de Expensify', + blog: 'Blog', + jobs: 'Empleo', + expensifyOrg: 'Expensify.org', + investorRelations: 'Relaciones Con Los Inversores', + getStarted: 'Comenzar', + createAccount: 'Crear Una Cuenta Nueva', + logIn: 'Conectarse', + }, + allStates: { + AK: { + stateISO: 'AK', + stateName: 'Alaska', + }, + AL: { + stateISO: 'AL', + stateName: 'Alabama', + }, + AR: { + stateISO: 'AR', + stateName: 'Arkansas', + }, + AZ: { + stateISO: 'AZ', + stateName: 'Arizona', + }, + CA: { + stateISO: 'CA', + stateName: 'California', + }, + CO: { + stateISO: 'CO', + stateName: 'Colorado', + }, + CT: { + stateISO: 'CT', + stateName: 'Connecticut', + }, + DE: { + stateISO: 'DE', + stateName: 'Delaware', + }, + FL: { + stateISO: 'FL', + stateName: 'Florida', + }, + GA: { + stateISO: 'GA', + stateName: 'Georgia', + }, + HI: { + stateISO: 'HI', + stateName: 'Hawái', + }, + IA: { + stateISO: 'IA', + stateName: 'Iowa', + }, + ID: { + stateISO: 'ID', + stateName: 'Idaho', + }, + IL: { + stateISO: 'IL', + stateName: 'Illinois', + }, + IN: { + stateISO: 'IN', + stateName: 'Indiana', + }, + KS: { + stateISO: 'KS', + stateName: 'Kansas', + }, + KY: { + stateISO: 'KY', + stateName: 'Kentucky', + }, + LA: { + stateISO: 'LA', + stateName: 'Luisiana', + }, + MA: { + stateISO: 'MA', + stateName: 'Massachusetts', + }, + MD: { + stateISO: 'MD', + stateName: 'Maryland', + }, + ME: { + stateISO: 'ME', + stateName: 'Maine', + }, + MI: { + stateISO: 'MI', + stateName: 'Míchigan', + }, + MN: { + stateISO: 'MN', + stateName: 'Minnesota', + }, + MO: { + stateISO: 'MO', + stateName: 'Misuri', + }, + MS: { + stateISO: 'MS', + stateName: 'Misisipi', + }, + MT: { + stateISO: 'MT', + stateName: 'Montana', + }, + NC: { + stateISO: 'NC', + stateName: 'Carolina del Norte', + }, + ND: { + stateISO: 'ND', + stateName: 'Dakota del Norte', + }, + NE: { + stateISO: 'NE', + stateName: 'Nebraska', + }, + NH: { + stateISO: 'NH', + stateName: 'Nuevo Hampshire', + }, + NJ: { + stateISO: 'NJ', + stateName: 'Nueva Jersey', + }, + NM: { + stateISO: 'NM', + stateName: 'Nuevo México', + }, + NV: { + stateISO: 'NV', + stateName: 'Nevada', + }, + NY: { + stateISO: 'NY', + stateName: 'Nueva York', + }, + OH: { + stateISO: 'OH', + stateName: 'Ohio', + }, + OK: { + stateISO: 'OK', + stateName: 'Oklahoma', + }, + OR: { + stateISO: 'OR', + stateName: 'Oregón', + }, + PA: { + stateISO: 'PA', + stateName: 'Pensilvania', + }, + PR: { + stateISO: 'PR', + stateName: 'Puerto Rico', + }, + RI: { + stateISO: 'RI', + stateName: 'Rhode Island', + }, + SC: { + stateISO: 'SC', + stateName: 'Carolina del Sur', + }, + SD: { + stateISO: 'SD', + stateName: 'Dakota del Sur', + }, + TN: { + stateISO: 'TN', + stateName: 'Tennessee', + }, + TX: { + stateISO: 'TX', + stateName: 'Texas', + }, + UT: { + stateISO: 'UT', + stateName: 'Utah', + }, + VA: { + stateISO: 'VA', + stateName: 'Virginia', + }, + VT: { + stateISO: 'VT', + stateName: 'Vermont', + }, + WA: { + stateISO: 'WA', + stateName: 'Washington', + }, + WI: { + stateISO: 'WI', + stateName: 'Wisconsin', + }, + WV: { + stateISO: 'WV', + stateName: 'Virginia Occidental', + }, + WY: { + stateISO: 'WY', + stateName: 'Wyoming', + }, + DC: { + stateISO: 'DC', + stateName: 'Distrito de Columbia', + }, + }, + allCountries: { + AF: 'Afganistán', + AL: 'Albania', + DE: 'Alemania', + AD: 'Andorra', + AO: 'Angola', + AI: 'Anguila', + AQ: 'Antártida', + AG: 'Antigua y Barbuda', + SA: 'Arabia Saudita', + DZ: 'Argelia', + AR: 'Argentina', + AM: 'Armenia', + AW: 'Aruba', + AU: 'Australia', + AT: 'Austria', + AZ: 'Azerbaiyán', + BS: 'Bahamas', + BH: 'Bahrein', + BD: 'Bangladesh', + BB: 'Barbados', + BE: 'Bélgica', + BZ: 'Belice', + BJ: 'Benin', + BT: 'Bhután', + BY: 'Bielorrusia', + MM: 'Birmania', + BO: 'Bolivia', + BQ: 'Bonaire, San Eustaquio y Saba', + BA: 'Bosnia y Herzegovina', + BW: 'Botsuana', + BR: 'Brazil', + BN: 'Brunéi', + BG: 'Bulgaria', + BF: 'Burkina Faso', + BI: 'Burundi', + CV: 'Cabo Verde', + KH: 'Camboya', + CM: 'Camerún', + CA: 'Canadá', + TD: 'Chad', + CL: 'Chile', + CN: 'China', + CY: 'Chipre', + VA: 'Ciudad del Vaticano', + CO: 'Colombia', + KM: 'Comoras', + KP: 'Corea del Norte', + KR: 'Corea del Sur', + CI: 'Costa de Marfil', + CR: 'Costa Rica', + HR: 'Croacia', + CU: 'Cuba', + CW: 'Curazao', + DK: 'Dinamarca', + DM: 'Dominica', + EC: 'Ecuador', + EG: 'Egipto', + SV: 'El Salvador', + AE: 'Emiratos Árabes Unidos', + ER: 'Eritrea', + SK: 'Eslovaquia', + SI: 'Eslovenia', + ES: 'España', + US: 'Estados Unidos de América', + EE: 'Estonia', + ET: 'Etiopía', + PH: 'Filipinas', + FI: 'Finlandia', + FJ: 'Fiyi', + FR: 'Francia', + GA: 'Gabón', + GM: 'Gambia', + GE: 'Georgia', + GH: 'Ghana', + GI: 'Gibraltar', + GD: 'Granada', + GR: 'Greece', + GL: 'Groenlandia', + GP: 'Guadeloupe', + GU: 'Guam', + GT: 'Guatemala', + GF: 'Guayana Francesa', + GG: 'Guernsey', + GN: 'Guinea', + GQ: 'Guinea Ecuatorial', + GW: 'Guinea-Bissau', + GY: 'Guyana', + HT: 'Haiti', + HN: 'Honduras', + HK: 'Hong Kong', + HU: 'Hungría', + IN: 'India', + ID: 'Indonesia', + IQ: 'Irak', + IR: 'Irán', + IE: 'Irlanda', + AC: 'Isla Ascensión', + IM: 'Isla de Man', + CX: 'Isla de Navidad', + NF: 'Isla Norfolk', + IS: 'Islandia', + BM: 'Islas Bermudas', + KY: 'Islas Caimán', + CC: 'Islas Cocos (Keeling)', + CK: 'Islas Cook', + AX: 'Islas de Åland', + FO: 'Islas Feroe', + GS: 'Islas Georgias del Sur y Sandwich del Sur', + MV: 'Islas Maldivas', + FK: 'Islas Malvinas', + MP: 'Islas Marianas del Norte', + MH: 'Islas Marshall', + PN: 'Islas Pitcairn', + SB: 'Islas Salomón', + TC: 'Islas Turcas y Caicos', + UM: 'Islas Ultramarinas Menores de Estados Unidos', + VG: 'Islas Vírgenes Británicas', + VI: 'Islas Vírgenes de los Estados Unidos', + IL: 'Israel', + IT: 'Italia', + JM: 'Jamaica', + JP: 'Japón', + JE: 'Jersey', + JO: 'Jordania', + KZ: 'Kazajistán', + KE: 'Kenia', + KG: 'Kirguistán', + KI: 'Kiribati', + XK: 'Kosovo', + KW: 'Kuwait', + LA: 'Laos', + LS: 'Lesoto', + LV: 'Letonia', + LB: 'Líbano', + LR: 'Liberia', + LY: 'Libia', + LI: 'Liechtenstein', + LT: 'Lituania', + LU: 'Luxemburgo', + MO: 'Macao', + MK: 'Macedônia', + MG: 'Madagascar', + MY: 'Malasia', + MW: 'Malawi', + ML: 'Mali', + MT: 'Malta', + MA: 'Marruecos', + MQ: 'Martinica', + MR: 'Mauritania', + MU: 'Mauritius', + YT: 'Mayotte', + MX: 'México', + FM: 'Micronesia', + MD: 'Moldavia', + MC: 'Mónaco', + MN: 'Mongolia', + ME: 'Montenegro', + MS: 'Montserrat', + MZ: 'Mozambique', + NA: 'Namibia', + NR: 'Nauru', + NP: 'Nepal', + NI: 'Nicaragua', + NE: 'Niger', + NG: 'Nigeria', + NU: 'Niue', + NO: 'Noruega', + NC: 'Nueva Caledonia', + NZ: 'Nueva Zealand', + OM: 'Omán', + NL: 'Países Bajos', + PK: 'Pakistán', + PW: 'Palau', + PS: 'Palestina', + PA: 'Panamá', + PG: 'Papúa Nueva Guinea', + PY: 'Paraguay', + PE: 'Perú', + PF: 'Polinesia Francesa', + PL: 'Polonia', + PT: 'Portugal', + PR: 'Puerto Rico', + QA: 'Qatar', + GB: 'Reino Unido', + CF: 'República Centroafricana', + CZ: 'República Checa', + SS: 'República de Sudán del Sur', + CG: 'República del Congo', + CD: 'República Democrática del Congo', + DO: 'República Dominicana', + RE: 'Reunión', + RW: 'Ruanda', + RO: 'Rumanía', + RU: 'Rusia', + EH: 'Sahara Occidental', + WS: 'Samoa', + AS: 'Samoa Americana', + BL: 'San Bartolomé', + KN: 'San Cristóbal y Nieves', + SM: 'San Marino', + MF: 'San Martín (Francia)', + PM: 'San Pedro y Miquelón', + VC: 'San Vicente y las Granadinas', + SH: 'Santa Elena', + LC: 'Santa Lucía', + ST: 'Santo Tomé y Príncipe', + SN: 'Senegal', + RS: 'Serbia', + SC: 'Seychelles', + SL: 'Sierra Leona', + SG: 'Singapur', + SX: 'Sint Maarten', + SY: 'Siria', + SO: 'Somalia', + LK: 'Sri Lanka', + ZA: 'Sudáfrica', + SD: 'Sudán', + SE: 'Suecia', + CH: 'Suiza', + SR: 'Surinám', + SJ: 'Svalbard y Jan Mayen', + SZ: 'Swazilandia', + TH: 'Tailandia', + TW: 'Taiwán', + TZ: 'Tanzania', + TJ: 'Tayikistán', + IO: 'Territorio Británico del Océano Índico', + TF: 'Territorios Australes y Antárticas Franceses', + TL: 'Timor Oriental', + TG: 'Togo', + TK: 'Tokelau', + TO: 'Tonga', + TT: 'Trinidad y Tobago', + TA: 'Tristán de Acuña', + TN: 'Tunez', + TM: 'Turkmenistán', + TR: 'Turquía', + TV: 'Tuvalu', + UA: 'Ucrania', + UG: 'Uganda', + UY: 'Uruguay', + UZ: 'Uzbekistan', + VU: 'Vanuatu', + VE: 'Venezuela', + VN: 'Vietnam', + WF: 'Wallis y Futuna', + YE: 'Yemen', + DJ: 'Yibuti', + ZM: 'Zambia', + ZW: 'Zimbabue', + }, + accessibilityHints: { + navigateToChatsList: 'Vuelve a la lista de chats', + chatWelcomeMessage: 'Mensaje de bienvenida al chat', + navigatesToChat: 'Navega a un chat', + newMessageLineIndicator: 'Indicador de nueva línea de mensaje', + chatMessage: 'mensaje de chat', + lastChatMessagePreview: 'Vista previa del último mensaje del chat', + workspaceName: 'Nombre del espacio de trabajo', + chatUserDisplayNames: 'Nombres de los usuarios del chat', + scrollToNewestMessages: 'Desplázate a los mensajes más recientes', + prestyledText: 'texto preestilizado', + viewAttachment: 'Ver archivo adjunto', + }, + parentReportAction: { + deletedReport: '[Informe eliminado]', + deletedMessage: '[Mensaje eliminado]', + deletedExpense: '[Gasto eliminado]', + reversedTransaction: '[Transacción anulada]', + deletedTask: '[Tarea eliminada]', + hiddenMessage: '[Mensaje oculto]', + }, + threads: { + thread: 'Hilo', + replies: 'Respuestas', + reply: 'Respuesta', + from: 'De', + in: 'en', + parentNavigationSummary: ({reportName, workspaceName}: ParentNavigationSummaryParams) => `De ${reportName}${workspaceName ? ` en ${workspaceName}` : ''}`, + }, + qrCodes: { + copy: 'Copiar URL', + copied: '¡Copiado!', + }, + actionableMentionWhisperOptions: { + invite: 'Invitar', + nothing: 'No hacer nada', + }, + actionableMentionJoinWorkspaceOptions: { + accept: 'Aceptar', + decline: 'Rechazar', + }, + actionableMentionTrackExpense: { + submit: 'Pedirle a alguien que lo pague', + categorize: 'Categorizarlo', + share: 'Compartirlo con mi contador', + nothing: 'Por ahora, nada', + }, + moderation: { + flagDescription: 'Todos los mensajes marcados se enviarán a un moderador para tu revisión.', + chooseAReason: 'Elige abajo un motivo para reportarlo:', + spam: 'Spam', + spamDescription: 'Publicidad no solicitada', + inconsiderate: 'Desconsiderado', + inconsiderateDescription: 'Frase insultante o irrespetuosa, con intenciones cuestionables', + intimidation: 'Intimidación', + intimidationDescription: 'Persigue agresivamente una agenda sobre objeciones válidas', + bullying: 'Bullying', + bullyingDescription: 'Se dirige a un individuo para obtener obediencia', + harassment: 'Acoso', + harassmentDescription: 'Comportamiento racista, misógino u otro comportamiento discriminatorio', + assault: 'Agresion', + assaultDescription: 'Ataque emocional específicamente dirigido con la intención de hacer daño', + flaggedContent: 'Este mensaje ha sido marcado por violar las reglas de nuestra comunidad y el contenido se ha ocultado.', + hideMessage: 'Ocultar mensaje', + revealMessage: 'Revelar mensaje', + levelOneResult: 'Envía una advertencia anónima y el mensaje es reportado para revisión.', + levelTwoResult: 'Mensaje ocultado en el canal, más advertencia anónima y mensaje reportado para revisión.', + levelThreeResult: 'Mensaje eliminado del canal, más advertencia anónima y mensaje reportado para revisión.', + }, + teachersUnitePage: { + teachersUnite: 'Profesores Unidos', + joinExpensifyOrg: 'Únete a Expensify.org para eliminar la injusticia en todo el mundo y ayuda a los profesores a dividir sus gastos para las aulas más necesitadas.', + iKnowATeacher: 'Yo conozco a un profesor', + iAmATeacher: 'Soy profesor', + getInTouch: '¡Excelente! Por favor, comparte tu información para que podamos ponernos en contacto con ellos.', + introSchoolPrincipal: 'Introducción al director del colegio', + schoolPrincipalVerfiyExpense: + 'Expensify.org divide el coste del material escolar esencial para que los estudiantes de familias con bajos ingresos puedan tener una mejor experiencia de aprendizaje. Se pedirá a tu director que verifique tus gastos.', + principalFirstName: 'Nombre del director', + principalLastName: 'Apellido del director', + principalWorkEmail: 'Correo electrónico de trabajo del director', + updateYourEmail: 'Actualiza tu dirección de correo electrónico', + updateEmail: 'Actualización de la dirección de correo electrónico', + contactMethods: 'Métodos de contacto.', + schoolMailAsDefault: + 'Antes de seguir adelante, asegúrate de establecer el correo electrónico de tu colegio como método de contacto predeterminado. Puede hacerlo en Configuración > Perfil > ', + error: { + enterPhoneEmail: 'Ingrese un correo electrónico o número de teléfono válido.', + enterEmail: 'Introduce un correo electrónico.', + enterValidEmail: 'Introduzca un correo electrónico válido.', + tryDifferentEmail: 'Por favor intenta con un e-mail diferente.', + }, + }, + cardTransactions: { + notActivated: 'No activado', + outOfPocket: 'Gastos por cuenta propia', + companySpend: 'Gastos de empresa', + }, + distance: { + addStop: 'Añadir parada', + deleteWaypoint: 'Eliminar punto de ruta', + deleteWaypointConfirmation: '¿Estás seguro de que quieres eliminar este punto de ruta?', + address: 'Dirección', + waypointDescription: { + start: 'Comienzo', + stop: 'Parada', + }, + mapPending: { + title: 'Mapa pendiente', + subtitle: 'El mapa se generará cuando vuelvas a estar en línea', + onlineSubtitle: 'Un momento mientras configuramos el mapa', + }, + error: { + selectSuggestedAddress: 'Por favor, selecciona una dirección sugerida o usa la ubicación actual.', + }, + }, + reportCardLostOrDamaged: { + report: 'Notificar la pérdida / daño de la tarjeta física', + screenTitle: 'Notificar la pérdida o deterioro de la tarjeta', + nextButtonLabel: 'Siguiente', + reasonTitle: '¿Por qué necesitas una tarjeta nueva?', + cardDamaged: 'Mi tarjeta está dañada', + cardLostOrStolen: 'He perdido o me han robado la tarjeta', + confirmAddressTitle: 'Por favor, confirma la dirección postal de tu nueva tarjeta.', + cardDamagedInfo: 'La nueva tarjeta te llegará en 2-3 días laborables. La tarjeta actual seguirá funcionando hasta que actives la nueva.', + cardLostOrStolenInfo: 'La tarjeta actual se desactivará permanentemente en cuanto realices el pedido. La mayoría de las tarjetas llegan en pocos días laborables.', + address: 'Dirección', + deactivateCardButton: 'Desactivar tarjeta', + shipNewCardButton: 'Enviar tarjeta nueva', + addressError: 'La dirección es obligatoria', + reasonError: 'Se requiere justificación', + }, + eReceipt: { + guaranteed: 'eRecibo garantizado', + transactionDate: 'Fecha de transacción', + }, + referralProgram: { + [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.START_CHAT]: { + buttonText1: 'Inicia un chat y ', + buttonText2: `recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`, + header: `Inicia un chat y recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`, + body: `¡Gana dinero por hablar con tus amigos! Inicia un chat con una cuenta nueva de Expensify y recibe $${CONST.REFERRAL_PROGRAM.REVENUE} cuando se conviertan en clientes.`, + }, + [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SUBMIT_EXPENSE]: { + buttonText1: 'Presenta un gasto, ', + buttonText2: `recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`, + header: `Presenta un gasto y consigue $${CONST.REFERRAL_PROGRAM.REVENUE}`, + body: `¡Vale la pena cobrar! Envia un gasto a una cuenta nueva de Expensify y recibe $${CONST.REFERRAL_PROGRAM.REVENUE} cuando se conviertan en clientes.`, + }, + [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.PAY_SOMEONE]: { + buttonText1: 'Pagar a alguien, ', + buttonText2: `recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`, + header: `Paga a alguien y recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`, + body: `¡Hay que gastar dinero para ganar dinero! Paga a alguien con Expensify y recibe $${CONST.REFERRAL_PROGRAM.REVENUE} cuando se conviertan en clientes.`, + }, + [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND]: { + buttonText1: 'Invita a un amigo y ', + buttonText2: `recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`, + header: `Recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`, + body: `Chatea, paga, presenta y divide gastos con un amigo y recibirás $${CONST.REFERRAL_PROGRAM.REVENUE} cuando se convierta en cliente. También puedes publicar tu enlace de invitación en las redes sociales.`, + }, + [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SHARE_CODE]: { + buttonText1: `Recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`, + header: `Recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`, + body: `Chatea, paga, presenta y divide gastos con un amigo y recibirás $${CONST.REFERRAL_PROGRAM.REVENUE} cuando se convierta en cliente. También puedes publicar tu enlace de invitación en las redes sociales.`, + }, + copyReferralLink: 'Copiar enlace de invitación', + }, + systemChatFooterMessage: { + [CONST.INTRO_CHOICES.MANAGE_TEAM]: { + phrase1: 'Chatea con tu especialista asignado en ', + phrase2: ' para obtener ayuda', + }, + default: { + phrase1: 'Envía un email a ', + phrase2: ' para obtener ayuda con la configuración', + }, + }, + violations: { + allTagLevelsRequired: 'Todas las etiquetas son obligatorias', + autoReportedRejectedExpense: ({rejectedBy, rejectReason}: ViolationsAutoReportedRejectedExpenseParams) => `${rejectedBy} rechazó la solicitud y comentó "${rejectReason}"`, + billableExpense: 'La opción facturable ya no es válida', + cashExpenseWithNoReceipt: ({formattedLimit}: ViolationsCashExpenseWithNoReceiptParams) => `Recibo obligatorio para cantidades mayores de ${formattedLimit}`, + categoryOutOfPolicy: 'La categoría ya no es válida', + conversionSurcharge: ({surcharge}: ViolationsConversionSurchargeParams = {}) => `${surcharge}% de recargo aplicado`, + customUnitOutOfPolicy: 'Tasa inválida para este espacio de trabajo', + duplicatedTransaction: 'Duplicado', + fieldRequired: 'Los campos del informe son obligatorios', + futureDate: 'Fecha futura no permitida', + invoiceMarkup: ({invoiceMarkup}: ViolationsInvoiceMarkupParams) => `Incrementado un ${invoiceMarkup}%`, + maxAge: ({maxAge}: ViolationsMaxAgeParams) => `Fecha de más de ${maxAge} días`, + missingCategory: 'Falta categoría', + missingComment: 'Descripción obligatoria para la categoría seleccionada', + missingTag: ({tagName}: ViolationsMissingTagParams) => `Falta ${tagName ?? 'etiqueta'}`, + modifiedAmount: ({type, displayPercentVariance}: ViolationsModifiedAmountParams) => { + switch (type) { + case 'distance': + return 'Importe difiere del calculado basado en distancia'; + case 'card': + return 'Importe mayor al de la transacción de la tarjeta'; + default: + if (displayPercentVariance) { + return `Importe ${displayPercentVariance}% mayor al del recibo escaneado`; + } + return 'Importe mayor al del recibo escaneado'; + } + }, + modifiedDate: 'Fecha difiere del recibo escaneado', + nonExpensiworksExpense: 'Gasto no proviene de Expensiworks', + overAutoApprovalLimit: ({formattedLimit}: ViolationsOverAutoApprovalLimitParams) => + `Importe supera el límite de aprobación automática${formattedLimit ? ` de ${formattedLimit}` : ''}`, + overCategoryLimit: ({formattedLimit}: ViolationsOverCategoryLimitParams) => `Importe supera el límite para la categoría${formattedLimit ? ` de ${formattedLimit}/persona` : ''}`, + overLimit: ({formattedLimit}: ViolationsOverLimitParams) => `Importe supera el límite${formattedLimit ? ` de ${formattedLimit}/persona` : ''}`, + overLimitAttendee: ({formattedLimit}: ViolationsOverLimitParams) => `Importe supera el límite${formattedLimit ? ` de ${formattedLimit}/persona` : ''}`, + perDayLimit: ({formattedLimit}: ViolationsPerDayLimitParams) => `Importe supera el límite diario de la categoría${formattedLimit ? ` de ${formattedLimit}/persona` : ''}`, + receiptNotSmartScanned: 'Recibo no verificado. Por favor, confirma tu exactitud', + receiptRequired: ({formattedLimit, category}: ViolationsReceiptRequiredParams) => { + let message = 'Recibo obligatorio'; + if (formattedLimit ?? category) { + message += ' para importes sobre'; + if (formattedLimit) { + message += ` ${formattedLimit}`; + } + if (category) { + message += ' el límite de la categoría'; + } + } + return message; + }, + reviewRequired: 'Revisión requerida', + rter: ({brokenBankConnection, isAdmin, email, isTransactionOlderThan7Days, member}: ViolationsRterParams) => { + if (brokenBankConnection) { + return isAdmin + ? `No se puede adjuntar recibo debido a un problema con la conexión a tu banco que ${email} necesita arreglar` + : 'No se puede adjuntar recibo debido a un problema con la conexión a tu banco que necesitas arreglar'; + } + if (!isTransactionOlderThan7Days) { + return isAdmin + ? `Pide a ${member} que marque la transacción como efectivo o espera 7 días e inténtalo de nuevo` + : 'Esperando a adjuntar automáticamente la transacción de tarjeta de crédito'; + } + return ''; + }, + smartscanFailed: 'No se pudo escanear el recibo. Introduce los datos manualmente', + someTagLevelsRequired: ({tagName}: ViolationsTagOutOfPolicyParams) => `Falta ${tagName ?? 'Tag'}`, + tagOutOfPolicy: ({tagName}: ViolationsTagOutOfPolicyParams) => `La etiqueta ${tagName ? `${tagName} ` : ''}ya no es válida`, + taxAmountChanged: 'El importe del impuesto fue modificado', + taxOutOfPolicy: ({taxName}: ViolationsTaxOutOfPolicyParams) => `${taxName ?? 'El impuesto'} ya no es válido`, + taxRateChanged: 'La tasa de impuesto fue modificada', + taxRequired: 'Falta la tasa de impuesto', + none: 'Ninguno', + taxCodeToKeep: 'Elige qué tasa de impuesto quieres conservar', + tagToKeep: 'Elige qué etiqueta quieres conservar', + isTransactionReimbursable: 'Elige si la transacción es reembolsable', + merchantToKeep: 'Elige qué comerciante quieres conservar', + descriptionToKeep: 'Elige qué descripción quieres conservar', + categoryToKeep: 'Elige qué categoría quieres conservar', + isTransactionBillable: 'Elige si la transacción es facturable', + keepThisOne: 'Mantener éste', + confirmDetails: 'Confirma los detalles que conservas', + confirmDuplicatesInfo: 'Los duplicados que no conserves se guardarán para que el usuario los elimine', + hold: 'Bloqueado', + }, + reportViolations: { + [CONST.REPORT_VIOLATIONS.FIELD_REQUIRED]: (fieldName: string) => `${fieldName} es obligatorio`, + }, + violationDismissal: { + rter: { + manual: 'marcó el recibo como pagado en efectivo.', + }, + duplicatedTransaction: { + manual: 'resolvió el duplicado', + }, + }, + videoPlayer: { + play: 'Reproducir', + pause: 'Pausar', + fullscreen: 'Pantalla completa', + playbackSpeed: 'Velocidad', + expand: 'Expandir', + mute: 'Silenciar', + unmute: 'Activar sonido', + normal: 'Normal', + }, + exitSurvey: { + header: 'Antes de irte', + reasonPage: { + title: 'Dinos por qué te vas', + subtitle: 'Antes de irte, por favor dinos por qué te gustaría cambiarte a Expensify Classic.', + }, + reasons: { + [CONST.EXIT_SURVEY.REASONS.FEATURE_NOT_AVAILABLE]: 'Necesito una función que sólo está disponible en Expensify Classic.', + [CONST.EXIT_SURVEY.REASONS.DONT_UNDERSTAND]: 'No entiendo cómo usar New Expensify.', + [CONST.EXIT_SURVEY.REASONS.PREFER_CLASSIC]: 'Entiendo cómo usar New Expensify, pero prefiero Expensify Classic.', + }, + prompts: { + [CONST.EXIT_SURVEY.REASONS.FEATURE_NOT_AVAILABLE]: '¿Qué función necesitas que no esté disponible en New Expensify?', + [CONST.EXIT_SURVEY.REASONS.DONT_UNDERSTAND]: '¿Qué estás tratando de hacer?', + [CONST.EXIT_SURVEY.REASONS.PREFER_CLASSIC]: '¿Por qué prefieres Expensify Classic?', + }, + responsePlaceholder: 'Tu respuesta', + thankYou: '¡Gracias por tus comentarios!', + thankYouSubtitle: 'Sus respuestas nos ayudarán a crear un mejor producto para hacer las cosas bien. ¡Muchas gracias!', + goToExpensifyClassic: 'Cambiar a Expensify Classic', + offlineTitle: 'Parece que estás atrapado aquí...', + offline: + 'Parece que estás desconectado. Desafortunadamente, Expensify Classic no funciona sin conexión, pero New Expensify sí. Si prefieres utilizar Expensify Classic, inténtalo de nuevo cuando tengas conexión a internet.', + }, + listBoundary: { + errorMessage: 'Se ha producido un error al cargar más mensajes.', + tryAgain: 'Inténtalo de nuevo', + }, + systemMessage: { + mergedWithCashTransaction: 'encontró un recibo para esta transacción.', + }, + subscription: { + authenticatePaymentCard: 'Autenticar tarjeta de pago', + mobileReducedFunctionalityMessage: 'No puedes hacer cambios en tu suscripción en la aplicación móvil.', + badge: { + freeTrial: ({numOfDays}) => `Prueba gratuita: ${numOfDays === 1 ? `queda 1 día` : `quedan ${numOfDays} días`}`, + }, + billingBanner: { + policyOwnerAmountOwed: { + title: 'Tu información de pago está desactualizada', + subtitle: ({date}) => `Actualiza tu tarjeta de pago antes del ${date} para continuar utilizando todas tus herramientas favoritas`, + }, + policyOwnerAmountOwedOverdue: { + title: 'Tu información de pago está desactualizada', + subtitle: 'Por favor, actualiza tu información de pago.', + }, + policyOwnerUnderInvoicing: { + title: 'Tu información de pago está desactualizada', + subtitle: ({date}) => `Tu pago está vencido. Por favor, paga tu factura antes del ${date} para evitar la interrupción del servicio.`, + }, + policyOwnerUnderInvoicingOverdue: { + title: 'Tu información de pago está desactualizada', + subtitle: 'Tu pago está vencido. Por favor, paga tu factura.', + }, + billingDisputePending: { + title: 'No se ha podido realizar el cobro a tu tarjeta', + subtitle: ({amountOwed, cardEnding}) => + `Has impugnado el cargo ${amountOwed} en la tarjeta terminada en ${cardEnding}. Tu cuenta estará bloqueada hasta que se resuelva la disputa con tu banco.`, + }, + cardAuthenticationRequired: { + title: 'No se ha podido realizar el cobro a tu tarjeta', + subtitle: ({cardEnding}) => + `Tu tarjeta de pago no ha sido autenticada completamente. Por favor, completa el proceso de autenticación para activar tu tarjeta de pago que termina en ${cardEnding}.`, + }, + insufficientFunds: { + title: 'No se ha podido realizar el cobro a tu tarjeta', + subtitle: ({amountOwed}) => + `Tu tarjeta de pago fue rechazada por falta de fondos. Vuelve a intentarlo o añade una nueva tarjeta de pago para liquidar tu saldo pendiente de ${amountOwed}.`, + }, + cardExpired: { + title: 'No se ha podido realizar el cobro a tu tarjeta', + subtitle: ({amountOwed}) => `Tu tarjeta de pago ha expirado. Por favor, añade una nueva tarjeta de pago para liquidar tu saldo pendiente de ${amountOwed}.`, + }, + cardExpireSoon: { + title: 'Tu tarjeta caducará pronto', + subtitle: + 'Tu tarjeta de pago caducará a finales de este mes. Haz clic en el menú de tres puntos que aparece a continuación para actualizarla y continuar utilizando todas tus herramientas favoritas.', + }, + retryBillingSuccess: { + title: 'Éxito!', + subtitle: 'Tu tarjeta fue facturada correctamente.', + }, + retryBillingError: { + title: 'No se ha podido realizar el cobro a tu tarjeta', + subtitle: + 'Antes de volver a intentarlo, llama directamente a tu banco para que autorice los cargos de Expensify y elimine las retenciones. De lo contrario, añade una tarjeta de pago diferente.', + }, + cardOnDispute: ({amountOwed, cardEnding}) => + `Has impugnado el cargo ${amountOwed} en la tarjeta terminada en ${cardEnding}. Tu cuenta estará bloqueada hasta que se resuelva la disputa con tu banco.`, + preTrial: { + title: 'Iniciar una prueba gratuita', + subtitleStart: 'El próximo paso es ', + subtitleLink: 'completar la configuración ', + subtitleEnd: 'para que tu equipo pueda empezar a enviar gastos.', + }, + trialStarted: { + title: ({numOfDays}) => `Prueba gratuita: ¡${numOfDays === 1 ? `queda 1 día` : `quedan ${numOfDays} días`}!`, + subtitle: 'Añade una tarjeta de pago para seguir utilizando tus funciones favoritas.', + }, + trialEnded: { + title: 'Tu prueba gratuita ha terminado', + subtitle: 'Añade una tarjeta de pago para seguir utilizando tus funciones favoritas.', + }, + }, + cardSection: { + title: 'Pago', + subtitle: 'Añade una tarjeta para pagar tu suscripción a Expensify.', + addCardButton: 'Añade tarjeta de pago', + cardNextPayment: ({nextPaymentDate}) => `Tu próxima fecha de pago es ${nextPaymentDate}.`, + cardEnding: ({cardNumber}) => `Tarjeta terminada en ${cardNumber}`, + cardInfo: ({name, expiration, currency}) => `Nombre: ${name}, Expiración: ${expiration}, Moneda: ${currency}`, + changeCard: 'Cambiar tarjeta de pago', + changeCurrency: 'Cambiar moneda de pago', + cardNotFound: 'No se ha añadido ninguna tarjeta de pago', + retryPaymentButton: 'Reintentar el pago', + authenticatePayment: 'Autenticar el pago', + requestRefund: 'Solicitar reembolso', + requestRefundModal: { + phrase1: 'Obtener un reembolso es fácil, simplemente baja tu cuenta de categoría antes de la próxima fecha de facturación y recibirás un reembolso.', + phrase2: + 'Atención: Bajar tu cuenta de categoría significa que tu(s) espacio(s) de trabajo será(n) eliminado(s). Esta acción no se puede deshacer, pero siempre puedes crear un nuevo espacio de trabajo si cambias de opinión.', + confirm: 'Eliminar y degradar', + }, + viewPaymentHistory: 'Ver historial de pagos', + }, + yourPlan: { + title: 'Tu plan', + collect: { + title: 'Recolectar', + priceAnnual: ({lower, upper}) => `Desde ${lower}/miembro activo con la Tarjeta Expensify, ${upper}/miembro activo sin la Tarjeta Expensify.`, + pricePayPerUse: ({lower, upper}) => `Desde ${lower}/miembro activo con la Tarjeta Expensify, ${upper}/miembro activo sin la Tarjeta Expensify.`, + benefit1: 'SmartScans ilimitados y seguimiento de la distancia', + benefit2: 'Tarjetas Expensify con Límites Inteligentes', + benefit3: 'Pago de facturas y facturación', + benefit4: 'Aprobación de gastos', + benefit5: 'Reembolso ACH', + benefit6: 'Integraciones con QuickBooks y Xero', + benefit7: 'Reportes e informes personalizados', + }, + control: { + title: 'Control', + priceAnnual: ({lower, upper}) => `Desde ${lower}/miembro activo con la Tarjeta Expensify, ${upper}/miembro activo sin la Tarjeta Expensify.`, + pricePayPerUse: ({lower, upper}) => `Desde ${lower}/miembro activo con la Tarjeta Expensify, ${upper}/miembro activo sin la Tarjeta Expensify.`, + benefit1: 'Todo en Recolectar, más:', + benefit2: 'Integraciones con NetSuite y Sage Intacct', + benefit3: 'Sincronización de Certinia y Workday', + benefit4: 'Varios aprobadores de gastos', + benefit5: 'SAML/SSO', + benefit6: 'Presupuestos', + }, + saveWithExpensifyTitle: 'Ahorra con la Tarjeta Expensify', + saveWithExpensifyDescription: 'Utiliza nuestra calculadora de ahorro para ver cómo el reembolso en efectivo de la Tarjeta Expensify puede reducir tu factura de Expensify', + saveWithExpensifyButton: 'Más información', + }, + details: { + title: 'Datos de suscripción', + annual: 'Suscripción anual', + taxExempt: 'Solicitar estado de exención de impuestos', + taxExemptEnabled: 'Exento de impuestos', + payPerUse: 'Pago por uso', + subscriptionSize: 'Tamaño de suscripción', + headsUp: + 'Atención: Si no estableces ahora el tamaño de tu suscripción, lo haremos automáticamente con el número de suscriptores activos del primer mes. A partir de ese momento, estarás suscrito para pagar al menos por ese número de afiliados durante los 12 meses siguientes. Puedes aumentar el tamaño de tu suscripción en cualquier momento, pero no puedes reducirlo hasta que finalice tu suscripción.', + zeroCommitment: 'Compromiso cero con la tarifa de suscripción anual reducida', + }, + subscriptionSize: { + title: 'Tamaño de suscripción', + yourSize: 'El tamaño de tu suscripción es el número de plazas abiertas que puede ocupar cualquier miembro activo en un mes determinado.', + eachMonth: + 'Cada mes, tu suscripción cubre hasta el número de miembros activos establecido anteriormente. Cada vez que aumentes el tamaño de tu suscripción, iniciarás una nueva suscripción de 12 meses con ese nuevo tamaño.', + note: 'Nota: Un miembro activo es cualquiera que haya creado, editado, enviado, aprobado, reembolsado, o exportado datos de gastos vinculados al espacio de trabajo de tu empresa.', + confirmDetails: 'Confirma los datos de tu nueva suscripción anual:', + subscriptionSize: 'Tamaño de suscripción', + activeMembers: ({size}) => `${size} miembros activos/mes`, + subscriptionRenews: 'Renovación de la suscripción', + youCantDowngrade: 'No puedes bajar de categoría durante tu suscripción anual.', + youAlreadyCommitted: ({size, date}) => + `Ya se ha comprometido a un tamaño de suscripción anual de ${size} miembros activos al mes hasta el ${date}. Puede cambiar a una suscripción de pago por uso en ${date} desactivando la auto-renovación.`, + error: { + size: 'Por favor ingrese un tamaño de suscripción valido.', + sameSize: 'Por favor, introduce un número diferente al de tu subscripción actual.', + }, + }, + paymentCard: { + addPaymentCard: 'Añade tarjeta de pago', + enterPaymentCardDetails: 'Introduce los datos de tu tarjeta de pago', + security: 'Expensify es PCI-DSS obediente, utiliza cifrado a nivel bancario, y emplea infraestructura redundante para proteger tus datos.', + learnMoreAboutSecurity: 'Conozca más sobre nuestra seguridad.', + }, + subscriptionSettings: { + title: 'Configuración de suscripción', + autoRenew: 'Auto-renovación', + autoIncrease: 'Auto-incremento', + saveUpTo: ({amountWithCurrency}) => `Ahorre hasta ${amountWithCurrency} al mes por miembro activo`, + automaticallyIncrease: + 'Aumenta automáticamente tus plazas anuales para dar lugar a los miembros activos que superen el tamaño de tu suscripción. Nota: Esto ampliará la fecha de finalización de tu suscripción anual.', + disableAutoRenew: 'Desactivar auto-renovación', + helpUsImprove: 'Ayúdanos a mejorar Expensify', + whatsMainReason: '¿Cuál es la razón principal por la que deseas desactivar la auto-renovación?', + renewsOn: ({date}) => `Se renovará el ${date}.`, + }, + requestEarlyCancellation: { + title: 'Solicitar cancelación anticipada', + subtitle: '¿Cuál es la razón principal por la que solicitas la cancelación anticipada?', + subscriptionCanceled: { + title: 'Suscripción cancelada', + subtitle: 'Tu suscripción anual ha sido cancelada.', + info: 'Ya puedes seguir utilizando tu(s) espacio(s) de trabajo en la modalidad de pago por uso.', + preventFutureActivity: { + part1: 'Si quieres evitar actividad y cargos futuros, debes ', + link: 'eliminar tu(s) espacio(s) de trabajo.', + part2: ' Ten en cuenta que cuando elimines tu(s) espacio(s) de trabajo, se te cobrará cualquier actividad pendienteque se haya incurrido durante el mes en curso.', + }, + }, + requestSubmitted: { + title: 'Solicitud enviada', + subtitle: { + part1: 'Gracias por hacernos saber que deseas cancelar tu suscripción. Estamos revisando tu solicitud y nos comunicaremos contigo en breve a través de tu chat con ', + link: 'Concierge', + part2: '.', + }, + }, + acknowledgement: { + part1: 'Al solicitar la cancelación anticipada, reconozco y acepto que Expensify no tiene ninguna obligación de conceder dicha solicitud en virtud de las ', + link: 'Condiciones de Servicio', + part2: ' de Expensify u otro acuerdo de servicios aplicable entre Expensify y yo, y que Expensify se reserva el derecho exclusivo a conceder dicha solicitud.', + }, + }, + }, + feedbackSurvey: { + tooLimited: 'Hay que mejorar la funcionalidad', + tooExpensive: 'Demasiado caro', + inadequateSupport: 'Atención al cliente inadecuada', + businessClosing: 'Cierre, reducción, o adquisición de la empresa', + additionalInfoTitle: '¿A qué software está migrando y por qué?', + additionalInfoInputLabel: 'Tu respuesta', + }, + roomChangeLog: { + updateRoomDescription: 'establece la descripción de la sala a:', + clearRoomDescription: 'la descripción de la habitación ha sido borrada', + }, + delegate: { + switchAccount: 'Cambiar de cuenta:', + copilotDelegatedAccess: 'Copilot: Acceso delegado', + copilotDelegatedAccessDescription: 'Permitir que otros miembros accedan a tu cuenta.', + addCopilot: 'Agregar copiloto', + membersCanAccessYourAccount: 'Estos miembros pueden acceder a tu cuenta:', + youCanAccessTheseAccounts: 'Puedes acceder a estas cuentas a través del conmutador de cuentas:', + role: (role?: string): string => { + switch (role) { + case CONST.DELEGATE_ROLE.ALL: + return 'Completo'; + case CONST.DELEGATE_ROLE.SUBMITTER: + return 'Limitado'; + default: + return ''; + } + }, + genericError: '¡Ups! Ha ocurrido un error. Por favor, inténtalo de nuevo.', + accessLevel: 'Nivel de acceso', + confirmCopilot: 'Confirma tu copiloto a continuación.', + accessLevelDescription: 'Elige un nivel de acceso a continuación. Tanto el acceso Completo como el Limitado permiten a los copilotos ver todas las conversaciones y gastos.', + roleDescription: (role?: string): string => { + switch (role) { + case CONST.DELEGATE_ROLE.ALL: + return 'Permite a otro miembro realizar todas las acciones en tu cuenta, en tu nombre. Incluye chat, presentaciones, aprobaciones, pagos, actualizaciones de configuración y más.'; + case CONST.DELEGATE_ROLE.SUBMITTER: + return 'Permite a otro miembro realizar la mayoría de las acciones en tu cuenta, en tu nombre. Excluye aprobaciones, pagos, rechazos y retenciones.'; + default: + return ''; + } + }, + makeSureItIsYou: 'Vamos a asegurarnos de que eres tú', + enterMagicCode: ({contactMethod}: EnterMagicCodeParams) => `Por favor, introduce el código mágico enviado a ${contactMethod} para agregar un copiloto.`, + notAllowed: 'No tan rápido...', + notAllowedMessageStart: ({accountOwnerEmail}: AccountOwnerParams) => `No tienes permiso para realizar esta acción para ${accountOwnerEmail}`, + notAllowedMessageHyperLinked: ' copiloto con acceso', + notAllowedMessageEnd: ' limitado', + }, + debug: { + debug: 'Depuración', + details: 'Detalles', + JSON: 'JSON', + reportActions: 'Actions', + reportActionPreview: 'Previa', + nothingToPreview: 'Nada que previsualizar', + editJson: 'Editar JSON:', + preview: 'Previa:', + missingProperty: ({propertyName}) => `Falta ${propertyName}`, + invalidProperty: ({propertyName, expectedType}) => `Propiedad inválida: ${propertyName} - Esperado: ${expectedType}`, + invalidValue: ({expectedValues}) => `Valor inválido - Esperado: ${expectedValues}`, + missingValue: 'Valor en falta', + createReportAction: 'Crear Report Action', + reportAction: 'Report Action', + report: 'Report', + hint: 'Los cambios de datos no se enviarán al backend', + textFields: 'Campos de texto', + numberFields: 'Campos numéricos', + booleanFields: 'Campos booleanos', + constantFields: 'Campos constantes', + dateTimeFields: 'Campos de fecha y hora', + date: 'Fecha', + time: 'Hora', + none: 'Ninguno', + }, +} satisfies EnglishTranslation; diff --git a/src/libs/ActiveClientManager/index.ts b/src/libs/ActiveClientManager/index.ts index e93cdb07d084..159e81de4c1d 100644 --- a/src/libs/ActiveClientManager/index.ts +++ b/src/libs/ActiveClientManager/index.ts @@ -62,7 +62,7 @@ const isClientTheLeader: IsClientTheLeader = () => { return true; } - const lastActiveClient = activeClients.length && activeClients[activeClients.length - 1]; + const lastActiveClient = activeClients.length && activeClients.at(activeClients.length - 1); return lastActiveClient === clientID; }; diff --git a/src/libs/CollectionUtils.ts b/src/libs/CollectionUtils.ts index 9d0ff5425da2..01676cb5d798 100644 --- a/src/libs/CollectionUtils.ts +++ b/src/libs/CollectionUtils.ts @@ -17,7 +17,7 @@ function lastItem(object: Record = {}): T | undefined { * e.g. reportActions_1 -> 1 */ function extractCollectionItemID(key: `${OnyxCollectionKey}${string}`): string { - return key.split('_')[1]; + return key.split('_').at(1) ?? ''; } export {lastItem, extractCollectionItemID}; diff --git a/src/libs/CurrencyUtils.ts b/src/libs/CurrencyUtils.ts index c3b80797d750..09328763bb35 100644 --- a/src/libs/CurrencyUtils.ts +++ b/src/libs/CurrencyUtils.ts @@ -68,7 +68,7 @@ function isCurrencySymbolLTR(currencyCode: string): boolean { }); // Currency is LTR when the first part is of currency type. - return parts[0].type === 'currency'; + return parts.at(0)?.type === 'currency'; } /** diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index e9a2eaa8027d..3efd711d4258 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -84,7 +84,7 @@ function getDefaultMileageRate(policy: OnyxInputOrEntry): MileageRate | } const mileageRates = Object.values(getMileageRates(policy)); - const distanceRate = mileageRates.find((rate) => rate.name === CONST.CUSTOM_UNITS.DEFAULT_RATE) ?? mileageRates[0] ?? {}; + const distanceRate = mileageRates.find((rate) => rate.name === CONST.CUSTOM_UNITS.DEFAULT_RATE) ?? mileageRates.at(0) ?? ({} as MileageRate); return { customUnitRateID: distanceRate.customUnitRateID, diff --git a/src/libs/E2E/tests/linkingTest.e2e.ts b/src/libs/E2E/tests/linkingTest.e2e.ts index 7e6c7ea697ba..c4d580e8c57b 100644 --- a/src/libs/E2E/tests/linkingTest.e2e.ts +++ b/src/libs/E2E/tests/linkingTest.e2e.ts @@ -45,11 +45,11 @@ const test = (config: NativeConfig) => { const subscription = DeviceEventEmitter.addListener('onViewableItemsChanged', (res: ViewableItemResponse) => { console.debug('[E2E] Viewable items retrieved, verifying correct message…', res); - if (!!res && res?.[0]?.item?.reportActionID === linkedReportActionID) { + if (!!res && res?.at(0)?.item?.reportActionID === linkedReportActionID) { appearMessageResolve(); subscription.remove(); } else { - console.debug(`[E2E] Provided message id '${res?.[0]?.item?.reportActionID}' doesn't match to an expected '${linkedReportActionID}'. Waiting for a next one…`); + console.debug(`[E2E] Provided message id '${res?.at(0)?.item?.reportActionID}' doesn't match to an expected '${linkedReportActionID}'. Waiting for a next one…`); } }); diff --git a/src/libs/EmojiTrie.ts b/src/libs/EmojiTrie.ts index e33ba77eab22..eafde585aa74 100644 --- a/src/libs/EmojiTrie.ts +++ b/src/libs/EmojiTrie.ts @@ -123,7 +123,7 @@ const emojiTrie: EmojiTrie = supportedLanguages.reduce((acc, lang) => { const buildEmojisTrie = (locale: Locale) => { Timing.start(CONST.TIMING.TRIE_INITIALIZATION); // Normalize the locale to lowercase and take the first part before any dash - const normalizedLocale = locale.toLowerCase().split('-')[0]; + const normalizedLocale = locale.toLowerCase().split('-').at(0); const localeToUse = supportedLanguages.includes(normalizedLocale as SupportedLanguage) ? (normalizedLocale as SupportedLanguage) : undefined; if (!localeToUse || emojiTrie[localeToUse]) { diff --git a/src/libs/EmojiUtils.ts b/src/libs/EmojiUtils.ts index a8958295bb96..f30a8e14bf5e 100644 --- a/src/libs/EmojiUtils.ts +++ b/src/libs/EmojiUtils.ts @@ -267,7 +267,7 @@ const getEmojiCodeWithSkinColor = (item: Emoji, preferredSkinToneIndex: OnyxEntr const {code, types} = item; if (typeof preferredSkinToneIndex === 'number' && types?.[preferredSkinToneIndex]) { - return types[preferredSkinToneIndex]; + return types.at(preferredSkinToneIndex) ?? ''; } return code; @@ -462,7 +462,7 @@ const getPreferredSkinToneIndex = (value: OnyxEntry): number => */ const getPreferredEmojiCode = (emoji: Emoji, preferredSkinTone: OnyxEntry): string => { if (emoji.types && typeof preferredSkinTone === 'number') { - const emojiCodeWithSkinTone = emoji.types[preferredSkinTone]; + const emojiCodeWithSkinTone = emoji.types.at(preferredSkinTone); // Note: it can happen that preferredSkinTone has a outdated format, // so it makes sense to check if we actually got a valid emoji code back @@ -504,7 +504,12 @@ const enrichEmojiReactionWithTimestamps = (emoji: ReportActionReaction, emojiNam const usersWithTimestamps: UsersReactions = {}; Object.entries(emoji.users ?? {}).forEach(([id, user]) => { const userTimestamps = Object.values(user?.skinTones ?? {}); - const oldestUserTimestamp = userTimestamps.reduce((min, curr) => (curr < min ? curr : min), userTimestamps[0]); + // eslint-disable-next-line no-nested-ternary + const oldestUserTimestamp = userTimestamps.reduce((min, curr) => (min ? (curr < min ? curr : min) : curr), userTimestamps.at(0)); + + if (!oldestUserTimestamp) { + return; + } if (!oldestEmojiTimestamp || oldestUserTimestamp < oldestEmojiTimestamp) { oldestEmojiTimestamp = oldestUserTimestamp; diff --git a/src/libs/ErrorUtils.ts b/src/libs/ErrorUtils.ts index cf852e533a20..e5b8c38c30a6 100644 --- a/src/libs/ErrorUtils.ts +++ b/src/libs/ErrorUtils.ts @@ -77,7 +77,7 @@ function getLatestErrorMessage(onyxData: O return ''; } - const key = Object.keys(errors).sort().reverse()[0]; + const key = Object.keys(errors).sort().reverse().at(0) ?? ''; return getErrorMessageWithTranslationData(errors[key] ?? ''); } @@ -88,7 +88,7 @@ function getLatestErrorMessageField(onyxDa return {}; } - const key = Object.keys(errors).sort().reverse()[0]; + const key = Object.keys(errors).sort().reverse().at(0) ?? ''; return {key: errors[key]}; } @@ -104,7 +104,7 @@ function getLatestErrorField(onyxData return {}; } - const key = Object.keys(errorsForField).sort().reverse()[0]; + const key = Object.keys(errorsForField).sort().reverse().at(0) ?? ''; return {[key]: getErrorMessageWithTranslationData(errorsForField[key])}; } @@ -115,7 +115,7 @@ function getEarliestErrorField(onyxDa return {}; } - const key = Object.keys(errorsForField).sort()[0]; + const key = Object.keys(errorsForField).sort().at(0) ?? ''; return {[key]: getErrorMessageWithTranslationData(errorsForField[key])}; } diff --git a/src/libs/GooglePlacesUtils.ts b/src/libs/GooglePlacesUtils.ts index 312a0dc61c1f..1f9fd838f8d4 100644 --- a/src/libs/GooglePlacesUtils.ts +++ b/src/libs/GooglePlacesUtils.ts @@ -45,7 +45,7 @@ function getPlaceAutocompleteTerms(addressTerms: AddressTerm[]): GetPlaceAutocom const result: GetPlaceAutocompleteTermsResult = {}; fieldsToExtract.forEach((fieldToExtract, index) => { const fieldTermIndex = addressTerms.length - (index + 1); - result[fieldToExtract] = fieldTermIndex >= 0 ? addressTerms[fieldTermIndex].value : ''; + result[fieldToExtract] = fieldTermIndex >= 0 ? addressTerms.at(fieldTermIndex)?.value : ''; }); return result; } diff --git a/src/libs/IOUUtils.ts b/src/libs/IOUUtils.ts index 55ac4ba2ac36..95c12c52889a 100644 --- a/src/libs/IOUUtils.ts +++ b/src/libs/IOUUtils.ts @@ -141,7 +141,7 @@ function insertTagIntoTransactionTagsString(transactionTags: string, tag: string const tagArray = TransactionUtils.getTagArrayFromName(transactionTags); tagArray[tagIndex] = tag; - while (tagArray.length > 0 && !tagArray[tagArray.length - 1]) { + while (tagArray.length > 0 && !tagArray.at(tagArray.length - 1)) { tagArray.pop(); } diff --git a/src/libs/LocaleDigitUtils.ts b/src/libs/LocaleDigitUtils.ts index 0362f4aa963f..e6e91d58b273 100644 --- a/src/libs/LocaleDigitUtils.ts +++ b/src/libs/LocaleDigitUtils.ts @@ -52,7 +52,7 @@ function toLocaleDigit(locale: Locale, digit: string): string { if (index < 0) { throw new Error(`"${digit}" must be in ${JSON.stringify(STANDARD_DIGITS)}`); } - return getLocaleDigits(locale)[index]; + return getLocaleDigits(locale).at(index) ?? ''; } /** @@ -68,7 +68,7 @@ function fromLocaleDigit(locale: Locale, localeDigit: string): string { if (index < 0) { throw new Error(`"${localeDigit}" must be in ${JSON.stringify(getLocaleDigits(locale))}`); } - return STANDARD_DIGITS[index]; + return STANDARD_DIGITS.at(index) ?? ''; } /** diff --git a/src/libs/Middleware/Pagination.ts b/src/libs/Middleware/Pagination.ts index ff5f5942674f..2b4d8de310af 100644 --- a/src/libs/Middleware/Pagination.ts +++ b/src/libs/Middleware/Pagination.ts @@ -108,10 +108,11 @@ const Pagination: Middleware = (requestResponse, request) => { // Detect if we are at the start of the list. This will always be the case for the initial request with no cursor. // For previous requests we check that no new data is returned. Ideally the server would return that info. - if ((type === 'initial' && !cursorID) || (type === 'next' && newPage.length === 1 && newPage[0] === cursorID)) { + if ((type === 'initial' && !cursorID) || (type === 'next' && newPage.length === 1 && newPage.at(0) === cursorID)) { newPage.unshift(CONST.PAGINATION_START_ID); } - if (isLastItem(sortedPageItems[sortedPageItems.length - 1])) { + const pageItem = sortedPageItems.at(sortedPageItems.length - 1); + if (pageItem && isLastItem(pageItem)) { newPage.push(CONST.PAGINATION_END_ID); } diff --git a/src/libs/ModifiedExpenseMessage.ts b/src/libs/ModifiedExpenseMessage.ts index 99f483cf6ef6..20c5f6d7dce9 100644 --- a/src/libs/ModifiedExpenseMessage.ts +++ b/src/libs/ModifiedExpenseMessage.ts @@ -100,11 +100,11 @@ function getForDistanceRequest(newMerchant: string, oldMerchant: string, newAmou if (CONST.REGEX.DISTANCE_MERCHANT.test(newMerchant) && CONST.REGEX.DISTANCE_MERCHANT.test(oldMerchant)) { const oldValues = oldMerchant.split('@'); - const oldDistance = oldValues[0]?.trim() || ''; - const oldRate = oldValues[1]?.trim() || ''; + const oldDistance = oldValues.at(0)?.trim() ?? ''; + const oldRate = oldValues.at(1)?.trim() ?? ''; const newValues = newMerchant.split('@'); - const newDistance = newValues[0]?.trim() || ''; - const newRate = newValues[1]?.trim() || ''; + const newDistance = newValues.at(0)?.trim() ?? ''; + const newRate = newValues.at(1)?.trim() ?? ''; if (oldDistance === newDistance && oldRate !== newRate) { changedField = 'rate'; @@ -235,8 +235,8 @@ function getForReportAction(reportID: string | undefined, reportAction: OnyxEntr sortedTagKeys.forEach((policyTagKey, index) => { const policyTagListName = policyTags[policyTagKey].name || localizedTagListName; - const newTag = splittedTag[index] ?? ''; - const oldTag = splittedOldTag[index] ?? ''; + const newTag = splittedTag.at(index) ?? ''; + const oldTag = splittedOldTag.at(index) ?? ''; if (newTag !== oldTag) { buildMessageFragmentForValue( diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index dee84a4f201f..5a84657d1677 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -1,9 +1,8 @@ -import React, {memo, useEffect, useMemo, useRef, useState} from 'react'; +import React, {memo, useEffect, useMemo, useRef} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import Onyx, {withOnyx} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; -import ActiveGuidesEventListener from '@components/ActiveGuidesEventListener'; import ComposeProviders from '@components/ComposeProviders'; import OptionsListContextProvider from '@components/OptionListContextProvider'; import {SearchContextProvider} from '@components/Search/SearchContext'; @@ -118,7 +117,7 @@ function getCentralPaneScreenListeners(screenName: CentralPaneName) { } function initializePusher() { - return Pusher.init({ + Pusher.init({ appKey: CONFIG.PUSHER.APP_KEY, cluster: CONFIG.PUSHER.CLUSTER, authEndpoint: `${CONFIG.EXPENSIFY.DEFAULT_API_ROOT}api/AuthenticatePusher?`, @@ -236,7 +235,6 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie [StyleUtils, shouldUseNarrowLayout, onboardingIsMediumOrLargerScreenWidth, styles], ); const modal = useRef({}); - const [didPusherInit, setDidPusherInit] = useState(false); let initialReportID: string | undefined; const isInitialRender = useRef(true); @@ -274,9 +272,7 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie NetworkConnection.listenForReconnect(); NetworkConnection.onReconnect(handleNetworkReconnect); PusherConnectionManager.init(); - initializePusher().then(() => { - setDidPusherInit(true); - }); + initializePusher(); // If we are on this screen then we are "logged in", but the user might not have "just logged in". They could be reopening the app // or returning from background. If so, we'll assume they have some app data already and we can call reconnectApp() instead of openApp(). @@ -576,7 +572,6 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie - {didPusherInit && } ); } diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/index.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/index.tsx index 89b83dae816c..7401c3368124 100644 --- a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/index.tsx +++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/index.tsx @@ -19,7 +19,7 @@ function getStateToRender(state: StackNavigationState): StackNavi // We need to render at least one HOME screen to make sure everything load properly. This may be not necessary after changing how IS_SIDEBAR_LOADED is handled. // Currently this value will be switched only after the first HOME screen is rendered. - if (routesToRender[0].name !== SCREENS.HOME) { + if (routesToRender.at(0)?.name !== SCREENS.HOME) { const routeToRender = state.routes.find((route) => route.name === SCREENS.HOME); if (routeToRender) { routesToRender.unshift(routeToRender); diff --git a/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/CustomFullScreenRouter.tsx b/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/CustomFullScreenRouter.tsx index 27e976d9be0c..7dc66d06fd4a 100644 --- a/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/CustomFullScreenRouter.tsx +++ b/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/CustomFullScreenRouter.tsx @@ -40,7 +40,7 @@ function adaptStateIfNecessary(state: StackState) { if (state.stale === true) { state.routes.push({ name: SCREENS.WORKSPACE.PROFILE, - params: state.routes[0]?.params, + params: state.routes.at(0)?.params, }); } } diff --git a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.tsx b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.tsx index a37a102ff8a3..009a11875e83 100644 --- a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.tsx +++ b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.tsx @@ -64,20 +64,24 @@ function ResponsiveStackNavigator(props: ResponsiveStackNavigatorProps) { if (shouldUseNarrowLayout) { const isSearchCentralPane = (route: RouteProp) => getTopmostCentralPaneRoute({routes: [route]} as State)?.name === SCREENS.SEARCH.CENTRAL_PANE; - const lastRoute = routes[routes.length - 1]; - const lastSearchCentralPane = isSearchCentralPane(lastRoute) ? lastRoute : undefined; + const lastRoute = routes.at(routes.length - 1); + // eslint-disable-next-line no-nested-ternary + const lastSearchCentralPane = lastRoute ? (isSearchCentralPane(lastRoute) ? lastRoute : undefined) : undefined; const filteredRoutes = routes.filter((route) => !isSearchCentralPane(route)); // On narrow layout, if we are on /search route we want to hide all central pane routes and show only the bottom tab navigator. if (lastSearchCentralPane) { - return { - stateToRender: { - ...state, - index: 0, - routes: [filteredRoutes[0]], - }, - searchRoute: lastSearchCentralPane, - }; + const filteredRoute = filteredRoutes.at(0); + if (filteredRoute) { + return { + stateToRender: { + ...state, + index: 0, + routes: [filteredRoute], + }, + searchRoute: lastSearchCentralPane, + }; + } } return { diff --git a/src/libs/Navigation/OnyxTabNavigator.tsx b/src/libs/Navigation/OnyxTabNavigator.tsx index 5c5bbe201ab4..a6a77403f148 100644 --- a/src/libs/Navigation/OnyxTabNavigator.tsx +++ b/src/libs/Navigation/OnyxTabNavigator.tsx @@ -123,8 +123,8 @@ function OnyxTabNavigator({ const state = event.data.state; const index = state.index; const routeNames = state.routeNames; - Tab.setSelectedTab(id, routeNames[index] as SelectedTabRequest); - onTabSelected(routeNames[index] as IOURequestType); + Tab.setSelectedTab(id, routeNames.at(index) as SelectedTabRequest); + onTabSelected(routeNames.at(index) as IOURequestType); }, ...(screenListeners ?? {}), }} diff --git a/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts b/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts index 2c96e5796309..da77fc7be98a 100644 --- a/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts +++ b/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts @@ -124,7 +124,11 @@ function getMatchingRootRouteForRHPRoute(route: NavigationPartialRoute): Navigat // If there is rhpNavigator in the state generated for backTo url, we want to get root route matching to this rhp screen. if (rhpNavigator && rhpNavigator.state) { - return getMatchingRootRouteForRHPRoute(findFocusedRoute(stateForBackTo) as NavigationPartialRoute); + const isRHPinState = stateForBackTo.routes.at(0)?.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR; + + if (isRHPinState) { + return getMatchingRootRouteForRHPRoute(findFocusedRoute(stateForBackTo) as NavigationPartialRoute); + } } // If we know that backTo targets the root route (central pane or full screen) we want to use it. @@ -336,7 +340,7 @@ function getAdaptedState(state: PartialState // - matching central pane on desktop layout // We want to make sure that the bottom tab search page is always pushed with matching central pane page. Even on the narrow layout. - if (isNarrowLayout && bottomTabNavigator.state?.routes[0].name !== SCREENS.SEARCH.BOTTOM_TAB) { + if (isNarrowLayout && bottomTabNavigator.state?.routes.at(0)?.name !== SCREENS.SEARCH.BOTTOM_TAB) { return { adaptedState: state, metainfo, diff --git a/src/libs/Navigation/linkingConfig/getMatchingCentralPaneRouteForState.ts b/src/libs/Navigation/linkingConfig/getMatchingCentralPaneRouteForState.ts index 0679bfa26285..cec00f705127 100644 --- a/src/libs/Navigation/linkingConfig/getMatchingCentralPaneRouteForState.ts +++ b/src/libs/Navigation/linkingConfig/getMatchingCentralPaneRouteForState.ts @@ -53,7 +53,10 @@ function getMatchingCentralPaneRouteForState(state: State, r return; } - const centralPaneName = TAB_TO_CENTRAL_PANE_MAPPING[topmostBottomTabRoute.name][0]; + const centralPaneName = TAB_TO_CENTRAL_PANE_MAPPING[topmostBottomTabRoute.name].at(0); + if (!centralPaneName) { + return; + } if (topmostBottomTabRoute.name === SCREENS.SETTINGS.ROOT) { // When we go back to the settings tab without switching the workspace id, we want to return to the previously opened screen diff --git a/src/libs/Network/SequentialQueue.ts b/src/libs/Network/SequentialQueue.ts index 9442ebda3cf0..2f7dc236ab7a 100644 --- a/src/libs/Network/SequentialQueue.ts +++ b/src/libs/Network/SequentialQueue.ts @@ -80,7 +80,10 @@ function process(): Promise { return Promise.resolve(); } - const requestToProcess = persistedRequests[0]; + const requestToProcess = persistedRequests.at(0); + if (!requestToProcess) { + return Promise.resolve(); + } // Set the current request to a promise awaiting its processing so that getCurrentRequest can be used to take some action after the current request has processed. currentRequest = Request.processWithMiddleware(requestToProcess, true) diff --git a/src/libs/NextStepUtils.ts b/src/libs/NextStepUtils.ts index 7f13e1297b6f..b66bf0b0abfa 100644 --- a/src/libs/NextStepUtils.ts +++ b/src/libs/NextStepUtils.ts @@ -73,7 +73,7 @@ function getNextApproverDisplayName(policy: Policy, ownerAccountID: number, subm return ReportUtils.getDisplayNameForParticipant(submitToAccountID); } - const nextApproverEmail = approvalChain.length === 1 ? approvalChain[0] : approvalChain[approvalChain.indexOf(currentUserEmail) + 1]; + const nextApproverEmail = approvalChain.length === 1 ? approvalChain.at(0) : approvalChain[approvalChain.indexOf(currentUserEmail) + 1]; if (!nextApproverEmail) { return ReportUtils.getDisplayNameForParticipant(submitToAccountID); } diff --git a/src/libs/NextStepUtils.ts.orig b/src/libs/NextStepUtils.ts.orig new file mode 100644 index 000000000000..7f13e1297b6f --- /dev/null +++ b/src/libs/NextStepUtils.ts.orig @@ -0,0 +1,313 @@ +import {format, lastDayOfMonth, setDate} from 'date-fns'; +import {Str} from 'expensify-common'; +import Onyx from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, Report, ReportNextStep} from '@src/types/onyx'; +import type {Message} from '@src/types/onyx/ReportNextStep'; +import type DeepValueOf from '@src/types/utils/DeepValueOf'; +import DateUtils from './DateUtils'; +import EmailUtils from './EmailUtils'; +import * as PersonalDetailsUtils from './PersonalDetailsUtils'; +import * as PolicyUtils from './PolicyUtils'; +import * as ReportUtils from './ReportUtils'; + +let currentUserAccountID = -1; +let currentUserEmail = ''; +Onyx.connect({ + key: ONYXKEYS.SESSION, + callback: (value) => { + if (!value) { + return; + } + + currentUserAccountID = value?.accountID ?? -1; + currentUserEmail = value?.email ?? ''; + }, +}); + +let allPolicies: OnyxCollection; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.POLICY, + waitForCollectionCallback: true, + callback: (value) => (allPolicies = value), +}); + +function parseMessage(messages: Message[] | undefined) { + let nextStepHTML = ''; + + messages?.forEach((part, index) => { + const isEmail = Str.isValidEmail(part.text); + let tagType = part.type ?? 'span'; + let content = Str.safeEscape(part.text); + + const previousPart = messages[index - 1]; + const nextPart = messages[index + 1]; + + if (currentUserEmail === part.text || part.clickToCopyText === currentUserEmail) { + tagType = 'strong'; + content = nextPart?.text === `'s` ? 'Your' : 'You'; + } else if (part.text === `'s` && (previousPart?.text === currentUserEmail || previousPart?.clickToCopyText === currentUserEmail)) { + content = ''; + } else if (isEmail) { + tagType = 'next-step-email'; + content = EmailUtils.prefixMailSeparatorsWithBreakOpportunities(content); + } + + nextStepHTML += `<${tagType}>${content}`; + }); + + const formattedHtml = nextStepHTML + .replace(/%expenses/g, 'expense(s)') + .replace(/%Expenses/g, 'Expense(s)') + .replace(/%tobe/g, 'are'); + + return `${formattedHtml}`; +} + +function getNextApproverDisplayName(policy: Policy, ownerAccountID: number, submitToAccountID: number, report: OnyxEntry) { + const approvalChain = ReportUtils.getApprovalChain(policy, ownerAccountID, report?.total ?? 0); + if (approvalChain.length === 0) { + return ReportUtils.getDisplayNameForParticipant(submitToAccountID); + } + + const nextApproverEmail = approvalChain.length === 1 ? approvalChain[0] : approvalChain[approvalChain.indexOf(currentUserEmail) + 1]; + if (!nextApproverEmail) { + return ReportUtils.getDisplayNameForParticipant(submitToAccountID); + } + + return PersonalDetailsUtils.getPersonalDetailByEmail(nextApproverEmail)?.displayName ?? nextApproverEmail; +} + +/** + * Generates an optimistic nextStep based on a current report status and other properties. + * + * @param report + * @param predictedNextStatus - a next expected status of the report + * @param parameters.isPaidWithExpensify - Whether a report has been paid with Expensify or outside + * @returns nextStep + */ +function buildNextStep(report: OnyxEntry, predictedNextStatus: ValueOf): ReportNextStep | null { + if (!ReportUtils.isExpenseReport(report)) { + return null; + } + + const {policyID = '', ownerAccountID = -1} = report ?? {}; + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`] ?? ({} as Policy); + const {harvesting, autoReportingOffset} = policy; + const autoReportingFrequency = PolicyUtils.getCorrectedAutoReportingFrequency(policy); + const submitToAccountID = PolicyUtils.getSubmitToAccountID(policy, ownerAccountID); + const ownerDisplayName = ReportUtils.getDisplayNameForParticipant(ownerAccountID); + const nextApproverDisplayName = getNextApproverDisplayName(policy, ownerAccountID, submitToAccountID, report); + + const reimburserAccountID = PolicyUtils.getReimburserAccountID(policy); + const reimburserDisplayName = ReportUtils.getDisplayNameForParticipant(reimburserAccountID); + const type: ReportNextStep['type'] = 'neutral'; + let optimisticNextStep: ReportNextStep | null; + + switch (predictedNextStatus) { + // Generates an optimistic nextStep once a report has been opened + case CONST.REPORT.STATUS_NUM.OPEN: + // Self review + optimisticNextStep = { + type, + icon: CONST.NEXT_STEP.ICONS.HOURGLASS, + message: [ + { + text: 'Waiting for ', + }, + { + text: `${ownerDisplayName}`, + type: 'strong', + clickToCopyText: ownerAccountID === currentUserAccountID ? currentUserEmail : '', + }, + { + text: ' to ', + }, + { + text: 'add', + }, + { + text: ' %expenses.', + }, + ], + }; + + // Scheduled submit enabled + if (harvesting?.enabled && autoReportingFrequency !== CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MANUAL) { + optimisticNextStep.message = [ + { + text: 'Waiting for ', + }, + { + text: `${ownerDisplayName}`, + type: 'strong', + clickToCopyText: ownerAccountID === currentUserAccountID ? currentUserEmail : '', + }, + { + text: `'s`, + type: 'strong', + }, + { + text: ' %expenses to automatically submit', + }, + ]; + let harvestingSuffix = ''; + + if (autoReportingFrequency) { + const currentDate = new Date(); + let autoSubmissionDate: Date | null = null; + let formattedDate = ''; + + if (autoReportingOffset === CONST.POLICY.AUTO_REPORTING_OFFSET.LAST_DAY_OF_MONTH) { + autoSubmissionDate = lastDayOfMonth(currentDate); + } else if (autoReportingOffset === CONST.POLICY.AUTO_REPORTING_OFFSET.LAST_BUSINESS_DAY_OF_MONTH) { + const lastBusinessDayOfMonth = DateUtils.getLastBusinessDayOfMonth(currentDate); + autoSubmissionDate = setDate(currentDate, lastBusinessDayOfMonth); + } else if (autoReportingOffset !== undefined) { + autoSubmissionDate = setDate(currentDate, autoReportingOffset); + } + + if (autoSubmissionDate) { + formattedDate = format(autoSubmissionDate, CONST.DATE.ORDINAL_DAY_OF_MONTH); + } + + const harvestingSuffixes: Record, string> = { + [CONST.POLICY.AUTO_REPORTING_FREQUENCIES.IMMEDIATE]: 'later today', + [CONST.POLICY.AUTO_REPORTING_FREQUENCIES.WEEKLY]: 'on Sunday', + [CONST.POLICY.AUTO_REPORTING_FREQUENCIES.SEMI_MONTHLY]: 'on the 1st and 16th of each month', + [CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MONTHLY]: formattedDate ? `on the ${formattedDate} of each month` : '', + [CONST.POLICY.AUTO_REPORTING_FREQUENCIES.TRIP]: 'at the end of their trip', + [CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT]: '', + [CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MANUAL]: '', + }; + + if (harvestingSuffixes[autoReportingFrequency]) { + harvestingSuffix = `${harvestingSuffixes[autoReportingFrequency]}`; + } + } + + optimisticNextStep.message.push({ + text: ` ${harvestingSuffix}`, + }); + } + + break; + + // Generates an optimistic nextStep once a report has been submitted + case CONST.REPORT.STATUS_NUM.SUBMITTED: { + // Another owner + optimisticNextStep = { + type, + icon: CONST.NEXT_STEP.ICONS.HOURGLASS, + message: [ + { + text: 'Waiting for ', + }, + { + text: nextApproverDisplayName, + type: 'strong', + }, + { + text: ' to ', + }, + { + text: 'approve', + }, + { + text: ' %expenses.', + }, + ], + }; + + break; + } + + // Generates an optimistic nextStep once a report has been closed for example in the case of Submit and Close approval flow + case CONST.REPORT.STATUS_NUM.CLOSED: + optimisticNextStep = { + icon: CONST.NEXT_STEP.ICONS.CHECKMARK, + type, + message: [ + { + text: 'No further action required!', + }, + ], + }; + + break; + + // Generates an optimistic nextStep once a report has been approved + case CONST.REPORT.STATUS_NUM.APPROVED: + if ( + ReportUtils.isInvoiceReport(report) || + !ReportUtils.isPayer( + { + accountID: currentUserAccountID, + email: currentUserEmail, + }, + report, + ) + ) { + optimisticNextStep = { + type, + icon: CONST.NEXT_STEP.ICONS.CHECKMARK, + message: [ + { + text: 'No further action required!', + }, + ], + }; + break; + } + // Self review + optimisticNextStep = { + type, + icon: CONST.NEXT_STEP.ICONS.HOURGLASS, + message: [ + { + text: 'Waiting for ', + }, + { + text: reimburserDisplayName, + type: 'strong', + }, + { + text: ' to ', + }, + { + text: 'pay', + }, + { + text: ' %expenses.', + }, + ], + }; + break; + + // Generates an optimistic nextStep once a report has been paid + case CONST.REPORT.STATUS_NUM.REIMBURSED: + // Paid with wallet + optimisticNextStep = { + type, + icon: CONST.NEXT_STEP.ICONS.CHECKMARK, + message: [ + { + text: 'No further action required!', + }, + ], + }; + + break; + + // Resets a nextStep + default: + optimisticNextStep = null; + } + + return optimisticNextStep; +} + +export {parseMessage, buildNextStep}; diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index afedd308371c..63cea4aa81e1 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -297,7 +297,11 @@ Onyx.connect({ // Iterate over the report actions to build the sorted and lastVisible report actions objects Object.entries(allReportActions).forEach((reportActions) => { - const reportID = reportActions[0].split('_')[1]; + const reportID = reportActions[0].split('_').at(1); + if (!reportID) { + return; + } + const reportActionsArray = Object.values(reportActions[1] ?? {}); let sortedReportActions = ReportActionUtils.getSortedReportActions(reportActionsArray, true); allSortedReportActions[reportID] = sortedReportActions; @@ -310,7 +314,12 @@ Onyx.connect({ sortedReportActions = ReportActionUtils.getCombinedReportActions(sortedReportActions, transactionThreadReportID, transactionThreadReportActionsArray, reportID, false); } - lastReportActions[reportID] = sortedReportActions[0]; + const firstReportAction = sortedReportActions.at(0); + if (!firstReportAction) { + return; + } + + lastReportActions[reportID] = firstReportAction; // The report is only visible if it is the last action not deleted that // does not match a closed or created state. @@ -322,7 +331,11 @@ Onyx.connect({ reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !ReportActionUtils.isResolvedActionTrackExpense(reportAction), ); - lastVisibleReportActions[reportID] = reportActionsForDisplay[0]; + const reportActionForDisplay = reportActionsForDisplay.at(0); + if (!reportActionForDisplay) { + return; + } + lastVisibleReportActions[reportID] = reportActionForDisplay; }); }, }); @@ -548,7 +561,7 @@ function getAlternateText(option: ReportUtils.OptionData, {showChatPreviewLine = return showChatPreviewLine && option.lastMessageText ? option.lastMessageText - : LocalePhoneNumber.formatPhoneNumber(option.participantsList && option.participantsList.length > 0 ? option.participantsList[0].login ?? '' : ''); + : LocalePhoneNumber.formatPhoneNumber(option.participantsList && option.participantsList.length > 0 ? option.participantsList.at(0)?.login ?? '' : ''); } function isSearchStringMatchUserDetails(personalDetail: PersonalDetails, searchValue: string) { @@ -722,7 +735,7 @@ function createOption( const personalDetailMap = getPersonalDetailsForAccountIDs(accountIDs, personalDetails); const personalDetailList = Object.values(personalDetailMap).filter((details): details is PersonalDetails => !!details); - const personalDetail = personalDetailList[0]; + const personalDetail = personalDetailList.at(0); let hasMultipleParticipants = personalDetailList.length > 1; let subtitle; let reportName; @@ -780,12 +793,12 @@ function createOption( result.alternateText = showPersonalDetails && personalDetail?.login ? personalDetail.login : getAlternateText(result, {showChatPreviewLine, forcePolicyNamePreview}); reportName = showPersonalDetails - ? ReportUtils.getDisplayNameForParticipant(accountIDs[0]) || LocalePhoneNumber.formatPhoneNumber(personalDetail?.login ?? '') + ? ReportUtils.getDisplayNameForParticipant(accountIDs.at(0)) || LocalePhoneNumber.formatPhoneNumber(personalDetail?.login ?? '') : ReportUtils.getReportName(report); } else { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - reportName = ReportUtils.getDisplayNameForParticipant(accountIDs[0]) || LocalePhoneNumber.formatPhoneNumber(personalDetail?.login ?? ''); - result.keyForList = String(accountIDs[0]); + reportName = ReportUtils.getDisplayNameForParticipant(accountIDs.at(0)) || LocalePhoneNumber.formatPhoneNumber(personalDetail?.login ?? ''); + result.keyForList = String(accountIDs.at(0)); result.alternateText = LocalePhoneNumber.formatPhoneNumber(personalDetails?.[accountIDs[0]]?.login ?? ''); } @@ -2368,9 +2381,9 @@ function getFirstKeyForList(data?: Option[] | null) { return ''; } - const firstNonEmptyDataObj = data[0]; + const firstNonEmptyDataObj = data.at(0); - return firstNonEmptyDataObj.keyForList ? firstNonEmptyDataObj.keyForList : ''; + return firstNonEmptyDataObj?.keyForList ? firstNonEmptyDataObj?.keyForList : ''; } function getPersonalDetailSearchTerms(item: Partial) { diff --git a/src/libs/PaginationUtils.ts b/src/libs/PaginationUtils.ts index a08868f5dcc1..a1c9078898bf 100644 --- a/src/libs/PaginationUtils.ts +++ b/src/libs/PaginationUtils.ts @@ -46,12 +46,12 @@ function findFirstItem(sortedItems: TResource[], page: string[], getI */ function findLastItem(sortedItems: TResource[], page: string[], getID: (item: TResource) => string): ItemWithIndex | null { for (let i = page.length - 1; i >= 0; i--) { - const id = page[i]; + const id = page.at(i); if (id === CONST.PAGINATION_END_ID) { return {id, index: sortedItems.length - 1}; } const index = sortedItems.findIndex((item) => getID(item) === id); - if (index !== -1) { + if (index !== -1 && id) { return {id, index}; } } @@ -121,10 +121,15 @@ function mergeAndSortContinuousPages(sortedItems: TResource[], pages: return b.lastIndex - a.lastIndex; }); - const result = [sortedPages[0]]; + const result = [sortedPages.at(0)]; for (let i = 1; i < sortedPages.length; i++) { - const page = sortedPages[i]; - const prevPage = result[result.length - 1]; + const page = sortedPages.at(i); + const prevPage = result.at(result.length - 1); + + if (!page || !prevPage) { + // eslint-disable-next-line no-continue + continue; + } // Current page is inside the previous page, skip if (page.lastIndex <= prevPage.lastIndex && page.lastID !== CONST.PAGINATION_END_ID) { @@ -151,7 +156,7 @@ function mergeAndSortContinuousPages(sortedItems: TResource[], pages: result.push(page); } - return result.map((page) => page.ids); + return result.map((page) => page?.ids ?? []); } /** @@ -167,7 +172,13 @@ function getContinuousChain(sortedItems: TResource[], pages: Pages, g const pagesWithIndexes = getPagesWithIndexes(sortedItems, pages, getID); - let page: PageWithIndex; + let page: PageWithIndex = { + ids: [], + firstID: '', + firstIndex: 0, + lastID: '', + lastIndex: 0, + }; if (id) { const index = sortedItems.findIndex((item) => getID(item) === id); @@ -179,14 +190,20 @@ function getContinuousChain(sortedItems: TResource[], pages: Pages, g const linkedPage = pagesWithIndexes.find((pageIndex) => index >= pageIndex.firstIndex && index <= pageIndex.lastIndex); + const item = sortedItems.at(index); // If we are linked to an action in a gap return it by itself - if (!linkedPage) { - return [sortedItems[index]]; + if (!linkedPage && item) { + return [item]; } - page = linkedPage; + if (linkedPage) { + page = linkedPage; + } } else { - page = pagesWithIndexes[0]; + const pageAtIndex0 = pagesWithIndexes.at(0); + if (pageAtIndex0) { + page = pageAtIndex0; + } } return page ? sortedItems.slice(page.firstIndex, page.lastIndex + 1) : sortedItems; diff --git a/src/libs/PersonalDetailsUtils.ts b/src/libs/PersonalDetailsUtils.ts index af48fd793b43..3d9aed117ca3 100644 --- a/src/libs/PersonalDetailsUtils.ts +++ b/src/libs/PersonalDetailsUtils.ts @@ -150,7 +150,7 @@ function getNewAccountIDsAndLogins(logins: string[], accountIDs: number[]) { const newAccountIDs: number[] = []; const newLogins: string[] = []; logins.forEach((login, index) => { - const accountID = accountIDs[index]; + const accountID = accountIDs.at(index) ?? -1; if (isEmptyObject(allPersonalDetails?.[accountID])) { newAccountIDs.push(accountID); newLogins.push(login); @@ -169,7 +169,7 @@ function getPersonalDetailsOnyxDataForOptimisticUsers(newLogins: string[], newAc const personalDetailsCleanup: PersonalDetailsList = {}; newLogins.forEach((login, index) => { - const accountID = newAccountIDs[index]; + const accountID = newAccountIDs.at(index) ?? -1; personalDetailsNew[accountID] = { login, accountID, @@ -233,7 +233,7 @@ function getFormattedStreet(street1 = '', street2 = '') { */ function getStreetLines(street = '') { const streets = street.split('\n'); - return [streets[0], streets[1]]; + return [streets.at(0), streets.at(1)]; } /** diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index ddcf1c0298b7..bfac0d6be3d6 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -144,9 +144,11 @@ function getRateDisplayValue(value: number, toLocaleDigit: (arg: string) => stri } if (withDecimals) { - const decimalPart = numValue.toString().split('.')[1]; - const fixedDecimalPoints = decimalPart.length > 2 && !decimalPart.endsWith('0') ? 3 : 2; - return Number(numValue).toFixed(fixedDecimalPoints).toString().replace('.', toLocaleDigit('.')); + const decimalPart = numValue.toString().split('.').at(1); + if (decimalPart) { + const fixedDecimalPoints = decimalPart.length > 2 && !decimalPart.endsWith('0') ? 3 : 2; + return Number(numValue).toFixed(fixedDecimalPoints).toString().replace('.', toLocaleDigit('.')); + } } return numValue.toString().replace('.', toLocaleDigit('.')).substring(0, value.toString().length); @@ -303,10 +305,11 @@ function getTagList(policyTagList: OnyxEntry, tagIndex: number): const tagLists = getTagLists(policyTagList); return ( - tagLists[tagIndex] ?? { + tagLists.at(tagIndex) ?? { name: '', required: false, tags: {}, + orderWeight: 0, } ); } @@ -502,12 +505,12 @@ function getDefaultApprover(policy: OnyxEntry): string { * Returns the accountID to whom the given employeeAccountID submits reports to in the given Policy. */ function getSubmitToAccountID(policy: OnyxEntry, employeeAccountID: number): number { - const employeeLogin = getLoginsByAccountIDs([employeeAccountID])[0]; + const employeeLogin = getLoginsByAccountIDs([employeeAccountID]).at(0) ?? ''; const defaultApprover = getDefaultApprover(policy); // For policy using the optional or basic workflow, the manager is the policy default approver. if (([CONST.POLICY.APPROVAL_MODE.OPTIONAL, CONST.POLICY.APPROVAL_MODE.BASIC] as Array>).includes(getApprovalWorkflow(policy))) { - return getAccountIDsByLogins([defaultApprover])[0]; + return getAccountIDsByLogins([defaultApprover]).at(0) ?? -1; } const employee = policy?.employeeList?.[employeeLogin]; @@ -515,12 +518,12 @@ function getSubmitToAccountID(policy: OnyxEntry, employeeAccountID: numb return -1; } - return getAccountIDsByLogins([employee.submitsTo ?? defaultApprover])[0]; + return getAccountIDsByLogins([employee.submitsTo ?? defaultApprover]).at(0) ?? -1; } function getSubmitToEmail(policy: OnyxEntry, employeeAccountID: number): string { const submitToAccountID = getSubmitToAccountID(policy, employeeAccountID); - return getLoginsByAccountIDs([submitToAccountID])[0] ?? ''; + return getLoginsByAccountIDs([submitToAccountID]).at(0) ?? ''; } /** @@ -549,7 +552,7 @@ function getForwardsToAccount(policy: OnyxEntry, employeeEmail: string, */ function getReimburserAccountID(policy: OnyxEntry): number { const reimburserEmail = policy?.achAccount?.reimburser ?? policy?.owner ?? ''; - return getAccountIDsByLogins([reimburserEmail])[0]; + return getAccountIDsByLogins([reimburserEmail]).at(0) ?? -1; } function getPersonalPolicy() { diff --git a/src/libs/Pusher/pusher.ts b/src/libs/Pusher/pusher.ts index 1a511eec391c..a3383dbadb8a 100644 --- a/src/libs/Pusher/pusher.ts +++ b/src/libs/Pusher/pusher.ts @@ -156,7 +156,7 @@ function getChannel(channelName: string): Channel | undefined { /** * Binds an event callback to a channel + eventName */ -function bindEventToChannel(channel: Channel | undefined, eventName?: EventName, eventCallback: (data: EventData) => void = () => {}) { +function bindEventToChannel(channel: Channel | undefined, eventName: EventName, eventCallback: (data: EventData) => void = () => {}) { if (!eventName || !channel) { return; } @@ -232,7 +232,7 @@ function bindEventToChannel(channel: Channel */ function subscribe( channelName: string, - eventName?: EventName, + eventName: EventName, eventCallback: (data: EventData) => void = () => {}, onResubscribe = () => {}, ): Promise { diff --git a/src/libs/Queue/Queue.ts b/src/libs/Queue/Queue.ts index 8b2011e64371..26586d8014a1 100644 --- a/src/libs/Queue/Queue.ts +++ b/src/libs/Queue/Queue.ts @@ -59,7 +59,7 @@ function createQueue(processItem: (item: T) => Promise): Queue { // Function to get the item at the front of the queue without removing it function peek(): T | undefined { - return elements.length > 0 ? elements[0] : undefined; + return elements.length > 0 ? elements.at(0) : undefined; } // Function to get the number of items in the queue diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index f584f694edd0..dbeadc28e0a1 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -130,13 +130,13 @@ function isDeletedAction(reportAction: OnyxInputOrEntry): boolean { @@ -519,8 +519,8 @@ function findPreviousAction(reportActions: ReportAction[] | undefined, actionInd for (let i = actionIndex + 1; i < reportActions.length; i++) { // Find the next non-pending deletion report action, as the pending delete action means that it is not displayed in the UI, but still is in the report actions list. // If we are offline, all actions are pending but shown in the UI, so we take the previous action, even if it is a delete. - if (isNetworkOffline || reportActions[i].pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { - return reportActions[i]; + if (isNetworkOffline || reportActions.at(i)?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + return reportActions.at(i); } } @@ -729,8 +729,12 @@ function replaceBaseURLInPolicyChangeLogAction(reportAction: ReportAction): Repo return updatedReportAction; } - if (Array.isArray(updatedReportAction.message) && updatedReportAction.message[0]) { - updatedReportAction.message[0].html = getReportActionHtml(reportAction)?.replace('%baseURL', environmentURL); + if (Array.isArray(updatedReportAction.message)) { + const message = updatedReportAction.message.at(0); + + if (message) { + message.html = getReportActionHtml(reportAction)?.replace('%baseURL', environmentURL); + } } return updatedReportAction; @@ -750,7 +754,7 @@ function getLastVisibleAction(reportID: string, actionsToMerge: Record !isDeletedAction(action) || (action?.childVisibleActionCount ?? 0) > 0 || isOffline); - return sortedFilterReportActions.length > 1 ? sortedFilterReportActions[sortedFilterReportActions.length - 2].reportActionID : ''; + return sortedFilterReportActions.length > 1 ? sortedFilterReportActions.at(sortedFilterReportActions.length - 2)?.reportActionID ?? '-1' : ''; } /** @@ -1041,7 +1045,7 @@ function getOneTransactionThreadReportID(reportID: string, reportActions: OnyxEn return; } - const singleAction = iouRequestActions[0]; + const singleAction = iouRequestActions.at(0); const originalMessage = getOriginalMessage(singleAction); // If there's only one IOU request action associated with the report but it's been deleted, then we don't consider this a oneTransaction report @@ -1051,7 +1055,7 @@ function getOneTransactionThreadReportID(reportID: string, reportActions: OnyxEn } // Ensure we have a childReportID associated with the IOU report action - return singleAction.childReportID; + return singleAction?.childReportID; } /** @@ -1437,7 +1441,7 @@ function isCurrentActionUnread(report: OnyxEntry, reportAction: ReportAc if (currentActionIndex === -1) { return false; } - const prevReportAction = sortedReportActions[currentActionIndex - 1]; + const prevReportAction = sortedReportActions.at(currentActionIndex - 1); return isReportActionUnread(reportAction, lastReadTime) && (!prevReportAction || !isReportActionUnread(prevReportAction, lastReadTime)); } @@ -1588,7 +1592,7 @@ function getExportIntegrationActionFragments(reportAction: OnyxEntry) { function getCardIssuedMessage(reportAction: OnyxEntry, shouldRenderHTML = false) { const assigneeAccountID = (reportAction?.originalMessage as IssueNewCardOriginalMessage)?.assigneeAccountID; - const assigneeDetails = PersonalDetailsUtils.getPersonalDetailsByIDs([assigneeAccountID], currentUserAccountID ?? -1)[0]; + const assigneeDetails = PersonalDetailsUtils.getPersonalDetailsByIDs([assigneeAccountID], currentUserAccountID ?? -1).at(0); - const assignee = shouldRenderHTML ? `` : assigneeDetails?.firstName ?? assigneeDetails.login ?? ''; + const assignee = shouldRenderHTML ? `` : assigneeDetails?.firstName ?? assigneeDetails?.login ?? ''; const link = shouldRenderHTML ? `${Localize.translateLocal('cardPage.expensifyCard')}` : Localize.translateLocal('cardPage.expensifyCard'); diff --git a/src/libs/ReportActionsUtils.ts.orig b/src/libs/ReportActionsUtils.ts.orig new file mode 100644 index 000000000000..f584f694edd0 --- /dev/null +++ b/src/libs/ReportActionsUtils.ts.orig @@ -0,0 +1,1839 @@ +import {fastMerge, Str} from 'expensify-common'; +import clone from 'lodash/clone'; +import lodashFindLast from 'lodash/findLast'; +import isEmpty from 'lodash/isEmpty'; +import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {OnyxInputOrEntry, PrivatePersonalDetails} from '@src/types/onyx'; +import type {IssueNewCardOriginalMessage, JoinWorkspaceResolution, OriginalMessageChangeLog, OriginalMessageExportIntegration} from '@src/types/onyx/OriginalMessage'; +import type Report from '@src/types/onyx/Report'; +import type ReportAction from '@src/types/onyx/ReportAction'; +import type {Message, OldDotReportAction, OriginalMessage, ReportActions} from '@src/types/onyx/ReportAction'; +import type ReportActionName from '@src/types/onyx/ReportActionName'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import DateUtils from './DateUtils'; +import * as Environment from './Environment/Environment'; +import getBase62ReportID from './getBase62ReportID'; +import isReportMessageAttachment from './isReportMessageAttachment'; +import * as Localize from './Localize'; +import Log from './Log'; +import type {MessageElementBase, MessageTextElement} from './MessageElement'; +import Parser from './Parser'; +import * as PersonalDetailsUtils from './PersonalDetailsUtils'; +import * as ReportConnection from './ReportConnection'; +import type {OptimisticIOUReportAction, PartialReportAction} from './ReportUtils'; +import StringUtils from './StringUtils'; +// eslint-disable-next-line import/no-cycle +import * as TransactionUtils from './TransactionUtils'; + +type LastVisibleMessage = { + lastMessageTranslationKey?: string; + lastMessageText: string; + lastMessageHtml?: string; +}; + +type MemberChangeMessageUserMentionElement = { + readonly kind: 'userMention'; + readonly accountID: number; +} & MessageElementBase; + +type MemberChangeMessageRoomReferenceElement = { + readonly kind: 'roomReference'; + readonly roomName: string; + readonly roomID: number; +} & MessageElementBase; + +type MemberChangeMessageElement = MessageTextElement | MemberChangeMessageUserMentionElement | MemberChangeMessageRoomReferenceElement; + +let allReportActions: OnyxCollection; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, + waitForCollectionCallback: true, + callback: (actions) => { + if (!actions) { + return; + } + + allReportActions = actions; + }, +}); + +let isNetworkOffline = false; +Onyx.connect({ + key: ONYXKEYS.NETWORK, + callback: (val) => (isNetworkOffline = val?.isOffline ?? false), +}); + +let currentUserAccountID: number | undefined; +let currentEmail = ''; +Onyx.connect({ + key: ONYXKEYS.SESSION, + callback: (value) => { + // When signed out, value is undefined + if (!value) { + return; + } + + currentUserAccountID = value.accountID; + currentEmail = value?.email ?? ''; + }, +}); + +let privatePersonalDetails: PrivatePersonalDetails | undefined; +Onyx.connect({ + key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, + callback: (personalDetails) => { + privatePersonalDetails = personalDetails; + }, +}); + +let environmentURL: string; +Environment.getEnvironmentURL().then((url: string) => (environmentURL = url)); + +/* + * Url to the Xero non reimbursable expenses list + */ +const XERO_NON_REIMBURSABLE_EXPENSES_URL = 'https://go.xero.com/Bank/BankAccounts.aspx'; + +/* + * Url to the NetSuite global search, which should be suffixed with the reportID. + */ +const NETSUITE_NON_REIMBURSABLE_EXPENSES_URL_PREFIX = + 'https://system.netsuite.com/app/common/search/ubersearchresults.nl?quicksearch=T&searchtype=Uber&frame=be&Uber_NAMEtype=KEYWORDSTARTSWITH&Uber_NAME='; + +/* + * Url prefix to any Salesforce transaction or transaction list. + */ +const SALESFORCE_EXPENSES_URL_PREFIX = 'https://login.salesforce.com/'; + +/* + * Url to the QBO expenses list + */ +const QBO_EXPENSES_URL = 'https://qbo.intuit.com/app/expenses'; + +const POLICY_CHANGE_LOG_ARRAY = Object.values(CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG); + +function isCreatedAction(reportAction: OnyxInputOrEntry): boolean { + return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED; +} + +function isDeletedAction(reportAction: OnyxInputOrEntry): boolean { + const message = reportAction?.message ?? []; + + if (!Array.isArray(message)) { + return message?.html === '' ?? message?.deleted; + } + + // A legacy deleted comment has either an empty array or an object with html field with empty string as value + const isLegacyDeletedComment = message.length === 0 || message[0]?.html === ''; + + return isLegacyDeletedComment || !!message[0]?.deleted; +} + +function getReportActionMessage(reportAction: PartialReportAction) { + return Array.isArray(reportAction?.message) ? reportAction.message[0] : reportAction?.message; +} + +function isDeletedParentAction(reportAction: OnyxInputOrEntry): boolean { + return (getReportActionMessage(reportAction)?.isDeletedParentAction ?? false) && (reportAction?.childVisibleActionCount ?? 0) > 0; +} + +function isReversedTransaction(reportAction: OnyxInputOrEntry) { + return (getReportActionMessage(reportAction)?.isReversedTransaction ?? false) && ((reportAction as ReportAction)?.childVisibleActionCount ?? 0) > 0; +} + +function isPendingRemove(reportAction: OnyxInputOrEntry): boolean { + return getReportActionMessage(reportAction)?.moderationDecision?.decision === CONST.MODERATION.MODERATOR_DECISION_PENDING_REMOVE; +} + +function isMoneyRequestAction(reportAction: OnyxInputOrEntry): reportAction is ReportAction { + return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.IOU); +} + +function isReportPreviewAction(reportAction: OnyxInputOrEntry): reportAction is ReportAction { + return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW); +} + +function isSubmittedAction(reportAction: OnyxInputOrEntry): reportAction is ReportAction { + return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.SUBMITTED); +} + +function isApprovedAction(reportAction: OnyxInputOrEntry): reportAction is ReportAction { + return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.APPROVED); +} + +function isForwardedAction(reportAction: OnyxInputOrEntry): reportAction is ReportAction { + return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.FORWARDED); +} + +function isModifiedExpenseAction(reportAction: OnyxInputOrEntry): reportAction is ReportAction { + return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE); +} + +function isPolicyChangeLogAction(reportAction: OnyxInputOrEntry): reportAction is ReportAction> { + return isActionOfType(reportAction, ...POLICY_CHANGE_LOG_ARRAY); +} + +function isChronosOOOListAction(reportAction: OnyxInputOrEntry): reportAction is ReportAction { + return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.CHRONOS_OOO_LIST); +} + +function isAddCommentAction(reportAction: OnyxInputOrEntry): reportAction is ReportAction { + return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT); +} + +function isCreatedTaskReportAction(reportAction: OnyxInputOrEntry): reportAction is ReportAction { + return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT) && !!getOriginalMessage(reportAction)?.taskReportID; +} + +function isTripPreview(reportAction: OnyxInputOrEntry): reportAction is ReportAction { + return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.TRIPPREVIEW); +} + +function isActionOfType( + action: OnyxInputOrEntry, + ...actionNames: T +): action is { + [K in keyof T]: ReportAction; +}[number] { + const actionName = action?.actionName as T[number]; + + // This is purely a performance optimization to limit the 'includes()' calls on Hermes + for (const i of actionNames) { + if (i === actionName) { + return true; + } + } + + return false; +} + +function getOriginalMessage(reportAction: OnyxInputOrEntry>): OriginalMessage | undefined { + if (!Array.isArray(reportAction?.message)) { + return reportAction?.message ?? reportAction?.originalMessage; + } + return reportAction.originalMessage; +} + +function isExportIntegrationAction(reportAction: OnyxInputOrEntry): boolean { + return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_INTEGRATION; +} + +/** + * We are in the process of deprecating reportAction.originalMessage and will be setting the db version of "message" to reportAction.message in the future see: https://github.com/Expensify/App/issues/39797 + * In the interim, we must check to see if we have an object or array for the reportAction.message, if we have an array we will use the originalMessage as this means we have not yet migrated. + */ +function getWhisperedTo(reportAction: OnyxInputOrEntry): number[] { + if (!reportAction) { + return []; + } + const originalMessage = getOriginalMessage(reportAction); + const message = getReportActionMessage(reportAction); + + if (!(originalMessage && typeof originalMessage === 'object' && 'whisperedTo' in originalMessage) && !(message && typeof message === 'object' && 'whisperedTo' in message)) { + return []; + } + + if (message !== null && !Array.isArray(message) && typeof message === 'object' && 'whisperedTo' in message) { + return message?.whisperedTo ?? []; + } + + if (originalMessage && typeof originalMessage === 'object' && 'whisperedTo' in originalMessage) { + return originalMessage?.whisperedTo ?? []; + } + + if (typeof originalMessage !== 'object') { + Log.info('Original message is not an object for reportAction: ', true, { + reportActionID: reportAction?.reportActionID, + actionName: reportAction?.actionName, + }); + } + + return []; +} + +function isWhisperAction(reportAction: OnyxInputOrEntry): boolean { + return getWhisperedTo(reportAction).length > 0; +} + +/** + * Checks whether the report action is a whisper targeting someone other than the current user. + */ +function isWhisperActionTargetedToOthers(reportAction: OnyxInputOrEntry): boolean { + if (!isWhisperAction(reportAction)) { + return false; + } + return !getWhisperedTo(reportAction).includes(currentUserAccountID ?? -1); +} + +function isReimbursementQueuedAction(reportAction: OnyxInputOrEntry): reportAction is ReportAction { + return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_QUEUED); +} + +function isMemberChangeAction( + reportAction: OnyxInputOrEntry, +): reportAction is ReportAction> { + return isActionOfType( + reportAction, + CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.INVITE_TO_ROOM, + CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.REMOVE_FROM_ROOM, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.INVITE_TO_ROOM, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.REMOVE_FROM_ROOM, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.LEAVE_POLICY, + ); +} + +function isInviteMemberAction( + reportAction: OnyxEntry, +): reportAction is ReportAction { + return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.INVITE_TO_ROOM, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.INVITE_TO_ROOM); +} + +function isLeavePolicyAction(reportAction: OnyxEntry): reportAction is ReportAction { + return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.LEAVE_POLICY); +} + +function isReimbursementDeQueuedAction(reportAction: OnyxEntry): reportAction is ReportAction { + return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_DEQUEUED); +} + +function isClosedAction(reportAction: OnyxEntry): reportAction is ReportAction { + return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.CLOSED); +} + +function isRenamedAction(reportAction: OnyxEntry): reportAction is ReportAction { + return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.RENAMED); +} + +function isRoomChangeLogAction(reportAction: OnyxEntry): reportAction is ReportAction> { + return isActionOfType(reportAction, ...Object.values(CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG)); +} + +function isInviteOrRemovedAction( + reportAction: OnyxInputOrEntry, +): reportAction is ReportAction> { + return isActionOfType( + reportAction, + CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.INVITE_TO_ROOM, + CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.REMOVE_FROM_ROOM, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.INVITE_TO_ROOM, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.REMOVE_FROM_ROOM, + ); +} + +/** + * Returns whether the comment is a thread parent message/the first message in a thread + */ +function isThreadParentMessage(reportAction: OnyxEntry, reportID: string): boolean { + const {childType, childVisibleActionCount = 0, childReportID} = reportAction ?? {}; + return childType === CONST.REPORT.TYPE.CHAT && (childVisibleActionCount > 0 || String(childReportID) === reportID); +} + +/** + * Returns the parentReportAction if the given report is a thread/task. + * + * @deprecated Use Onyx.connect() or withOnyx() instead + */ +function getParentReportAction(report: OnyxInputOrEntry): OnyxEntry { + if (!report?.parentReportID || !report.parentReportActionID) { + return undefined; + } + return allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`]?.[report.parentReportActionID]; +} + +/** + * Determines if the given report action is sent money report action by checking for 'pay' type and presence of IOUDetails object. + */ +function isSentMoneyReportAction(reportAction: OnyxEntry): boolean { + return ( + isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.IOU) && + getOriginalMessage(reportAction)?.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && + !!getOriginalMessage(reportAction)?.IOUDetails + ); +} + +/** + * Returns whether the thread is a transaction thread, which is any thread with IOU parent + * report action from requesting money (type - create) or from sending money (type - pay with IOUDetails field) + */ +function isTransactionThread(parentReportAction: OnyxInputOrEntry): boolean { + if (isEmptyObject(parentReportAction) || !isMoneyRequestAction(parentReportAction)) { + return false; + } + const originalMessage = getOriginalMessage(parentReportAction); + return ( + originalMessage?.type === CONST.IOU.REPORT_ACTION_TYPE.CREATE || + originalMessage?.type === CONST.IOU.REPORT_ACTION_TYPE.TRACK || + (originalMessage?.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && !!originalMessage?.IOUDetails) + ); +} + +/** + * Sort an array of reportActions by their created timestamp first, and reportActionID second + * This gives us a stable order even in the case of multiple reportActions created on the same millisecond + * + */ +function getSortedReportActions(reportActions: ReportAction[] | null, shouldSortInDescendingOrder = false): ReportAction[] { + if (!Array.isArray(reportActions)) { + throw new Error(`ReportActionsUtils.getSortedReportActions requires an array, received ${typeof reportActions}`); + } + + const invertedMultiplier = shouldSortInDescendingOrder ? -1 : 1; + + const sortedActions = reportActions?.filter(Boolean).sort((first, second) => { + // First sort by timestamp + if (first.created !== second.created) { + return (first.created < second.created ? -1 : 1) * invertedMultiplier; + } + + // Then by action type, ensuring that `CREATED` actions always come first if they have the same timestamp as another action type + if ((first.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED || second.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) && first.actionName !== second.actionName) { + return (first.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED ? -1 : 1) * invertedMultiplier; + } + // Ensure that `REPORT_PREVIEW` actions always come after if they have the same timestamp as another action type + if ((first.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW || second.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) && first.actionName !== second.actionName) { + return (first.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW ? 1 : -1) * invertedMultiplier; + } + + // Then fallback on reportActionID as the final sorting criteria. It is a random number, + // but using this will ensure that the order of reportActions with the same created time and action type + // will be consistent across all users and devices + return (first.reportActionID < second.reportActionID ? -1 : 1) * invertedMultiplier; + }); + + return sortedActions; +} + +/** + * Returns a sorted and filtered list of report actions from a report and it's associated child + * transaction thread report in order to correctly display reportActions from both reports in the one-transaction report view. + */ +function getCombinedReportActions( + reportActions: ReportAction[], + transactionThreadReportID: string | null, + transactionThreadReportActions: ReportAction[], + reportID?: string, + shouldFilterIOUAction = true, +): ReportAction[] { + const isSentMoneyReport = reportActions.some((action) => isSentMoneyReportAction(action)); + + // We don't want to combine report actions of transaction thread in iou report of send money request because we display the transaction report of send money request as a normal thread + if (isEmpty(transactionThreadReportID) || isSentMoneyReport) { + return reportActions; + } + + // Usually, we filter out the created action from the transaction thread report actions, since we already have the parent report's created action in `reportActions` + // However, in the case of moving track expense, the transaction thread will be created first in a track expense, thus we should keep the CREATED of the transaction thread and filter out CREATED action of the IOU + // This makes sense because in a combined report action list, whichever CREATED is first need to be retained. + const transactionThreadCreatedAction = transactionThreadReportActions?.find((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED); + const parentReportCreatedAction = reportActions?.find((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED); + + let filteredTransactionThreadReportActions = transactionThreadReportActions; + let filteredParentReportActions = reportActions; + + if (transactionThreadCreatedAction && parentReportCreatedAction && transactionThreadCreatedAction.created > parentReportCreatedAction.created) { + filteredTransactionThreadReportActions = transactionThreadReportActions?.filter((action) => action.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED); + } else if (transactionThreadCreatedAction) { + filteredParentReportActions = reportActions?.filter((action) => action.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED); + } + + const report = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + const isSelfDM = report?.chatType === CONST.REPORT.CHAT_TYPE.SELF_DM; + // Filter out request and send money request actions because we don't want to show any preview actions for one transaction reports + const filteredReportActions = [...filteredParentReportActions, ...filteredTransactionThreadReportActions].filter((action) => { + if (!isMoneyRequestAction(action) || !shouldFilterIOUAction) { + return true; + } + const actionType = getOriginalMessage(action)?.type ?? ''; + if (isSelfDM) { + return actionType !== CONST.IOU.REPORT_ACTION_TYPE.CREATE; + } + return actionType !== CONST.IOU.REPORT_ACTION_TYPE.CREATE && actionType !== CONST.IOU.REPORT_ACTION_TYPE.TRACK; + }); + + return getSortedReportActions(filteredReportActions, true); +} + +/** + * Finds most recent IOU request action ID. + */ +function getMostRecentIOURequestActionID(reportActions: ReportAction[] | null): string | null { + if (!Array.isArray(reportActions)) { + return null; + } + const iouRequestTypes: Array> = [ + CONST.IOU.REPORT_ACTION_TYPE.CREATE, + CONST.IOU.REPORT_ACTION_TYPE.SPLIT, + CONST.IOU.REPORT_ACTION_TYPE.TRACK, + ]; + const iouRequestActions = + reportActions?.filter((action) => { + if (!isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.IOU)) { + return false; + } + const actionType = getOriginalMessage(action)?.type; + if (!actionType) { + return false; + } + return iouRequestTypes.includes(actionType); + }) ?? []; + + if (iouRequestActions.length === 0) { + return null; + } + + const sortedReportActions = getSortedReportActions(iouRequestActions); + return sortedReportActions.at(-1)?.reportActionID ?? null; +} + +/** + * Returns array of links inside a given report action + */ +function extractLinksFromMessageHtml(reportAction: OnyxEntry): string[] { + const htmlContent = getReportActionHtml(reportAction); + + const regex = CONST.REGEX_LINK_IN_ANCHOR; + + if (!htmlContent) { + return []; + } + + return [...htmlContent.matchAll(regex)].map((match) => match[1]); +} + +/** + * Returns the report action immediately before the specified index. + * @param reportActions - all actions + * @param actionIndex - index of the action + */ +function findPreviousAction(reportActions: ReportAction[] | undefined, actionIndex: number): OnyxEntry { + if (!reportActions) { + return undefined; + } + + for (let i = actionIndex + 1; i < reportActions.length; i++) { + // Find the next non-pending deletion report action, as the pending delete action means that it is not displayed in the UI, but still is in the report actions list. + // If we are offline, all actions are pending but shown in the UI, so we take the previous action, even if it is a delete. + if (isNetworkOffline || reportActions[i].pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + return reportActions[i]; + } + } + + return undefined; +} + +/** + * Returns true when the report action immediately before the specified index is a comment made by the same actor who who is leaving a comment in the action at the specified index. + * Also checks to ensure that the comment is not too old to be shown as a grouped comment. + * + * @param actionIndex - index of the comment item in state to check + */ +function isConsecutiveActionMadeByPreviousActor(reportActions: ReportAction[] | undefined, actionIndex: number): boolean { + const previousAction = findPreviousAction(reportActions, actionIndex); + const currentAction = reportActions?.[actionIndex]; + + // It's OK for there to be no previous action, and in that case, false will be returned + // so that the comment isn't grouped + if (!currentAction || !previousAction) { + return false; + } + + // Comments are only grouped if they happen within 5 minutes of each other + if (new Date(currentAction.created).getTime() - new Date(previousAction.created).getTime() > 300000) { + return false; + } + + // Do not group if previous action was a created action + if (previousAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) { + return false; + } + + // Do not group if previous or current action was a renamed action + if (previousAction.actionName === CONST.REPORT.ACTIONS.TYPE.RENAMED || currentAction.actionName === CONST.REPORT.ACTIONS.TYPE.RENAMED) { + return false; + } + + // Do not group if the delegate account ID is different + if (previousAction.delegateAccountID !== currentAction.delegateAccountID) { + return false; + } + + // Do not group if one of previous / current action is report preview and another one is not report preview + if ((isReportPreviewAction(previousAction) && !isReportPreviewAction(currentAction)) || (isReportPreviewAction(currentAction) && !isReportPreviewAction(previousAction))) { + return false; + } + + if (isSubmittedAction(currentAction)) { + const currentActionAdminAccountID = currentAction.adminAccountID; + + return currentActionAdminAccountID === previousAction.actorAccountID || currentActionAdminAccountID === previousAction.adminAccountID; + } + + if (isSubmittedAction(previousAction)) { + return typeof previousAction.adminAccountID === 'number' + ? currentAction.actorAccountID === previousAction.adminAccountID + : currentAction.actorAccountID === previousAction.actorAccountID; + } + + return currentAction.actorAccountID === previousAction.actorAccountID; +} + +/** + * Checks if a reportAction is deprecated. + */ +function isReportActionDeprecated(reportAction: OnyxEntry, key: string | number): boolean { + if (!reportAction) { + return true; + } + + // HACK ALERT: We're temporarily filtering out any reportActions keyed by sequenceNumber + // to prevent bugs during the migration from sequenceNumber -> reportActionID + if (String(reportAction.sequenceNumber) === key) { + Log.info('Front-end filtered out reportAction keyed by sequenceNumber!', false, reportAction); + return true; + } + + const deprecatedOldDotReportActions: ReportActionName[] = [ + CONST.REPORT.ACTIONS.TYPE.DELETED_ACCOUNT, + CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_REQUESTED, + CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_SETUP_REQUESTED, + CONST.REPORT.ACTIONS.TYPE.DONATION, + ]; + if (deprecatedOldDotReportActions.includes(reportAction.actionName)) { + Log.info('Front end filtered out reportAction for being an older, deprecated report action', false, reportAction); + return true; + } + + return false; +} + +const {POLICY_CHANGE_LOG: policyChangelogTypes, ROOM_CHANGE_LOG: roomChangeLogTypes, ...otherActionTypes} = CONST.REPORT.ACTIONS.TYPE; +const supportedActionTypes: ReportActionName[] = [...Object.values(otherActionTypes), ...Object.values(policyChangelogTypes), ...Object.values(roomChangeLogTypes)]; + +/** + * Checks if a reportAction is fit for display, meaning that it's not deprecated, is of a valid + * and supported type, it's not deleted and also not closed. + */ +function shouldReportActionBeVisible(reportAction: OnyxEntry, key: string | number): boolean { + if (!reportAction) { + return false; + } + + if (isReportActionDeprecated(reportAction, key)) { + return false; + } + + // Filter out any unsupported reportAction types + if (!supportedActionTypes.includes(reportAction.actionName)) { + return false; + } + + // Ignore closed action here since we're already displaying a footer that explains why the report was closed + if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CLOSED) { + return false; + } + + // Ignore markedAsReimbursed action here since we're already display message that explains the expense was paid + // elsewhere in the IOU reportAction + if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED) { + return false; + } + + if (isWhisperActionTargetedToOthers(reportAction)) { + return false; + } + + if (isPendingRemove(reportAction) && !reportAction.childVisibleActionCount) { + return false; + } + + if (isTripPreview(reportAction)) { + return true; + } + + // All other actions are displayed except thread parents, deleted, or non-pending actions + const isDeleted = isDeletedAction(reportAction); + const isPending = !!reportAction.pendingAction; + + return !isDeleted || isPending || isDeletedParentAction(reportAction) || isReversedTransaction(reportAction); +} + +/** + * Checks if the new marker should be hidden for the report action. + */ +function shouldHideNewMarker(reportAction: OnyxEntry): boolean { + if (!reportAction) { + return true; + } + return !isNetworkOffline && reportAction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; +} + +/** + * Checks whether an action is actionable track expense. + * + */ +function isActionableTrackExpense(reportAction: OnyxInputOrEntry): reportAction is ReportAction { + return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_TRACK_EXPENSE_WHISPER); +} + +/** + * Checks whether an action is actionable track expense and resolved. + * + */ +function isResolvedActionTrackExpense(reportAction: OnyxEntry): boolean { + const originalMessage = getOriginalMessage(reportAction); + const resolution = originalMessage && typeof originalMessage === 'object' && 'resolution' in originalMessage ? originalMessage?.resolution : null; + return isActionableTrackExpense(reportAction) && !!resolution; +} + +/** + * Checks if a reportAction is fit for display as report last action, meaning that + * it satisfies shouldReportActionBeVisible, it's not whisper action and not deleted. + */ +function shouldReportActionBeVisibleAsLastAction(reportAction: OnyxInputOrEntry): boolean { + if (!reportAction) { + return false; + } + + if (Object.keys(reportAction.errors ?? {}).length > 0) { + return false; + } + + // If a whisper action is the REPORT_PREVIEW action, we are displaying it. + // If the action's message text is empty and it is not a deleted parent with visible child actions, hide it. Else, consider the action to be displayable. + return ( + shouldReportActionBeVisible(reportAction, reportAction.reportActionID) && + !(isWhisperAction(reportAction) && !isReportPreviewAction(reportAction) && !isMoneyRequestAction(reportAction)) && + !(isDeletedAction(reportAction) && !isDeletedParentAction(reportAction)) && + !isResolvedActionTrackExpense(reportAction) + ); +} + +/** + * For policy change logs, report URLs are generated in the server, + * which includes a baseURL placeholder that's replaced in the client. + */ +function replaceBaseURLInPolicyChangeLogAction(reportAction: ReportAction): ReportAction { + if (!reportAction?.message || !isPolicyChangeLogAction(reportAction)) { + return reportAction; + } + + const updatedReportAction = clone(reportAction); + + if (!updatedReportAction.message) { + return updatedReportAction; + } + + if (Array.isArray(updatedReportAction.message) && updatedReportAction.message[0]) { + updatedReportAction.message[0].html = getReportActionHtml(reportAction)?.replace('%baseURL', environmentURL); + } + + return updatedReportAction; +} + +function getLastVisibleAction(reportID: string, actionsToMerge: Record | null> = {}): OnyxEntry { + let reportActions: Array = []; + if (!isEmpty(actionsToMerge)) { + reportActions = Object.values(fastMerge(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] ?? {}, actionsToMerge ?? {}, true)) as Array< + ReportAction | null | undefined + >; + } else { + reportActions = Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] ?? {}); + } + const visibleReportActions = reportActions.filter((action): action is ReportAction => shouldReportActionBeVisibleAsLastAction(action)); + const sortedReportActions = getSortedReportActions(visibleReportActions, true); + if (sortedReportActions.length === 0) { + return undefined; + } + return sortedReportActions[0]; +} + +function getLastVisibleMessage( + reportID: string, + actionsToMerge: Record | null> = {}, + reportAction: OnyxInputOrEntry | undefined = undefined, +): LastVisibleMessage { + const lastVisibleAction = reportAction ?? getLastVisibleAction(reportID, actionsToMerge); + const message = getReportActionMessage(lastVisibleAction); + + if (message && isReportMessageAttachment(message)) { + return { + lastMessageTranslationKey: CONST.TRANSLATION_KEYS.ATTACHMENT, + lastMessageText: CONST.ATTACHMENT_MESSAGE_TEXT, + lastMessageHtml: CONST.TRANSLATION_KEYS.ATTACHMENT, + }; + } + + if (isCreatedAction(lastVisibleAction)) { + return { + lastMessageText: '', + }; + } + + let messageText = getReportActionMessageText(lastVisibleAction) ?? ''; + if (messageText) { + messageText = StringUtils.lineBreaksToSpaces(String(messageText)).substring(0, CONST.REPORT.LAST_MESSAGE_TEXT_MAX_LENGTH).trim(); + } + return { + lastMessageText: messageText, + }; +} + +/** + * A helper method to filter out report actions keyed by sequenceNumbers. + */ +function filterOutDeprecatedReportActions(reportActions: OnyxEntry): ReportAction[] { + return Object.entries(reportActions ?? {}) + .filter(([key, reportAction]) => !isReportActionDeprecated(reportAction, key)) + .map((entry) => entry[1]); +} + +/** + * This method returns the report actions that are ready for display in the ReportActionsView. + * The report actions need to be sorted by created timestamp first, and reportActionID second + * to ensure they will always be displayed in the same order (in case multiple actions have the same timestamp). + * This is all handled with getSortedReportActions() which is used by several other methods to keep the code DRY. + */ +function getSortedReportActionsForDisplay(reportActions: OnyxEntry | ReportAction[], shouldIncludeInvisibleActions = false): ReportAction[] { + let filteredReportActions: ReportAction[] = []; + if (!reportActions) { + return []; + } + + if (shouldIncludeInvisibleActions) { + filteredReportActions = Object.values(reportActions).filter(Boolean); + } else { + filteredReportActions = Object.entries(reportActions) + .filter(([key, reportAction]) => shouldReportActionBeVisible(reportAction, key)) + .map(([, reportAction]) => reportAction); + } + + const baseURLAdjustedReportActions = filteredReportActions.map((reportAction) => replaceBaseURLInPolicyChangeLogAction(reportAction)); + return getSortedReportActions(baseURLAdjustedReportActions, true); +} + +/** + * In some cases, there can be multiple closed report actions in a chat report. + * This method returns the last closed report action so we can always show the correct archived report reason. + * Additionally, archived #admins and #announce do not have the closed report action so we will return null if none is found. + * + */ +function getLastClosedReportAction(reportActions: OnyxEntry): OnyxEntry { + // If closed report action is not present, return early + if (!Object.values(reportActions ?? {}).some((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CLOSED)) { + return undefined; + } + + const filteredReportActions = filterOutDeprecatedReportActions(reportActions); + const sortedReportActions = getSortedReportActions(filteredReportActions); + return lodashFindLast(sortedReportActions, (action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CLOSED); +} + +/** + * The first visible action is the second last action in sortedReportActions which satisfy following conditions: + * 1. That is not pending deletion as pending deletion actions are kept in sortedReportActions in memory. + * 2. That has at least one visible child action. + * 3. While offline all actions in `sortedReportActions` are visible. + * 4. We will get the second last action from filtered actions because the last + * action is always the created action + */ +function getFirstVisibleReportActionID(sortedReportActions: ReportAction[] = [], isOffline = false): string { + if (!Array.isArray(sortedReportActions)) { + return ''; + } + const sortedFilterReportActions = sortedReportActions.filter((action) => !isDeletedAction(action) || (action?.childVisibleActionCount ?? 0) > 0 || isOffline); + return sortedFilterReportActions.length > 1 ? sortedFilterReportActions[sortedFilterReportActions.length - 2].reportActionID : ''; +} + +/** + * @returns The latest report action in the `onyxData` or `null` if one couldn't be found + */ +function getLatestReportActionFromOnyxData(onyxData: OnyxUpdate[] | null): NonNullable> | null { + const reportActionUpdate = onyxData?.find((onyxUpdate) => onyxUpdate.key.startsWith(ONYXKEYS.COLLECTION.REPORT_ACTIONS)); + + if (!reportActionUpdate) { + return null; + } + + const reportActions = Object.values((reportActionUpdate.value as ReportActions) ?? {}); + const sortedReportActions = getSortedReportActions(reportActions); + return sortedReportActions.at(-1) ?? null; +} + +/** + * Find the transaction associated with this reportAction, if one exists. + */ +function getLinkedTransactionID(reportActionOrID: string | OnyxEntry, reportID?: string): string | null { + const reportAction = typeof reportActionOrID === 'string' ? allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`]?.[reportActionOrID] : reportActionOrID; + if (!reportAction || !isMoneyRequestAction(reportAction)) { + return null; + } + return getOriginalMessage(reportAction)?.IOUTransactionID ?? null; +} + +function getReportAction(reportID: string, reportActionID: string): ReportAction | undefined { + return allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`]?.[reportActionID]; +} + +function getMostRecentReportActionLastModified(): string { + // Start with the oldest date possible + let mostRecentReportActionLastModified = DateUtils.getDBTime(0); + + // Flatten all the actions + // Loop over them all to find the one that is the most recent + const flatReportActions = Object.values(allReportActions ?? {}) + .flatMap((actions) => (actions ? Object.values(actions) : [])) + .filter(Boolean); + flatReportActions.forEach((action) => { + // Pending actions should not be counted here as a user could create a comment or some other action while offline and the server might know about + // messages they have not seen yet. + if (action.pendingAction) { + return; + } + + const lastModified = action.lastModified ?? action.created; + + if (lastModified < mostRecentReportActionLastModified) { + return; + } + + mostRecentReportActionLastModified = lastModified; + }); + + // We might not have actions so we also look at the report objects to see if any have a lastVisibleActionLastModified that is more recent. We don't need to get + // any reports that have been updated before either a recently updated report or reportAction as we should be up to date on these + Object.values(ReportConnection.getAllReports() ?? {}).forEach((report) => { + const reportLastVisibleActionLastModified = report?.lastVisibleActionLastModified ?? report?.lastVisibleActionCreated; + if (!reportLastVisibleActionLastModified || reportLastVisibleActionLastModified < mostRecentReportActionLastModified) { + return; + } + + mostRecentReportActionLastModified = reportLastVisibleActionLastModified; + }); + + return mostRecentReportActionLastModified; +} + +/** + * @returns The report preview action or `null` if one couldn't be found + */ +function getReportPreviewAction(chatReportID: string, iouReportID: string): OnyxEntry> { + return Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`] ?? {}).find( + (reportAction): reportAction is ReportAction => + reportAction && isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) && getOriginalMessage(reportAction)?.linkedReportID === iouReportID, + ); +} + +/** + * Get the iouReportID for a given report action. + */ +function getIOUReportIDFromReportActionPreview(reportAction: OnyxEntry): string { + return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) ? getOriginalMessage(reportAction)?.linkedReportID ?? '-1' : '-1'; +} + +/** + * A helper method to identify if the message is deleted or not. + */ +function isMessageDeleted(reportAction: OnyxInputOrEntry): boolean { + return getReportActionMessage(reportAction)?.isDeletedParentAction ?? false; +} + +/** + * Returns the number of expenses associated with a report preview + */ +function getNumberOfMoneyRequests(reportPreviewAction: OnyxEntry): number { + return reportPreviewAction?.childMoneyRequestCount ?? 0; +} + +function isSplitBillAction(reportAction: OnyxInputOrEntry): reportAction is ReportAction { + return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.IOU) && getOriginalMessage(reportAction)?.type === CONST.IOU.REPORT_ACTION_TYPE.SPLIT; +} + +function isTrackExpenseAction(reportAction: OnyxEntry): reportAction is ReportAction { + return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.IOU) && getOriginalMessage(reportAction)?.type === CONST.IOU.REPORT_ACTION_TYPE.TRACK; +} + +function isPayAction(reportAction: OnyxInputOrEntry): reportAction is ReportAction { + return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.IOU) && getOriginalMessage(reportAction)?.type === CONST.IOU.REPORT_ACTION_TYPE.PAY; +} + +function isTaskAction(reportAction: OnyxEntry): boolean { + const reportActionName = reportAction?.actionName; + return ( + reportActionName === CONST.REPORT.ACTIONS.TYPE.TASK_COMPLETED || + reportActionName === CONST.REPORT.ACTIONS.TYPE.TASK_CANCELLED || + reportActionName === CONST.REPORT.ACTIONS.TYPE.TASK_REOPENED || + reportActionName === CONST.REPORT.ACTIONS.TYPE.TASK_EDITED + ); +} + +/** + * @param actionName - The name of the action + * @returns - Whether the action is a tag modification action + * */ +function isTagModificationAction(actionName: string): boolean { + return ( + actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_TAG || + actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_TAG_ENABLED || + actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_TAG_NAME || + actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_TAG || + actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_TAG + ); +} + +// Get all IOU report actions for the report. +const iouRequestTypes = new Set>([ + CONST.IOU.REPORT_ACTION_TYPE.CREATE, + CONST.IOU.REPORT_ACTION_TYPE.SPLIT, + CONST.IOU.REPORT_ACTION_TYPE.PAY, + CONST.IOU.REPORT_ACTION_TYPE.TRACK, +]); + +/** + * Gets the reportID for the transaction thread associated with a report by iterating over the reportActions and identifying the IOU report actions. + * Returns a reportID if there is exactly one transaction thread for the report, and null otherwise. + */ +function getOneTransactionThreadReportID(reportID: string, reportActions: OnyxEntry | ReportAction[], isOffline: boolean | undefined = undefined): string | undefined { + // If the report is not an IOU, Expense report, or Invoice, it shouldn't be treated as one-transaction report. + const report = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + if (report?.type !== CONST.REPORT.TYPE.IOU && report?.type !== CONST.REPORT.TYPE.EXPENSE && report?.type !== CONST.REPORT.TYPE.INVOICE) { + return; + } + + const reportActionsArray = Array.isArray(reportActions) ? reportActions : Object.values(reportActions ?? {}); + if (!reportActionsArray.length) { + return; + } + + const iouRequestActions = []; + for (const action of reportActionsArray) { + if (!isMoneyRequestAction(action)) { + // eslint-disable-next-line no-continue + continue; + } + + const originalMessage = getOriginalMessage(action); + const actionType = originalMessage?.type; + if ( + actionType && + iouRequestTypes.has(actionType) && + action.childReportID && + // Include deleted IOU reportActions if: + // - they have an assocaited IOU transaction ID or + // - they have visibile childActions (like comments) that we'd want to display + // - the action is pending deletion and the user is offline + (!!originalMessage?.IOUTransactionID || + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + (isMessageDeleted(action) && action.childVisibleActionCount) || + (action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && (isOffline ?? isNetworkOffline))) + ) { + iouRequestActions.push(action); + } + } + + // If we don't have any IOU request actions, or we have more than one IOU request actions, this isn't a oneTransaction report + if (!iouRequestActions.length || iouRequestActions.length > 1) { + return; + } + + const singleAction = iouRequestActions[0]; + const originalMessage = getOriginalMessage(singleAction); + + // If there's only one IOU request action associated with the report but it's been deleted, then we don't consider this a oneTransaction report + // and want to display it using the standard view + if ((originalMessage?.deleted ?? '') !== '' && isMoneyRequestAction(singleAction)) { + return; + } + + // Ensure we have a childReportID associated with the IOU report action + return singleAction.childReportID; +} + +/** + * When we delete certain reports, we want to check whether there are any visible actions left to display. + * If there are no visible actions left (including system messages), we can hide the report from view entirely + */ +function doesReportHaveVisibleActions(reportID: string, actionsToMerge: ReportActions = {}): boolean { + const reportActions = Object.values(fastMerge(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] ?? {}, actionsToMerge, true)); + const visibleReportActions = Object.values(reportActions ?? {}).filter((action) => shouldReportActionBeVisibleAsLastAction(action)); + + // Exclude the task system message and the created message + const visibleReportActionsWithoutTaskSystemMessage = visibleReportActions.filter((action) => !isTaskAction(action) && !isCreatedAction(action)); + return visibleReportActionsWithoutTaskSystemMessage.length > 0; +} + +function getAllReportActions(reportID: string): ReportActions { + return allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] ?? {}; +} + +/** + * Check whether a report action is an attachment (a file, such as an image or a zip). + * + */ +function isReportActionAttachment(reportAction: OnyxInputOrEntry): boolean { + const message = getReportActionMessage(reportAction); + + if (reportAction && ('isAttachmentOnly' in reportAction || 'isAttachmentWithText' in reportAction)) { + return reportAction.isAttachmentOnly ?? reportAction.isAttachmentWithText ?? false; + } + + if (message) { + return isReportMessageAttachment(message); + } + + return false; +} + +// eslint-disable-next-line rulesdir/no-negated-variables +function isNotifiableReportAction(reportAction: OnyxEntry): boolean { + if (!reportAction) { + return false; + } + + const actions: ReportActionName[] = [CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, CONST.REPORT.ACTIONS.TYPE.IOU, CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE]; + + return actions.includes(reportAction.actionName); +} + +function getMemberChangeMessageElements(reportAction: OnyxEntry): readonly MemberChangeMessageElement[] { + const isInviteAction = isInviteMemberAction(reportAction); + const isLeaveAction = isLeavePolicyAction(reportAction); + + if (!isMemberChangeAction(reportAction)) { + return []; + } + + // Currently, we only render messages when members are invited + let verb = Localize.translateLocal('workspace.invite.removed'); + if (isInviteAction) { + verb = Localize.translateLocal('workspace.invite.invited'); + } + + if (isLeaveAction) { + verb = Localize.translateLocal('workspace.invite.leftWorkspace'); + } + + const originalMessage = getOriginalMessage(reportAction); + const targetAccountIDs: number[] = originalMessage?.targetAccountIDs ?? []; + const personalDetails = PersonalDetailsUtils.getPersonalDetailsByIDs(targetAccountIDs, 0); + + const mentionElements = targetAccountIDs.map((accountID): MemberChangeMessageUserMentionElement => { + const personalDetail = personalDetails.find((personal) => personal.accountID === accountID); + const handleText = PersonalDetailsUtils.getEffectiveDisplayName(personalDetail) ?? Localize.translateLocal('common.hidden'); + + return { + kind: 'userMention', + content: `@${handleText}`, + accountID, + }; + }); + + const buildRoomElements = (): readonly MemberChangeMessageElement[] => { + const roomName = originalMessage?.roomName; + + if (roomName) { + const preposition = isInviteAction ? ` ${Localize.translateLocal('workspace.invite.to')} ` : ` ${Localize.translateLocal('workspace.invite.from')} `; + + if (originalMessage.reportID) { + return [ + { + kind: 'text', + content: preposition, + }, + { + kind: 'roomReference', + roomName, + roomID: originalMessage.reportID, + content: roomName, + }, + ]; + } + } + + return []; + }; + + return [ + { + kind: 'text', + content: `${verb} `, + }, + ...Localize.formatMessageElementList(mentionElements), + ...buildRoomElements(), + ]; +} + +function getReportActionHtml(reportAction: PartialReportAction): string { + return getReportActionMessage(reportAction)?.html ?? ''; +} + +function getReportActionText(reportAction: PartialReportAction): string { + const message = getReportActionMessage(reportAction); + // Sometime html can be an empty string + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const text = (message?.html || message?.text) ?? ''; + return text ? Parser.htmlToText(text) : ''; +} + +function getTextFromHtml(html?: string): string { + return html ? Parser.htmlToText(html) : ''; +} + +function isOldDotLegacyAction(action: OldDotReportAction | PartialReportAction): action is PartialReportAction { + return [ + CONST.REPORT.ACTIONS.TYPE.DELETED_ACCOUNT, + CONST.REPORT.ACTIONS.TYPE.DONATION, + CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_QUICK_BOOKS, + CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_REQUESTED, + CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_SETUP, + ].some((oldDotActionName) => oldDotActionName === action?.actionName); +} + +function isOldDotReportAction(action: ReportAction | OldDotReportAction) { + return [ + CONST.REPORT.ACTIONS.TYPE.CHANGE_FIELD, + CONST.REPORT.ACTIONS.TYPE.CHANGE_POLICY, + CONST.REPORT.ACTIONS.TYPE.CHANGE_TYPE, + CONST.REPORT.ACTIONS.TYPE.DELEGATE_SUBMIT, + CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_CSV, + CONST.REPORT.ACTIONS.TYPE.INTEGRATIONS_MESSAGE, + CONST.REPORT.ACTIONS.TYPE.MANAGER_ATTACH_RECEIPT, + CONST.REPORT.ACTIONS.TYPE.MANAGER_DETACH_RECEIPT, + CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED, + CONST.REPORT.ACTIONS.TYPE.MARK_REIMBURSED_FROM_INTEGRATION, + CONST.REPORT.ACTIONS.TYPE.OUTDATED_BANK_ACCOUNT, + CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_ACH_BOUNCE, + CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_ACH_CANCELLED, + CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_ACCOUNT_CHANGED, + CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_DELAYED, + CONST.REPORT.ACTIONS.TYPE.SELECTED_FOR_RANDOM_AUDIT, + CONST.REPORT.ACTIONS.TYPE.SHARE, + CONST.REPORT.ACTIONS.TYPE.STRIPE_PAID, + CONST.REPORT.ACTIONS.TYPE.TAKE_CONTROL, + CONST.REPORT.ACTIONS.TYPE.UNSHARE, + CONST.REPORT.ACTIONS.TYPE.DELETED_ACCOUNT, + CONST.REPORT.ACTIONS.TYPE.DONATION, + CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_QUICK_BOOKS, + CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_REQUESTED, + CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_SETUP, + ].some((oldDotActionName) => oldDotActionName === action.actionName); +} + +function getMessageOfOldDotLegacyAction(legacyAction: PartialReportAction) { + if (!Array.isArray(legacyAction?.message)) { + return getReportActionText(legacyAction); + } + if (legacyAction.message.length !== 0) { + // Sometime html can be an empty string + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + return legacyAction?.message?.map((element) => getTextFromHtml(element?.html || element?.text)).join('') ?? ''; + } + return ''; +} + +/** + * Helper method to format message of OldDot Actions. + */ +function getMessageOfOldDotReportAction(oldDotAction: PartialReportAction | OldDotReportAction, withMarkdown = true): string { + if (isOldDotLegacyAction(oldDotAction)) { + return getMessageOfOldDotLegacyAction(oldDotAction); + } + + const {originalMessage, actionName} = oldDotAction; + switch (actionName) { + case CONST.REPORT.ACTIONS.TYPE.CHANGE_FIELD: { + const {oldValue, newValue, fieldName} = originalMessage; + if (!oldValue) { + return Localize.translateLocal('report.actions.type.changeFieldEmpty', {newValue, fieldName}); + } + return Localize.translateLocal('report.actions.type.changeField', {oldValue, newValue, fieldName}); + } + case CONST.REPORT.ACTIONS.TYPE.CHANGE_POLICY: { + const {fromPolicy, toPolicy} = originalMessage; + return Localize.translateLocal('report.actions.type.changePolicy', {fromPolicy, toPolicy}); + } + case CONST.REPORT.ACTIONS.TYPE.DELEGATE_SUBMIT: { + const {delegateUser, originalManager} = originalMessage; + return Localize.translateLocal('report.actions.type.delegateSubmit', {delegateUser, originalManager}); + } + case CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_CSV: + return Localize.translateLocal('report.actions.type.exportedToCSV'); + case CONST.REPORT.ACTIONS.TYPE.INTEGRATIONS_MESSAGE: { + const {result, label} = originalMessage; + const errorMessage = result?.messages?.join(', ') ?? ''; + return Localize.translateLocal('report.actions.type.integrationsMessage', errorMessage, label); + } + case CONST.REPORT.ACTIONS.TYPE.MANAGER_ATTACH_RECEIPT: + return Localize.translateLocal('report.actions.type.managerAttachReceipt'); + case CONST.REPORT.ACTIONS.TYPE.MANAGER_DETACH_RECEIPT: + return Localize.translateLocal('report.actions.type.managerDetachReceipt'); + case CONST.REPORT.ACTIONS.TYPE.MARK_REIMBURSED_FROM_INTEGRATION: { + const {amount, currency} = originalMessage; + return Localize.translateLocal('report.actions.type.markedReimbursedFromIntegration', {amount, currency}); + } + case CONST.REPORT.ACTIONS.TYPE.OUTDATED_BANK_ACCOUNT: + return Localize.translateLocal('report.actions.type.outdatedBankAccount'); + case CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_ACH_BOUNCE: + return Localize.translateLocal('report.actions.type.reimbursementACHBounce'); + case CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_ACH_CANCELLED: + return Localize.translateLocal('report.actions.type.reimbursementACHCancelled'); + case CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_ACCOUNT_CHANGED: + return Localize.translateLocal('report.actions.type.reimbursementAccountChanged'); + case CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_DELAYED: + return Localize.translateLocal('report.actions.type.reimbursementDelayed'); + case CONST.REPORT.ACTIONS.TYPE.SELECTED_FOR_RANDOM_AUDIT: + return Localize.translateLocal(`report.actions.type.selectedForRandomAudit${withMarkdown ? 'Markdown' : ''}`); + case CONST.REPORT.ACTIONS.TYPE.SHARE: + return Localize.translateLocal('report.actions.type.share', {to: originalMessage.to}); + case CONST.REPORT.ACTIONS.TYPE.UNSHARE: + return Localize.translateLocal('report.actions.type.unshare', {to: originalMessage.to}); + case CONST.REPORT.ACTIONS.TYPE.TAKE_CONTROL: + return Localize.translateLocal('report.actions.type.takeControl'); + default: + return ''; + } +} + +function getMemberChangeMessageFragment(reportAction: OnyxEntry): Message { + const messageElements: readonly MemberChangeMessageElement[] = getMemberChangeMessageElements(reportAction); + const html = messageElements + .map((messageElement) => { + switch (messageElement.kind) { + case 'userMention': + return `${messageElement.content}`; + case 'roomReference': + return `${messageElement.roomName}`; + default: + return messageElement.content; + } + }) + .join(''); + + return { + html: `${html}`, + text: getReportActionMessage(reportAction) ? getReportActionText(reportAction) : '', + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + }; +} + +function getUpdateRoomDescriptionFragment(reportAction: ReportAction): Message { + const html = getUpdateRoomDescriptionMessage(reportAction); + return { + html: `${html}`, + text: getReportActionMessage(reportAction) ? getReportActionText(reportAction) : '', + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + }; +} + +function getMemberChangeMessagePlainText(reportAction: OnyxEntry): string { + const messageElements = getMemberChangeMessageElements(reportAction); + return messageElements.map((element) => element.content).join(''); +} + +function getReportActionMessageFragments(action: ReportAction): Message[] { + if (isOldDotReportAction(action)) { + const oldDotMessage = getMessageOfOldDotReportAction(action); + const html = isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.SELECTED_FOR_RANDOM_AUDIT) ? Parser.replace(oldDotMessage) : oldDotMessage; + return [{text: oldDotMessage, html: `${html}`, type: 'COMMENT'}]; + } + + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.UPDATE_ROOM_DESCRIPTION)) { + const message = getUpdateRoomDescriptionMessage(action); + return [{text: message, html: `${message}`, type: 'COMMENT'}]; + } + + const actionMessage = action.previousMessage ?? action.message; + if (Array.isArray(actionMessage)) { + return actionMessage.filter((item): item is Message => !!item); + } + return actionMessage ? [actionMessage] : []; +} + +/** + * Helper method to determine if the provided accountID has submitted an expense on the specified report. + * + * @param reportID + * @param currentAccountID + * @returns + */ +function hasRequestFromCurrentAccount(reportID: string, currentAccountID: number): boolean { + if (!reportID) { + return false; + } + + const reportActions = Object.values(getAllReportActions(reportID)); + if (reportActions.length === 0) { + return false; + } + + return reportActions.some((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && action.actorAccountID === currentAccountID); +} + +/** + * Checks if a given report action corresponds to an actionable mention whisper. + * @param reportAction + */ +function isActionableMentionWhisper(reportAction: OnyxEntry): reportAction is ReportAction { + return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_MENTION_WHISPER); +} + +/** + * Checks if a given report action corresponds to an actionable report mention whisper. + * @param reportAction + */ +function isActionableReportMentionWhisper(reportAction: OnyxEntry): reportAction is ReportAction { + return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_REPORT_MENTION_WHISPER); +} + +/** + * Constructs a message for an actionable mention whisper report action. + * @param reportAction + * @returns the actionable mention whisper message. + */ +function getActionableMentionWhisperMessage(reportAction: OnyxEntry>): string { + if (!reportAction) { + return ''; + } + const originalMessage = getOriginalMessage(reportAction); + const targetAccountIDs: number[] = originalMessage?.inviteeAccountIDs ?? []; + const personalDetails = PersonalDetailsUtils.getPersonalDetailsByIDs(targetAccountIDs, 0); + const mentionElements = targetAccountIDs.map((accountID): string => { + const personalDetail = personalDetails.find((personal) => personal.accountID === accountID); + const displayName = PersonalDetailsUtils.getEffectiveDisplayName(personalDetail); + const handleText = isEmpty(displayName) ? Localize.translateLocal('common.hidden') : displayName; + return `@${handleText}`; + }); + const preMentionsText = 'Heads up, '; + const mentions = mentionElements.join(', ').replace(/, ([^,]*)$/, ' and $1'); + const postMentionsText = ` ${mentionElements.length > 1 ? "aren't members" : "isn't a member"} of this room.`; + + return `${preMentionsText}${mentions}${postMentionsText}`; +} + +/** + * @private + */ +function isReportActionUnread(reportAction: OnyxEntry, lastReadTime: string) { + if (!lastReadTime) { + return !isCreatedAction(reportAction); + } + + return !!(reportAction && lastReadTime && reportAction.created && lastReadTime < reportAction.created); +} + +/** + * Check whether the current report action of the report is unread or not + * + */ +function isCurrentActionUnread(report: OnyxEntry, reportAction: ReportAction): boolean { + const lastReadTime = report?.lastReadTime ?? ''; + const sortedReportActions = getSortedReportActions(Object.values(getAllReportActions(report?.reportID ?? '-1'))); + const currentActionIndex = sortedReportActions.findIndex((action) => action.reportActionID === reportAction.reportActionID); + if (currentActionIndex === -1) { + return false; + } + const prevReportAction = sortedReportActions[currentActionIndex - 1]; + return isReportActionUnread(reportAction, lastReadTime) && (!prevReportAction || !isReportActionUnread(prevReportAction, lastReadTime)); +} + +/** + * Checks if a given report action corresponds to a join request action. + * @param reportAction + */ +function isActionableJoinRequest(reportAction: OnyxEntry): reportAction is ReportAction { + return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_JOIN_REQUEST); +} + +/** + * Checks if any report actions correspond to a join request action that is still pending. + * @param reportID + */ +function isActionableJoinRequestPending(reportID: string): boolean { + const findPendingRequest = Object.values(getAllReportActions(reportID)).find( + (reportActionItem) => isActionableJoinRequest(reportActionItem) && getOriginalMessage(reportActionItem)?.choice === ('' as JoinWorkspaceResolution), + ); + return !!findPendingRequest; +} + +function isApprovedOrSubmittedReportAction(action: OnyxEntry) { + return [CONST.REPORT.ACTIONS.TYPE.APPROVED, CONST.REPORT.ACTIONS.TYPE.SUBMITTED].some((type) => type === action?.actionName); +} + +/** + * Gets the text version of the message in a report action + */ +function getReportActionMessageText(reportAction: OnyxEntry): string { + if (!Array.isArray(reportAction?.message)) { + return getReportActionText(reportAction); + } + // Sometime html can be an empty string + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + return reportAction?.message?.reduce((acc, curr) => `${acc}${getTextFromHtml(curr?.html || curr?.text)}`, '') ?? ''; +} + +function getDismissedViolationMessageText(originalMessage: ReportAction['originalMessage']): string { + const reason = originalMessage?.reason; + const violationName = originalMessage?.violationName; + return Localize.translateLocal(`violationDismissal.${violationName}.${reason}` as TranslationPaths); +} + +/** + * Check if the linked transaction is on hold + */ +function isLinkedTransactionHeld(reportActionID: string, reportID: string): boolean { + return TransactionUtils.isOnHoldByTransactionID(getLinkedTransactionID(reportActionID, reportID) ?? '-1'); +} + +function getMentionedAccountIDsFromAction(reportAction: OnyxInputOrEntry) { + return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT) ? getOriginalMessage(reportAction)?.mentionedAccountIDs ?? [] : []; +} + +function getMentionedEmailsFromMessage(message: string) { + const mentionEmailRegex = /(.*?)<\/mention-user>/g; + const matches = [...message.matchAll(mentionEmailRegex)]; + return matches.map((match) => Str.removeSMSDomain(match[1].substring(1))); +} + +function didMessageMentionCurrentUser(reportAction: OnyxInputOrEntry) { + const accountIDsFromMessage = getMentionedAccountIDsFromAction(reportAction); + const message = getReportActionMessage(reportAction)?.html ?? ''; + const emailsFromMessage = getMentionedEmailsFromMessage(message); + return accountIDsFromMessage.includes(currentUserAccountID ?? -1) || emailsFromMessage.includes(currentEmail) || message.includes(''); +} + +/** + * Check if the current user is the requestor of the action + */ +function wasActionTakenByCurrentUser(reportAction: OnyxInputOrEntry): boolean { + return currentUserAccountID === reportAction?.actorAccountID; +} + +/** + * Get IOU action for a reportID and transactionID + */ +function getIOUActionForReportID(reportID: string, transactionID: string): OnyxEntry { + const report = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + const reportActions = getAllReportActions(report?.reportID ?? ''); + const action = Object.values(reportActions ?? {})?.find((reportAction) => { + const IOUTransactionID = isMoneyRequestAction(reportAction) ? getOriginalMessage(reportAction)?.IOUTransactionID : -1; + return IOUTransactionID === transactionID; + }); + return action; +} + +/** + * Get the track expense actionable whisper of the corresponding track expense + */ +function getTrackExpenseActionableWhisper(transactionID: string, chatReportID: string) { + const chatReportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`] ?? {}; + return Object.values(chatReportActions).find((action: ReportAction) => isActionableTrackExpense(action) && getOriginalMessage(action)?.transactionID === transactionID); +} + +/** + * Checks if a given report action corresponds to a add payment card action. + * @param reportAction + */ +function isActionableAddPaymentCard(reportAction: OnyxEntry): reportAction is ReportAction { + return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_ADD_PAYMENT_CARD; +} + +function getExportIntegrationLastMessageText(reportAction: OnyxEntry): string { + const fragments = getExportIntegrationActionFragments(reportAction); + return fragments.reduce((acc, fragment) => `${acc} ${fragment.text}`, ''); +} + +function getExportIntegrationMessageHTML(reportAction: OnyxEntry): string { + const fragments = getExportIntegrationActionFragments(reportAction); + const htmlFragments = fragments.map((fragment) => (fragment.url ? `${fragment.text}` : fragment.text)); + return htmlFragments.join(' '); +} + +function getExportIntegrationActionFragments(reportAction: OnyxEntry): Array<{text: string; url: string}> { + if (reportAction?.actionName !== 'EXPORTINTEGRATION') { + throw Error(`received wrong action type. actionName: ${reportAction?.actionName}`); + } + + const isPending = reportAction?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD; + const originalMessage = (getOriginalMessage(reportAction) ?? {}) as OriginalMessageExportIntegration; + const {label, markedManually} = originalMessage; + const reimbursableUrls = originalMessage.reimbursableUrls ?? []; + const nonReimbursableUrls = originalMessage.nonReimbursableUrls ?? []; + const reportID = reportAction?.reportID ?? ''; + const wasExportedAfterBase62 = (reportAction?.created ?? '') > '2022-11-14'; + const base62ReportID = getBase62ReportID(Number(reportID)); + + const result: Array<{text: string; url: string}> = []; + if (isPending) { + result.push({ + text: Localize.translateLocal('report.actions.type.exportedToIntegration.pending', {label}), + url: '', + }); + } else if (markedManually) { + result.push({ + text: Localize.translateLocal('report.actions.type.exportedToIntegration.manual', {label}), + url: '', + }); + } else { + result.push({ + text: Localize.translateLocal('report.actions.type.exportedToIntegration.automatic', {label}), + url: '', + }); + } + + if (reimbursableUrls.length === 1) { + result.push({ + text: Localize.translateLocal('report.actions.type.exportedToIntegration.reimburseableLink'), + url: reimbursableUrls[0], + }); + } + + if (nonReimbursableUrls.length) { + const text = Localize.translateLocal('report.actions.type.exportedToIntegration.nonReimbursableLink'); + let url = ''; + + if (nonReimbursableUrls.length === 1) { + url = nonReimbursableUrls[0]; + } else { + switch (label) { + case CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.xero: + url = XERO_NON_REIMBURSABLE_EXPENSES_URL; + break; + case CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.netsuite: + url = NETSUITE_NON_REIMBURSABLE_EXPENSES_URL_PREFIX; + url += wasExportedAfterBase62 ? base62ReportID : reportID; + break; + case CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.financialForce: + // The first three characters in a Salesforce ID is the expense type + url = nonReimbursableUrls[0].substring(0, SALESFORCE_EXPENSES_URL_PREFIX.length + 3); + break; + default: + url = QBO_EXPENSES_URL; + } + } + + result.push({text, url}); + } + + return result; +} + +function getUpdateRoomDescriptionMessage(reportAction: ReportAction): string { + const originalMessage = getOriginalMessage(reportAction) as OriginalMessageChangeLog; + if (originalMessage?.description) { + return `${Localize.translateLocal('roomChangeLog.updateRoomDescription')} ${originalMessage?.description}`; + } + + return Localize.translateLocal('roomChangeLog.clearRoomDescription'); +} + +function isPolicyChangeLogAddEmployeeMessage(reportAction: OnyxInputOrEntry): reportAction is ReportAction { + return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_EMPLOYEE); +} + +function getPolicyChangeLogAddEmployeeMessage(reportAction: OnyxInputOrEntry): string { + if (!isPolicyChangeLogAddEmployeeMessage(reportAction)) { + return ''; + } + + const originalMessage = getOriginalMessage(reportAction); + const email = originalMessage?.email ?? ''; + const role = originalMessage?.role ?? ''; + return Localize.translateLocal('report.actions.type.addEmployee', email, role); +} + +function isPolicyChangeLogChangeRoleMessage(reportAction: OnyxInputOrEntry): reportAction is ReportAction { + return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EMPLOYEE); +} + +function getPolicyChangeLogChangeRoleMessage(reportAction: OnyxInputOrEntry): string { + if (!isPolicyChangeLogChangeRoleMessage(reportAction)) { + return ''; + } + const originalMessage = getOriginalMessage(reportAction); + const email = originalMessage?.email ?? ''; + const newRole = originalMessage?.newValue ?? ''; + const oldRole = originalMessage?.oldValue ?? ''; + return Localize.translateLocal('report.actions.type.updateRole', email, oldRole, newRole); +} + +function isPolicyChangeLogDeleteMemberMessage( + reportAction: OnyxInputOrEntry, +): reportAction is ReportAction { + return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_EMPLOYEE); +} + +function getPolicyChangeLogDeleteMemberMessage(reportAction: OnyxInputOrEntry): string { + if (!isPolicyChangeLogDeleteMemberMessage(reportAction)) { + return ''; + } + const originalMessage = getOriginalMessage(reportAction); + const email = originalMessage?.email ?? ''; + const role = originalMessage?.role ?? ''; + return Localize.translateLocal('report.actions.type.removeMember', email, role); +} + +function getRenamedAction(reportAction: OnyxEntry>) { + const originalMessage = getOriginalMessage(reportAction); + return Localize.translateLocal('newRoomPage.renamedRoomAction', { + oldName: originalMessage?.oldName ?? '', + newName: originalMessage?.newName ?? '', + }); +} + +function getRemovedFromApprovalChainMessage(reportAction: OnyxEntry>) { + const originalMessage = getOriginalMessage(reportAction); + const submittersNames = PersonalDetailsUtils.getPersonalDetailsByIDs(originalMessage?.submittersAccountIDs ?? [], currentUserAccountID ?? -1).map( + ({displayName, login}) => displayName ?? login ?? 'Unknown Submitter', + ); + return Localize.translateLocal('workspaceActions.removedFromApprovalWorkflow', {submittersNames}); +} + +function isCardIssuedAction(reportAction: OnyxEntry) { + return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.CARD_ISSUED, CONST.REPORT.ACTIONS.TYPE.CARD_ISSUED_VIRTUAL, CONST.REPORT.ACTIONS.TYPE.CARD_MISSING_ADDRESS); +} + +function getCardIssuedMessage(reportAction: OnyxEntry, shouldRenderHTML = false) { + const assigneeAccountID = (reportAction?.originalMessage as IssueNewCardOriginalMessage)?.assigneeAccountID; + const assigneeDetails = PersonalDetailsUtils.getPersonalDetailsByIDs([assigneeAccountID], currentUserAccountID ?? -1)[0]; + + const assignee = shouldRenderHTML ? `` : assigneeDetails?.firstName ?? assigneeDetails.login ?? ''; + const link = shouldRenderHTML + ? `${Localize.translateLocal('cardPage.expensifyCard')}` + : Localize.translateLocal('cardPage.expensifyCard'); + + const missingDetails = + !privatePersonalDetails?.legalFirstName || + !privatePersonalDetails?.legalLastName || + !privatePersonalDetails?.dob || + !privatePersonalDetails?.phoneNumber || + isEmptyObject(privatePersonalDetails?.addresses) || + privatePersonalDetails.addresses.length === 0; + + const isAssigneeCurrentUser = currentUserAccountID === assigneeAccountID; + + const shouldShowAddMissingDetailsButton = reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.CARD_MISSING_ADDRESS && missingDetails && isAssigneeCurrentUser; + switch (reportAction?.actionName) { + case CONST.REPORT.ACTIONS.TYPE.CARD_ISSUED: + return Localize.translateLocal('workspace.expensifyCard.issuedCard', assignee); + case CONST.REPORT.ACTIONS.TYPE.CARD_ISSUED_VIRTUAL: + return Localize.translateLocal('workspace.expensifyCard.issuedCardVirtual', {assignee, link}); + case CONST.REPORT.ACTIONS.TYPE.CARD_MISSING_ADDRESS: + return Localize.translateLocal(`workspace.expensifyCard.${shouldShowAddMissingDetailsButton ? 'issuedCardNoShippingDetails' : 'addedShippingDetails'}`, assignee); + default: + return ''; + } +} + +export { + doesReportHaveVisibleActions, + extractLinksFromMessageHtml, + getActionableMentionWhisperMessage, + getAllReportActions, + getCombinedReportActions, + getDismissedViolationMessageText, + getFirstVisibleReportActionID, + getIOUActionForReportID, + getIOUReportIDFromReportActionPreview, + getLastClosedReportAction, + getLastVisibleAction, + getLastVisibleMessage, + getLatestReportActionFromOnyxData, + getLinkedTransactionID, + getMemberChangeMessageFragment, + getUpdateRoomDescriptionFragment, + getMemberChangeMessagePlainText, + getReportActionMessageFragments, + getMessageOfOldDotReportAction, + getMostRecentIOURequestActionID, + getMostRecentReportActionLastModified, + getNumberOfMoneyRequests, + getOneTransactionThreadReportID, + getOriginalMessage, + getParentReportAction, + getRemovedFromApprovalChainMessage, + getReportAction, + getReportActionHtml, + getReportActionMessage, + getReportActionMessageText, + getReportActionText, + getReportPreviewAction, + getSortedReportActions, + getSortedReportActionsForDisplay, + getTextFromHtml, + getTrackExpenseActionableWhisper, + getWhisperedTo, + hasRequestFromCurrentAccount, + isActionOfType, + isActionableJoinRequest, + isActionableJoinRequestPending, + isActionableMentionWhisper, + isActionableReportMentionWhisper, + isActionableTrackExpense, + isAddCommentAction, + isApprovedOrSubmittedReportAction, + isChronosOOOListAction, + isClosedAction, + isConsecutiveActionMadeByPreviousActor, + isCreatedAction, + isCreatedTaskReportAction, + isCurrentActionUnread, + isDeletedAction, + isDeletedParentAction, + isLinkedTransactionHeld, + isMemberChangeAction, + isExportIntegrationAction, + isMessageDeleted, + isModifiedExpenseAction, + isMoneyRequestAction, + isNotifiableReportAction, + isOldDotReportAction, + isPayAction, + isPendingRemove, + isPolicyChangeLogAction, + isReimbursementDeQueuedAction, + isReimbursementQueuedAction, + isRenamedAction, + isReportActionAttachment, + isReportActionDeprecated, + isReportPreviewAction, + isResolvedActionTrackExpense, + isReversedTransaction, + isRoomChangeLogAction, + isSentMoneyReportAction, + isSplitBillAction, + isTaskAction, + isThreadParentMessage, + isTrackExpenseAction, + isTransactionThread, + isTripPreview, + isWhisperAction, + isSubmittedAction, + isApprovedAction, + isForwardedAction, + isWhisperActionTargetedToOthers, + isTagModificationAction, + shouldHideNewMarker, + shouldReportActionBeVisible, + shouldReportActionBeVisibleAsLastAction, + wasActionTakenByCurrentUser, + isInviteOrRemovedAction, + isActionableAddPaymentCard, + getExportIntegrationActionFragments, + getExportIntegrationLastMessageText, + getExportIntegrationMessageHTML, + getUpdateRoomDescriptionMessage, + didMessageMentionCurrentUser, + getPolicyChangeLogAddEmployeeMessage, + getPolicyChangeLogChangeRoleMessage, + getPolicyChangeLogDeleteMemberMessage, + getRenamedAction, + isCardIssuedAction, + getCardIssuedMessage, +}; + +export type {LastVisibleMessage}; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index c18b6c41b420..aa6412a2bdfd 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -1238,7 +1238,7 @@ function shouldDisableDetailPage(report: OnyxEntry): boolean { const participantAccountIDs = Object.keys(report?.participants ?? {}) .map(Number) .filter((accountID) => accountID !== currentUserAccountID); - return isOptimisticPersonalDetail(participantAccountIDs[0]); + return isOptimisticPersonalDetail(participantAccountIDs.at(0) ?? -1); } return false; } @@ -1345,7 +1345,7 @@ function findLastAccessedReport(ignoreDomainRooms: boolean, openOnAdminRoom = fa // Filter out the systemChat report from the reports list, as we don't want to drop the user into that report over Concierge when they first log in reportsValues = reportsValues.filter((report) => !isSystemChat(report)) ?? []; if (reportsValues.length === 1) { - return reportsValues[0]; + return reportsValues.at(0); } return adminReport ?? reportsValues.find((report) => !isConciergeChatReport(report)); @@ -1622,7 +1622,7 @@ function isPayAtEndExpenseReport(reportID: string, transactions: Transaction[] | return false; } - return TransactionUtils.isPayAtEndExpense(transactions?.[0] ?? TransactionUtils.getAllReportTransactions(reportID)[0]); + return TransactionUtils.isPayAtEndExpense(transactions?.[0] ?? TransactionUtils.getAllReportTransactions(reportID).at(0)); } /** @@ -2182,7 +2182,7 @@ function getGroupChatName(participants?: SelectedParticipant[], shouldApplyLimit .join(', '); } - return Localize.translateLocal('groupChat.defaultReportName', {displayName: getDisplayNameForParticipant(participantAccountIDs[0], false)}); + return Localize.translateLocal('groupChat.defaultReportName', {displayName: getDisplayNameForParticipant(participantAccountIDs.at(0), false)}); } function getParticipants(reportID: string) { @@ -3565,7 +3565,7 @@ function getAdminRoomInvitedParticipants(parentReportAction: OnyxEntry 1 ? participants.join(` ${Localize.translateLocal('common.and')} `) : participants[0]; + const users = participants.length > 1 ? participants.join(` ${Localize.translateLocal('common.and')} `) : participants.at(0); if (!users) { return parentReportActionMessage; } @@ -3633,7 +3633,7 @@ function parseReportActionHtmlToText(reportAction: OnyxEntry, repo const accountIDToName: Record = {}; const accountIDs = Array.from(html.matchAll(mentionUserRegex), (mention) => Number(mention[1])); const logins = PersonalDetailsUtils.getLoginsByAccountIDs(accountIDs); - accountIDs.forEach((id, index) => (accountIDToName[id] = logins[index])); + accountIDs.forEach((id, index) => (accountIDToName[id] = logins.at(index) ?? '')); const textMessage = Str.removeSMSDomain(Parser.htmlToText(html, {reportIDToName, accountIDToName})); parsedReportActionMessageCache[key] = textMessage; @@ -3872,7 +3872,7 @@ function getPayeeName(report: OnyxEntry): string | undefined { if (participantsWithoutCurrentUser.length === 0) { return undefined; } - return getDisplayNameForParticipant(participantsWithoutCurrentUser[0], true); + return getDisplayNameForParticipant(participantsWithoutCurrentUser.at(0), true); } /** @@ -3948,7 +3948,7 @@ function navigateToDetailsPage(report: OnyxEntry) { const participantAccountID = getParticipantsAccountIDsForDisplay(report); if (isSelfDMReport || isOneOnOneChatReport) { - Navigation.navigate(ROUTES.PROFILE.getRoute(participantAccountID[0])); + Navigation.navigate(ROUTES.PROFILE.getRoute(participantAccountID.at(0))); return; } @@ -3965,7 +3965,7 @@ function goBackToDetailsPage(report: OnyxEntry) { const participantAccountID = getParticipantsAccountIDsForDisplay(report); if (isOneOnOneChatReport) { - Navigation.navigate(ROUTES.PROFILE.getRoute(participantAccountID[0])); + Navigation.navigate(ROUTES.PROFILE.getRoute(participantAccountID.at(0))); return; } @@ -4001,7 +4001,7 @@ function goBackFromPrivateNotes(report: OnyxEntry, session: OnyxEntry, sortedAndFiltered const newMarkerIndex = lodashFindLastIndex(sortedAndFilteredReportActions, (reportAction) => (reportAction.created ?? '') > (report?.lastReadTime ?? '')); - return 'reportActionID' in sortedAndFilteredReportActions[newMarkerIndex] ? sortedAndFilteredReportActions[newMarkerIndex].reportActionID : ''; + return 'reportActionID' in (sortedAndFilteredReportActions?.at(newMarkerIndex) ?? {}) ? sortedAndFilteredReportActions.at(newMarkerIndex)?.reportActionID ?? '' : ''; } /** @@ -6337,8 +6340,8 @@ function parseReportRouteParams(route: string): ReportRouteParams { const pathSegments = parsingRoute.split('/'); - const reportIDSegment = pathSegments[1]; - const hasRouteReportActionID = !Number.isNaN(Number(pathSegments[2])); + const reportIDSegment = pathSegments.at(1); + const hasRouteReportActionID = !Number.isNaN(Number(reportIDSegment)); // Check for "undefined" or any other unwanted string values if (!reportIDSegment || reportIDSegment === 'undefined') { @@ -6633,7 +6636,7 @@ function canLeaveRoom(report: OnyxEntry, isPolicyEmployee: boolean): boo } function isCurrentUserTheOnlyParticipant(participantAccountIDs?: number[]): boolean { - return !!(participantAccountIDs?.length === 1 && participantAccountIDs?.[0] === currentUserAccountID); + return !!(participantAccountIDs?.length === 1 && participantAccountIDs?.at(0) === currentUserAccountID); } /** @@ -7375,13 +7378,13 @@ function getOptimisticDataForParentReportAction(reportID: string, lastVisibleAct const totalAncestor = ancestors.reportIDs.length; return Array.from(Array(totalAncestor), (_, index) => { - const ancestorReport = getReportOrDraftReport(ancestors.reportIDs[index]); + const ancestorReport = getReportOrDraftReport(ancestors.reportIDs.at(index)); if (!ancestorReport || isEmptyObject(ancestorReport)) { return null; } - const ancestorReportAction = ReportActionsUtils.getReportAction(ancestorReport.reportID, ancestors.reportActionsIDs[index]); + const ancestorReportAction = ReportActionsUtils.getReportAction(ancestorReport.reportID, ancestors.reportActionsIDs.at(index) ?? ''); if (!ancestorReportAction || isEmptyObject(ancestorReportAction)) { return null; diff --git a/src/libs/ReportUtils.ts.orig b/src/libs/ReportUtils.ts.orig new file mode 100644 index 000000000000..c18b6c41b420 --- /dev/null +++ b/src/libs/ReportUtils.ts.orig @@ -0,0 +1,8168 @@ +import {format} from 'date-fns'; +import {Str} from 'expensify-common'; +import lodashEscape from 'lodash/escape'; +import lodashFindLastIndex from 'lodash/findLastIndex'; +import lodashIntersection from 'lodash/intersection'; +import isEmpty from 'lodash/isEmpty'; +import lodashIsEqual from 'lodash/isEqual'; +import isNumber from 'lodash/isNumber'; +import lodashMaxBy from 'lodash/maxBy'; +import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; +import type {SvgProps} from 'react-native-svg'; +import type {OriginalMessageIOU, OriginalMessageModifiedExpense} from 'src/types/onyx/OriginalMessage'; +import type {TupleToUnion, ValueOf} from 'type-fest'; +import type {FileObject} from '@components/AttachmentModal'; +import {FallbackAvatar, QBOCircle, XeroCircle} from '@components/Icon/Expensicons'; +import * as defaultGroupAvatars from '@components/Icon/GroupDefaultAvatars'; +import * as defaultWorkspaceAvatars from '@components/Icon/WorkspaceDefaultAvatars'; +import type {MoneyRequestAmountInputProps} from '@components/MoneyRequestAmountInput'; +import type {IOUAction, IOUType} from '@src/CONST'; +import CONST from '@src/CONST'; +import type {ParentNavigationSummaryParams, TranslationPaths} from '@src/languages/types'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Route} from '@src/ROUTES'; +import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; +import type { + Beta, + OnyxInputOrEntry, + PersonalDetails, + PersonalDetailsList, + Policy, + PolicyReportField, + Report, + ReportAction, + ReportMetadata, + ReportNameValuePairs, + ReportViolationName, + ReportViolations, + Session, + Task, + Transaction, + TransactionViolation, + UserWallet, +} from '@src/types/onyx'; +import type {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'; +import type {Errors, Icon, PendingAction} from '@src/types/onyx/OnyxCommon'; +import type {OriginalMessageChangeLog, PaymentMethodType} from '@src/types/onyx/OriginalMessage'; +import type {Status} from '@src/types/onyx/PersonalDetails'; +import type {ConnectionName} from '@src/types/onyx/Policy'; +import type {NotificationPreference, Participants, PendingChatMember, Participant as ReportParticipant} from '@src/types/onyx/Report'; +import type {Message, ReportActions} from '@src/types/onyx/ReportAction'; +import type {Comment, TransactionChanges, WaypointCollection} from '@src/types/onyx/Transaction'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import type IconAsset from '@src/types/utils/IconAsset'; +import * as IOU from './actions/IOU'; +import * as PolicyActions from './actions/Policy/Policy'; +import * as store from './actions/ReimbursementAccount/store'; +import * as SessionUtils from './actions/Session'; +import * as CurrencyUtils from './CurrencyUtils'; +import DateUtils from './DateUtils'; +import {hasValidDraftComment} from './DraftCommentUtils'; +import getAttachmentDetails from './fileDownload/getAttachmentDetails'; +import getIsSmallScreenWidth from './getIsSmallScreenWidth'; +import isReportMessageAttachment from './isReportMessageAttachment'; +import localeCompare from './LocaleCompare'; +import * as LocalePhoneNumber from './LocalePhoneNumber'; +import * as Localize from './Localize'; +import Log from './Log'; +import {isEmailPublicDomain} from './LoginUtils'; +import ModifiedExpenseMessage from './ModifiedExpenseMessage'; +import linkingConfig from './Navigation/linkingConfig'; +import Navigation from './Navigation/Navigation'; +import * as NumberUtils from './NumberUtils'; +import Parser from './Parser'; +import Permissions from './Permissions'; +import * as PersonalDetailsUtils from './PersonalDetailsUtils'; +import * as PhoneNumber from './PhoneNumber'; +import * as PolicyUtils from './PolicyUtils'; +import type {LastVisibleMessage} from './ReportActionsUtils'; +import * as ReportActionsUtils from './ReportActionsUtils'; +import * as ReportConnection from './ReportConnection'; +import StringUtils from './StringUtils'; +import * as TransactionUtils from './TransactionUtils'; +import * as Url from './Url'; +import type {AvatarSource} from './UserUtils'; +import * as UserUtils from './UserUtils'; + +type AvatarRange = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18; + +type SpendBreakdown = { + nonReimbursableSpend: number; + reimbursableSpend: number; + totalDisplaySpend: number; +}; + +type ParticipantDetails = [number, string, AvatarSource, AvatarSource]; + +type OptimisticAddCommentReportAction = Pick< + ReportAction, + | 'reportActionID' + | 'actionName' + | 'actorAccountID' + | 'person' + | 'automatic' + | 'avatar' + | 'created' + | 'message' + | 'isFirstItem' + | 'isAttachmentOnly' + | 'isAttachmentWithText' + | 'pendingAction' + | 'shouldShow' + | 'originalMessage' + | 'childReportID' + | 'parentReportID' + | 'childType' + | 'childReportName' + | 'childManagerAccountID' + | 'childStatusNum' + | 'childStateNum' + | 'errors' + | 'childVisibleActionCount' + | 'childCommenterCount' + | 'childLastVisibleActionCreated' + | 'childOldestFourAccountIDs' +> & {isOptimisticAction: boolean}; + +type OptimisticReportAction = { + commentText: string; + reportAction: OptimisticAddCommentReportAction; +}; + +type UpdateOptimisticParentReportAction = { + childVisibleActionCount: number; + childCommenterCount: number; + childLastVisibleActionCreated: string; + childOldestFourAccountIDs: string | undefined; +}; + +type OptimisticExpenseReport = Pick< + Report, + | 'reportID' + | 'chatReportID' + | 'policyID' + | 'type' + | 'ownerAccountID' + | 'managerID' + | 'currency' + | 'reportName' + | 'stateNum' + | 'statusNum' + | 'total' + | 'nonReimbursableTotal' + | 'parentReportID' + | 'lastVisibleActionCreated' + | 'parentReportActionID' + | 'participants' + | 'fieldList' +>; + +type OptimisticIOUReportAction = Pick< + ReportAction, + | 'actionName' + | 'actorAccountID' + | 'automatic' + | 'avatar' + | 'isAttachmentOnly' + | 'originalMessage' + | 'message' + | 'person' + | 'reportActionID' + | 'shouldShow' + | 'created' + | 'pendingAction' + | 'receipt' + | 'childReportID' + | 'childVisibleActionCount' + | 'childCommenterCount' +>; + +type PartialReportAction = OnyxInputOrEntry | Partial | OptimisticIOUReportAction | OptimisticApprovedReportAction | OptimisticSubmittedReportAction | undefined; + +type ReportRouteParams = { + reportID: string; + isSubReportPageRoute: boolean; +}; + +type ReportOfflinePendingActionAndErrors = { + reportPendingAction: PendingAction | undefined; + reportErrors: Errors | null | undefined; +}; + +type OptimisticApprovedReportAction = Pick< + ReportAction, + 'actionName' | 'actorAccountID' | 'automatic' | 'avatar' | 'isAttachmentOnly' | 'originalMessage' | 'message' | 'person' | 'reportActionID' | 'shouldShow' | 'created' | 'pendingAction' +>; + +type OptimisticUnapprovedReportAction = Pick< + ReportAction, + 'actionName' | 'actorAccountID' | 'automatic' | 'avatar' | 'isAttachmentOnly' | 'originalMessage' | 'message' | 'person' | 'reportActionID' | 'shouldShow' | 'created' | 'pendingAction' +>; + +type OptimisticSubmittedReportAction = Pick< + ReportAction, + | 'actionName' + | 'actorAccountID' + | 'adminAccountID' + | 'automatic' + | 'avatar' + | 'isAttachmentOnly' + | 'originalMessage' + | 'message' + | 'person' + | 'reportActionID' + | 'shouldShow' + | 'created' + | 'pendingAction' +>; + +type OptimisticHoldReportAction = Pick< + ReportAction, + 'actionName' | 'actorAccountID' | 'automatic' | 'avatar' | 'isAttachmentOnly' | 'originalMessage' | 'message' | 'person' | 'reportActionID' | 'shouldShow' | 'created' | 'pendingAction' +>; + +type OptimisticCancelPaymentReportAction = Pick< + ReportAction, + 'actionName' | 'actorAccountID' | 'message' | 'originalMessage' | 'person' | 'reportActionID' | 'shouldShow' | 'created' | 'pendingAction' +>; + +type OptimisticEditedTaskReportAction = Pick< + ReportAction, + 'reportActionID' | 'actionName' | 'pendingAction' | 'actorAccountID' | 'automatic' | 'avatar' | 'created' | 'shouldShow' | 'message' | 'person' +>; + +type OptimisticClosedReportAction = Pick< + ReportAction, + 'actionName' | 'actorAccountID' | 'automatic' | 'avatar' | 'created' | 'message' | 'originalMessage' | 'pendingAction' | 'person' | 'reportActionID' | 'shouldShow' +>; + +type OptimisticDismissedViolationReportAction = Pick< + ReportAction, + 'actionName' | 'actorAccountID' | 'avatar' | 'created' | 'message' | 'originalMessage' | 'person' | 'reportActionID' | 'shouldShow' | 'pendingAction' +>; + +type OptimisticCreatedReportAction = Pick< + ReportAction, + 'actorAccountID' | 'automatic' | 'avatar' | 'created' | 'message' | 'person' | 'reportActionID' | 'shouldShow' | 'pendingAction' | 'actionName' +>; + +type OptimisticRenamedReportAction = Pick< + ReportAction, + 'actorAccountID' | 'automatic' | 'avatar' | 'created' | 'message' | 'person' | 'reportActionID' | 'shouldShow' | 'pendingAction' | 'actionName' | 'originalMessage' +>; + +type OptimisticRoomDescriptionUpdatedReportAction = Pick< + ReportAction, + 'actorAccountID' | 'created' | 'message' | 'person' | 'reportActionID' | 'pendingAction' | 'actionName' | 'originalMessage' +>; + +type OptimisticChatReport = Pick< + Report, + | 'type' + | 'chatType' + | 'chatReportID' + | 'iouReportID' + | 'isOwnPolicyExpenseChat' + | 'isPinned' + | 'lastActorAccountID' + | 'lastMessageTranslationKey' + | 'lastMessageHtml' + | 'lastMessageText' + | 'lastReadTime' + | 'lastVisibleActionCreated' + | 'oldPolicyName' + | 'ownerAccountID' + | 'pendingFields' + | 'parentReportActionID' + | 'parentReportID' + | 'participants' + | 'policyID' + | 'reportID' + | 'reportName' + | 'stateNum' + | 'statusNum' + | 'visibility' + | 'description' + | 'writeCapability' + | 'avatarUrl' + | 'avatarFileName' + | 'invoiceReceiver' + | 'isHidden' +> & { + isOptimisticReport: true; +}; + +type OptimisticExportIntegrationAction = OriginalMessageExportedToIntegration & + Pick< + ReportAction, + 'reportActionID' | 'actorAccountID' | 'avatar' | 'created' | 'lastModified' | 'message' | 'person' | 'shouldShow' | 'pendingAction' | 'errors' | 'automatic' + >; + +type OptimisticTaskReportAction = Pick< + ReportAction, + | 'reportActionID' + | 'actionName' + | 'actorAccountID' + | 'automatic' + | 'avatar' + | 'created' + | 'isAttachmentOnly' + | 'message' + | 'originalMessage' + | 'person' + | 'pendingAction' + | 'shouldShow' + | 'isFirstItem' + | 'previousMessage' + | 'errors' + | 'linkMetadata' +>; + +type OptimisticWorkspaceChats = { + announceChatReportID: string; + announceChatData: OptimisticChatReport; + announceReportActionData: Record; + announceCreatedReportActionID: string; + adminsChatReportID: string; + adminsChatData: OptimisticChatReport; + adminsReportActionData: Record; + adminsCreatedReportActionID: string; + expenseChatReportID: string; + expenseChatData: OptimisticChatReport; + expenseReportActionData: Record; + expenseCreatedReportActionID: string; +}; + +type OptimisticModifiedExpenseReportAction = Pick< + ReportAction, + 'actionName' | 'actorAccountID' | 'automatic' | 'avatar' | 'created' | 'isAttachmentOnly' | 'message' | 'originalMessage' | 'person' | 'pendingAction' | 'reportActionID' | 'shouldShow' +> & {reportID?: string}; + +type OptimisticTaskReport = Pick< + Report, + | 'reportID' + | 'reportName' + | 'description' + | 'ownerAccountID' + | 'participants' + | 'managerID' + | 'type' + | 'parentReportID' + | 'policyID' + | 'stateNum' + | 'statusNum' + | 'parentReportActionID' + | 'lastVisibleActionCreated' + | 'hasParentAccess' +>; + +type TransactionDetails = { + created: string; + amount: number; + taxAmount?: number; + taxCode?: string; + currency: string; + merchant: string; + waypoints?: WaypointCollection | string; + customUnitRateID?: string; + comment: string; + category: string; + billable: boolean; + tag: string; + mccGroup?: ValueOf; + cardID: number; + originalAmount: number; + originalCurrency: string; +}; + +type OptimisticIOUReport = Pick< + Report, + | 'cachedTotal' + | 'type' + | 'chatReportID' + | 'currency' + | 'managerID' + | 'policyID' + | 'ownerAccountID' + | 'participants' + | 'reportID' + | 'stateNum' + | 'statusNum' + | 'total' + | 'reportName' + | 'parentReportID' + | 'lastVisibleActionCreated' + | 'fieldList' +>; +type DisplayNameWithTooltips = Array>; + +type CustomIcon = { + src: IconAsset; + color?: string; +}; + +type OptionData = { + text?: string; + alternateText?: string; + allReportErrors?: Errors; + brickRoadIndicator?: ValueOf | '' | null; + tooltipText?: string | null; + alternateTextMaxLines?: number; + boldStyle?: boolean; + customIcon?: CustomIcon; + subtitle?: string; + login?: string; + accountID?: number; + pronouns?: string; + status?: Status | null; + phoneNumber?: string; + isUnread?: boolean | null; + isUnreadWithMention?: boolean | null; + hasDraftComment?: boolean | null; + keyForList?: string; + searchText?: string; + isIOUReportOwner?: boolean | null; + shouldShowSubscript?: boolean | null; + isPolicyExpenseChat?: boolean | null; + isMoneyRequestReport?: boolean | null; + isInvoiceReport?: boolean; + isExpenseRequest?: boolean | null; + isAllowedToComment?: boolean | null; + isThread?: boolean | null; + isTaskReport?: boolean | null; + parentReportAction?: OnyxEntry; + displayNamesWithTooltips?: DisplayNameWithTooltips | null; + isDefaultRoom?: boolean; + isInvoiceRoom?: boolean; + isExpenseReport?: boolean; + isOptimisticPersonalDetail?: boolean; + selected?: boolean; + isOptimisticAccount?: boolean; + isSelected?: boolean; + descriptiveText?: string; + notificationPreference?: NotificationPreference | null; + isDisabled?: boolean | null; + name?: string | null; + isSelfDM?: boolean; + isOneOnOneChat?: boolean; + reportID?: string; + enabled?: boolean; + code?: string; + transactionThreadReportID?: string | null; + shouldShowAmountInput?: boolean; + amountInputProps?: MoneyRequestAmountInputProps; + tabIndex?: 0 | -1; + isConciergeChat?: boolean; + isBold?: boolean; +} & Report; + +type OnyxDataTaskAssigneeChat = { + optimisticData: OnyxUpdate[]; + successData: OnyxUpdate[]; + failureData: OnyxUpdate[]; + optimisticAssigneeAddComment?: OptimisticReportAction; + optimisticChatCreatedReportAction?: OptimisticCreatedReportAction; +}; + +type Ancestor = { + report: Report; + reportAction: ReportAction; + shouldDisplayNewMarker: boolean; +}; + +type AncestorIDs = { + reportIDs: string[]; + reportActionsIDs: string[]; +}; + +type MissingPaymentMethod = 'bankAccount' | 'wallet'; + +type OutstandingChildRequest = { + hasOutstandingChildRequest?: boolean; +}; + +type ParsingDetails = { + shouldEscapeText?: boolean; + reportID?: string; + policyID?: string; +}; + +type Thread = { + parentReportID: string; + parentReportActionID: string; +} & Report; + +let currentUserEmail: string | undefined; +let currentUserPrivateDomain: string | undefined; +let currentUserAccountID: number | undefined; +let isAnonymousUser = false; + +// This cache is used to save parse result of report action html message into text +// to prevent unnecessary parsing when the report action is not changed/modified. +// Example case: when we need to get a report name of a thread which is dependent on a report action message. +const parsedReportActionMessageCache: Record = {}; + +const defaultAvatarBuildingIconTestID = 'SvgDefaultAvatarBuilding Icon'; +Onyx.connect({ + key: ONYXKEYS.SESSION, + callback: (value) => { + // When signed out, val is undefined + if (!value) { + return; + } + + currentUserEmail = value.email; + currentUserAccountID = value.accountID; + isAnonymousUser = value.authTokenType === CONST.AUTH_TOKEN_TYPES.ANONYMOUS; + currentUserPrivateDomain = isEmailPublicDomain(currentUserEmail ?? '') ? '' : Str.extractEmailDomain(currentUserEmail ?? ''); + }, +}); + +let allPersonalDetails: OnyxEntry; +let allPersonalDetailLogins: string[]; +let currentUserPersonalDetails: OnyxEntry; +Onyx.connect({ + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + callback: (value) => { + currentUserPersonalDetails = value?.[currentUserAccountID ?? -1] ?? undefined; + allPersonalDetails = value ?? {}; + allPersonalDetailLogins = Object.values(allPersonalDetails).map((personalDetail) => personalDetail?.login ?? ''); + }, +}); + +let allReportsDraft: OnyxCollection; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT_DRAFT, + waitForCollectionCallback: true, + callback: (value) => (allReportsDraft = value), +}); + +let allPolicies: OnyxCollection; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.POLICY, + waitForCollectionCallback: true, + callback: (value) => (allPolicies = value), +}); + +let allBetas: OnyxEntry; +Onyx.connect({ + key: ONYXKEYS.BETAS, + callback: (value) => (allBetas = value), +}); + +let allTransactions: OnyxCollection = {}; +let reportsTransactions: Record = {}; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (value) => { + if (!value) { + return; + } + allTransactions = Object.fromEntries(Object.entries(value).filter(([, transaction]) => transaction)); + + reportsTransactions = Object.values(value).reduce>((all, transaction) => { + const reportsMap = all; + if (!transaction) { + return reportsMap; + } + + if (!reportsMap[transaction.reportID]) { + reportsMap[transaction.reportID] = []; + } + reportsMap[transaction.reportID].push(transaction); + + return all; + }, {}); + }, +}); + +let allReportActions: OnyxCollection; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, + waitForCollectionCallback: true, + callback: (actions) => { + if (!actions) { + return; + } + allReportActions = actions; + }, +}); + +let allReportMetadata: OnyxCollection; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT_METADATA, + waitForCollectionCallback: true, + callback: (value) => { + if (!value) { + return; + } + allReportMetadata = value; + }, +}); + +let allReportNameValuePair: OnyxCollection; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, + waitForCollectionCallback: true, + callback: (value) => { + if (!value) { + return; + } + allReportNameValuePair = value; + }, +}); + +let allReportsViolations: OnyxCollection; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT_VIOLATIONS, + waitForCollectionCallback: true, + callback: (value) => { + if (!value) { + return; + } + allReportsViolations = value; + }, +}); + +let isFirstTimeNewExpensifyUser = false; +Onyx.connect({ + key: ONYXKEYS.NVP_IS_FIRST_TIME_NEW_EXPENSIFY_USER, + callback: (value) => { + isFirstTimeNewExpensifyUser = value ?? false; + }, +}); + +let onboarding: OnyxEntry; +Onyx.connect({ + key: ONYXKEYS.NVP_ONBOARDING, + callback: (value) => (onboarding = value), +}); + +function getCurrentUserAvatar(): AvatarSource | undefined { + return currentUserPersonalDetails?.avatar; +} + +function getCurrentUserDisplayNameOrEmail(): string | undefined { + return currentUserPersonalDetails?.displayName ?? currentUserEmail; +} + +function getChatType(report: OnyxInputOrEntry | Participant): ValueOf | undefined { + return report?.chatType; +} + +/** + * Get the report or draft report given a reportID + */ +function getReportOrDraftReport(reportID: string | undefined): OnyxEntry { + const allReports = ReportConnection.getAllReports(); + return allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] ?? allReportsDraft?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${reportID}`]; +} + +/** + * Check if a report is a draft report + */ +function isDraftReport(reportID: string | undefined): boolean { + const draftReport = allReportsDraft?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${reportID}`]; + + return !!draftReport; +} + +/** + * Returns the report + */ +function getReport(reportID: string): OnyxEntry { + return ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; +} + +/** + * Returns the report + */ +function getReportNameValuePairs(reportID?: string): OnyxEntry { + return allReportNameValuePair?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID ?? -1}`]; +} + +/** + * Returns the parentReport if the given report is a thread + */ +function getParentReport(report: OnyxEntry): OnyxEntry { + if (!report?.parentReportID) { + return undefined; + } + return ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`]; +} + +/** + * Returns the root parentReport if the given report is nested. + * Uses recursion to iterate any depth of nested reports. + */ +function getRootParentReport(report: OnyxEntry): OnyxEntry { + if (!report) { + return undefined; + } + + // Returns the current report as the root report, because it does not have a parentReportID + if (!report?.parentReportID) { + return report; + } + + const parentReport = getReportOrDraftReport(report?.parentReportID); + + // Runs recursion to iterate a parent report + return getRootParentReport(!isEmptyObject(parentReport) ? parentReport : undefined); +} + +/** + * Returns the policy of the report + */ +function getPolicy(policyID: string | undefined): OnyxEntry { + if (!allPolicies || !policyID) { + return undefined; + } + return allPolicies[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; +} + +/** + * Get the policy type from a given report + * @param policies must have Onyxkey prefix (i.e 'policy_') for keys + */ +function getPolicyType(report: OnyxInputOrEntry, policies: OnyxCollection): string { + return policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]?.type ?? ''; +} + +const unavailableTranslation = Localize.translateLocal('workspace.common.unavailable'); +/** + * Get the policy name from a given report + */ +function getPolicyName(report: OnyxInputOrEntry, returnEmptyIfNotFound = false, policy?: OnyxInputOrEntry): string { + const noPolicyFound = returnEmptyIfNotFound ? '' : unavailableTranslation; + if (isEmptyObject(report) || (isEmptyObject(allPolicies) && !report?.policyName)) { + return noPolicyFound; + } + + const finalPolicy = policy ?? allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]; + + const parentReport = getRootParentReport(report); + + // Rooms send back the policy name with the reportSummary, + // since they can also be accessed by people who aren't in the workspace + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const policyName = finalPolicy?.name || report?.policyName || report?.oldPolicyName || parentReport?.oldPolicyName || noPolicyFound; + + return policyName; +} + +/** + * Returns the concatenated title for the PrimaryLogins of a report + */ +function getReportParticipantsTitle(accountIDs: number[]): string { + // Somehow it's possible for the logins coming from report.participantAccountIDs to contain undefined values so we use .filter(Boolean) to remove them. + return accountIDs.filter(Boolean).join(', '); +} + +/** + * Checks if a report is a chat report. + */ +function isChatReport(report: OnyxEntry): boolean { + return report?.type === CONST.REPORT.TYPE.CHAT; +} + +function isInvoiceReport(report: OnyxInputOrEntry): boolean { + return report?.type === CONST.REPORT.TYPE.INVOICE; +} + +/** + * Checks if a report is an Expense report. + */ +function isExpenseReport(report: OnyxInputOrEntry): boolean { + return report?.type === CONST.REPORT.TYPE.EXPENSE; +} + +/** + * Checks if a report is an IOU report using report or reportID + */ +function isIOUReport(reportOrID: OnyxInputOrEntry | string): boolean { + const report = typeof reportOrID === 'string' ? ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportOrID}`] ?? null : reportOrID; + return report?.type === CONST.REPORT.TYPE.IOU; +} + +/** + * Checks if a report is an IOU report using report + */ +function isIOUReportUsingReport(report: OnyxEntry): report is Report { + return report?.type === CONST.REPORT.TYPE.IOU; +} +/** + * Checks if a report is a task report. + */ +function isTaskReport(report: OnyxInputOrEntry): boolean { + return report?.type === CONST.REPORT.TYPE.TASK; +} + +/** + * Checks if a task has been cancelled + * When a task is deleted, the parentReportAction is updated to have a isDeletedParentAction deleted flag + * This is because when you delete a task, we still allow you to chat on the report itself + * There's another situation where you don't have access to the parentReportAction (because it was created in a chat you don't have access to) + * In this case, we have added the key to the report itself + */ +function isCanceledTaskReport(report: OnyxInputOrEntry, parentReportAction: OnyxInputOrEntry = null): boolean { + if (!isEmptyObject(parentReportAction) && (ReportActionsUtils.getReportActionMessage(parentReportAction)?.isDeletedParentAction ?? false)) { + return true; + } + + if (!isEmptyObject(report) && report?.isDeletedParentAction) { + return true; + } + + return false; +} + +/** + * Checks if a report is an open task report. + * + * @param parentReportAction - The parent report action of the report (Used to check if the task has been canceled) + */ +function isOpenTaskReport(report: OnyxInputOrEntry, parentReportAction: OnyxInputOrEntry = null): boolean { + return ( + isTaskReport(report) && !isCanceledTaskReport(report, parentReportAction) && report?.stateNum === CONST.REPORT.STATE_NUM.OPEN && report?.statusNum === CONST.REPORT.STATUS_NUM.OPEN + ); +} + +/** + * Checks if a report is a completed task report. + */ +function isCompletedTaskReport(report: OnyxEntry): boolean { + return isTaskReport(report) && report?.stateNum === CONST.REPORT.STATE_NUM.APPROVED && report?.statusNum === CONST.REPORT.STATUS_NUM.APPROVED; +} + +/** + * Checks if the current user is the manager of the supplied report + */ +function isReportManager(report: OnyxEntry): boolean { + return !!(report && report.managerID === currentUserAccountID); +} + +/** + * Checks if the supplied report has been approved + */ +function isReportApproved(reportOrID: OnyxInputOrEntry | string, parentReportAction: OnyxEntry = undefined): boolean { + const report = typeof reportOrID === 'string' ? ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportOrID}`] ?? null : reportOrID; + if (!report) { + return parentReportAction?.childStateNum === CONST.REPORT.STATE_NUM.APPROVED && parentReportAction?.childStatusNum === CONST.REPORT.STATUS_NUM.APPROVED; + } + return report?.stateNum === CONST.REPORT.STATE_NUM.APPROVED && report?.statusNum === CONST.REPORT.STATUS_NUM.APPROVED; +} + +/** + * Checks if the supplied report has been manually reimbursed + */ +function isReportManuallyReimbursed(report: OnyxEntry): boolean { + return report?.stateNum === CONST.REPORT.STATE_NUM.APPROVED && report?.statusNum === CONST.REPORT.STATUS_NUM.REIMBURSED; +} + +/** + * Checks if the supplied report is an expense report in Open state and status. + */ +function isOpenExpenseReport(report: OnyxInputOrEntry): boolean { + return isExpenseReport(report) && report?.stateNum === CONST.REPORT.STATE_NUM.OPEN && report?.statusNum === CONST.REPORT.STATUS_NUM.OPEN; +} + +/** + * Checks if the supplied report has a member with the array passed in params. + */ +function hasParticipantInArray(report: OnyxEntry, memberAccountIDs: number[]) { + if (!report?.participants) { + return false; + } + + const memberAccountIDsSet = new Set(memberAccountIDs); + + for (const accountID in report.participants) { + if (memberAccountIDsSet.has(Number(accountID))) { + return true; + } + } + + return false; +} + +/** + * Whether the Money Request report is settled + */ +function isSettled(reportID: string | undefined): boolean { + const allReports = ReportConnection.getAllReports(); + if (!allReports || !reportID) { + return false; + } + const report = allReports[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] ?? null; + if (isEmptyObject(report) || report.isWaitingOnBankAccount) { + return false; + } + + // In case the payment is scheduled and we are waiting for the payee to set up their wallet, + // consider the report as paid as well. + if (report.isWaitingOnBankAccount && report.statusNum === CONST.REPORT.STATUS_NUM.APPROVED) { + return true; + } + + return report?.statusNum === CONST.REPORT.STATUS_NUM.REIMBURSED; +} + +/** + * Whether the current user is the submitter of the report + */ +function isCurrentUserSubmitter(reportID: string): boolean { + const allReports = ReportConnection.getAllReports(); + if (!allReports) { + return false; + } + const report = allReports[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + return !!(report && report.ownerAccountID === currentUserAccountID); +} + +/** + * Whether the provided report is an Admin room + */ +function isAdminRoom(report: OnyxEntry): boolean { + return getChatType(report) === CONST.REPORT.CHAT_TYPE.POLICY_ADMINS; +} + +/** + * Whether the provided report is an Admin-only posting room + */ +function isAdminsOnlyPostingRoom(report: OnyxEntry): boolean { + return report?.writeCapability === CONST.REPORT.WRITE_CAPABILITIES.ADMINS; +} + +/** + * Whether the provided report is a Announce room + */ +function isAnnounceRoom(report: OnyxEntry): boolean { + return getChatType(report) === CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE; +} + +/** + * Whether the provided report is a default room + */ +function isDefaultRoom(report: OnyxEntry): boolean { + return CONST.DEFAULT_POLICY_ROOM_CHAT_TYPES.some((type) => type === getChatType(report)); +} + +/** + * Whether the provided report is a Domain room + */ +function isDomainRoom(report: OnyxEntry): boolean { + return getChatType(report) === CONST.REPORT.CHAT_TYPE.DOMAIN_ALL; +} + +/** + * Whether the provided report is a user created policy room + */ +function isUserCreatedPolicyRoom(report: OnyxEntry): boolean { + return getChatType(report) === CONST.REPORT.CHAT_TYPE.POLICY_ROOM; +} + +/** + * Whether the provided report is a Policy Expense chat. + */ +function isPolicyExpenseChat(report: OnyxInputOrEntry | Participant): boolean { + return getChatType(report) === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT || (report?.isPolicyExpenseChat ?? false); +} + +function isInvoiceRoom(report: OnyxEntry): boolean { + return getChatType(report) === CONST.REPORT.CHAT_TYPE.INVOICE; +} + +function isInvoiceRoomWithID(reportID?: string): boolean { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const report = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID || -1}`]; + return isInvoiceRoom(report); +} + +/** + * Checks if a report is a completed task report. + */ +function isTripRoom(report: OnyxEntry): boolean { + return isChatReport(report) && getChatType(report) === CONST.REPORT.CHAT_TYPE.TRIP_ROOM; +} + +function isIndividualInvoiceRoom(report: OnyxEntry): boolean { + return isInvoiceRoom(report) && report?.invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL; +} + +function isCurrentUserInvoiceReceiver(report: OnyxEntry): boolean { + if (report?.invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL) { + return currentUserAccountID === report.invoiceReceiver.accountID; + } + + if (report?.invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.BUSINESS) { + const policy = PolicyUtils.getPolicy(report.invoiceReceiver.policyID); + return PolicyUtils.isPolicyAdmin(policy); + } + + return false; +} + +/** + * Whether the provided report belongs to a Control policy and is an expense chat + */ +function isControlPolicyExpenseChat(report: OnyxEntry): boolean { + return isPolicyExpenseChat(report) && getPolicyType(report, allPolicies) === CONST.POLICY.TYPE.CORPORATE; +} + +/** + * Whether the provided policyType is a Free, Collect or Control policy type + */ +function isGroupPolicy(policyType: string): boolean { + return policyType === CONST.POLICY.TYPE.CORPORATE || policyType === CONST.POLICY.TYPE.TEAM || policyType === CONST.POLICY.TYPE.FREE; +} + +/** + * Whether the provided report belongs to a Free, Collect or Control policy + */ +function isReportInGroupPolicy(report: OnyxInputOrEntry, policy?: OnyxInputOrEntry): boolean { + const policyType = policy?.type ?? getPolicyType(report, allPolicies); + return isGroupPolicy(policyType); +} + +/** + * Whether the provided report belongs to a Control or Collect policy + */ +function isPaidGroupPolicy(report: OnyxEntry): boolean { + const policyType = getPolicyType(report, allPolicies); + return policyType === CONST.POLICY.TYPE.CORPORATE || policyType === CONST.POLICY.TYPE.TEAM; +} + +/** + * Whether the provided report belongs to a Control or Collect policy and is an expense chat + */ +function isPaidGroupPolicyExpenseChat(report: OnyxEntry): boolean { + return isPolicyExpenseChat(report) && isPaidGroupPolicy(report); +} + +/** + * Whether the provided report belongs to a Control policy and is an expense report + */ +function isControlPolicyExpenseReport(report: OnyxEntry): boolean { + return isExpenseReport(report) && getPolicyType(report, allPolicies) === CONST.POLICY.TYPE.CORPORATE; +} + +/** + * Whether the provided report belongs to a Control or Collect policy and is an expense report + */ +function isPaidGroupPolicyExpenseReport(report: OnyxEntry): boolean { + return isExpenseReport(report) && isPaidGroupPolicy(report); +} + +/** + * Checks if the supplied report is an invoice report in Open state and status. + */ +function isOpenInvoiceReport(report: OnyxEntry): boolean { + return isInvoiceReport(report) && report?.statusNum === CONST.REPORT.STATUS_NUM.OPEN; +} + +/** + * Whether the provided report is a chat room + */ +function isChatRoom(report: OnyxEntry): boolean { + return isUserCreatedPolicyRoom(report) || isDefaultRoom(report) || isInvoiceRoom(report) || isTripRoom(report); +} + +/** + * Whether the provided report is a public room + */ +function isPublicRoom(report: OnyxEntry): boolean { + return report?.visibility === CONST.REPORT.VISIBILITY.PUBLIC || report?.visibility === CONST.REPORT.VISIBILITY.PUBLIC_ANNOUNCE; +} + +/** + * Whether the provided report is a public announce room + */ +function isPublicAnnounceRoom(report: OnyxEntry): boolean { + return report?.visibility === CONST.REPORT.VISIBILITY.PUBLIC_ANNOUNCE; +} + +/** + * If the report is a policy expense, the route should be for adding bank account for that policy + * else since the report is a personal IOU, the route should be for personal bank account. + */ +function getBankAccountRoute(report: OnyxEntry): Route { + return isPolicyExpenseChat(report) ? ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN.getRoute('', report?.policyID) : ROUTES.SETTINGS_ADD_BANK_ACCOUNT; +} + +/** + * Check if personal detail of accountID is empty or optimistic data + */ +function isOptimisticPersonalDetail(accountID: number): boolean { + return isEmptyObject(allPersonalDetails?.[accountID]) || !!allPersonalDetails?.[accountID]?.isOptimisticPersonalDetail; +} + +/** + * Checks if a report is a task report from a policy expense chat. + */ +function isWorkspaceTaskReport(report: OnyxEntry): boolean { + if (!isTaskReport(report)) { + return false; + } + const parentReport = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`]; + return isPolicyExpenseChat(parentReport); +} + +/** + * Returns true if report has a parent + */ +function isThread(report: OnyxInputOrEntry): report is Thread { + return !!(report?.parentReportID && report?.parentReportActionID); +} + +/** + * Returns true if report is of type chat and has a parent and is therefore a Thread. + */ +function isChatThread(report: OnyxInputOrEntry): report is Thread { + return isThread(report) && report?.type === CONST.REPORT.TYPE.CHAT; +} + +function isDM(report: OnyxEntry): boolean { + return isChatReport(report) && !getChatType(report) && !isThread(report); +} + +function isSelfDM(report: OnyxInputOrEntry): boolean { + return getChatType(report) === CONST.REPORT.CHAT_TYPE.SELF_DM; +} + +function isGroupChat(report: OnyxEntry | Partial): boolean { + return getChatType(report) === CONST.REPORT.CHAT_TYPE.GROUP; +} + +/** + * Only returns true if this is the Expensify DM report. + */ +function isSystemChat(report: OnyxEntry): boolean { + return getChatType(report) === CONST.REPORT.CHAT_TYPE.SYSTEM; +} + +function getDefaultNotificationPreferenceForReport(report: OnyxEntry): ValueOf { + if (isAnnounceRoom(report)) { + return CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS; + } + if (isPublicRoom(report)) { + return CONST.REPORT.NOTIFICATION_PREFERENCE.DAILY; + } + if (!getChatType(report) || isGroupChat(report)) { + return CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS; + } + if (isAdminRoom(report) || isPolicyExpenseChat(report) || isInvoiceRoom(report)) { + return CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS; + } + if (isSelfDM(report)) { + return CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE; + } + return CONST.REPORT.NOTIFICATION_PREFERENCE.DAILY; +} + +/** + * Get the notification preference given a report + */ +function getReportNotificationPreference(report: OnyxEntry, shouldDefaltToHidden = true): ValueOf { + if (!shouldDefaltToHidden) { + return report?.participants?.[currentUserAccountID ?? -1]?.notificationPreference ?? getDefaultNotificationPreferenceForReport(report); + } + return report?.participants?.[currentUserAccountID ?? -1]?.notificationPreference ?? CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; +} + +const CONCIERGE_ACCOUNT_ID_STRING = CONST.ACCOUNT_ID.CONCIERGE.toString(); +/** + * Only returns true if this is our main 1:1 DM report with Concierge. + */ +function isConciergeChatReport(report: OnyxInputOrEntry): boolean { + if (!report?.participants || isThread(report)) { + return false; + } + + const participantAccountIDs = new Set(Object.keys(report.participants)); + if (participantAccountIDs.size !== 2) { + return false; + } + + return participantAccountIDs.has(CONCIERGE_ACCOUNT_ID_STRING); +} + +function findSelfDMReportID(): string | undefined { + const allReports = ReportConnection.getAllReports(); + if (!allReports) { + return; + } + + const selfDMReport = Object.values(allReports).find((report) => isSelfDM(report) && !isThread(report)); + return selfDMReport?.reportID; +} + +/** + * Checks if the supplied report belongs to workspace based on the provided params. If the report's policyID is _FAKE_ or has no value, it means this report is a DM. + * In this case report and workspace members must be compared to determine whether the report belongs to the workspace. + */ +function doesReportBelongToWorkspace(report: OnyxEntry, policyMemberAccountIDs: number[], policyID?: string) { + const isPolicyRelatedReport = report?.policyID === policyID || !!(report?.invoiceReceiver && 'policyID' in report.invoiceReceiver && report.invoiceReceiver.policyID === policyID); + return isConciergeChatReport(report) || (report?.policyID === CONST.POLICY.ID_FAKE || !report?.policyID ? hasParticipantInArray(report, policyMemberAccountIDs) : isPolicyRelatedReport); +} + +/** + * Given an array of reports, return them filtered by a policyID and policyMemberAccountIDs. + */ +function filterReportsByPolicyIDAndMemberAccountIDs(reports: Array>, policyMemberAccountIDs: number[] = [], policyID?: string) { + return reports.filter((report) => !!report && doesReportBelongToWorkspace(report, policyMemberAccountIDs, policyID)); +} + +/** + * Returns true if report is still being processed + */ +function isProcessingReport(report: OnyxEntry): boolean { + return report?.stateNum === CONST.REPORT.STATE_NUM.SUBMITTED && report?.statusNum === CONST.REPORT.STATUS_NUM.SUBMITTED; +} + +/** + * Check if the report is a single chat report that isn't a thread + * and personal detail of participant is optimistic data + */ +function shouldDisableDetailPage(report: OnyxEntry): boolean { + if (isChatRoom(report) || isPolicyExpenseChat(report) || isChatThread(report) || isTaskReport(report)) { + return false; + } + if (isOneOnOneChat(report)) { + const participantAccountIDs = Object.keys(report?.participants ?? {}) + .map(Number) + .filter((accountID) => accountID !== currentUserAccountID); + return isOptimisticPersonalDetail(participantAccountIDs[0]); + } + return false; +} + +/** + * Returns true if this report has only one participant and it's an Expensify account. + */ +function isExpensifyOnlyParticipantInReport(report: OnyxEntry): boolean { + const otherParticipants = Object.keys(report?.participants ?? {}) + .map(Number) + .filter((accountID) => accountID !== currentUserAccountID); + return otherParticipants.length === 1 && otherParticipants.some((accountID) => CONST.EXPENSIFY_ACCOUNT_IDS.includes(accountID)); +} + +/** + * Returns whether a given report can have tasks created in it. + * We only prevent the task option if it's a DM/group-DM and the other users are all special Expensify accounts + * + */ +function canCreateTaskInReport(report: OnyxEntry): boolean { + const otherParticipants = Object.keys(report?.participants ?? {}) + .map(Number) + .filter((accountID) => accountID !== currentUserAccountID); + const areExpensifyAccountsOnlyOtherParticipants = otherParticipants.length >= 1 && otherParticipants.every((accountID) => CONST.EXPENSIFY_ACCOUNT_IDS.includes(accountID)); + if (areExpensifyAccountsOnlyOtherParticipants && isDM(report)) { + return false; + } + + return true; +} + +/** + * Returns true if there are any guides accounts (team.expensify.com) in a list of accountIDs + * by cross-referencing the accountIDs with personalDetails since guides that are participants + * of the user's chats should have their personal details in Onyx. + */ +function hasExpensifyGuidesEmails(accountIDs: number[]): boolean { + return accountIDs.some((accountID) => Str.extractEmailDomain(allPersonalDetails?.[accountID]?.login ?? '') === CONST.EMAIL.GUIDES_DOMAIN); +} + +function getMostRecentlyVisitedReport(reports: Array>, reportMetadata: OnyxCollection): OnyxEntry { + const filteredReports = reports.filter((report) => { + const shouldKeep = !isChatThread(report) || getReportNotificationPreference(report) !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; + return shouldKeep && !!report?.reportID && !!(reportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report.reportID}`]?.lastVisitTime ?? report?.lastReadTime); + }); + return lodashMaxBy(filteredReports, (a) => new Date(reportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${a?.reportID}`]?.lastVisitTime ?? a?.lastReadTime ?? '').valueOf()); +} + +function findLastAccessedReport(ignoreDomainRooms: boolean, openOnAdminRoom = false, policyID?: string, excludeReportID?: string): OnyxEntry { + // If it's the user's first time using New Expensify, then they could either have: + // - just a Concierge report, if so we'll return that + // - their Concierge report, and a separate report that must have deeplinked them to the app before they created their account. + // If it's the latter, we'll use the deeplinked report over the Concierge report, + // since the Concierge report would be incorrectly selected over the deep-linked report in the logic below. + + const policyMemberAccountIDs = PolicyUtils.getPolicyEmployeeListByIdWithoutCurrentUser(allPolicies, policyID, currentUserAccountID); + + const allReports = ReportConnection.getAllReports(); + let reportsValues = Object.values(allReports ?? {}); + + if (!!policyID || policyMemberAccountIDs.length > 0) { + reportsValues = filterReportsByPolicyIDAndMemberAccountIDs(reportsValues, policyMemberAccountIDs, policyID); + } + + let adminReport: OnyxEntry; + if (openOnAdminRoom) { + adminReport = reportsValues.find((report) => { + const chatType = getChatType(report); + return chatType === CONST.REPORT.CHAT_TYPE.POLICY_ADMINS; + }); + } + + // if the user hasn't completed the onboarding flow, whether the user should be in the concierge chat or system chat + // should be consistent with what chat the user will land after onboarding flow + if (!getIsSmallScreenWidth() && !Array.isArray(onboarding) && !onboarding?.hasCompletedGuidedSetupFlow) { + return reportsValues.find(isChatUsedForOnboarding); + } + + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const shouldFilter = excludeReportID || ignoreDomainRooms; + if (shouldFilter) { + reportsValues = reportsValues.filter((report) => { + if (excludeReportID && report?.reportID === excludeReportID) { + return false; + } + + // We allow public announce rooms, admins, and announce rooms through since we bypass the default rooms beta for them. + // Check where ReportUtils.findLastAccessedReport is called in MainDrawerNavigator.js for more context. + // Domain rooms are now the only type of default room that are on the defaultRooms beta. + if ( + ignoreDomainRooms && + isDomainRoom(report) && + getPolicyType(report, allPolicies) !== CONST.POLICY.TYPE.FREE && + !hasExpensifyGuidesEmails(Object.keys(report?.participants ?? {}).map(Number)) + ) { + return false; + } + + return true; + }); + } + + if (isFirstTimeNewExpensifyUser) { + // Filter out the systemChat report from the reports list, as we don't want to drop the user into that report over Concierge when they first log in + reportsValues = reportsValues.filter((report) => !isSystemChat(report)) ?? []; + if (reportsValues.length === 1) { + return reportsValues[0]; + } + + return adminReport ?? reportsValues.find((report) => !isConciergeChatReport(report)); + } + + // If we only have two reports and one of them is the system chat, filter it out so we don't + // overwrite showing the concierge chat + const hasSystemChat = reportsValues.find((report) => isSystemChat(report)) ?? false; + if (reportsValues.length === 2 && hasSystemChat) { + reportsValues = reportsValues.filter((report) => !isSystemChat(report)) ?? []; + } + + // We are getting the last read report from the metadata of the report. + const lastRead = getMostRecentlyVisitedReport(reportsValues, allReportMetadata); + + return adminReport ?? lastRead; +} + +/** + * Whether the provided report has expenses + */ +function hasExpenses(reportID?: string): boolean { + return !!Object.values(allTransactions ?? {}).find((transaction) => `${transaction?.reportID}` === `${reportID}`); +} + +/** + * Whether the provided report is a closed expense report with no expenses + */ +function isClosedExpenseReportWithNoExpenses(report: OnyxEntry): boolean { + return report?.statusNum === CONST.REPORT.STATUS_NUM.CLOSED && isExpenseReport(report) && !hasExpenses(report.reportID); +} + +/** + * Whether the provided report is an archived room + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function isArchivedRoom(report: OnyxInputOrEntry, reportNameValuePairs?: OnyxInputOrEntry): boolean { + return !!report?.private_isArchived; +} + +/** + * Whether the report with the provided reportID is an archived room + */ +function isArchivedRoomWithID(reportID?: string) { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const report = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID || -1}`]; + return isArchivedRoom(report, getReportNameValuePairs(reportID)); +} + +/** + * Whether the provided report is a closed report + */ +function isClosedReport(report: OnyxEntry): boolean { + return report?.statusNum === CONST.REPORT.STATUS_NUM.CLOSED; +} + +/** + * Whether the provided report is the admin's room + */ +function isJoinRequestInAdminRoom(report: OnyxEntry): boolean { + if (!report) { + return false; + } + // If this policy isn't owned by Expensify, + // Account manager/guide should not have the workspace join request pinned to their LHN, + // since they are not a part of the company, and should not action it on their behalf. + if (report.policyID) { + const policy = getPolicy(report.policyID); + if (!PolicyUtils.isExpensifyTeam(policy?.owner) && PolicyUtils.isExpensifyTeam(currentUserPersonalDetails?.login)) { + return false; + } + } + return ReportActionsUtils.isActionableJoinRequestPending(report.reportID); +} + +/** + * Checks if the user has auditor permission in the provided report + */ +function isAuditor(report: OnyxEntry): boolean { + if (report?.policyID) { + const policy = getPolicy(report.policyID); + return PolicyUtils.isPolicyAuditor(policy); + } + + if (Array.isArray(report?.permissions) && report?.permissions.length > 0) { + return report?.permissions?.includes(CONST.REPORT.PERMISSIONS.AUDITOR); + } + + return false; +} + +/** + * Checks if the user can write in the provided report + */ +function canWriteInReport(report: OnyxEntry): boolean { + if (Array.isArray(report?.permissions) && report?.permissions.length > 0 && !report?.permissions?.includes(CONST.REPORT.PERMISSIONS.AUDITOR)) { + return report?.permissions?.includes(CONST.REPORT.PERMISSIONS.WRITE); + } + + return true; +} + +/** + * Checks if the current user is allowed to comment on the given report. + */ +function isAllowedToComment(report: OnyxEntry): boolean { + if (isAuditor(report)) { + return true; + } + + if (!canWriteInReport(report)) { + return false; + } + + // Default to allowing all users to post + const capability = report?.writeCapability ?? CONST.REPORT.WRITE_CAPABILITIES.ALL; + + if (capability === CONST.REPORT.WRITE_CAPABILITIES.ALL) { + return true; + } + + // If unauthenticated user opens public chat room using deeplink, they do not have policies available and they cannot comment + if (!allPolicies) { + return false; + } + + // If we've made it here, commenting on this report is restricted. + // If the user is an admin, allow them to post. + const policy = allPolicies[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]; + return policy?.role === CONST.POLICY.ROLE.ADMIN; +} + +/** + * Checks if the current user is the admin of the policy given the policy expense chat. + */ +function isPolicyExpenseChatAdmin(report: OnyxEntry, policies: OnyxCollection): boolean { + if (!isPolicyExpenseChat(report)) { + return false; + } + + const policyRole = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]?.role; + + return policyRole === CONST.POLICY.ROLE.ADMIN; +} + +/** + * Checks if the current user is the admin of the policy. + */ +function isPolicyAdmin(policyID: string, policies: OnyxCollection): boolean { + const policyRole = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]?.role; + + return policyRole === CONST.POLICY.ROLE.ADMIN; +} + +/** + * Checks whether all the transactions linked to the IOU report are of the Distance Request type with pending routes + */ +function hasOnlyTransactionsWithPendingRoutes(iouReportID: string | undefined): boolean { + const transactions = reportsTransactions[iouReportID ?? ''] ?? []; + + // Early return false in case not having any transaction + if (!transactions || transactions.length === 0) { + return false; + } + + return transactions.every((transaction) => TransactionUtils.isFetchingWaypointsFromServer(transaction)); +} + +/** + * If the report is a thread and has a chat type set, it is a workspace chat. + */ +function isWorkspaceThread(report: OnyxEntry): boolean { + const chatType = getChatType(report); + return isThread(report) && isChatReport(report) && CONST.WORKSPACE_ROOM_TYPES.some((type) => chatType === type); +} + +/** + * Returns true if reportAction is the first chat preview of a Thread + */ +function isThreadFirstChat(reportAction: OnyxInputOrEntry, reportID: string): boolean { + return reportAction?.childReportID?.toString() === reportID; +} + +/** + * Checks if a report is a child report. + */ +function isChildReport(report: OnyxEntry): boolean { + return isThread(report) || isTaskReport(report); +} + +/** + * An Expense Request is a thread where the parent report is an Expense Report and + * the parentReportAction is a transaction. + */ +function isExpenseRequest(report: OnyxInputOrEntry): report is Thread { + if (isThread(report)) { + const parentReportAction = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`]?.[report.parentReportActionID]; + const parentReport = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`]; + return isExpenseReport(parentReport) && !isEmptyObject(parentReportAction) && ReportActionsUtils.isTransactionThread(parentReportAction); + } + return false; +} + +/** + * An IOU Request is a thread where the parent report is an IOU Report and + * the parentReportAction is a transaction. + */ +function isIOURequest(report: OnyxInputOrEntry): boolean { + if (isThread(report)) { + const parentReportAction = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`]?.[report.parentReportActionID]; + const parentReport = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`]; + return isIOUReport(parentReport) && !isEmptyObject(parentReportAction) && ReportActionsUtils.isTransactionThread(parentReportAction); + } + return false; +} + +/** + * A Track Expense Report is a thread where the parent the parentReportAction is a transaction, and + * parentReportAction has type of track. + */ +function isTrackExpenseReport(report: OnyxInputOrEntry): boolean { + if (isThread(report)) { + const parentReportAction = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`]?.[report.parentReportActionID]; + return !isEmptyObject(parentReportAction) && ReportActionsUtils.isTrackExpenseAction(parentReportAction); + } + return false; +} + +/** + * Checks if a report is an IOU or expense request. + */ +function isMoneyRequest(reportOrID: OnyxEntry | string): boolean { + const report = typeof reportOrID === 'string' ? ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportOrID}`] ?? null : reportOrID; + return isIOURequest(report) || isExpenseRequest(report); +} + +/** + * Checks if a report is an IOU or expense report. + */ +function isMoneyRequestReport(reportOrID: OnyxInputOrEntry | string): boolean { + const report = typeof reportOrID === 'string' ? ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportOrID}`] ?? null : reportOrID; + return isIOUReport(report) || isExpenseReport(report); +} + +/** + * Checks if a report contains only Non-Reimbursable transactions + */ +function hasOnlyNonReimbursableTransactions(iouReportID: string | undefined): boolean { + if (!iouReportID) { + return false; + } + + const transactions = reportsTransactions[iouReportID ?? ''] ?? []; + if (!transactions || transactions.length === 0) { + return false; + } + + return transactions.every((transaction) => !TransactionUtils.getReimbursable(transaction)); +} + +/** + * Checks if a report has only one transaction associated with it + */ +function isOneTransactionReport(reportID: string): boolean { + const reportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] ?? ([] as ReportAction[]); + return ReportActionsUtils.getOneTransactionThreadReportID(reportID, reportActions) !== null; +} + +/* + * Whether the report contains only one expense and the expense should be paid later + */ +function isPayAtEndExpenseReport(reportID: string, transactions: Transaction[] | undefined): boolean { + if ((!!transactions && transactions.length !== 1) || !isOneTransactionReport(reportID)) { + return false; + } + + return TransactionUtils.isPayAtEndExpense(transactions?.[0] ?? TransactionUtils.getAllReportTransactions(reportID)[0]); +} + +/** + * Checks if a report is a transaction thread associated with a report that has only one transaction + */ +function isOneTransactionThread(reportID: string, parentReportID: string, threadParentReportAction: OnyxEntry): boolean { + const parentReportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`] ?? ([] as ReportAction[]); + const transactionThreadReportID = ReportActionsUtils.getOneTransactionThreadReportID(parentReportID, parentReportActions); + return reportID === transactionThreadReportID && !ReportActionsUtils.isSentMoneyReportAction(threadParentReportAction); +} + +/** + * Should return true only for personal 1:1 report + * + */ +function isOneOnOneChat(report: OnyxEntry): boolean { + const participants = report?.participants ?? {}; + const isCurrentUserParticipant = participants[currentUserAccountID ?? 0] ? 1 : 0; + const participantAmount = Object.keys(participants).length - isCurrentUserParticipant; + if (participantAmount !== 1) { + return false; + } + return !isChatRoom(report) && !isExpenseRequest(report) && !isMoneyRequestReport(report) && !isPolicyExpenseChat(report) && !isTaskReport(report) && isDM(report) && !isIOUReport(report); +} + +/** + * Checks if the current user is a payer of the expense + */ + +function isPayer(session: OnyxEntry, iouReport: OnyxEntry) { + const isApproved = isReportApproved(iouReport); + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${iouReport?.policyID}`] ?? null; + const policyType = policy?.type; + const isAdmin = policyType !== CONST.POLICY.TYPE.PERSONAL && policy?.role === CONST.POLICY.ROLE.ADMIN; + const isManager = iouReport?.managerID === session?.accountID; + if (isPaidGroupPolicy(iouReport)) { + if (policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES) { + const isReimburser = session?.email === policy?.achAccount?.reimburser; + return (!policy?.achAccount?.reimburser || isReimburser) && (isApproved || isManager); + } + if (policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL) { + return isAdmin && (isApproved || isManager); + } + return false; + } + return isAdmin || (isMoneyRequestReport(iouReport) && isManager); +} + +/** + * Checks if the current user is the action's author + */ +function isActionCreator(reportAction: OnyxInputOrEntry | Partial): boolean { + return reportAction?.actorAccountID === currentUserAccountID; +} + +/** + * Returns the notification preference of the action's child report if it exists. + * Otherwise, calculates it based on the action's authorship. + */ +function getChildReportNotificationPreference(reportAction: OnyxInputOrEntry | Partial): NotificationPreference { + const childReportNotificationPreference = reportAction?.childReportNotificationPreference ?? ''; + if (childReportNotificationPreference) { + return childReportNotificationPreference; + } + + return isActionCreator(reportAction) ? CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS : CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; +} + +function canAddOrDeleteTransactions(moneyRequestReport: OnyxEntry): boolean { + if (!isMoneyRequestReport(moneyRequestReport) || isArchivedRoom(moneyRequestReport)) { + return false; + } + + const policy = getPolicy(moneyRequestReport?.policyID); + if (PolicyUtils.isInstantSubmitEnabled(policy) && PolicyUtils.isSubmitAndClose(policy) && hasOnlyNonReimbursableTransactions(moneyRequestReport?.reportID)) { + return false; + } + + if (isReportApproved(moneyRequestReport) || isSettled(moneyRequestReport?.reportID)) { + return false; + } + + return true; +} + +/** + * Checks whether the supplied report supports adding more transactions to it. + * Return true if: + * - report is a non-settled IOU + * - report is a draft + * - report is a processing expense report and its policy has Instant reporting frequency + */ +function canAddTransaction(moneyRequestReport: OnyxEntry): boolean { + if (!isMoneyRequestReport(moneyRequestReport)) { + return false; + } + + if (isReportInGroupPolicy(moneyRequestReport) && isProcessingReport(moneyRequestReport) && !PolicyUtils.isInstantSubmitEnabled(getPolicy(moneyRequestReport?.policyID))) { + return false; + } + + return canAddOrDeleteTransactions(moneyRequestReport); +} + +/** + * Checks whether the supplied report supports deleting more transactions from it. + * Return true if: + * - report is a non-settled IOU + * - report is a non-approved IOU + */ +function canDeleteTransaction(moneyRequestReport: OnyxEntry): boolean { + return canAddOrDeleteTransactions(moneyRequestReport); +} + +/** + * Can only delete if the author is this user and the action is an ADD_COMMENT action or an IOU action in an unsettled report, or if the user is a + * policy admin + */ +function canDeleteReportAction(reportAction: OnyxInputOrEntry, reportID: string): boolean { + const report = getReportOrDraftReport(reportID); + + const isActionOwner = reportAction?.actorAccountID === currentUserAccountID; + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`] ?? null; + + if (ReportActionsUtils.isMoneyRequestAction(reportAction)) { + // For now, users cannot delete split actions + const isSplitAction = ReportActionsUtils.getOriginalMessage(reportAction)?.type === CONST.IOU.REPORT_ACTION_TYPE.SPLIT; + + if (isSplitAction) { + return false; + } + + const linkedReport = isThreadFirstChat(reportAction, reportID) ? getReportOrDraftReport(report?.parentReportID) : report; + if (isActionOwner) { + if (!isEmptyObject(linkedReport) && (isMoneyRequestReport(linkedReport) || isInvoiceReport(linkedReport))) { + return canDeleteTransaction(linkedReport); + } + return true; + } + } + + if ( + reportAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT || + reportAction?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || + ReportActionsUtils.isCreatedTaskReportAction(reportAction) || + reportAction?.actorAccountID === CONST.ACCOUNT_ID.CONCIERGE + ) { + return false; + } + + const isAdmin = policy?.role === CONST.POLICY.ROLE.ADMIN && !isEmptyObject(report) && !isDM(report); + + return isActionOwner || isAdmin; +} + +/** + * Returns true if Concierge is one of the chat participants (1:1 as well as group chats) + */ +function chatIncludesConcierge(report: Partial>): boolean { + const participantAccountIDs = Object.keys(report?.participants ?? {}).map(Number); + return participantAccountIDs.includes(CONST.ACCOUNT_ID.CONCIERGE); +} + +/** + * Returns true if there is any automated expensify account `in accountIDs + */ +function hasAutomatedExpensifyAccountIDs(accountIDs: number[]): boolean { + return accountIDs.some((accountID) => CONST.EXPENSIFY_ACCOUNT_IDS.includes(accountID)); +} + +function getReportRecipientAccountIDs(report: OnyxEntry, currentLoginAccountID: number): number[] { + let finalReport: OnyxEntry = report; + // In 1:1 chat threads, the participants will be the same as parent report. If a report is specifically a 1:1 chat thread then we will + // get parent report and use its participants array. + if (isThread(report) && !(isTaskReport(report) || isMoneyRequestReport(report))) { + const parentReport = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`]; + if (isOneOnOneChat(parentReport)) { + finalReport = parentReport; + } + } + + let finalParticipantAccountIDs: number[] = []; + if (isTaskReport(report)) { + // Task reports `managerID` will change when assignee is changed, in that case the old `managerID` is still present in `participants` + // along with the new one. We only need the `managerID` as a participant here. + finalParticipantAccountIDs = report?.managerID ? [report?.managerID] : []; + } else { + finalParticipantAccountIDs = Object.keys(finalReport?.participants ?? {}).map(Number); + } + + const otherParticipantsWithoutExpensifyAccountIDs = finalParticipantAccountIDs.filter((accountID) => { + if (accountID === currentLoginAccountID) { + return false; + } + if (CONST.EXPENSIFY_ACCOUNT_IDS.includes(accountID)) { + return false; + } + return true; + }); + + return otherParticipantsWithoutExpensifyAccountIDs; +} + +/** + * Whether the time row should be shown for a report. + */ +function canShowReportRecipientLocalTime(personalDetails: OnyxEntry, report: OnyxEntry, accountID: number): boolean { + const reportRecipientAccountIDs = getReportRecipientAccountIDs(report, accountID); + const hasMultipleParticipants = reportRecipientAccountIDs.length > 1; + const reportRecipient = personalDetails?.[reportRecipientAccountIDs[0]]; + const reportRecipientTimezone = reportRecipient?.timezone ?? CONST.DEFAULT_TIME_ZONE; + const isReportParticipantValidated = reportRecipient?.validated ?? false; + return !!( + !hasMultipleParticipants && + !isChatRoom(report) && + !isPolicyExpenseChat(getRootParentReport(report)) && + reportRecipient && + reportRecipientTimezone?.selected && + isReportParticipantValidated + ); +} + +/** + * Shorten last message text to fixed length and trim spaces. + */ +function formatReportLastMessageText(lastMessageText: string, isModifiedExpenseMessage = false): string { + if (isModifiedExpenseMessage) { + return String(lastMessageText).trim().replace(CONST.REGEX.LINE_BREAK, '').trim(); + } + return StringUtils.lineBreaksToSpaces(String(lastMessageText).trim()).substring(0, CONST.REPORT.LAST_MESSAGE_TEXT_MAX_LENGTH).trim(); +} + +/** + * Helper method to return the default avatar associated with the given login + */ +function getDefaultWorkspaceAvatar(workspaceName?: string): React.FC { + if (!workspaceName) { + return defaultWorkspaceAvatars.WorkspaceBuilding; + } + + // Remove all chars not A-Z or 0-9 including underscore + const alphaNumeric = workspaceName + .normalize('NFD') + .replace(/[^0-9a-z]/gi, '') + .toUpperCase(); + + const workspace = `Workspace${alphaNumeric[0]}` as keyof typeof defaultWorkspaceAvatars; + const defaultWorkspaceAvatar = defaultWorkspaceAvatars[workspace]; + + return !alphaNumeric ? defaultWorkspaceAvatars.WorkspaceBuilding : defaultWorkspaceAvatar; +} + +/** + * Helper method to return the default avatar testID associated with the given login + */ +function getDefaultWorkspaceAvatarTestID(workspaceName: string): string { + if (!workspaceName) { + return defaultAvatarBuildingIconTestID; + } + + // Remove all chars not A-Z or 0-9 including underscore + const alphaNumeric = workspaceName + .normalize('NFD') + .replace(/[^0-9a-z]/gi, '') + .toLowerCase(); + + return !alphaNumeric ? defaultAvatarBuildingIconTestID : `SvgDefaultAvatar_${alphaNumeric[0]} Icon`; +} + +/** + * Helper method to return the default avatar associated with the given reportID + */ +function getDefaultGroupAvatar(reportID?: string): IconAsset { + if (!reportID) { + return defaultGroupAvatars.Avatar1; + } + const reportIDHashBucket: AvatarRange = ((Number(reportID) % CONST.DEFAULT_GROUP_AVATAR_COUNT) + 1) as AvatarRange; + return defaultGroupAvatars[`Avatar${reportIDHashBucket}`]; +} + +/** + * Returns the appropriate icons for the given chat report using the stored personalDetails. + * The Avatar sources can be URLs or Icon components according to the chat type. + */ +function getIconsForParticipants(participants: number[], personalDetails: OnyxInputOrEntry): Icon[] { + const participantDetails: ParticipantDetails[] = []; + const participantsList = participants || []; + + for (const accountID of participantsList) { + const avatarSource = personalDetails?.[accountID]?.avatar ?? FallbackAvatar; + const displayNameLogin = personalDetails?.[accountID]?.displayName ? personalDetails?.[accountID]?.displayName : personalDetails?.[accountID]?.login; + participantDetails.push([accountID, displayNameLogin ?? '', avatarSource, personalDetails?.[accountID]?.fallbackIcon ?? '']); + } + + const sortedParticipantDetails = participantDetails.sort((first, second) => { + // First sort by displayName/login + const displayNameLoginOrder = localeCompare(first[1], second[1]); + if (displayNameLoginOrder !== 0) { + return displayNameLoginOrder; + } + + // Then fallback on accountID as the final sorting criteria. + // This will ensure that the order of avatars with same login/displayName + // stay consistent across all users and devices + return first[0] - second[0]; + }); + + // Now that things are sorted, gather only the avatars (second element in the array) and return those + const avatars: Icon[] = []; + + for (const sortedParticipantDetail of sortedParticipantDetails) { + const userIcon = { + id: sortedParticipantDetail[0], + source: sortedParticipantDetail[2], + type: CONST.ICON_TYPE_AVATAR, + name: sortedParticipantDetail[1], + fallbackIcon: sortedParticipantDetail[3], + }; + avatars.push(userIcon); + } + + return avatars; +} + +/** + * Cache the workspace icons + */ +const workSpaceIconsCache = new Map(); + +/** + * Given a report, return the associated workspace icon. + */ +function getWorkspaceIcon(report: OnyxInputOrEntry, policy?: OnyxInputOrEntry): Icon { + const workspaceName = getPolicyName(report, false, policy); + const cacheKey = report?.policyID ?? workspaceName; + const iconFromCache = workSpaceIconsCache.get(cacheKey); + // disabling to protect against empty strings + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const policyAvatarURL = report?.policyAvatar || allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]?.avatarURL; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const policyExpenseChatAvatarSource = policyAvatarURL || getDefaultWorkspaceAvatar(workspaceName); + + const isSameAvatarURL = iconFromCache?.icon?.source === policyExpenseChatAvatarSource; + const hasWorkSpaceNameChanged = iconFromCache?.name !== workspaceName; + + if (iconFromCache && (isSameAvatarURL || report?.policyAvatar === undefined) && !hasWorkSpaceNameChanged) { + return iconFromCache.icon; + } + + const workspaceIcon: Icon = { + source: policyExpenseChatAvatarSource ?? '', + type: CONST.ICON_TYPE_WORKSPACE, + name: workspaceName, + id: report?.policyID, + }; + workSpaceIconsCache.set(cacheKey, {name: workspaceName, icon: workspaceIcon}); + return workspaceIcon; +} + +/** + * Gets the personal details for a login by looking in the ONYXKEYS.PERSONAL_DETAILS_LIST Onyx key (stored in the local variable, allPersonalDetails). If it doesn't exist in Onyx, + * then a default object is constructed. + */ +function getPersonalDetailsForAccountID(accountID: number, personalDetailsData?: Partial): Partial { + if (!accountID) { + return {}; + } + + const defaultDetails = { + isOptimisticPersonalDetail: true, + }; + + if (!personalDetailsData) { + return allPersonalDetails?.[accountID] ?? defaultDetails; + } + + return personalDetailsData?.[accountID] ?? defaultDetails; +} + +/** + * Returns the personal details or a default object if the personal details are not available. + */ +function getPersonalDetailsOrDefault(personalDetails: Partial | undefined | null): Partial { + return personalDetails ?? {isOptimisticPersonalDetail: true}; +} + +const hiddenTranslation = Localize.translateLocal('common.hidden'); + +const phoneNumberCache: Record = {}; + +/** + * Get the displayName for a single report participant. + */ +function getDisplayNameForParticipant( + accountID?: number, + shouldUseShortForm = false, + shouldFallbackToHidden = true, + shouldAddCurrentUserPostfix = false, + personalDetailsData?: Partial, +): string { + if (!accountID) { + return ''; + } + + const personalDetails = getPersonalDetailsOrDefault(personalDetailsData?.[accountID] ?? allPersonalDetails?.[accountID]); + if (!personalDetails) { + return ''; + } + + const login = personalDetails.login ?? ''; + + // Check if the phone number is already cached + let formattedLogin = phoneNumberCache[login]; + if (!formattedLogin) { + formattedLogin = LocalePhoneNumber.formatPhoneNumber(login); + // Store the formatted phone number in the cache + phoneNumberCache[login] = formattedLogin; + } + + // This is to check if account is an invite/optimistically created one + // and prevent from falling back to 'Hidden', so a correct value is shown + // when searching for a new user + if (personalDetails.isOptimisticPersonalDetail === true) { + return formattedLogin; + } + + // For selfDM, we display the user's displayName followed by '(you)' as a postfix + const shouldAddPostfix = shouldAddCurrentUserPostfix && accountID === currentUserAccountID; + + const longName = PersonalDetailsUtils.getDisplayNameOrDefault(personalDetails, formattedLogin, shouldFallbackToHidden, shouldAddPostfix); + + // If the user's personal details (first name) should be hidden, make sure we return "hidden" instead of the short name + if (shouldFallbackToHidden && longName === hiddenTranslation) { + return longName; + } + + const shortName = personalDetails.firstName ? personalDetails.firstName : longName; + return shouldUseShortForm ? shortName : longName; +} + +function getParticipantsAccountIDsForDisplay(report: OnyxEntry, shouldExcludeHidden = false, shouldExcludeDeleted = false): number[] { + const reportParticipants = report?.participants ?? {}; + let participantsEntries = Object.entries(reportParticipants); + + // We should not show participants that have an optimistic entry with the same login in the personal details + const nonOptimisticLoginMap: Record = {}; + + for (const entry of participantsEntries) { + const [accountID] = entry; + const personalDetail = allPersonalDetails?.[accountID]; + if (personalDetail?.login && !personalDetail.isOptimisticPersonalDetail) { + nonOptimisticLoginMap[personalDetail.login] = true; + } + } + + participantsEntries = participantsEntries.filter(([accountID]) => { + const personalDetail = allPersonalDetails?.[accountID]; + if (personalDetail?.login && personalDetail.isOptimisticPersonalDetail) { + return !nonOptimisticLoginMap[personalDetail.login]; + } + return true; + }); + + let participantsIds = participantsEntries.map(([accountID]) => Number(accountID)); + + // For 1:1 chat, we don't want to include the current user as a participant in order to not mark 1:1 chats as having multiple participants + // For system chat, we want to display Expensify as the only participant + const shouldExcludeCurrentUser = isOneOnOneChat(report) || isSystemChat(report); + + if (shouldExcludeCurrentUser || shouldExcludeHidden || shouldExcludeDeleted) { + participantsIds = participantsIds.filter((accountID) => { + if (shouldExcludeCurrentUser && accountID === currentUserAccountID) { + return false; + } + + if (shouldExcludeHidden && reportParticipants[accountID]?.notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN) { + return false; + } + + if ( + shouldExcludeDeleted && + report?.pendingChatMembers?.findLast((member) => Number(member.accountID) === accountID)?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE + ) { + return false; + } + + return true; + }); + } + + return participantsIds.filter((accountID) => isNumber(accountID)); +} + +function getParticipantsList(report: Report, personalDetails: OnyxEntry, isRoomMembersList = false): number[] { + const isReportGroupChat = isGroupChat(report); + const isReportIOU = isIOUReport(report); + const shouldExcludeHiddenParticipants = !isReportGroupChat && !isReportIOU; + const chatParticipants = getParticipantsAccountIDsForDisplay(report, isRoomMembersList || shouldExcludeHiddenParticipants); + + return chatParticipants.filter((accountID) => { + const details = personalDetails?.[accountID]; + + if (!isRoomMembersList) { + if (!details) { + Log.hmmm(`[ReportParticipantsPage] no personal details found for Group chat member with accountID: ${accountID}`); + return false; + } + } else { + // When adding a new member to a room (whose personal detail does not exist in Onyx), an optimistic personal detail + // is created. However, when the real personal detail is returned from the backend, a duplicate member may appear + // briefly before the optimistic personal detail is deleted. To address this, we filter out the optimistically created + // member here. + const isDuplicateOptimisticDetail = + details?.isOptimisticPersonalDetail && chatParticipants.some((accID) => accID !== accountID && details.login === personalDetails?.[accID]?.login); + + if (!details || isDuplicateOptimisticDetail) { + Log.hmmm(`[RoomMembersPage] no personal details found for room member with accountID: ${accountID}`); + return false; + } + } + return true; + }); +} + +function buildParticipantsFromAccountIDs(accountIDs: number[]): Participants { + const finalParticipants: Participants = {}; + return accountIDs.reduce((participants, accountID) => { + // eslint-disable-next-line no-param-reassign + participants[accountID] = {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}; + return participants; + }, finalParticipants); +} + +/** + * Returns the report name if the report is a group chat + */ +function getGroupChatName(participants?: SelectedParticipant[], shouldApplyLimit = false, report?: OnyxEntry): string | undefined { + // If we have a report always try to get the name from the report. + if (report?.reportName) { + return report.reportName; + } + + let participantAccountIDs = participants?.map((participant) => participant.accountID) ?? Object.keys(report?.participants ?? {}).map(Number); + if (shouldApplyLimit) { + participantAccountIDs = participantAccountIDs.slice(0, 5); + } + const isMultipleParticipantReport = participantAccountIDs.length > 1; + + if (isMultipleParticipantReport) { + return participantAccountIDs + .map( + (participantAccountID, index) => + getDisplayNameForParticipant(participantAccountID, isMultipleParticipantReport) || LocalePhoneNumber.formatPhoneNumber(participants?.[index]?.login ?? ''), + ) + .sort((first, second) => localeCompare(first ?? '', second ?? '')) + .filter(Boolean) + .join(', '); + } + + return Localize.translateLocal('groupChat.defaultReportName', {displayName: getDisplayNameForParticipant(participantAccountIDs[0], false)}); +} + +function getParticipants(reportID: string) { + const report = getReportOrDraftReport(reportID); + if (!report) { + return {}; + } + + return report.participants; +} + +/** + * Returns the appropriate icons for the given chat report using the stored personalDetails. + * The Avatar sources can be URLs or Icon components according to the chat type. + */ +function getIcons( + report: OnyxInputOrEntry, + personalDetails: OnyxInputOrEntry, + defaultIcon: AvatarSource | null = null, + defaultName = '', + defaultAccountID = -1, + policy?: OnyxInputOrEntry, + invoiceReceiverPolicy?: OnyxInputOrEntry, +): Icon[] { + if (isEmptyObject(report)) { + const fallbackIcon: Icon = { + source: defaultIcon ?? FallbackAvatar, + type: CONST.ICON_TYPE_AVATAR, + name: defaultName, + id: defaultAccountID, + }; + return [fallbackIcon]; + } + if (isExpenseRequest(report)) { + const parentReportAction = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`]?.[report.parentReportActionID]; + const workspaceIcon = getWorkspaceIcon(report, policy); + const memberIcon = { + source: personalDetails?.[parentReportAction?.actorAccountID ?? -1]?.avatar ?? FallbackAvatar, + id: parentReportAction?.actorAccountID, + type: CONST.ICON_TYPE_AVATAR, + name: personalDetails?.[parentReportAction?.actorAccountID ?? -1]?.displayName ?? '', + fallbackIcon: personalDetails?.[parentReportAction?.actorAccountID ?? -1]?.fallbackIcon, + }; + + return [memberIcon, workspaceIcon]; + } + if (isChatThread(report)) { + const parentReportAction = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`]?.[report.parentReportActionID]; + + const actorAccountID = getReportActionActorAccountID(parentReportAction, report); + const actorDisplayName = PersonalDetailsUtils.getDisplayNameOrDefault(allPersonalDetails?.[actorAccountID ?? -1], '', false); + const actorIcon = { + id: actorAccountID, + source: personalDetails?.[actorAccountID ?? -1]?.avatar ?? FallbackAvatar, + name: actorDisplayName, + type: CONST.ICON_TYPE_AVATAR, + fallbackIcon: personalDetails?.[parentReportAction?.actorAccountID ?? -1]?.fallbackIcon, + }; + + if (isWorkspaceThread(report)) { + const workspaceIcon = getWorkspaceIcon(report, policy); + return [actorIcon, workspaceIcon]; + } + return [actorIcon]; + } + if (isTaskReport(report)) { + const ownerIcon = { + id: report?.ownerAccountID, + source: personalDetails?.[report?.ownerAccountID ?? -1]?.avatar ?? FallbackAvatar, + type: CONST.ICON_TYPE_AVATAR, + name: personalDetails?.[report?.ownerAccountID ?? -1]?.displayName ?? '', + fallbackIcon: personalDetails?.[report?.ownerAccountID ?? -1]?.fallbackIcon, + }; + + if (isWorkspaceTaskReport(report)) { + const workspaceIcon = getWorkspaceIcon(report, policy); + return [ownerIcon, workspaceIcon]; + } + + return [ownerIcon]; + } + if (isDomainRoom(report)) { + // Get domain name after the #. Domain Rooms use our default workspace avatar pattern. + const domainName = report?.reportName?.substring(1); + const policyExpenseChatAvatarSource = getDefaultWorkspaceAvatar(domainName); + const domainIcon: Icon = { + source: policyExpenseChatAvatarSource, + type: CONST.ICON_TYPE_WORKSPACE, + name: domainName ?? '', + id: report?.policyID, + }; + return [domainIcon]; + } + if (isAdminRoom(report) || isAnnounceRoom(report) || isChatRoom(report) || isArchivedRoom(report, getReportNameValuePairs(report?.reportID))) { + const icons = [getWorkspaceIcon(report, policy)]; + + if (isInvoiceRoom(report)) { + if (report?.invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL) { + icons.push(...getIconsForParticipants([report?.invoiceReceiver.accountID], personalDetails)); + } else { + const receiverPolicyID = report?.invoiceReceiver?.policyID; + const receiverPolicy = invoiceReceiverPolicy ?? getPolicy(receiverPolicyID); + if (!isEmptyObject(receiverPolicy)) { + icons.push({ + source: receiverPolicy?.avatarURL ?? getDefaultWorkspaceAvatar(receiverPolicy.name), + type: CONST.ICON_TYPE_WORKSPACE, + name: receiverPolicy.name, + id: receiverPolicyID, + }); + } + } + } + + return icons; + } + if (isPolicyExpenseChat(report) || isExpenseReport(report)) { + const workspaceIcon = getWorkspaceIcon(report, policy); + const memberIcon = { + source: personalDetails?.[report?.ownerAccountID ?? -1]?.avatar ?? FallbackAvatar, + id: report?.ownerAccountID, + type: CONST.ICON_TYPE_AVATAR, + name: personalDetails?.[report?.ownerAccountID ?? -1]?.displayName ?? '', + fallbackIcon: personalDetails?.[report?.ownerAccountID ?? -1]?.fallbackIcon, + }; + return isExpenseReport(report) ? [memberIcon, workspaceIcon] : [workspaceIcon, memberIcon]; + } + if (isIOUReport(report)) { + const managerIcon = { + source: personalDetails?.[report?.managerID ?? -1]?.avatar ?? FallbackAvatar, + id: report?.managerID, + type: CONST.ICON_TYPE_AVATAR, + name: personalDetails?.[report?.managerID ?? -1]?.displayName ?? '', + fallbackIcon: personalDetails?.[report?.managerID ?? -1]?.fallbackIcon, + }; + const ownerIcon = { + id: report?.ownerAccountID, + source: personalDetails?.[report?.ownerAccountID ?? -1]?.avatar ?? FallbackAvatar, + type: CONST.ICON_TYPE_AVATAR, + name: personalDetails?.[report?.ownerAccountID ?? -1]?.displayName ?? '', + fallbackIcon: personalDetails?.[report?.ownerAccountID ?? -1]?.fallbackIcon, + }; + const isManager = currentUserAccountID === report?.managerID; + + // For one transaction IOUs, display a simplified report icon + if (isOneTransactionReport(report?.reportID ?? '-1')) { + return [ownerIcon]; + } + + return isManager ? [managerIcon, ownerIcon] : [ownerIcon, managerIcon]; + } + + if (isSelfDM(report)) { + return getIconsForParticipants([currentUserAccountID ?? -1], personalDetails); + } + + if (isSystemChat(report)) { + return getIconsForParticipants([CONST.ACCOUNT_ID.NOTIFICATIONS ?? 0], personalDetails); + } + + if (isGroupChat(report)) { + const groupChatIcon = { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + source: report.avatarUrl || getDefaultGroupAvatar(report.reportID), + id: -1, + type: CONST.ICON_TYPE_AVATAR, + name: getGroupChatName(undefined, true, report), + }; + return [groupChatIcon]; + } + + if (isInvoiceReport(report)) { + const invoiceRoomReport = getReportOrDraftReport(report.chatReportID); + const icons = [getWorkspaceIcon(invoiceRoomReport, policy)]; + + if (invoiceRoomReport?.invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL) { + icons.push(...getIconsForParticipants([invoiceRoomReport?.invoiceReceiver.accountID], personalDetails)); + + return icons; + } + + const receiverPolicyID = invoiceRoomReport?.invoiceReceiver?.policyID; + const receiverPolicy = invoiceReceiverPolicy ?? getPolicy(receiverPolicyID); + + if (!isEmptyObject(receiverPolicy)) { + icons.push({ + source: receiverPolicy?.avatarURL ?? getDefaultWorkspaceAvatar(receiverPolicy.name), + type: CONST.ICON_TYPE_WORKSPACE, + name: receiverPolicy.name, + id: receiverPolicyID, + }); + } + + return icons; + } + + if (isOneOnOneChat(report)) { + const otherParticipantsAccountIDs = Object.keys(report.participants ?? {}) + .map(Number) + .filter((accountID) => accountID !== currentUserAccountID); + return getIconsForParticipants(otherParticipantsAccountIDs, personalDetails); + } + + const participantAccountIDs = Object.keys(report.participants ?? {}).map(Number); + return getIconsForParticipants(participantAccountIDs, personalDetails); +} + +function getDisplayNamesWithTooltips( + personalDetailsList: PersonalDetails[] | PersonalDetailsList | OptionData[], + shouldUseShortForm: boolean, + shouldFallbackToHidden = true, + shouldAddCurrentUserPostfix = false, +): DisplayNameWithTooltips { + const personalDetailsListArray = Array.isArray(personalDetailsList) ? personalDetailsList : Object.values(personalDetailsList); + + return personalDetailsListArray + .map((user) => { + const accountID = Number(user?.accountID); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const displayName = getDisplayNameForParticipant(accountID, shouldUseShortForm, shouldFallbackToHidden, shouldAddCurrentUserPostfix) || user?.login || ''; + const avatar = user && 'avatar' in user ? user.avatar : undefined; + + let pronouns = user?.pronouns ?? undefined; + if (pronouns?.startsWith(CONST.PRONOUNS.PREFIX)) { + const pronounTranslationKey = pronouns.replace(CONST.PRONOUNS.PREFIX, ''); + pronouns = Localize.translateLocal(`pronouns.${pronounTranslationKey}` as TranslationPaths); + } + + return { + displayName, + avatar, + login: user?.login ?? '', + accountID, + pronouns, + }; + }) + .sort((first, second) => { + // First sort by displayName/login + const displayNameLoginOrder = localeCompare(first.displayName, second.displayName); + if (displayNameLoginOrder !== 0) { + return displayNameLoginOrder; + } + + // Then fallback on accountID as the final sorting criteria. + return first.accountID - second.accountID; + }); +} + +/** + * Returns the the display names of the given user accountIDs + */ +function getUserDetailTooltipText(accountID: number, fallbackUserDisplayName = ''): string { + const displayNameForParticipant = getDisplayNameForParticipant(accountID); + return displayNameForParticipant || fallbackUserDisplayName; +} + +/** + * For a deleted parent report action within a chat report, + * let us return the appropriate display message + * + * @param reportAction - The deleted report action of a chat report for which we need to return message. + */ +function getDeletedParentActionMessageForChatReport(reportAction: OnyxEntry): string { + // By default, let us display [Deleted message] + let deletedMessageText = Localize.translateLocal('parentReportAction.deletedMessage'); + if (ReportActionsUtils.isCreatedTaskReportAction(reportAction)) { + // For canceled task report, let us display [Deleted task] + deletedMessageText = Localize.translateLocal('parentReportAction.deletedTask'); + } + return deletedMessageText; +} + +/** + * Returns the preview message for `REIMBURSEMENT_QUEUED` action + */ +function getReimbursementQueuedActionMessage( + reportAction: OnyxEntry>, + reportOrID: OnyxEntry | string, + shouldUseShortDisplayName = true, +): string { + const report = typeof reportOrID === 'string' ? ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportOrID}`] : reportOrID; + const submitterDisplayName = getDisplayNameForParticipant(report?.ownerAccountID, shouldUseShortDisplayName) ?? ''; + const originalMessage = ReportActionsUtils.getOriginalMessage(reportAction); + let messageKey: TranslationPaths; + if (originalMessage?.paymentType === CONST.IOU.PAYMENT_TYPE.EXPENSIFY) { + messageKey = 'iou.waitingOnEnabledWallet'; + } else { + messageKey = 'iou.waitingOnBankAccount'; + } + + return Localize.translateLocal(messageKey, {submitterDisplayName}); +} + +/** + * Returns the preview message for `REIMBURSEMENT_DEQUEUED` action + */ +function getReimbursementDeQueuedActionMessage( + reportAction: OnyxEntry>, + reportOrID: OnyxEntry | string, + isLHNPreview = false, +): string { + const report = typeof reportOrID === 'string' ? ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportOrID}`] : reportOrID; + const originalMessage = ReportActionsUtils.getOriginalMessage(reportAction); + const amount = originalMessage?.amount; + const currency = originalMessage?.currency; + const formattedAmount = CurrencyUtils.convertToDisplayString(amount, currency); + if (originalMessage?.cancellationReason === CONST.REPORT.CANCEL_PAYMENT_REASONS.ADMIN) { + const payerOrApproverName = report?.managerID === currentUserAccountID || !isLHNPreview ? '' : getDisplayNameForParticipant(report?.managerID, true); + return Localize.translateLocal('iou.adminCanceledRequest', {manager: payerOrApproverName, amount: formattedAmount}); + } + const submitterDisplayName = getDisplayNameForParticipant(report?.ownerAccountID, true) ?? ''; + return Localize.translateLocal('iou.canceledRequest', {submitterDisplayName, amount: formattedAmount}); +} + +/** + * Builds an optimistic REIMBURSEMENT_DEQUEUED report action with a randomly generated reportActionID. + * + */ +function buildOptimisticCancelPaymentReportAction(expenseReportID: string, amount: number, currency: string): OptimisticCancelPaymentReportAction { + return { + actionName: CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_DEQUEUED, + actorAccountID: currentUserAccountID, + message: [ + { + cancellationReason: CONST.REPORT.CANCEL_PAYMENT_REASONS.ADMIN, + expenseReportID, + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + text: '', + amount, + currency, + }, + ], + originalMessage: { + cancellationReason: CONST.REPORT.CANCEL_PAYMENT_REASONS.ADMIN, + expenseReportID, + amount, + currency, + }, + person: [ + { + style: 'strong', + text: getCurrentUserDisplayNameOrEmail(), + type: 'TEXT', + }, + ], + reportActionID: NumberUtils.rand64(), + shouldShow: true, + created: DateUtils.getDBTime(), + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }; +} + +/** + * Returns the last visible message for a given report after considering the given optimistic actions + * + * @param reportID - the report for which last visible message has to be fetched + * @param [actionsToMerge] - the optimistic merge actions that needs to be considered while fetching last visible message + + */ +function getLastVisibleMessage(reportID: string | undefined, actionsToMerge: ReportActions = {}): LastVisibleMessage { + const report = getReportOrDraftReport(reportID); + const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(reportID ?? '-1', actionsToMerge); + + // For Chat Report with deleted parent actions, let us fetch the correct message + if (ReportActionsUtils.isDeletedParentAction(lastVisibleAction) && !isEmptyObject(report) && isChatReport(report)) { + const lastMessageText = getDeletedParentActionMessageForChatReport(lastVisibleAction); + return { + lastMessageText, + }; + } + + // Fetch the last visible message for report represented by reportID and based on actions to merge. + return ReportActionsUtils.getLastVisibleMessage(reportID ?? '-1', actionsToMerge); +} + +/** + * Checks if a report is waiting for the manager to complete an action. + * Example: the assignee of an open task report or the manager of a processing expense report. + * + * @param [parentReportAction] - The parent report action of the report (Used to check if the task has been canceled) + */ +function isWaitingForAssigneeToCompleteAction(report: OnyxEntry, parentReportAction: OnyxEntry): boolean { + if (report?.hasOutstandingChildTask) { + return true; + } + + if (!report?.hasParentAccess && isReportManager(report)) { + if (isOpenTaskReport(report, parentReportAction)) { + return true; + } + + if (isProcessingReport(report) && isExpenseReport(report)) { + return true; + } + } + + return false; +} + +function isUnreadWithMention(reportOrOption: OnyxEntry | OptionData): boolean { + if (!reportOrOption) { + return false; + } + // lastMentionedTime and lastReadTime are both datetime strings and can be compared directly + const lastMentionedTime = reportOrOption.lastMentionedTime ?? ''; + const lastReadTime = reportOrOption.lastReadTime ?? ''; + return !!('isUnreadWithMention' in reportOrOption && reportOrOption.isUnreadWithMention) || lastReadTime < lastMentionedTime; +} + +/** + * Determines if the option requires action from the current user. This can happen when it: + * - is unread and the user was mentioned in one of the unread comments + * - is for an outstanding task waiting on the user + * - has an outstanding child expense that is waiting for an action from the current user (e.g. pay, approve, add bank account) + * - is either the system or concierge chat, the user free trial has ended and it didn't add a payment card yet + * + * @param option (report or optionItem) + * @param parentReportAction (the report action the current report is a thread of) + */ +function requiresAttentionFromCurrentUser(optionOrReport: OnyxEntry | OptionData, parentReportAction?: OnyxEntry) { + if (!optionOrReport) { + return false; + } + + if (isJoinRequestInAdminRoom(optionOrReport)) { + return true; + } + + if ( + isArchivedRoom(optionOrReport, getReportNameValuePairs(optionOrReport?.reportID)) || + isArchivedRoom(getReportOrDraftReport(optionOrReport.parentReportID), getReportNameValuePairs(optionOrReport?.reportID)) + ) { + return false; + } + + if (isUnreadWithMention(optionOrReport)) { + return true; + } + + if (isWaitingForAssigneeToCompleteAction(optionOrReport, parentReportAction)) { + return true; + } + + // Has a child report that is awaiting action (e.g. approve, pay, add bank account) from current user + if (optionOrReport.hasOutstandingChildRequest) { + return true; + } + + if (hasMissingInvoiceBankAccount(optionOrReport.reportID)) { + return true; + } + + if (isInvoiceRoom(optionOrReport)) { + const invoiceRoomReportActions = ReportActionsUtils.getAllReportActions(optionOrReport.reportID); + + return Object.values(invoiceRoomReportActions).some( + (reportAction) => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && reportAction.childReportID && hasMissingInvoiceBankAccount(reportAction.childReportID), + ); + } + + return false; +} + +/** + * Returns number of transactions that are nonReimbursable + * + */ +function hasNonReimbursableTransactions(iouReportID: string | undefined): boolean { + const transactions = reportsTransactions[iouReportID ?? ''] ?? []; + return transactions.filter((transaction) => transaction.reimbursable === false).length > 0; +} + +function getMoneyRequestSpendBreakdown(report: OnyxInputOrEntry, allReportsDict?: OnyxCollection): SpendBreakdown { + const allAvailableReports = allReportsDict ?? ReportConnection.getAllReports(); + let moneyRequestReport; + if (isMoneyRequestReport(report) || isInvoiceReport(report)) { + moneyRequestReport = report; + } + if (allAvailableReports && report?.iouReportID) { + moneyRequestReport = allAvailableReports[`${ONYXKEYS.COLLECTION.REPORT}${report.iouReportID}`]; + } + if (moneyRequestReport) { + let nonReimbursableSpend = moneyRequestReport.nonReimbursableTotal ?? 0; + let totalSpend = moneyRequestReport.total ?? 0; + + if (nonReimbursableSpend + totalSpend !== 0) { + // There is a possibility that if the Expense report has a negative total. + // This is because there are instances where you can get a credit back on your card, + // or you enter a negative expense to “offset” future expenses + nonReimbursableSpend = isExpenseReport(moneyRequestReport) ? nonReimbursableSpend * -1 : Math.abs(nonReimbursableSpend); + totalSpend = isExpenseReport(moneyRequestReport) ? totalSpend * -1 : Math.abs(totalSpend); + + const totalDisplaySpend = totalSpend; + const reimbursableSpend = totalDisplaySpend - nonReimbursableSpend; + + return { + nonReimbursableSpend, + reimbursableSpend, + totalDisplaySpend, + }; + } + } + return { + nonReimbursableSpend: 0, + reimbursableSpend: 0, + totalDisplaySpend: 0, + }; +} + +/** + * Get the title for a policy expense chat which depends on the role of the policy member seeing this report + */ +function getPolicyExpenseChatName(report: OnyxEntry, policy?: OnyxEntry): string | undefined { + const ownerAccountID = report?.ownerAccountID; + const personalDetails = allPersonalDetails?.[ownerAccountID ?? -1]; + const login = personalDetails ? personalDetails.login : null; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const reportOwnerDisplayName = getDisplayNameForParticipant(ownerAccountID) || login || report?.reportName; + + // If the policy expense chat is owned by this user, use the name of the policy as the report name. + if (report?.isOwnPolicyExpenseChat) { + return getPolicyName(report, false, policy); + } + + let policyExpenseChatRole = 'user'; + const policyItem = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]; + if (policyItem) { + policyExpenseChatRole = policyItem.role || 'user'; + } + + // If this user is not admin and this policy expense chat has been archived because of account merging, this must be an old workspace chat + // of the account which was merged into the current user's account. Use the name of the policy as the name of the report. + if (isArchivedRoom(report, getReportNameValuePairs(report?.reportID))) { + const lastAction = ReportActionsUtils.getLastVisibleAction(report?.reportID ?? '-1'); + const archiveReason = ReportActionsUtils.isClosedAction(lastAction) ? ReportActionsUtils.getOriginalMessage(lastAction)?.reason : CONST.REPORT.ARCHIVE_REASON.DEFAULT; + if (archiveReason === CONST.REPORT.ARCHIVE_REASON.ACCOUNT_MERGED && policyExpenseChatRole !== CONST.POLICY.ROLE.ADMIN) { + return getPolicyName(report, false, policy); + } + } + + // If user can see this report and they are not its owner, they must be an admin and the report name should be the name of the policy member + return reportOwnerDisplayName; +} + +function getArchiveReason(reportActions: OnyxEntry): ValueOf | undefined { + const lastClosedReportAction = ReportActionsUtils.getLastClosedReportAction(reportActions); + + if (!lastClosedReportAction) { + return undefined; + } + + return ReportActionsUtils.isClosedAction(lastClosedReportAction) ? ReportActionsUtils.getOriginalMessage(lastClosedReportAction)?.reason : CONST.REPORT.ARCHIVE_REASON.DEFAULT; +} + +/** + * Given a report field, check if the field is for the report title. + */ +function isReportFieldOfTypeTitle(reportField: OnyxEntry): boolean { + return reportField?.type === 'formula' && reportField?.fieldID === CONST.REPORT_FIELD_TITLE_FIELD_ID; +} + +/** + * Check if Report has any held expenses + */ +function isHoldCreator(transaction: OnyxEntry, reportID: string): boolean { + const holdReportAction = ReportActionsUtils.getReportAction(reportID, `${transaction?.comment?.hold ?? ''}`); + return isActionCreator(holdReportAction); +} + +/** + * Given a report field, check if the field can be edited or not. + * For title fields, its considered disabled if `deletable` prop is `true` (https://github.com/Expensify/App/issues/35043#issuecomment-1911275433) + * For non title fields, its considered disabled if: + * 1. The user is not admin of the report + * 2. Report is settled or it is closed + */ +function isReportFieldDisabled(report: OnyxEntry, reportField: OnyxEntry, policy: OnyxEntry): boolean { + const isReportSettled = isSettled(report?.reportID); + const isReportClosed = isClosedReport(report); + const isTitleField = isReportFieldOfTypeTitle(reportField); + const isAdmin = isPolicyAdmin(report?.policyID ?? '-1', {[`${ONYXKEYS.COLLECTION.POLICY}${policy?.id ?? '-1'}`]: policy}); + return isTitleField ? !reportField?.deletable : !isAdmin && (isReportSettled || isReportClosed); +} + +/** + * Given a set of report fields, return the field of type formula + */ +function getFormulaTypeReportField(reportFields: Record) { + return Object.values(reportFields).find((field) => field?.type === 'formula'); +} + +/** + * Given a set of report fields, return the field that refers to title + */ +function getTitleReportField(reportFields: Record) { + return Object.values(reportFields).find((field) => isReportFieldOfTypeTitle(field)); +} + +/** + * Get the key for a report field + */ +function getReportFieldKey(reportFieldId: string) { + // We don't need to add `expensify_` prefix to the title field key, because backend stored title under a unique key `text_title`, + // and all the other report field keys are stored under `expensify_FIELD_ID`. + if (reportFieldId === CONST.REPORT_FIELD_TITLE_FIELD_ID) { + return reportFieldId; + } + + return `expensify_${reportFieldId}`; +} + +/** + * Get the report fields attached to the policy given policyID + */ +function getReportFieldsByPolicyID(policyID: string): Record { + const policyReportFields = Object.entries(allPolicies ?? {}).find(([key]) => key.replace(ONYXKEYS.COLLECTION.POLICY, '') === policyID); + const fieldList = policyReportFields?.[1]?.fieldList; + + if (!policyReportFields || !fieldList) { + return {}; + } + + return fieldList; +} + +/** + * Get the report fields that we should display a MoneyReportView gets opened + */ + +function getAvailableReportFields(report: Report, policyReportFields: PolicyReportField[]): PolicyReportField[] { + // Get the report fields that are attached to a report. These will persist even if a field is deleted from the policy. + const reportFields = Object.values(report.fieldList ?? {}); + const reportIsSettled = isSettled(report.reportID); + + // If the report is settled, we don't want to show any new field that gets added to the policy. + if (reportIsSettled) { + return reportFields; + } + + // If the report is unsettled, we want to merge the new fields that get added to the policy with the fields that + // are attached to the report. + const mergedFieldIds = Array.from(new Set([...policyReportFields.map(({fieldID}) => fieldID), ...reportFields.map(({fieldID}) => fieldID)])); + + const fields = mergedFieldIds.map((id) => { + const field = report?.fieldList?.[getReportFieldKey(id)]; + + if (field) { + return field; + } + + const policyReportField = policyReportFields.find(({fieldID}) => fieldID === id); + + if (policyReportField) { + return policyReportField; + } + + return null; + }); + + return fields.filter(Boolean) as PolicyReportField[]; +} + +/** + * Get the title for an IOU or expense chat which will be showing the payer and the amount + */ +function getMoneyRequestReportName(report: OnyxEntry, policy?: OnyxEntry, invoiceReceiverPolicy?: OnyxEntry): string { + const isReportSettled = isSettled(report?.reportID ?? '-1'); + const reportFields = isReportSettled ? report?.fieldList : getReportFieldsByPolicyID(report?.policyID ?? '-1'); + const titleReportField = getFormulaTypeReportField(reportFields ?? {}); + + if (titleReportField && report?.reportName && isPaidGroupPolicyExpenseReport(report)) { + return report.reportName; + } + + const moneyRequestTotal = getMoneyRequestSpendBreakdown(report).totalDisplaySpend; + const formattedAmount = CurrencyUtils.convertToDisplayString(moneyRequestTotal, report?.currency); + + let payerOrApproverName; + if (isExpenseReport(report)) { + payerOrApproverName = getPolicyName(report, false, policy); + } else if (isInvoiceReport(report)) { + const chatReport = getReportOrDraftReport(report?.chatReportID); + payerOrApproverName = getInvoicePayerName(chatReport, invoiceReceiverPolicy); + } else { + payerOrApproverName = getDisplayNameForParticipant(report?.managerID) ?? ''; + } + + const payerPaidAmountMessage = Localize.translateLocal('iou.payerPaidAmount', { + payer: payerOrApproverName, + amount: formattedAmount, + }); + + if (isReportApproved(report)) { + return Localize.translateLocal('iou.managerApprovedAmount', { + manager: payerOrApproverName, + amount: formattedAmount, + }); + } + + if (report?.isWaitingOnBankAccount) { + return `${payerPaidAmountMessage} ${CONST.DOT_SEPARATOR} ${Localize.translateLocal('iou.pending')}`; + } + + if (!isSettled(report?.reportID) && hasNonReimbursableTransactions(report?.reportID)) { + payerOrApproverName = getDisplayNameForParticipant(report?.ownerAccountID) ?? ''; + return Localize.translateLocal('iou.payerSpentAmount', {payer: payerOrApproverName, amount: formattedAmount}); + } + + if (isProcessingReport(report) || isOpenExpenseReport(report) || isOpenInvoiceReport(report) || moneyRequestTotal === 0) { + return Localize.translateLocal('iou.payerOwesAmount', {payer: payerOrApproverName, amount: formattedAmount}); + } + + return payerPaidAmountMessage; +} + +/** + * Gets transaction created, amount, currency, comment, and waypoints (for distance expense) + * into a flat object. Used for displaying transactions and sending them in API commands + */ + +function getTransactionDetails(transaction: OnyxInputOrEntry, createdDateFormat: string = CONST.DATE.FNS_FORMAT_STRING): TransactionDetails | undefined { + if (!transaction) { + return; + } + const report = getReportOrDraftReport(transaction?.reportID); + return { + created: TransactionUtils.getFormattedCreated(transaction, createdDateFormat), + amount: TransactionUtils.getAmount(transaction, !isEmptyObject(report) && isExpenseReport(report)), + taxAmount: TransactionUtils.getTaxAmount(transaction, !isEmptyObject(report) && isExpenseReport(report)), + taxCode: TransactionUtils.getTaxCode(transaction), + currency: TransactionUtils.getCurrency(transaction), + comment: TransactionUtils.getDescription(transaction), + merchant: TransactionUtils.getMerchant(transaction), + waypoints: TransactionUtils.getWaypoints(transaction), + customUnitRateID: TransactionUtils.getRateID(transaction), + category: TransactionUtils.getCategory(transaction), + billable: TransactionUtils.getBillable(transaction), + tag: TransactionUtils.getTag(transaction), + mccGroup: TransactionUtils.getMCCGroup(transaction), + cardID: TransactionUtils.getCardID(transaction), + originalAmount: TransactionUtils.getOriginalAmount(transaction), + originalCurrency: TransactionUtils.getOriginalCurrency(transaction), + }; +} + +function getTransactionCommentObject(transaction: OnyxEntry): Comment { + return { + ...transaction?.comment, + waypoints: TransactionUtils.getWaypoints(transaction), + }; +} + +/** + * Can only edit if: + * + * - in case of IOU report + * - the current user is the requestor and is not settled yet + * - in case of expense report + * - the current user is the requestor and is not settled yet + * - the current user is the manager of the report + * - or the current user is an admin on the policy the expense report is tied to + * + * This is used in conjunction with canEditRestrictedField to control editing of specific fields like amount, currency, created, receipt, and distance. + * On its own, it only controls allowing/disallowing navigating to the editing pages or showing/hiding the 'Edit' icon on report actions + */ +function canEditMoneyRequest(reportAction: OnyxInputOrEntry>, linkedTransaction?: OnyxEntry): boolean { + const isDeleted = ReportActionsUtils.isDeletedAction(reportAction); + + if (isDeleted) { + return false; + } + + const allowedReportActionType: Array> = [CONST.IOU.REPORT_ACTION_TYPE.TRACK, CONST.IOU.REPORT_ACTION_TYPE.CREATE]; + const originalMessage = ReportActionsUtils.getOriginalMessage(reportAction); + const actionType = originalMessage?.type; + + if (!actionType || !allowedReportActionType.includes(actionType)) { + return false; + } + + const transaction = linkedTransaction ?? getLinkedTransaction(reportAction ?? undefined); + + // In case the transaction is failed to be created, we should disable editing the money request + if (!transaction?.transactionID || (transaction?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD && !isEmptyObject(transaction.errors))) { + return false; + } + + const moneyRequestReportID = originalMessage?.IOUReportID ?? -1; + + if (!moneyRequestReportID) { + return actionType === CONST.IOU.REPORT_ACTION_TYPE.TRACK; + } + + const moneyRequestReport = getReportOrDraftReport(String(moneyRequestReportID)); + const isRequestor = currentUserAccountID === reportAction?.actorAccountID; + + const isSubmitted = isProcessingReport(moneyRequestReport); + if (isIOUReport(moneyRequestReport)) { + return isSubmitted && isRequestor; + } + + const policy = getPolicy(moneyRequestReport?.policyID ?? '-1'); + const isAdmin = policy?.role === CONST.POLICY.ROLE.ADMIN; + const isManager = currentUserAccountID === moneyRequestReport?.managerID; + + if (isInvoiceReport(moneyRequestReport) && isManager) { + return false; + } + + // Admin & managers can always edit coding fields such as tag, category, billable, etc. As long as the report has a state higher than OPEN. + if ((isAdmin || isManager) && !isOpenExpenseReport(moneyRequestReport)) { + return true; + } + + if (policy?.type === CONST.POLICY.TYPE.CORPORATE && moneyRequestReport && isSubmitted && isCurrentUserSubmitter(moneyRequestReport.reportID)) { + const isForwarded = PolicyUtils.getSubmitToAccountID(policy, moneyRequestReport.ownerAccountID ?? -1) !== moneyRequestReport.managerID; + return !isForwarded; + } + + return !isReportApproved(moneyRequestReport) && !isSettled(moneyRequestReport?.reportID) && !isClosedReport(moneyRequestReport) && isRequestor; +} + +/** + * Checks if the current user can edit the provided property of an expense + * + */ +function canEditFieldOfMoneyRequest(reportAction: OnyxInputOrEntry, fieldToEdit: ValueOf): boolean { + // A list of fields that cannot be edited by anyone, once an expense has been settled + const restrictedFields: string[] = [ + CONST.EDIT_REQUEST_FIELD.AMOUNT, + CONST.EDIT_REQUEST_FIELD.CURRENCY, + CONST.EDIT_REQUEST_FIELD.MERCHANT, + CONST.EDIT_REQUEST_FIELD.DATE, + CONST.EDIT_REQUEST_FIELD.RECEIPT, + CONST.EDIT_REQUEST_FIELD.DISTANCE, + CONST.EDIT_REQUEST_FIELD.DISTANCE_RATE, + ]; + + if (!ReportActionsUtils.isMoneyRequestAction(reportAction) || !canEditMoneyRequest(reportAction)) { + return false; + } + + // If we're editing fields such as category, tag, description, etc. the check above should be enough for handling the permission + if (!restrictedFields.includes(fieldToEdit)) { + return true; + } + + const iouMessage = ReportActionsUtils.getOriginalMessage(reportAction); + const moneyRequestReport = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${iouMessage?.IOUReportID}`] ?? ({} as Report); + const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${iouMessage?.IOUTransactionID}`] ?? ({} as Transaction); + + if (isSettled(String(moneyRequestReport.reportID)) || isReportApproved(String(moneyRequestReport.reportID))) { + return false; + } + + if ( + (fieldToEdit === CONST.EDIT_REQUEST_FIELD.AMOUNT || fieldToEdit === CONST.EDIT_REQUEST_FIELD.CURRENCY || fieldToEdit === CONST.EDIT_REQUEST_FIELD.DATE) && + TransactionUtils.isCardTransaction(transaction) + ) { + return false; + } + + const policy = getPolicy(moneyRequestReport?.policyID); + const isAdmin = isExpenseReport(moneyRequestReport) && policy?.role === CONST.POLICY.ROLE.ADMIN; + const isManager = isExpenseReport(moneyRequestReport) && currentUserAccountID === moneyRequestReport?.managerID; + + if ((fieldToEdit === CONST.EDIT_REQUEST_FIELD.AMOUNT || fieldToEdit === CONST.EDIT_REQUEST_FIELD.CURRENCY) && TransactionUtils.isDistanceRequest(transaction)) { + return isAdmin || isManager; + } + + if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.RECEIPT) { + const isRequestor = currentUserAccountID === reportAction?.actorAccountID; + return ( + !isInvoiceReport(moneyRequestReport) && + !TransactionUtils.isReceiptBeingScanned(transaction) && + !TransactionUtils.isDistanceRequest(transaction) && + (isAdmin || isManager || isRequestor) + ); + } + + if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.DISTANCE_RATE) { + // The distance rate can be modified only on the distance expense reports + return isExpenseReport(moneyRequestReport) && TransactionUtils.isDistanceRequest(transaction); + } + + return true; +} + +/** + * Can only edit if: + * + * - It was written by the current user + * - It's an ADD_COMMENT that is not an attachment + * - It's an expense where conditions for editability are defined in canEditMoneyRequest method + * - It's not pending deletion + */ +function canEditReportAction(reportAction: OnyxInputOrEntry): boolean { + const isCommentOrIOU = reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT || reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU; + const message = reportAction ? ReportActionsUtils.getReportActionMessage(reportAction) : undefined; + + return !!( + reportAction?.actorAccountID === currentUserAccountID && + isCommentOrIOU && + (!ReportActionsUtils.isMoneyRequestAction(reportAction) || canEditMoneyRequest(reportAction)) && // Returns true for non-IOU actions + !isReportMessageAttachment(message) && + ((!reportAction.isAttachmentWithText && !reportAction.isAttachmentOnly) || !reportAction.isOptimisticAction) && + !ReportActionsUtils.isDeletedAction(reportAction) && + !ReportActionsUtils.isCreatedTaskReportAction(reportAction) && + reportAction?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE + ); +} + +function canHoldUnholdReportAction(reportAction: OnyxInputOrEntry): {canHoldRequest: boolean; canUnholdRequest: boolean} { + if (!ReportActionsUtils.isMoneyRequestAction(reportAction)) { + return {canHoldRequest: false, canUnholdRequest: false}; + } + + const moneyRequestReportID = ReportActionsUtils.getOriginalMessage(reportAction)?.IOUReportID ?? 0; + const moneyRequestReport = getReportOrDraftReport(String(moneyRequestReportID)); + + if (!moneyRequestReportID || !moneyRequestReport) { + return {canHoldRequest: false, canUnholdRequest: false}; + } + + const isRequestSettled = isSettled(moneyRequestReport?.reportID); + const isApproved = isReportApproved(moneyRequestReport); + const transactionID = moneyRequestReport ? ReportActionsUtils.getOriginalMessage(reportAction)?.IOUTransactionID : 0; + const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] ?? ({} as Transaction); + + const parentReportAction = isThread(moneyRequestReport) + ? allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${moneyRequestReport.parentReportID}`]?.[moneyRequestReport.parentReportActionID] + : undefined; + + const isRequestIOU = isIOUReport(moneyRequestReport); + const isHoldActionCreator = isHoldCreator(transaction, reportAction.childReportID ?? '-1'); + + const isTrackExpenseMoneyReport = isTrackExpenseReport(moneyRequestReport); + const isActionOwner = + typeof parentReportAction?.actorAccountID === 'number' && + typeof currentUserPersonalDetails?.accountID === 'number' && + parentReportAction.actorAccountID === currentUserPersonalDetails?.accountID; + const isApprover = isMoneyRequestReport(moneyRequestReport) && moneyRequestReport?.managerID !== null && currentUserPersonalDetails?.accountID === moneyRequestReport?.managerID; + const isAdmin = isPolicyAdmin(moneyRequestReport.policyID ?? '-1', allPolicies); + const isOnHold = TransactionUtils.isOnHold(transaction); + const isScanning = TransactionUtils.hasReceipt(transaction) && TransactionUtils.isReceiptBeingScanned(transaction); + const isClosed = isClosedReport(moneyRequestReport); + + const canModifyStatus = !isTrackExpenseMoneyReport && (isAdmin || isActionOwner || isApprover); + const canModifyUnholdStatus = !isTrackExpenseMoneyReport && (isAdmin || (isActionOwner && isHoldActionCreator) || isApprover); + const isDeletedParentAction = isEmptyObject(parentReportAction) || ReportActionsUtils.isDeletedAction(parentReportAction); + + const canHoldOrUnholdRequest = !isRequestSettled && !isApproved && !isDeletedParentAction && !isClosed; + const canHoldRequest = canHoldOrUnholdRequest && !isOnHold && (isRequestIOU || canModifyStatus) && !isScanning && !!transaction?.reimbursable; + const canUnholdRequest = + !!(canHoldOrUnholdRequest && isOnHold && !TransactionUtils.isDuplicate(transaction.transactionID, true) && (isRequestIOU ? isHoldActionCreator : canModifyUnholdStatus)) && + !!transaction?.reimbursable; + + return {canHoldRequest, canUnholdRequest}; +} + +const changeMoneyRequestHoldStatus = (reportAction: OnyxEntry, backTo?: string): void => { + if (!ReportActionsUtils.isMoneyRequestAction(reportAction)) { + return; + } + const moneyRequestReportID = ReportActionsUtils.getOriginalMessage(reportAction)?.IOUReportID ?? 0; + + const moneyRequestReport = getReportOrDraftReport(String(moneyRequestReportID)); + if (!moneyRequestReportID || !moneyRequestReport) { + return; + } + + const transactionID = ReportActionsUtils.getOriginalMessage(reportAction)?.IOUTransactionID ?? ''; + const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] ?? ({} as Transaction); + const isOnHold = TransactionUtils.isOnHold(transaction); + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${moneyRequestReport.policyID}`] ?? null; + + if (isOnHold) { + IOU.unholdRequest(transactionID, reportAction.childReportID ?? ''); + } else { + const activeRoute = encodeURIComponent(Navigation.getActiveRouteWithoutParams()); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + Navigation.navigate(ROUTES.MONEY_REQUEST_HOLD_REASON.getRoute(policy?.type ?? CONST.POLICY.TYPE.PERSONAL, transactionID, reportAction.childReportID ?? '', backTo || activeRoute)); + } +}; + +/** + * Gets all transactions on an IOU report with a receipt + */ +function getTransactionsWithReceipts(iouReportID: string | undefined): Transaction[] { + const transactions = reportsTransactions[iouReportID ?? ''] ?? []; + return transactions.filter((transaction) => TransactionUtils.hasReceipt(transaction)); +} + +/** + * For report previews, we display a "Receipt scan in progress" indicator + * instead of the report total only when we have no report total ready to show. This is the case when + * all requests are receipts that are being SmartScanned. As soon as we have a non-receipt request, + * or as soon as one receipt request is done scanning, we have at least one + * "ready" expense, and we remove this indicator to show the partial report total. + */ +function areAllRequestsBeingSmartScanned(iouReportID: string, reportPreviewAction: OnyxEntry): boolean { + const transactionsWithReceipts = getTransactionsWithReceipts(iouReportID); + // If we have more requests than requests with receipts, we have some manual requests + if (ReportActionsUtils.getNumberOfMoneyRequests(reportPreviewAction) > transactionsWithReceipts.length) { + return false; + } + return transactionsWithReceipts.every((transaction) => TransactionUtils.isReceiptBeingScanned(transaction)); +} + +/** + * Get the transactions related to a report preview with receipts + * Get the details linked to the IOU reportAction + * + * NOTE: This method is only meant to be used inside this action file. Do not export and use it elsewhere. Use withOnyx or Onyx.connect() instead. + */ +function getLinkedTransaction(reportAction: OnyxEntry): OnyxEntry { + let transactionID = ''; + + if (ReportActionsUtils.isMoneyRequestAction(reportAction)) { + transactionID = ReportActionsUtils.getOriginalMessage(reportAction)?.IOUTransactionID ?? '-1'; + } + + return allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; +} + +/** + * Check if any of the transactions in the report has required missing fields + */ +function hasMissingSmartscanFields(iouReportID: string): boolean { + const reportTransactions = reportsTransactions[iouReportID] ?? []; + + return reportTransactions.some(TransactionUtils.hasMissingSmartscanFields); +} + +/** + * Check if iouReportID has required missing fields + */ +function shouldShowRBRForMissingSmartscanFields(iouReportID: string): boolean { + const reportActions = Object.values(ReportActionsUtils.getAllReportActions(iouReportID)); + return reportActions.some((action) => { + if (!ReportActionsUtils.isMoneyRequestAction(action)) { + return false; + } + const transaction = getLinkedTransaction(action); + if (isEmptyObject(transaction)) { + return false; + } + if (!ReportActionsUtils.wasActionTakenByCurrentUser(action)) { + return false; + } + return TransactionUtils.hasMissingSmartscanFields(transaction); + }); +} + +/** + * Given a parent IOU report action get report name for the LHN. + */ +function getTransactionReportName(reportAction: OnyxEntry): string { + if (ReportActionsUtils.isReversedTransaction(reportAction)) { + return Localize.translateLocal('parentReportAction.reversedTransaction'); + } + + if (ReportActionsUtils.isDeletedAction(reportAction)) { + return Localize.translateLocal('parentReportAction.deletedExpense'); + } + + const transaction = getLinkedTransaction(reportAction); + + if (isEmptyObject(transaction)) { + // Transaction data might be empty on app's first load, if so we fallback to Expense/Track Expense + return ReportActionsUtils.isTrackExpenseAction(reportAction) ? Localize.translateLocal('iou.trackExpense') : Localize.translateLocal('iou.expense'); + } + + if (TransactionUtils.hasReceipt(transaction) && TransactionUtils.isReceiptBeingScanned(transaction)) { + return Localize.translateLocal('iou.receiptScanning'); + } + + if (TransactionUtils.hasMissingSmartscanFields(transaction)) { + return Localize.translateLocal('iou.receiptMissingDetails'); + } + + if (TransactionUtils.isFetchingWaypointsFromServer(transaction) && TransactionUtils.getMerchant(transaction) === Localize.translateLocal('iou.fieldPending')) { + return Localize.translateLocal('iou.fieldPending'); + } + + if (ReportActionsUtils.isSentMoneyReportAction(reportAction)) { + return getIOUReportActionDisplayMessage(reportAction as ReportAction, transaction); + } + + const report = getReportOrDraftReport(transaction?.reportID); + const amount = TransactionUtils.getAmount(transaction, !isEmptyObject(report) && isExpenseReport(report)) ?? 0; + const formattedAmount = CurrencyUtils.convertToDisplayString(amount, TransactionUtils.getCurrency(transaction)) ?? ''; + const comment = (!TransactionUtils.isMerchantMissing(transaction) ? TransactionUtils.getMerchant(transaction) : TransactionUtils.getDescription(transaction)) ?? ''; + if (ReportActionsUtils.isTrackExpenseAction(reportAction)) { + return Localize.translateLocal('iou.threadTrackReportName', {formattedAmount, comment}); + } + return Localize.translateLocal('iou.threadExpenseReportName', {formattedAmount, comment}); +} + +/** + * Get expense message for an IOU report + * + * @param [iouReportAction] This is always an IOU action. When necessary, report preview actions will be unwrapped and the child iou report action is passed here (the original report preview + * action will be passed as `originalReportAction` in this case). + * @param [originalReportAction] This can be either a report preview action or the IOU action. This will be the original report preview action in cases where `iouReportAction` was unwrapped + * from a report preview action. Otherwise, it will be the same as `iouReportAction`. + */ +function getReportPreviewMessage( + reportOrID: OnyxInputOrEntry | string, + iouReportAction: OnyxInputOrEntry = null, + shouldConsiderScanningReceiptOrPendingRoute = false, + isPreviewMessageForParentChatReport = false, + policy?: OnyxInputOrEntry, + isForListPreview = false, + originalReportAction: OnyxInputOrEntry = iouReportAction, +): string { + const report = typeof reportOrID === 'string' ? ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportOrID}`] : reportOrID; + const reportActionMessage = ReportActionsUtils.getReportActionHtml(iouReportAction); + + if (isEmptyObject(report) || !report?.reportID) { + // The iouReport is not found locally after SignIn because the OpenApp API won't return iouReports if they're settled + // As a temporary solution until we know how to solve this the best, we just use the message that returned from BE + return reportActionMessage; + } + + if (!isEmptyObject(iouReportAction) && !isIOUReport(report) && iouReportAction && ReportActionsUtils.isSplitBillAction(iouReportAction)) { + // This covers group chats where the last action is a split expense action + const linkedTransaction = getLinkedTransaction(iouReportAction); + if (isEmptyObject(linkedTransaction)) { + return reportActionMessage; + } + + if (!isEmptyObject(linkedTransaction)) { + if (TransactionUtils.isReceiptBeingScanned(linkedTransaction)) { + return Localize.translateLocal('iou.receiptScanning'); + } + + if (TransactionUtils.hasMissingSmartscanFields(linkedTransaction)) { + return Localize.translateLocal('iou.receiptMissingDetails'); + } + + const amount = TransactionUtils.getAmount(linkedTransaction, !isEmptyObject(report) && isExpenseReport(report)) ?? 0; + const formattedAmount = CurrencyUtils.convertToDisplayString(amount, TransactionUtils.getCurrency(linkedTransaction)) ?? ''; + return Localize.translateLocal('iou.didSplitAmount', {formattedAmount, comment: TransactionUtils.getDescription(linkedTransaction) ?? ''}); + } + } + + if (!isEmptyObject(iouReportAction) && !isIOUReport(report) && iouReportAction && ReportActionsUtils.isTrackExpenseAction(iouReportAction)) { + // This covers group chats where the last action is a track expense action + const linkedTransaction = getLinkedTransaction(iouReportAction); + if (isEmptyObject(linkedTransaction)) { + return reportActionMessage; + } + + if (!isEmptyObject(linkedTransaction)) { + if (TransactionUtils.isReceiptBeingScanned(linkedTransaction)) { + return Localize.translateLocal('iou.receiptScanning'); + } + + if (TransactionUtils.hasMissingSmartscanFields(linkedTransaction)) { + return Localize.translateLocal('iou.receiptMissingDetails'); + } + + const amount = TransactionUtils.getAmount(linkedTransaction, !isEmptyObject(report) && isExpenseReport(report)) ?? 0; + const formattedAmount = CurrencyUtils.convertToDisplayString(amount, TransactionUtils.getCurrency(linkedTransaction)) ?? ''; + return Localize.translateLocal('iou.trackedAmount', {formattedAmount, comment: TransactionUtils.getDescription(linkedTransaction) ?? ''}); + } + } + + const containsNonReimbursable = hasNonReimbursableTransactions(report.reportID); + const totalAmount = getMoneyRequestSpendBreakdown(report).totalDisplaySpend; + + const policyName = getPolicyName(report, false, policy); + const payerName = isExpenseReport(report) ? policyName : getDisplayNameForParticipant(report.managerID, !isPreviewMessageForParentChatReport); + + const formattedAmount = CurrencyUtils.convertToDisplayString(totalAmount, report.currency); + + if (isReportApproved(report) && isPaidGroupPolicy(report)) { + return Localize.translateLocal('iou.managerApprovedAmount', { + manager: payerName ?? '', + amount: formattedAmount, + }); + } + + let linkedTransaction; + if (!isEmptyObject(iouReportAction) && shouldConsiderScanningReceiptOrPendingRoute && iouReportAction && ReportActionsUtils.isMoneyRequestAction(iouReportAction)) { + linkedTransaction = getLinkedTransaction(iouReportAction); + } + + if (!isEmptyObject(linkedTransaction) && TransactionUtils.hasReceipt(linkedTransaction) && TransactionUtils.isReceiptBeingScanned(linkedTransaction)) { + return Localize.translateLocal('iou.receiptScanning'); + } + + if (!isEmptyObject(linkedTransaction) && TransactionUtils.isFetchingWaypointsFromServer(linkedTransaction) && !TransactionUtils.getAmount(linkedTransaction)) { + return Localize.translateLocal('iou.fieldPending'); + } + + const originalMessage = !isEmptyObject(iouReportAction) && ReportActionsUtils.isMoneyRequestAction(iouReportAction) ? ReportActionsUtils.getOriginalMessage(iouReportAction) : undefined; + + // Show Paid preview message if it's settled or if the amount is paid & stuck at receivers end for only chat reports. + if (isSettled(report.reportID) || (report.isWaitingOnBankAccount && isPreviewMessageForParentChatReport)) { + // A settled report preview message can come in three formats "paid ... elsewhere" or "paid ... with Expensify" + let translatePhraseKey: TranslationPaths = 'iou.paidElsewhereWithAmount'; + if (isPreviewMessageForParentChatReport) { + translatePhraseKey = 'iou.payerPaidAmount'; + } else if ( + [CONST.IOU.PAYMENT_TYPE.VBBA, CONST.IOU.PAYMENT_TYPE.EXPENSIFY].some((paymentType) => paymentType === originalMessage?.paymentType) || + !!reportActionMessage.match(/ (with Expensify|using Expensify)$/) || + report.isWaitingOnBankAccount + ) { + translatePhraseKey = 'iou.paidWithExpensifyWithAmount'; + } + + let actualPayerName = report.managerID === currentUserAccountID ? '' : getDisplayNameForParticipant(report.managerID, true); + actualPayerName = actualPayerName && isForListPreview && !isPreviewMessageForParentChatReport ? `${actualPayerName}:` : actualPayerName; + const payerDisplayName = isPreviewMessageForParentChatReport ? payerName : actualPayerName; + + return Localize.translateLocal(translatePhraseKey, {amount: formattedAmount, payer: payerDisplayName ?? ''}); + } + + if (report.isWaitingOnBankAccount) { + const submitterDisplayName = getDisplayNameForParticipant(report.ownerAccountID ?? -1, true) ?? ''; + return Localize.translateLocal('iou.waitingOnBankAccount', {submitterDisplayName}); + } + + const lastActorID = iouReportAction?.actorAccountID; + let amount = originalMessage?.amount; + let currency = originalMessage?.currency ? originalMessage?.currency : report.currency; + + if (!isEmptyObject(linkedTransaction)) { + amount = TransactionUtils.getAmount(linkedTransaction, isExpenseReport(report)); + currency = TransactionUtils.getCurrency(linkedTransaction); + } + + if (isEmptyObject(linkedTransaction) && !isEmptyObject(iouReportAction)) { + linkedTransaction = getLinkedTransaction(iouReportAction); + } + + let comment = !isEmptyObject(linkedTransaction) ? TransactionUtils.getDescription(linkedTransaction) : undefined; + if (!isEmptyObject(originalReportAction) && ReportActionsUtils.isReportPreviewAction(originalReportAction) && ReportActionsUtils.getNumberOfMoneyRequests(originalReportAction) !== 1) { + comment = undefined; + } + + // if we have the amount in the originalMessage and lastActorID, we can use that to display the preview message for the latest expense + if (amount !== undefined && lastActorID && !isPreviewMessageForParentChatReport) { + const amountToDisplay = CurrencyUtils.convertToDisplayString(Math.abs(amount), currency); + + // We only want to show the actor name in the preview if it's not the current user who took the action + const requestorName = lastActorID && lastActorID !== currentUserAccountID ? getDisplayNameForParticipant(lastActorID, !isPreviewMessageForParentChatReport) : ''; + return `${requestorName ? `${requestorName}: ` : ''}${Localize.translateLocal('iou.submittedAmount', {formattedAmount: amountToDisplay, comment})}`; + } + + if (containsNonReimbursable) { + return Localize.translateLocal('iou.payerSpentAmount', {payer: getDisplayNameForParticipant(report.ownerAccountID) ?? '', amount: formattedAmount}); + } + + return Localize.translateLocal('iou.payerOwesAmount', {payer: payerName ?? '', amount: formattedAmount, comment}); +} + +/** + * Given the updates user made to the expense, compose the originalMessage + * object of the modified expense action. + * + * At the moment, we only allow changing one transaction field at a time. + */ +function getModifiedExpenseOriginalMessage( + oldTransaction: OnyxInputOrEntry, + transactionChanges: TransactionChanges, + isFromExpenseReport: boolean, + policy: OnyxInputOrEntry, + updatedTransaction?: OnyxInputOrEntry, +): OriginalMessageModifiedExpense { + const originalMessage: OriginalMessageModifiedExpense = {}; + // Remark: Comment field is the only one which has new/old prefixes for the keys (newComment/ oldComment), + // all others have old/- pattern such as oldCreated/created + if ('comment' in transactionChanges) { + originalMessage.oldComment = TransactionUtils.getDescription(oldTransaction); + originalMessage.newComment = transactionChanges?.comment; + } + if ('created' in transactionChanges) { + originalMessage.oldCreated = TransactionUtils.getFormattedCreated(oldTransaction); + originalMessage.created = transactionChanges?.created; + } + if ('merchant' in transactionChanges) { + originalMessage.oldMerchant = TransactionUtils.getMerchant(oldTransaction); + originalMessage.merchant = transactionChanges?.merchant; + } + + // The amount is always a combination of the currency and the number value so when one changes we need to store both + // to match how we handle the modified expense action in oldDot + const didAmountOrCurrencyChange = 'amount' in transactionChanges || 'currency' in transactionChanges; + if (didAmountOrCurrencyChange) { + originalMessage.oldAmount = TransactionUtils.getAmount(oldTransaction, isFromExpenseReport); + originalMessage.amount = transactionChanges?.amount ?? transactionChanges.oldAmount; + originalMessage.oldCurrency = TransactionUtils.getCurrency(oldTransaction); + originalMessage.currency = transactionChanges?.currency ?? transactionChanges.oldCurrency; + } + + if ('category' in transactionChanges) { + originalMessage.oldCategory = TransactionUtils.getCategory(oldTransaction); + originalMessage.category = transactionChanges?.category; + } + + if ('tag' in transactionChanges) { + originalMessage.oldTag = TransactionUtils.getTag(oldTransaction); + originalMessage.tag = transactionChanges?.tag; + } + + // We only want to display a tax rate update system message when tax rate is updated by user. + // Tax rate can change as a result of currency update. In such cases, we want to skip displaying a system message, as discussed. + const didTaxCodeChange = 'taxCode' in transactionChanges; + if (didTaxCodeChange && !didAmountOrCurrencyChange) { + originalMessage.oldTaxRate = policy?.taxRates?.taxes[TransactionUtils.getTaxCode(oldTransaction)]?.value; + originalMessage.taxRate = transactionChanges?.taxCode && policy?.taxRates?.taxes[transactionChanges?.taxCode].value; + } + + // We only want to display a tax amount update system message when tax amount is updated by user. + // Tax amount can change as a result of amount, currency or tax rate update. In such cases, we want to skip displaying a system message, as discussed. + if ('taxAmount' in transactionChanges && !(didAmountOrCurrencyChange || didTaxCodeChange)) { + originalMessage.oldTaxAmount = TransactionUtils.getTaxAmount(oldTransaction, isFromExpenseReport); + originalMessage.taxAmount = transactionChanges?.taxAmount; + originalMessage.currency = TransactionUtils.getCurrency(oldTransaction); + } + + if ('billable' in transactionChanges) { + const oldBillable = TransactionUtils.getBillable(oldTransaction); + originalMessage.oldBillable = oldBillable ? Localize.translateLocal('common.billable').toLowerCase() : Localize.translateLocal('common.nonBillable').toLowerCase(); + originalMessage.billable = transactionChanges?.billable ? Localize.translateLocal('common.billable').toLowerCase() : Localize.translateLocal('common.nonBillable').toLowerCase(); + } + + if ('customUnitRateID' in transactionChanges && updatedTransaction?.comment?.customUnit?.customUnitRateID) { + originalMessage.oldAmount = TransactionUtils.getAmount(oldTransaction, isFromExpenseReport); + originalMessage.oldCurrency = TransactionUtils.getCurrency(oldTransaction); + originalMessage.oldMerchant = TransactionUtils.getMerchant(oldTransaction); + + const modifiedDistanceFields = TransactionUtils.calculateAmountForUpdatedWaypointOrRate(updatedTransaction, transactionChanges, policy, isFromExpenseReport); + + // For the originalMessage, we should use the non-negative amount, similar to what TransactionUtils.getAmount does for oldAmount + originalMessage.amount = Math.abs(modifiedDistanceFields.modifiedAmount); + originalMessage.currency = modifiedDistanceFields.modifiedCurrency ?? CONST.CURRENCY.USD; + originalMessage.merchant = modifiedDistanceFields.modifiedMerchant; + } + + return originalMessage; +} + +/** + * Check if original message is an object and can be used as a ChangeLog type + * @param originalMessage + */ +function isChangeLogObject(originalMessage?: OriginalMessageChangeLog): OriginalMessageChangeLog | undefined { + if (originalMessage && typeof originalMessage === 'object') { + return originalMessage; + } + return undefined; +} + +/** + * Build invited usernames for admin chat threads + * @param parentReportAction + * @param parentReportActionMessage + */ +function getAdminRoomInvitedParticipants(parentReportAction: OnyxEntry, parentReportActionMessage: string) { + if (isEmptyObject(parentReportAction)) { + return parentReportActionMessage || Localize.translateLocal('parentReportAction.deletedMessage'); + } + if (!ReportActionsUtils.getOriginalMessage(parentReportAction)) { + return parentReportActionMessage || Localize.translateLocal('parentReportAction.deletedMessage'); + } + if (!ReportActionsUtils.isPolicyChangeLogAction(parentReportAction) || !ReportActionsUtils.isRoomChangeLogAction(parentReportAction)) { + return parentReportActionMessage || Localize.translateLocal('parentReportAction.deletedMessage'); + } + + const originalMessage = isChangeLogObject(ReportActionsUtils.getOriginalMessage(parentReportAction)); + const participantAccountIDs = originalMessage?.targetAccountIDs ?? []; + + const participants = participantAccountIDs.map((id: number) => { + const name = getDisplayNameForParticipant(id); + if (name && name?.length > 0) { + return name; + } + return Localize.translateLocal('common.hidden'); + }); + const users = participants.length > 1 ? participants.join(` ${Localize.translateLocal('common.and')} `) : participants[0]; + if (!users) { + return parentReportActionMessage; + } + const actionType = parentReportAction.actionName; + const isInviteAction = actionType === CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.INVITE_TO_ROOM || actionType === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.INVITE_TO_ROOM; + + const verbKey = isInviteAction ? 'workspace.invite.invited' : 'workspace.invite.removed'; + const prepositionKey = isInviteAction ? 'workspace.invite.to' : 'workspace.invite.from'; + + const verb = Localize.translateLocal(verbKey); + const preposition = Localize.translateLocal(prepositionKey); + + const roomName = originalMessage?.roomName ?? ''; + + return roomName ? `${verb} ${users} ${preposition} ${roomName}` : `${verb} ${users}`; +} + +/** + * Get the invoice payer name based on its type: + * - Individual - a receiver display name. + * - Policy - a receiver policy name. + */ +function getInvoicePayerName(report: OnyxEntry, invoiceReceiverPolicy?: OnyxEntry): string { + const invoiceReceiver = report?.invoiceReceiver; + const isIndividual = invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL; + + if (isIndividual) { + return PersonalDetailsUtils.getDisplayNameOrDefault(allPersonalDetails?.[invoiceReceiver.accountID]); + } + + return getPolicyName(report, false, invoiceReceiverPolicy ?? allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${invoiceReceiver?.policyID}`]); +} + +/** + * Parse html of reportAction into text + */ +function parseReportActionHtmlToText(reportAction: OnyxEntry, reportID: string, childReportID?: string): string { + if (!reportAction) { + return ''; + } + const key = `${reportID}_${reportAction.reportActionID}_${reportAction.lastModified}`; + const cachedText = parsedReportActionMessageCache[key]; + if (cachedText !== undefined) { + return cachedText; + } + + const {html, text} = ReportActionsUtils.getReportActionMessage(reportAction) ?? {}; + + if (!html) { + return text ?? ''; + } + + const mentionReportRegex = //gi; + const matches = html.matchAll(mentionReportRegex); + + const reportIDToName: Record = {}; + for (const match of matches) { + if (match[1] !== childReportID) { + // eslint-disable-next-line @typescript-eslint/no-use-before-define + reportIDToName[match[1]] = getReportName(getReportOrDraftReport(match[1])) ?? ''; + } + } + + const mentionUserRegex = //gi; + const accountIDToName: Record = {}; + const accountIDs = Array.from(html.matchAll(mentionUserRegex), (mention) => Number(mention[1])); + const logins = PersonalDetailsUtils.getLoginsByAccountIDs(accountIDs); + accountIDs.forEach((id, index) => (accountIDToName[id] = logins[index])); + + const textMessage = Str.removeSMSDomain(Parser.htmlToText(html, {reportIDToName, accountIDToName})); + parsedReportActionMessageCache[key] = textMessage; + + return textMessage; +} + +/** + * Get the report action message for a report action. + */ +function getReportActionMessage(reportAction: OnyxEntry, reportID?: string, childReportID?: string) { + if (isEmptyObject(reportAction)) { + return ''; + } + if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.HOLD) { + return Localize.translateLocal('iou.heldExpense'); + } + + if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_INTEGRATION) { + return ReportActionsUtils.getExportIntegrationLastMessageText(reportAction); + } + + if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.UNHOLD) { + return Localize.translateLocal('iou.unheldExpense'); + } + if (ReportActionsUtils.isApprovedOrSubmittedReportAction(reportAction)) { + return ReportActionsUtils.getReportActionMessageText(reportAction); + } + if (ReportActionsUtils.isReimbursementQueuedAction(reportAction)) { + return getReimbursementQueuedActionMessage(reportAction, getReportOrDraftReport(reportID), false); + } + + return parseReportActionHtmlToText(reportAction, reportID ?? '', childReportID); +} + +/** + * Get the title for an invoice room. + */ +function getInvoicesChatName(report: OnyxEntry, receiverPolicy: OnyxEntry): string { + const invoiceReceiver = report?.invoiceReceiver; + const isIndividual = invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL; + const invoiceReceiverAccountID = isIndividual ? invoiceReceiver.accountID : -1; + const invoiceReceiverPolicyID = isIndividual ? '' : invoiceReceiver?.policyID ?? '-1'; + const invoiceReceiverPolicy = receiverPolicy ?? getPolicy(invoiceReceiverPolicyID); + const isCurrentUserReceiver = (isIndividual && invoiceReceiverAccountID === currentUserAccountID) || (!isIndividual && PolicyUtils.isPolicyAdmin(invoiceReceiverPolicy)); + + if (isCurrentUserReceiver) { + return getPolicyName(report); + } + + if (isIndividual) { + return PersonalDetailsUtils.getDisplayNameOrDefault(allPersonalDetails?.[invoiceReceiverAccountID]); + } + + return getPolicyName(report, false, invoiceReceiverPolicy); +} + +const reportNameCache = new Map(); + +/** + * Get a cache key for the report name. + */ +const getCacheKey = (report: OnyxEntry): string => `${report?.reportID}-${report?.lastVisibleActionCreated}-${report?.reportName}`; + +/** + * Get the title for a report. + */ +function getReportName( + report: OnyxEntry, + policy?: OnyxEntry, + parentReportActionParam?: OnyxInputOrEntry, + personalDetails?: Partial, + invoiceReceiverPolicy?: OnyxEntry, +): string { + const reportID = report?.reportID; + const cacheKey = getCacheKey(report); + + if (reportID) { + const reportNameFromCache = reportNameCache.get(cacheKey); + + if (reportNameFromCache?.reportName && reportNameFromCache.reportName === report?.reportName) { + return reportNameFromCache.reportName; + } + } + + let formattedName: string | undefined; + let parentReportAction: OnyxEntry; + if (parentReportActionParam) { + parentReportAction = parentReportActionParam; + } else { + parentReportAction = isThread(report) ? allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`]?.[report.parentReportActionID] : undefined; + } + const parentReportActionMessage = ReportActionsUtils.getReportActionMessage(parentReportAction); + + if (parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.SUBMITTED) { + return getIOUSubmittedMessage(parentReportAction); + } + if (parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.FORWARDED) { + return getIOUForwardedMessage(parentReportAction, report); + } + if (parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REJECTED) { + return getRejectedReportMessage(); + } + if (parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.APPROVED) { + return getIOUApprovedMessage(parentReportAction); + } + + if (isChatThread(report)) { + if (!isEmptyObject(parentReportAction) && ReportActionsUtils.isTransactionThread(parentReportAction)) { + formattedName = getTransactionReportName(parentReportAction); + if (isArchivedRoom(report, getReportNameValuePairs(report?.reportID))) { + formattedName += ` (${Localize.translateLocal('common.archived')})`; + } + return formatReportLastMessageText(formattedName); + } + + if (!isEmptyObject(parentReportAction) && ReportActionsUtils.isOldDotReportAction(parentReportAction)) { + return ReportActionsUtils.getMessageOfOldDotReportAction(parentReportAction); + } + + if (parentReportActionMessage?.isDeletedParentAction) { + return Localize.translateLocal('parentReportAction.deletedMessage'); + } + + const isAttachment = ReportActionsUtils.isReportActionAttachment(!isEmptyObject(parentReportAction) ? parentReportAction : undefined); + const reportActionMessage = getReportActionMessage(parentReportAction, report?.parentReportID, report?.reportID ?? '').replace(/(\n+|\r\n|\n|\r)/gm, ' '); + if (isAttachment && reportActionMessage) { + return `[${Localize.translateLocal('common.attachment')}]`; + } + if ( + parentReportActionMessage?.moderationDecision?.decision === CONST.MODERATION.MODERATOR_DECISION_PENDING_HIDE || + parentReportActionMessage?.moderationDecision?.decision === CONST.MODERATION.MODERATOR_DECISION_HIDDEN || + parentReportActionMessage?.moderationDecision?.decision === CONST.MODERATION.MODERATOR_DECISION_PENDING_REMOVE + ) { + return Localize.translateLocal('parentReportAction.hiddenMessage'); + } + if (isAdminRoom(report) || isUserCreatedPolicyRoom(report)) { + return getAdminRoomInvitedParticipants(parentReportAction, reportActionMessage); + } + if (reportActionMessage && isArchivedRoom(report, getReportNameValuePairs(report?.reportID))) { + return `${reportActionMessage} (${Localize.translateLocal('common.archived')})`; + } + if (!isEmptyObject(parentReportAction) && ReportActionsUtils.isModifiedExpenseAction(parentReportAction)) { + return ModifiedExpenseMessage.getForReportAction(report?.reportID, parentReportAction); + } + + if (isTripRoom(report)) { + return report?.reportName ?? ''; + } + + if (ReportActionsUtils.isCardIssuedAction(parentReportAction)) { + return ReportActionsUtils.getCardIssuedMessage(parentReportAction); + } + return reportActionMessage; + } + + if (isClosedExpenseReportWithNoExpenses(report)) { + return Localize.translateLocal('parentReportAction.deletedReport'); + } + + if (isTaskReport(report) && isCanceledTaskReport(report, parentReportAction)) { + return Localize.translateLocal('parentReportAction.deletedTask'); + } + + if (isGroupChat(report)) { + return getGroupChatName(undefined, true, report) ?? ''; + } + + if (isChatRoom(report) || isTaskReport(report)) { + formattedName = report?.reportName; + } + + if (isPolicyExpenseChat(report)) { + formattedName = getPolicyExpenseChatName(report, policy); + } + + if (isMoneyRequestReport(report)) { + formattedName = getMoneyRequestReportName(report, policy); + } + + if (isInvoiceReport(report)) { + formattedName = getMoneyRequestReportName(report, policy, invoiceReceiverPolicy); + } + + if (isInvoiceRoom(report)) { + formattedName = getInvoicesChatName(report, invoiceReceiverPolicy); + } + + if (isArchivedRoom(report, getReportNameValuePairs(report?.reportID))) { + formattedName += ` (${Localize.translateLocal('common.archived')})`; + } + + if (isSelfDM(report)) { + formattedName = getDisplayNameForParticipant(currentUserAccountID, undefined, undefined, true, personalDetails); + } + + if (formattedName) { + if (reportID) { + reportNameCache.set(cacheKey, {lastVisibleActionCreated: report?.lastVisibleActionCreated ?? '', reportName: formattedName}); + } + + return formatReportLastMessageText(formattedName); + } + + // Not a room or PolicyExpenseChat, generate title from first 5 other participants + const participantsWithoutCurrentUser: number[] = []; + Object.keys(report?.participants ?? {}).forEach((accountID) => { + const accID = Number(accountID); + if (accID !== currentUserAccountID && participantsWithoutCurrentUser.length < 5) { + participantsWithoutCurrentUser.push(accID); + } + }); + const isMultipleParticipantReport = participantsWithoutCurrentUser.length > 1; + const participantNames = participantsWithoutCurrentUser.map((accountID) => getDisplayNameForParticipant(accountID, isMultipleParticipantReport, true, false, personalDetails)).join(', '); + formattedName = participantNames; + + if (reportID) { + reportNameCache.set(cacheKey, {lastVisibleActionCreated: report?.lastVisibleActionCreated ?? '', reportName: formattedName}); + } + + return formattedName; +} + +/** + * Get the payee name given a report. + */ +function getPayeeName(report: OnyxEntry): string | undefined { + if (isEmptyObject(report)) { + return undefined; + } + + const participantsWithoutCurrentUser = Object.keys(report?.participants ?? {}) + .map(Number) + .filter((accountID) => accountID !== currentUserAccountID); + + if (participantsWithoutCurrentUser.length === 0) { + return undefined; + } + return getDisplayNameForParticipant(participantsWithoutCurrentUser[0], true); +} + +/** + * Get either the policyName or domainName the chat is tied to + */ +function getChatRoomSubtitle(report: OnyxEntry): string | undefined { + if (isChatThread(report)) { + return ''; + } + if (isSelfDM(report)) { + return Localize.translateLocal('reportActionsView.yourSpace'); + } + if (isInvoiceRoom(report)) { + return Localize.translateLocal('workspace.common.invoices'); + } + if (!isDefaultRoom(report) && !isUserCreatedPolicyRoom(report) && !isPolicyExpenseChat(report)) { + return ''; + } + if (getChatType(report) === CONST.REPORT.CHAT_TYPE.DOMAIN_ALL) { + // The domainAll rooms are just #domainName, so we ignore the prefix '#' to get the domainName + return report?.reportName?.substring(1) ?? ''; + } + if ((isPolicyExpenseChat(report) && !!report?.isOwnPolicyExpenseChat) || isExpenseReport(report)) { + return Localize.translateLocal('workspace.common.workspace'); + } + if (isArchivedRoom(report, getReportNameValuePairs(report?.reportID))) { + return report?.oldPolicyName ?? ''; + } + return getPolicyName(report); +} + +/** + * Get pending members for reports + */ +function getPendingChatMembers(accountIDs: number[], previousPendingChatMembers: PendingChatMember[], pendingAction: PendingAction): PendingChatMember[] { + const pendingChatMembers = accountIDs.map((accountID) => ({accountID: accountID.toString(), pendingAction})); + return [...previousPendingChatMembers, ...pendingChatMembers]; +} + +/** + * Gets the parent navigation subtitle for the report + */ +function getParentNavigationSubtitle(report: OnyxEntry, invoiceReceiverPolicy?: OnyxEntry): ParentNavigationSummaryParams { + const parentReport = getParentReport(report); + if (isEmptyObject(parentReport)) { + return {}; + } + + if (isInvoiceReport(report) || isInvoiceRoom(parentReport)) { + let reportName = `${getPolicyName(parentReport)} & ${getInvoicePayerName(parentReport, invoiceReceiverPolicy)}`; + + if (isArchivedRoom(parentReport, getReportNameValuePairs(parentReport?.reportID))) { + reportName += ` (${Localize.translateLocal('common.archived')})`; + } + + return { + reportName, + }; + } + + return { + reportName: getReportName(parentReport), + workspaceName: getPolicyName(parentReport, true), + }; +} + +/** + * Navigate to the details page of a given report + */ +function navigateToDetailsPage(report: OnyxEntry) { + const isSelfDMReport = isSelfDM(report); + const isOneOnOneChatReport = isOneOnOneChat(report); + const participantAccountID = getParticipantsAccountIDsForDisplay(report); + + if (isSelfDMReport || isOneOnOneChatReport) { + Navigation.navigate(ROUTES.PROFILE.getRoute(participantAccountID[0])); + return; + } + + if (report?.reportID) { + Navigation.navigate(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report?.reportID)); + } +} + +/** + * Go back to the details page of a given report + */ +function goBackToDetailsPage(report: OnyxEntry) { + const isOneOnOneChatReport = isOneOnOneChat(report); + const participantAccountID = getParticipantsAccountIDsForDisplay(report); + + if (isOneOnOneChatReport) { + Navigation.navigate(ROUTES.PROFILE.getRoute(participantAccountID[0])); + return; + } + + Navigation.goBack(ROUTES.REPORT_SETTINGS.getRoute(report?.reportID ?? '-1')); +} + +function navigateBackAfterDeleteTransaction(backRoute: Route | undefined, isFromRHP?: boolean) { + if (!backRoute) { + return; + } + const topmostCentralPaneRoute = Navigation.getTopMostCentralPaneRouteFromRootState(); + if (topmostCentralPaneRoute?.name === SCREENS.SEARCH.CENTRAL_PANE) { + Navigation.dismissModal(); + return; + } + if (isFromRHP) { + Navigation.dismissModal(); + } + Navigation.isNavigationReady().then(() => { + Navigation.goBack(backRoute); + }); +} + +/** + * Go back to the previous page from the edit private page of a given report + */ +function goBackFromPrivateNotes(report: OnyxEntry, session: OnyxEntry) { + if (isEmpty(report) || isEmpty(session) || !session.accountID) { + return; + } + const currentUserPrivateNote = report.privateNotes?.[session.accountID]?.note ?? ''; + if (isEmpty(currentUserPrivateNote)) { + const participantAccountIDs = getParticipantsAccountIDsForDisplay(report); + + if (isOneOnOneChat(report)) { + Navigation.goBack(ROUTES.PROFILE.getRoute(participantAccountIDs[0])); + return; + } + + if (report?.reportID) { + Navigation.goBack(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report?.reportID)); + return; + } + } + Navigation.goBack(ROUTES.PRIVATE_NOTES_LIST.getRoute(report.reportID)); +} + +/** + * Generate a random reportID up to 53 bits aka 9,007,199,254,740,991 (Number.MAX_SAFE_INTEGER). + * There were approximately 98,000,000 reports with sequential IDs generated before we started using this approach, those make up roughly one billionth of the space for these numbers, + * so we live with the 1 in a billion chance of a collision with an older ID until we can switch to 64-bit IDs. + * + * In a test of 500M reports (28 years of reports at our current max rate) we got 20-40 collisions meaning that + * this is more than random enough for our needs. + */ +function generateReportID(): string { + return (Math.floor(Math.random() * 2 ** 21) * 2 ** 32 + Math.floor(Math.random() * 2 ** 32)).toString(); +} + +function hasReportNameError(report: OnyxEntry): boolean { + return !isEmptyObject(report?.errorFields?.reportName); +} + +/** + * Adds a domain to a short mention, converting it into a full mention with email or SMS domain. + * @param mention The user mention to be converted. + * @returns The converted mention as a full mention string or undefined if conversion is not applicable. + */ +function addDomainToShortMention(mention: string): string | undefined { + if (!Str.isValidEmail(mention) && currentUserPrivateDomain) { + const mentionWithEmailDomain = `${mention}@${currentUserPrivateDomain}`; + if (allPersonalDetailLogins.includes(mentionWithEmailDomain)) { + return mentionWithEmailDomain; + } + } + if (Str.isValidE164Phone(mention)) { + const mentionWithSmsDomain = PhoneNumber.addSMSDomainIfPhoneNumber(mention); + if (allPersonalDetailLogins.includes(mentionWithSmsDomain)) { + return mentionWithSmsDomain; + } + } + return undefined; +} + +/** + * Replaces all valid short mention found in a text to a full mention + * + * Example: + * "Hello \@example -> Hello \@example\@expensify.com" + */ +function completeShortMention(text: string): string { + return text.replace(CONST.REGEX.SHORT_MENTION, (match) => { + if (!Str.isValidMention(match)) { + return match; + } + const mention = match.substring(1); + const mentionWithDomain = addDomainToShortMention(mention); + return mentionWithDomain ? `@${mentionWithDomain}` : match; + }); +} + +/** + * For comments shorter than or equal to 10k chars, convert the comment from MD into HTML because that's how it is stored in the database + * For longer comments, skip parsing, but still escape the text, and display plaintext for performance reasons. It takes over 40s to parse a 100k long string!! + */ +function getParsedComment(text: string, parsingDetails?: ParsingDetails): string { + let isGroupPolicyReport = false; + if (parsingDetails?.reportID) { + const currentReport = getReportOrDraftReport(parsingDetails?.reportID); + isGroupPolicyReport = isReportInGroupPolicy(currentReport); + } + + if (parsingDetails?.policyID) { + const policyType = getPolicy(parsingDetails?.policyID)?.type; + if (policyType) { + isGroupPolicyReport = isGroupPolicy(policyType); + } + } + + const textWithMention = completeShortMention(text); + + return text.length <= CONST.MAX_MARKUP_LENGTH + ? Parser.replace(textWithMention, {shouldEscapeText: parsingDetails?.shouldEscapeText, disabledRules: isGroupPolicyReport ? [] : ['reportMentions']}) + : lodashEscape(text); +} + +function getUploadingAttachmentHtml(file?: FileObject): string { + if (!file || typeof file.uri !== 'string') { + return ''; + } + + const dataAttributes = [ + `${CONST.ATTACHMENT_OPTIMISTIC_SOURCE_ATTRIBUTE}="${file.uri}"`, + `${CONST.ATTACHMENT_SOURCE_ATTRIBUTE}="${file.uri}"`, + `${CONST.ATTACHMENT_ORIGINAL_FILENAME_ATTRIBUTE}="${file.name}"`, + 'width' in file && `${CONST.ATTACHMENT_THUMBNAIL_WIDTH_ATTRIBUTE}="${file.width}"`, + 'height' in file && `${CONST.ATTACHMENT_THUMBNAIL_HEIGHT_ATTRIBUTE}="${file.height}"`, + ] + .filter((x) => !!x) + .join(' '); + + // file.type is a known mime type like image/png, image/jpeg, video/mp4 etc. + if (file.type?.startsWith('image')) { + return `${file.name}`; + } + if (file.type?.startsWith('video')) { + return ``; + } + + // For all other types, we present a generic download link + return `${file.name}`; +} + +function getReportDescriptionText(report: Report): string { + if (!report.description) { + return ''; + } + + return Parser.htmlToText(report.description); +} + +function getPolicyDescriptionText(policy: OnyxEntry): string { + if (!policy?.description) { + return ''; + } + + return Parser.htmlToText(policy.description); +} + +function buildOptimisticAddCommentReportAction( + text?: string, + file?: FileObject, + actorAccountID?: number, + createdOffset = 0, + shouldEscapeText?: boolean, + reportID?: string, +): OptimisticReportAction { + const commentText = getParsedComment(text ?? '', {shouldEscapeText, reportID}); + const attachmentHtml = getUploadingAttachmentHtml(file); + + const htmlForNewComment = `${commentText}${commentText && attachmentHtml ? '

' : ''}${attachmentHtml}`; + const textForNewComment = Parser.htmlToText(htmlForNewComment); + + const isAttachmentOnly = file && !text; + const isAttachmentWithText = !!text && file !== undefined; + const accountID = actorAccountID ?? currentUserAccountID ?? -1; + + // Remove HTML from text when applying optimistic offline comment + return { + commentText, + reportAction: { + reportActionID: NumberUtils.rand64(), + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + actorAccountID: accountID, + person: [ + { + style: 'strong', + text: allPersonalDetails?.[accountID]?.displayName ?? currentUserEmail, + type: 'TEXT', + }, + ], + automatic: false, + avatar: allPersonalDetails?.[accountID]?.avatar, + created: DateUtils.getDBTimeWithSkew(Date.now() + createdOffset), + message: [ + { + translationKey: isAttachmentOnly ? CONST.TRANSLATION_KEYS.ATTACHMENT : '', + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + html: htmlForNewComment, + text: textForNewComment, + }, + ], + originalMessage: { + html: htmlForNewComment, + whisperedTo: [], + }, + isFirstItem: false, + isAttachmentOnly, + isAttachmentWithText, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + shouldShow: true, + isOptimisticAction: true, + }, + }; +} + +/** + * update optimistic parent reportAction when a comment is added or remove in the child report + * @param parentReportAction - Parent report action of the child report + * @param lastVisibleActionCreated - Last visible action created of the child report + * @param type - The type of action in the child report + */ + +function updateOptimisticParentReportAction(parentReportAction: OnyxEntry, lastVisibleActionCreated: string, type: string): UpdateOptimisticParentReportAction { + let childVisibleActionCount = parentReportAction?.childVisibleActionCount ?? 0; + let childCommenterCount = parentReportAction?.childCommenterCount ?? 0; + let childOldestFourAccountIDs = parentReportAction?.childOldestFourAccountIDs; + + if (type === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) { + childVisibleActionCount += 1; + const oldestFourAccountIDs = childOldestFourAccountIDs ? childOldestFourAccountIDs.split(',') : []; + if (oldestFourAccountIDs.length < 4) { + const index = oldestFourAccountIDs.findIndex((accountID) => accountID === currentUserAccountID?.toString()); + if (index === -1) { + childCommenterCount += 1; + oldestFourAccountIDs.push(currentUserAccountID?.toString() ?? ''); + } + } + childOldestFourAccountIDs = oldestFourAccountIDs.join(','); + } else if (type === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + if (childVisibleActionCount > 0) { + childVisibleActionCount -= 1; + } + + if (childVisibleActionCount === 0) { + childCommenterCount = 0; + childOldestFourAccountIDs = ''; + } + } + + return { + childVisibleActionCount, + childCommenterCount, + childLastVisibleActionCreated: lastVisibleActionCreated, + childOldestFourAccountIDs, + }; +} + +/** + * Builds an optimistic reportAction for the parent report when a task is created + * @param taskReportID - Report ID of the task + * @param taskTitle - Title of the task + * @param taskAssigneeAccountID - AccountID of the person assigned to the task + * @param text - Text of the comment + * @param parentReportID - Report ID of the parent report + * @param createdOffset - The offset for task's created time that created via a loop + */ +function buildOptimisticTaskCommentReportAction( + taskReportID: string, + taskTitle: string, + taskAssigneeAccountID: number, + text: string, + parentReportID: string, + actorAccountID?: number, + createdOffset = 0, +): OptimisticReportAction { + const reportAction = buildOptimisticAddCommentReportAction(text, undefined, undefined, createdOffset, undefined, taskReportID); + if (Array.isArray(reportAction.reportAction.message) && reportAction.reportAction.message?.[0]) { + reportAction.reportAction.message[0].taskReportID = taskReportID; + } else if (!Array.isArray(reportAction.reportAction.message) && reportAction.reportAction.message) { + reportAction.reportAction.message.taskReportID = taskReportID; + } + + // These parameters are not saved on the reportAction, but are used to display the task in the UI + // Added when we fetch the reportActions on a report + // eslint-disable-next-line + reportAction.reportAction.originalMessage = { + html: ReportActionsUtils.getReportActionHtml(reportAction.reportAction), + taskReportID: ReportActionsUtils.getReportActionMessage(reportAction.reportAction)?.taskReportID, + whisperedTo: [], + }; + reportAction.reportAction.childReportID = taskReportID; + reportAction.reportAction.parentReportID = parentReportID; + reportAction.reportAction.childType = CONST.REPORT.TYPE.TASK; + reportAction.reportAction.childReportName = taskTitle; + reportAction.reportAction.childManagerAccountID = taskAssigneeAccountID; + reportAction.reportAction.childStatusNum = CONST.REPORT.STATUS_NUM.OPEN; + reportAction.reportAction.childStateNum = CONST.REPORT.STATE_NUM.OPEN; + + if (actorAccountID) { + reportAction.reportAction.actorAccountID = actorAccountID; + } + + return reportAction; +} + +/** + * Builds an optimistic IOU report with a randomly generated reportID + * + * @param payeeAccountID - AccountID of the person generating the IOU. + * @param payerAccountID - AccountID of the other person participating in the IOU. + * @param total - IOU amount in the smallest unit of the currency. + * @param chatReportID - Report ID of the chat where the IOU is. + * @param currency - IOU currency. + * @param isSendingMoney - If we pay someone the IOU should be created as settled + */ + +function buildOptimisticIOUReport(payeeAccountID: number, payerAccountID: number, total: number, chatReportID: string, currency: string, isSendingMoney = false): OptimisticIOUReport { + const formattedTotal = CurrencyUtils.convertToDisplayString(total, currency); + const personalDetails = getPersonalDetailsForAccountID(payerAccountID); + const payerEmail = 'login' in personalDetails ? personalDetails.login : ''; + const policyID = getReport(chatReportID)?.policyID ?? '-1'; + const policy = getPolicy(policyID); + + const participants: Participants = { + [payeeAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN}, + [payerAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN}, + }; + + return { + type: CONST.REPORT.TYPE.IOU, + cachedTotal: formattedTotal, + chatReportID, + currency, + managerID: payerAccountID, + ownerAccountID: payeeAccountID, + participants, + reportID: generateReportID(), + stateNum: isSendingMoney ? CONST.REPORT.STATE_NUM.APPROVED : CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: isSendingMoney ? CONST.REPORT.STATUS_NUM.REIMBURSED : CONST.REPORT.STATE_NUM.SUBMITTED, + total, + + // We don't translate reportName because the server response is always in English + reportName: `${payerEmail} owes ${formattedTotal}`, + parentReportID: chatReportID, + lastVisibleActionCreated: DateUtils.getDBTime(), + fieldList: policy?.fieldList, + }; +} + +function getHumanReadableStatus(statusNum: number): string { + const status = Object.keys(CONST.REPORT.STATUS_NUM).find((key) => CONST.REPORT.STATUS_NUM[key as keyof typeof CONST.REPORT.STATUS_NUM] === statusNum); + return status ? `${status.charAt(0)}${status.slice(1).toLowerCase()}` : ''; +} + +/** + * Populates the report field formula with the values from the report and policy. + * Currently, this only supports optimistic expense reports. + * Each formula field is either replaced with a value, or removed. + * If after all replacements the formula is empty, the original formula is returned. + * See {@link https://help.expensify.com/articles/expensify-classic/insights-and-custom-reporting/Custom-Templates} + */ +function populateOptimisticReportFormula(formula: string, report: OptimisticExpenseReport, policy: OnyxEntry): string { + const createdDate = report.lastVisibleActionCreated ? new Date(report.lastVisibleActionCreated) : undefined; + const result = formula + // We don't translate because the server response is always in English + .replaceAll('{report:type}', 'Expense Report') + .replaceAll('{report:startdate}', createdDate ? format(createdDate, CONST.DATE.FNS_FORMAT_STRING) : '') + .replaceAll('{report:total}', report.total !== undefined ? CurrencyUtils.convertToDisplayString(Math.abs(report.total), report.currency).toString() : '') + .replaceAll('{report:currency}', report.currency ?? '') + .replaceAll('{report:policyname}', policy?.name ?? '') + .replaceAll('{report:created}', createdDate ? format(createdDate, CONST.DATE.FNS_DATE_TIME_FORMAT_STRING) : '') + .replaceAll('{report:created:yyyy-MM-dd}', createdDate ? format(createdDate, CONST.DATE.FNS_FORMAT_STRING) : '') + .replaceAll('{report:status}', report.statusNum !== undefined ? getHumanReadableStatus(report.statusNum) : '') + .replaceAll('{user:email}', currentUserEmail ?? '') + .replaceAll('{user:email|frontPart}', currentUserEmail ? currentUserEmail.split('@')[0] : '') + .replaceAll(/\{report:(.+)}/g, ''); + + return result.trim().length ? result : formula; +} + +/** Builds an optimistic invoice report with a randomly generated reportID */ +function buildOptimisticInvoiceReport(chatReportID: string, policyID: string, receiverAccountID: number, receiverName: string, total: number, currency: string): OptimisticExpenseReport { + const formattedTotal = CurrencyUtils.convertToDisplayString(total, currency); + + return { + reportID: generateReportID(), + chatReportID, + policyID, + type: CONST.REPORT.TYPE.INVOICE, + ownerAccountID: currentUserAccountID, + managerID: receiverAccountID, + currency, + // We don’t translate reportName because the server response is always in English + reportName: `${receiverName} owes ${formattedTotal}`, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + total, + participants: { + [currentUserAccountID ?? -1]: { + notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, + }, + }, + parentReportID: chatReportID, + lastVisibleActionCreated: DateUtils.getDBTime(), + }; +} + +/** + * Builds an optimistic Expense report with a randomly generated reportID + * + * @param chatReportID - Report ID of the PolicyExpenseChat where the Expense Report is + * @param policyID - The policy ID of the PolicyExpenseChat + * @param payeeAccountID - AccountID of the employee (payee) + * @param total - Amount in cents + * @param currency + * @param reimbursable – Whether the expense is reimbursable + * @param parentReportActionID – The parent ReportActionID of the PolicyExpenseChat + */ +function buildOptimisticExpenseReport( + chatReportID: string, + policyID: string, + payeeAccountID: number, + total: number, + currency: string, + reimbursable = true, + parentReportActionID?: string, +): OptimisticExpenseReport { + // The amount for Expense reports are stored as negative value in the database + const storedTotal = total * -1; + const policyName = getPolicyName(ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`]); + const formattedTotal = CurrencyUtils.convertToDisplayString(storedTotal, currency); + const policy = getPolicy(policyID); + + const isInstantSubmitEnabled = PolicyUtils.isInstantSubmitEnabled(policy); + + const stateNum = isInstantSubmitEnabled ? CONST.REPORT.STATE_NUM.SUBMITTED : CONST.REPORT.STATE_NUM.OPEN; + const statusNum = isInstantSubmitEnabled ? CONST.REPORT.STATUS_NUM.SUBMITTED : CONST.REPORT.STATUS_NUM.OPEN; + + const expenseReport: OptimisticExpenseReport = { + reportID: generateReportID(), + chatReportID, + policyID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: payeeAccountID, + currency, + // We don't translate reportName because the server response is always in English + reportName: `${policyName} owes ${formattedTotal}`, + stateNum, + statusNum, + total: storedTotal, + nonReimbursableTotal: reimbursable ? 0 : storedTotal, + participants: { + [payeeAccountID]: { + notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, + }, + }, + parentReportID: chatReportID, + lastVisibleActionCreated: DateUtils.getDBTime(), + parentReportActionID, + }; + + // Get the approver/manager for this report to properly display the optimistic data + const submitToAccountID = PolicyUtils.getSubmitToAccountID(policy, payeeAccountID); + if (submitToAccountID) { + expenseReport.managerID = submitToAccountID; + } + + const titleReportField = getTitleReportField(getReportFieldsByPolicyID(policyID) ?? {}); + if (!!titleReportField && isPaidGroupPolicyExpenseReport(expenseReport)) { + expenseReport.reportName = populateOptimisticReportFormula(titleReportField.defaultValue, expenseReport, policy); + } + + expenseReport.fieldList = policy?.fieldList; + + return expenseReport; +} + +function getFormattedAmount(reportAction: ReportAction) { + if (!ReportActionsUtils.isSubmittedAction(reportAction) && !ReportActionsUtils.isForwardedAction(reportAction) && !ReportActionsUtils.isApprovedAction(reportAction)) { + return ''; + } + const originalMessage = ReportActionsUtils.getOriginalMessage(reportAction); + const formattedAmount = CurrencyUtils.convertToDisplayString(Math.abs(originalMessage?.amount ?? 0), originalMessage?.currency); + return formattedAmount; +} + +function getIOUSubmittedMessage(reportAction: ReportAction) { + return Localize.translateLocal('iou.submittedAmount', {formattedAmount: getFormattedAmount(reportAction)}); +} + +function getIOUApprovedMessage(reportAction: ReportAction) { + return Localize.translateLocal('iou.approvedAmount', {amount: getFormattedAmount(reportAction)}); +} + +/** + * We pass the reportID as older FORWARDED actions do not have the amount & currency stored in the message + * so we retrieve the amount from the report instead + */ +function getIOUForwardedMessage(reportAction: ReportAction, reportOrID: OnyxInputOrEntry | string) { + const expenseReport = typeof reportOrID === 'string' ? getReport(reportOrID) : reportOrID; + const originalMessage = ReportActionsUtils.getOriginalMessage(reportAction) as OriginalMessageIOU; + let formattedAmount; + + // Older FORWARDED action might not have the amount stored in the original message, we'll fallback to getting the amount from the report instead. + if (originalMessage?.amount) { + formattedAmount = getFormattedAmount(reportAction); + } else { + formattedAmount = CurrencyUtils.convertToDisplayString(getMoneyRequestSpendBreakdown(expenseReport).totalDisplaySpend, expenseReport?.currency); + } + + return Localize.translateLocal('iou.forwardedAmount', {amount: formattedAmount}); +} + +function getRejectedReportMessage() { + return Localize.translateLocal('iou.rejectedThisReport'); +} + +function getWorkspaceNameUpdatedMessage(action: ReportAction) { + const {oldName, newName} = ReportActionsUtils.getOriginalMessage(action as ReportAction) ?? {}; + const message = oldName && newName ? Localize.translateLocal('workspaceActions.renamedWorkspaceNameAction', {oldName, newName}) : ReportActionsUtils.getReportActionText(action); + return message; +} + +/** + * @param iouReportID - the report ID of the IOU report the action belongs to + * @param type - IOUReportAction type. Can be oneOf(create, decline, cancel, pay, split) + * @param total - IOU total in cents + * @param comment - IOU comment + * @param currency - IOU currency + * @param paymentType - IOU paymentMethodType. Can be oneOf(Elsewhere, Expensify) + * @param isSettlingUp - Whether we are settling up an IOU + */ +function getIOUReportActionMessage(iouReportID: string, type: string, total: number, comment: string, currency: string, paymentType = '', isSettlingUp = false): Message[] { + const report = getReportOrDraftReport(iouReportID); + const amount = + type === CONST.IOU.REPORT_ACTION_TYPE.PAY && !isEmptyObject(report) + ? CurrencyUtils.convertToDisplayString(getMoneyRequestSpendBreakdown(report).totalDisplaySpend, currency) + : CurrencyUtils.convertToDisplayString(total, currency); + + let paymentMethodMessage; + switch (paymentType) { + case CONST.IOU.PAYMENT_TYPE.VBBA: + case CONST.IOU.PAYMENT_TYPE.EXPENSIFY: + paymentMethodMessage = ' with Expensify'; + break; + default: + paymentMethodMessage = ` elsewhere`; + break; + } + + let iouMessage; + switch (type) { + case CONST.REPORT.ACTIONS.TYPE.APPROVED: + iouMessage = `approved ${amount}`; + break; + case CONST.REPORT.ACTIONS.TYPE.FORWARDED: + iouMessage = Localize.translateLocal('iou.forwardedAmount', {amount}); + break; + case CONST.REPORT.ACTIONS.TYPE.UNAPPROVED: + iouMessage = `unapproved ${amount}`; + break; + case CONST.IOU.REPORT_ACTION_TYPE.CREATE: + iouMessage = `submitted ${amount}${comment && ` for ${comment}`}`; + break; + case CONST.IOU.REPORT_ACTION_TYPE.TRACK: + iouMessage = `tracking ${amount}${comment && ` for ${comment}`}`; + break; + case CONST.IOU.REPORT_ACTION_TYPE.SPLIT: + iouMessage = `split ${amount}${comment && ` for ${comment}`}`; + break; + case CONST.IOU.REPORT_ACTION_TYPE.DELETE: + iouMessage = `deleted the ${amount} expense${comment && ` for ${comment}`}`; + break; + case CONST.IOU.REPORT_ACTION_TYPE.PAY: + iouMessage = isSettlingUp ? `paid ${amount}${paymentMethodMessage}` : `sent ${amount}${comment && ` for ${comment}`}${paymentMethodMessage}`; + break; + case CONST.REPORT.ACTIONS.TYPE.SUBMITTED: + iouMessage = Localize.translateLocal('iou.submittedAmount', {formattedAmount: amount}); + break; + default: + break; + } + + return [ + { + html: lodashEscape(iouMessage), + text: iouMessage ?? '', + isEdited: false, + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + }, + ]; +} + +/** + * Builds an optimistic IOU reportAction object + * + * @param type - IOUReportAction type. Can be oneOf(create, delete, pay, split). + * @param amount - IOU amount in cents. + * @param currency + * @param comment - User comment for the IOU. + * @param participants - An array with participants details. + * @param [transactionID] - Not required if the IOUReportAction type is 'pay' + * @param [paymentType] - Only required if the IOUReportAction type is 'pay'. Can be oneOf(elsewhere, Expensify). + * @param [iouReportID] - Only required if the IOUReportActions type is oneOf(decline, cancel, pay). Generates a randomID as default. + * @param [isSettlingUp] - Whether we are settling up an IOU. + * @param [isSendMoneyFlow] - Whether this is pay someone flow + * @param [receipt] + * @param [isOwnPolicyExpenseChat] - Whether this is an expense report create from the current user's policy expense chat + */ +function buildOptimisticIOUReportAction( + type: ValueOf, + amount: number, + currency: string, + comment: string, + participants: Participant[], + transactionID: string, + paymentType?: PaymentMethodType, + iouReportID = '', + isSettlingUp = false, + isSendMoneyFlow = false, + isOwnPolicyExpenseChat = false, + created = DateUtils.getDBTime(), + linkedExpenseReportAction?: OnyxEntry, +): OptimisticIOUReportAction { + const IOUReportID = iouReportID || generateReportID(); + + const originalMessage: ReportAction['originalMessage'] = { + amount, + comment, + currency, + IOUTransactionID: transactionID, + IOUReportID, + type, + }; + + if (type === CONST.IOU.REPORT_ACTION_TYPE.PAY) { + // In pay someone flow, we store amount, comment, currency in IOUDetails when type = pay + if (isSendMoneyFlow) { + const keys = ['amount', 'comment', 'currency'] as const; + keys.forEach((key) => { + delete originalMessage[key]; + }); + originalMessage.IOUDetails = {amount, comment, currency}; + originalMessage.paymentType = paymentType; + } else { + // In case of pay someone action, we dont store the comment + // and there is no single transctionID to link the action to. + delete originalMessage.IOUTransactionID; + delete originalMessage.comment; + originalMessage.paymentType = paymentType; + } + } + + // IOUs of type split only exist in group DMs and those don't have an iouReport so we need to delete the IOUReportID key + if (type === CONST.IOU.REPORT_ACTION_TYPE.SPLIT) { + delete originalMessage.IOUReportID; + // Split expense made from a policy expense chat only have the payee's accountID as the participant because the payer could be any policy admin + if (isOwnPolicyExpenseChat) { + originalMessage.participantAccountIDs = currentUserAccountID ? [currentUserAccountID] : []; + } else { + originalMessage.participantAccountIDs = currentUserAccountID + ? [currentUserAccountID, ...participants.map((participant) => participant.accountID ?? -1)] + : participants.map((participant) => participant.accountID ?? -1); + } + } + + return { + ...linkedExpenseReportAction, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + actorAccountID: currentUserAccountID, + automatic: false, + avatar: getCurrentUserAvatar(), + isAttachmentOnly: false, + originalMessage, + message: getIOUReportActionMessage(iouReportID, type, amount, comment, currency, paymentType, isSettlingUp), + person: [ + { + style: 'strong', + text: getCurrentUserDisplayNameOrEmail(), + type: 'TEXT', + }, + ], + reportActionID: NumberUtils.rand64(), + shouldShow: true, + created, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }; +} + +/** + * Builds an optimistic APPROVED report action with a randomly generated reportActionID. + */ +function buildOptimisticApprovedReportAction(amount: number, currency: string, expenseReportID: string): OptimisticApprovedReportAction { + const originalMessage = { + amount, + currency, + expenseReportID, + }; + + return { + actionName: CONST.REPORT.ACTIONS.TYPE.APPROVED, + actorAccountID: currentUserAccountID, + automatic: false, + avatar: getCurrentUserAvatar(), + isAttachmentOnly: false, + originalMessage, + message: getIOUReportActionMessage(expenseReportID, CONST.REPORT.ACTIONS.TYPE.APPROVED, Math.abs(amount), '', currency), + person: [ + { + style: 'strong', + text: getCurrentUserDisplayNameOrEmail(), + type: 'TEXT', + }, + ], + reportActionID: NumberUtils.rand64(), + shouldShow: true, + created: DateUtils.getDBTime(), + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }; +} + +/** + * Builds an optimistic APPROVED report action with a randomly generated reportActionID. + */ +function buildOptimisticUnapprovedReportAction(amount: number, currency: string, expenseReportID: string): OptimisticUnapprovedReportAction { + return { + actionName: CONST.REPORT.ACTIONS.TYPE.UNAPPROVED, + actorAccountID: currentUserAccountID, + automatic: false, + avatar: getCurrentUserAvatar(), + isAttachmentOnly: false, + originalMessage: { + amount, + currency, + expenseReportID, + }, + message: getIOUReportActionMessage(expenseReportID, CONST.REPORT.ACTIONS.TYPE.UNAPPROVED, Math.abs(amount), '', currency), + person: [ + { + style: 'strong', + text: getCurrentUserDisplayNameOrEmail(), + type: 'TEXT', + }, + ], + reportActionID: NumberUtils.rand64(), + shouldShow: true, + created: DateUtils.getDBTime(), + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }; +} + +/** + * Builds an optimistic MOVED report action with a randomly generated reportActionID. + * This action is used when we move reports across workspaces. + */ +function buildOptimisticMovedReportAction(fromPolicyID: string, toPolicyID: string, newParentReportID: string, movedReportID: string, policyName: string): ReportAction { + const originalMessage = { + fromPolicyID, + toPolicyID, + newParentReportID, + movedReportID, + }; + + const movedActionMessage = [ + { + html: `moved the report to the ${policyName} workspace`, + text: `moved the report to the ${policyName} workspace`, + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + }, + ]; + + return { + actionName: CONST.REPORT.ACTIONS.TYPE.MOVED, + actorAccountID: currentUserAccountID, + automatic: false, + avatar: getCurrentUserAvatar(), + isAttachmentOnly: false, + originalMessage, + message: movedActionMessage, + person: [ + { + style: 'strong', + text: getCurrentUserDisplayNameOrEmail(), + type: 'TEXT', + }, + ], + reportActionID: NumberUtils.rand64(), + shouldShow: true, + created: DateUtils.getDBTime(), + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }; +} + +/** + * Builds an optimistic SUBMITTED report action with a randomly generated reportActionID. + * + */ +function buildOptimisticSubmittedReportAction(amount: number, currency: string, expenseReportID: string, adminAccountID: number | undefined): OptimisticSubmittedReportAction { + const originalMessage = { + amount, + currency, + expenseReportID, + }; + + return { + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + actorAccountID: currentUserAccountID, + adminAccountID, + automatic: false, + avatar: getCurrentUserAvatar(), + isAttachmentOnly: false, + originalMessage, + message: getIOUReportActionMessage(expenseReportID, CONST.REPORT.ACTIONS.TYPE.SUBMITTED, Math.abs(amount), '', currency), + person: [ + { + style: 'strong', + text: getCurrentUserDisplayNameOrEmail(), + type: 'TEXT', + }, + ], + reportActionID: NumberUtils.rand64(), + shouldShow: true, + created: DateUtils.getDBTime(), + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }; +} + +/** + * Builds an optimistic report preview action with a randomly generated reportActionID. + * + * @param chatReport + * @param iouReport + * @param [comment] - User comment for the IOU. + * @param [transaction] - optimistic first transaction of preview + * @param reportActionID + */ +function buildOptimisticReportPreview( + chatReport: OnyxInputOrEntry, + iouReport: Report, + comment = '', + transaction: OnyxInputOrEntry = null, + childReportID?: string, + reportActionID?: string, +): ReportAction { + const hasReceipt = TransactionUtils.hasReceipt(transaction); + const message = getReportPreviewMessage(iouReport); + const created = DateUtils.getDBTime(); + return { + reportActionID: reportActionID ?? NumberUtils.rand64(), + reportID: chatReport?.reportID, + actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + originalMessage: { + linkedReportID: iouReport?.reportID, + }, + message: [ + { + html: message, + text: message, + isEdited: false, + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + }, + ], + created, + accountID: iouReport?.managerID ?? -1, + // The preview is initially whispered if created with a receipt, so the actor is the current user as well + actorAccountID: hasReceipt ? currentUserAccountID : iouReport?.managerID ?? -1, + childReportID: childReportID ?? iouReport?.reportID, + childMoneyRequestCount: 1, + childLastMoneyRequestComment: comment, + childRecentReceiptTransactionIDs: hasReceipt && !isEmptyObject(transaction) ? {[transaction?.transactionID ?? '-1']: created} : undefined, + }; +} + +/** + * Builds an optimistic ACTIONABLETRACKEXPENSEWHISPER action with a randomly generated reportActionID. + */ +function buildOptimisticActionableTrackExpenseWhisper(iouAction: OptimisticIOUReportAction, transactionID: string): ReportAction { + const currentTime = DateUtils.getDBTime(); + const targetEmail = CONST.EMAIL.CONCIERGE; + const actorAccountID = PersonalDetailsUtils.getAccountIDsByLogins([targetEmail])[0]; + const reportActionID = NumberUtils.rand64(); + return { + actionName: CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_TRACK_EXPENSE_WHISPER, + actorAccountID, + avatar: UserUtils.getDefaultAvatarURL(actorAccountID), + created: DateUtils.addMillisecondsFromDateTime(currentTime, 1), + lastModified: DateUtils.addMillisecondsFromDateTime(currentTime, 1), + message: [ + { + html: CONST.ACTIONABLE_TRACK_EXPENSE_WHISPER_MESSAGE, + text: CONST.ACTIONABLE_TRACK_EXPENSE_WHISPER_MESSAGE, + whisperedTo: [], + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + }, + ], + originalMessage: { + lastModified: DateUtils.addMillisecondsFromDateTime(currentTime, 1), + transactionID, + }, + person: [ + { + text: CONST.DISPLAY_NAME.EXPENSIFY_CONCIERGE, + type: 'TEXT', + }, + ], + reportActionID, + shouldShow: true, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }; +} + +/** + * Builds an optimistic modified expense action with a randomly generated reportActionID. + */ +function buildOptimisticModifiedExpenseReportAction( + transactionThread: OnyxInputOrEntry, + oldTransaction: OnyxInputOrEntry, + transactionChanges: TransactionChanges, + isFromExpenseReport: boolean, + policy: OnyxInputOrEntry, + updatedTransaction?: OnyxInputOrEntry, +): OptimisticModifiedExpenseReportAction { + const originalMessage = getModifiedExpenseOriginalMessage(oldTransaction, transactionChanges, isFromExpenseReport, policy, updatedTransaction); + return { + actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE, + actorAccountID: currentUserAccountID, + automatic: false, + avatar: getCurrentUserAvatar(), + created: DateUtils.getDBTime(), + isAttachmentOnly: false, + message: [ + { + // Currently we are composing the message from the originalMessage and message is only used in OldDot and not in the App + text: 'You', + style: 'strong', + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + }, + ], + originalMessage, + person: [ + { + style: 'strong', + text: currentUserPersonalDetails?.displayName ?? String(currentUserAccountID), + type: 'TEXT', + }, + ], + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + reportActionID: NumberUtils.rand64(), + reportID: transactionThread?.reportID, + shouldShow: true, + }; +} + +/** + * Builds an optimistic modified expense action for a tracked expense move with a randomly generated reportActionID. + * @param transactionThreadID - The reportID of the transaction thread + * @param movedToReportID - The reportID of the report the transaction is moved to + */ +function buildOptimisticMovedTrackedExpenseModifiedReportAction(transactionThreadID: string, movedToReportID: string): OptimisticModifiedExpenseReportAction { + return { + actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE, + actorAccountID: currentUserAccountID, + automatic: false, + avatar: getCurrentUserAvatar(), + created: DateUtils.getDBTime(), + isAttachmentOnly: false, + message: [ + { + // Currently we are composing the message from the originalMessage and message is only used in OldDot and not in the App + text: 'You', + style: 'strong', + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + }, + ], + originalMessage: { + movedToReportID, + }, + person: [ + { + style: 'strong', + text: currentUserPersonalDetails?.displayName ?? String(currentUserAccountID), + type: 'TEXT', + }, + ], + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + reportActionID: NumberUtils.rand64(), + reportID: transactionThreadID, + shouldShow: true, + }; +} + +/** + * Updates a report preview action that exists for an IOU report. + * + * @param [comment] - User comment for the IOU. + * @param [transaction] - optimistic newest transaction of a report preview + * + */ +function updateReportPreview( + iouReport: OnyxEntry, + reportPreviewAction: ReportAction, + isPayRequest = false, + comment = '', + transaction?: OnyxEntry, +): ReportAction { + const hasReceipt = TransactionUtils.hasReceipt(transaction); + const recentReceiptTransactions = reportPreviewAction?.childRecentReceiptTransactionIDs ?? {}; + const transactionsToKeep = TransactionUtils.getRecentTransactions(recentReceiptTransactions); + const previousTransactionsArray = Object.entries(recentReceiptTransactions ?? {}).map(([key, value]) => (transactionsToKeep.includes(key) ? {[key]: value} : null)); + const previousTransactions: Record = {}; + + for (const obj of previousTransactionsArray) { + for (const key in obj) { + if (obj) { + previousTransactions[key] = obj[key]; + } + } + } + + const message = getReportPreviewMessage(iouReport, reportPreviewAction); + const originalMessage = ReportActionsUtils.getOriginalMessage(reportPreviewAction); + return { + ...reportPreviewAction, + message: [ + { + html: message, + text: message, + isEdited: false, + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + }, + ], + childLastMoneyRequestComment: comment || reportPreviewAction?.childLastMoneyRequestComment, + childMoneyRequestCount: (reportPreviewAction?.childMoneyRequestCount ?? 0) + (isPayRequest ? 0 : 1), + childRecentReceiptTransactionIDs: hasReceipt + ? { + ...(transaction && {[transaction.transactionID]: transaction?.created}), + ...previousTransactions, + } + : recentReceiptTransactions, + // As soon as we add a transaction without a receipt to the report, it will have ready expenses, + // so we remove the whisper + originalMessage: { + ...(originalMessage ?? {}), + whisperedTo: hasReceipt ? originalMessage?.whisperedTo : [], + linkedReportID: originalMessage?.linkedReportID ?? '0', + }, + }; +} + +function buildOptimisticTaskReportAction( + taskReportID: string, + actionName: typeof CONST.REPORT.ACTIONS.TYPE.TASK_COMPLETED | typeof CONST.REPORT.ACTIONS.TYPE.TASK_REOPENED | typeof CONST.REPORT.ACTIONS.TYPE.TASK_CANCELLED, + message = '', + actorAccountID = currentUserAccountID, + createdOffset = 0, +): OptimisticTaskReportAction { + const originalMessage = { + taskReportID, + type: actionName, + text: message, + html: message, + whisperedTo: [], + }; + return { + actionName, + actorAccountID, + automatic: false, + avatar: getCurrentUserAvatar(), + isAttachmentOnly: false, + originalMessage, + message: [ + { + text: message, + taskReportID, + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + }, + ], + person: [ + { + style: 'strong', + text: currentUserPersonalDetails?.displayName ?? String(currentUserAccountID), + type: 'TEXT', + }, + ], + reportActionID: NumberUtils.rand64(), + shouldShow: true, + created: DateUtils.getDBTimeWithSkew(Date.now() + createdOffset), + isFirstItem: false, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }; +} + +function isWorkspaceChat(chatType: string) { + return chatType === CONST.REPORT.CHAT_TYPE.POLICY_ADMINS || chatType === CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE || chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT; +} + +/** + * Builds an optimistic chat report with a randomly generated reportID and as much information as we currently have + */ +function buildOptimisticChatReport( + participantList: number[], + reportName: string = CONST.REPORT.DEFAULT_REPORT_NAME, + chatType?: ValueOf, + policyID: string = CONST.POLICY.OWNER_EMAIL_FAKE, + ownerAccountID: number = CONST.REPORT.OWNER_ACCOUNT_ID_FAKE, + isOwnPolicyExpenseChat = false, + oldPolicyName = '', + visibility?: ValueOf, + writeCapability?: ValueOf, + notificationPreference: NotificationPreference = CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, + parentReportActionID = '', + parentReportID = '', + description = '', + avatarUrl = '', + avatarFileName = '', + optimisticReportID = '', +): OptimisticChatReport { + const isWorkspaceChatType = chatType && isWorkspaceChat(chatType); + const participants = participantList.reduce((reportParticipants: Participants, accountID: number) => { + const participant: ReportParticipant = { + notificationPreference, + ...(!isWorkspaceChatType && {role: accountID === currentUserAccountID ? CONST.REPORT.ROLE.ADMIN : CONST.REPORT.ROLE.MEMBER}), + }; + // eslint-disable-next-line no-param-reassign + reportParticipants[accountID] = participant; + return reportParticipants; + }, {} as Participants); + const currentTime = DateUtils.getDBTime(); + const isNewlyCreatedWorkspaceChat = chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT && isOwnPolicyExpenseChat; + const optimisticChatReport: OptimisticChatReport = { + isOptimisticReport: true, + type: CONST.REPORT.TYPE.CHAT, + chatType, + isOwnPolicyExpenseChat, + isPinned: isNewlyCreatedWorkspaceChat, + lastActorAccountID: 0, + lastMessageTranslationKey: '', + lastMessageHtml: '', + lastMessageText: undefined, + lastReadTime: currentTime, + lastVisibleActionCreated: currentTime, + oldPolicyName, + ownerAccountID: ownerAccountID || CONST.REPORT.OWNER_ACCOUNT_ID_FAKE, + parentReportActionID, + parentReportID, + participants, + policyID, + reportID: optimisticReportID || generateReportID(), + reportName, + stateNum: 0, + statusNum: 0, + visibility, + description, + writeCapability, + avatarUrl, + avatarFileName, + }; + + if (chatType === CONST.REPORT.CHAT_TYPE.INVOICE) { + // TODO: update to support workspace as an invoice receiver when workspace-to-workspace invoice room implemented + optimisticChatReport.invoiceReceiver = { + type: 'individual', + accountID: participantList[0], + }; + } + + return optimisticChatReport; +} + +function buildOptimisticGroupChatReport( + participantAccountIDs: number[], + reportName: string, + avatarUri: string, + avatarFilename: string, + optimisticReportID?: string, + notificationPreference?: NotificationPreference, +) { + return buildOptimisticChatReport( + participantAccountIDs, + reportName, + CONST.REPORT.CHAT_TYPE.GROUP, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + notificationPreference, + undefined, + undefined, + undefined, + avatarUri, + avatarFilename, + optimisticReportID, + ); +} + +/** + * Returns the necessary reportAction onyx data to indicate that the chat has been created optimistically + * @param [created] - Action created time + */ +function buildOptimisticCreatedReportAction(emailCreatingAction: string, created = DateUtils.getDBTime()): OptimisticCreatedReportAction { + return { + reportActionID: NumberUtils.rand64(), + actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + actorAccountID: currentUserAccountID, + message: [ + { + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + style: 'strong', + text: emailCreatingAction, + }, + { + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + style: 'normal', + text: ' created this report', + }, + ], + person: [ + { + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + style: 'strong', + text: getCurrentUserDisplayNameOrEmail(), + }, + ], + automatic: false, + avatar: getCurrentUserAvatar(), + created, + shouldShow: true, + }; +} + +/** + * Returns the necessary reportAction onyx data to indicate that the room has been renamed + */ +function buildOptimisticRenamedRoomReportAction(newName: string, oldName: string): OptimisticRenamedReportAction { + const now = DateUtils.getDBTime(); + return { + reportActionID: NumberUtils.rand64(), + actionName: CONST.REPORT.ACTIONS.TYPE.RENAMED, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + actorAccountID: currentUserAccountID, + message: [ + { + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + style: 'strong', + text: 'You', + }, + { + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + style: 'normal', + text: ` renamed this report. New title is '${newName}' (previously '${oldName}').`, + }, + ], + person: [ + { + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + style: 'strong', + text: getCurrentUserDisplayNameOrEmail(), + }, + ], + originalMessage: { + oldName, + newName, + html: `Room renamed to ${newName}`, + lastModified: now, + }, + automatic: false, + avatar: getCurrentUserAvatar(), + created: now, + shouldShow: true, + }; +} + +/** + * Returns the necessary reportAction onyx data to indicate that the room description has been updated + */ +function buildOptimisticRoomDescriptionUpdatedReportAction(description: string): OptimisticRoomDescriptionUpdatedReportAction { + const now = DateUtils.getDBTime(); + return { + reportActionID: NumberUtils.rand64(), + actionName: CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.UPDATE_ROOM_DESCRIPTION, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + actorAccountID: currentUserAccountID, + message: [ + { + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + text: description ? `set the room description to: ${Parser.htmlToText(description)}` : 'cleared the room description', + html: description ? `set the room description to: ${description}` : 'cleared the room description', + }, + ], + person: [ + { + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + style: 'strong', + text: getCurrentUserDisplayNameOrEmail(), + }, + ], + originalMessage: { + description, + lastModified: now, + }, + created: now, + }; +} + +/** + * Returns the necessary reportAction onyx data to indicate that the transaction has been put on hold optimistically + * @param [created] - Action created time + */ +function buildOptimisticHoldReportAction(created = DateUtils.getDBTime()): OptimisticHoldReportAction { + return { + reportActionID: NumberUtils.rand64(), + actionName: CONST.REPORT.ACTIONS.TYPE.HOLD, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + actorAccountID: currentUserAccountID, + message: [ + { + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + style: 'normal', + text: Localize.translateLocal('iou.heldExpense'), + }, + ], + person: [ + { + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + style: 'strong', + text: getCurrentUserDisplayNameOrEmail(), + }, + ], + automatic: false, + avatar: getCurrentUserAvatar(), + created, + shouldShow: true, + }; +} + +/** + * Returns the necessary reportAction onyx data to indicate that the transaction has been put on hold optimistically + * @param [created] - Action created time + */ +function buildOptimisticHoldReportActionComment(comment: string, created = DateUtils.getDBTime()): OptimisticHoldReportAction { + return { + reportActionID: NumberUtils.rand64(), + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + actorAccountID: currentUserAccountID, + message: [ + { + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + text: comment, + html: comment, // as discussed on https://github.com/Expensify/App/pull/39452 we will not support HTML for now + }, + ], + person: [ + { + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + style: 'strong', + text: getCurrentUserDisplayNameOrEmail(), + }, + ], + automatic: false, + avatar: getCurrentUserAvatar(), + created, + shouldShow: true, + }; +} + +/** + * Returns the necessary reportAction onyx data to indicate that the transaction has been removed from hold optimistically + * @param [created] - Action created time + */ +function buildOptimisticUnHoldReportAction(created = DateUtils.getDBTime()): OptimisticHoldReportAction { + return { + reportActionID: NumberUtils.rand64(), + actionName: CONST.REPORT.ACTIONS.TYPE.UNHOLD, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + actorAccountID: currentUserAccountID, + message: [ + { + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + style: 'normal', + text: Localize.translateLocal('iou.unheldExpense'), + }, + ], + person: [ + { + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + style: 'normal', + text: getCurrentUserDisplayNameOrEmail(), + }, + ], + automatic: false, + avatar: getCurrentUserAvatar(), + created, + shouldShow: true, + }; +} + +function buildOptimisticEditedTaskFieldReportAction({title, description}: Task): OptimisticEditedTaskReportAction { + // We do not modify title & description in one request, so we need to create a different optimistic action for each field modification + let field = ''; + let value = ''; + if (title !== undefined) { + field = 'task title'; + value = title; + } else if (description !== undefined) { + field = 'description'; + value = description; + } + + let changelog = 'edited this task'; + if (field && value) { + changelog = `updated the ${field} to ${value}`; + } else if (field) { + changelog = `removed the ${field}`; + } + + return { + reportActionID: NumberUtils.rand64(), + actionName: CONST.REPORT.ACTIONS.TYPE.TASK_EDITED, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + actorAccountID: currentUserAccountID, + message: [ + { + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + text: changelog, + html: description ? getParsedComment(changelog) : changelog, + }, + ], + person: [ + { + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + style: 'strong', + text: getCurrentUserDisplayNameOrEmail(), + }, + ], + automatic: false, + avatar: getCurrentUserAvatar(), + created: DateUtils.getDBTime(), + shouldShow: false, + }; +} + +function buildOptimisticChangedTaskAssigneeReportAction(assigneeAccountID: number): OptimisticEditedTaskReportAction { + return { + reportActionID: NumberUtils.rand64(), + actionName: CONST.REPORT.ACTIONS.TYPE.TASK_EDITED, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + actorAccountID: currentUserAccountID, + message: [ + { + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + text: `assigned to ${getDisplayNameForParticipant(assigneeAccountID)}`, + html: `assigned to `, + }, + ], + person: [ + { + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + style: 'strong', + text: getCurrentUserDisplayNameOrEmail(), + }, + ], + automatic: false, + avatar: getCurrentUserAvatar(), + created: DateUtils.getDBTime(), + shouldShow: false, + }; +} + +/** + * Returns the necessary reportAction onyx data to indicate that a chat has been archived + * + * @param reason - A reason why the chat has been archived + */ +function buildOptimisticClosedReportAction( + emailClosingReport: string, + policyName: string, + reason: ValueOf = CONST.REPORT.ARCHIVE_REASON.DEFAULT, +): OptimisticClosedReportAction { + return { + actionName: CONST.REPORT.ACTIONS.TYPE.CLOSED, + actorAccountID: currentUserAccountID, + automatic: false, + avatar: getCurrentUserAvatar(), + created: DateUtils.getDBTime(), + message: [ + { + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + style: 'strong', + text: emailClosingReport, + }, + { + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + style: 'normal', + text: ' closed this report', + }, + ], + originalMessage: { + policyName, + reason, + }, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + person: [ + { + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + style: 'strong', + text: getCurrentUserDisplayNameOrEmail(), + }, + ], + reportActionID: NumberUtils.rand64(), + shouldShow: true, + }; +} + +/** + * Returns an optimistic Dismissed Violation Report Action. Use the originalMessage customize this to the type of + * violation being dismissed. + */ +function buildOptimisticDismissedViolationReportAction( + originalMessage: ReportAction['originalMessage'], +): OptimisticDismissedViolationReportAction { + return { + actionName: CONST.REPORT.ACTIONS.TYPE.DISMISSED_VIOLATION, + actorAccountID: currentUserAccountID, + avatar: getCurrentUserAvatar(), + created: DateUtils.getDBTime(), + message: [ + { + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + style: 'normal', + text: ReportActionsUtils.getDismissedViolationMessageText(originalMessage), + }, + ], + originalMessage, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + person: [ + { + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + style: 'strong', + text: getCurrentUserDisplayNameOrEmail(), + }, + ], + reportActionID: NumberUtils.rand64(), + shouldShow: true, + }; +} + +function buildOptimisticWorkspaceChats(policyID: string, policyName: string, expenseReportId?: string): OptimisticWorkspaceChats { + const announceChatData = buildOptimisticChatReport( + currentUserAccountID ? [currentUserAccountID] : [], + CONST.REPORT.WORKSPACE_CHAT_ROOMS.ANNOUNCE, + CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE, + policyID, + CONST.POLICY.OWNER_ACCOUNT_ID_FAKE, + false, + policyName, + undefined, + CONST.REPORT.WRITE_CAPABILITIES.ADMINS, + CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, + ); + const announceChatReportID = announceChatData.reportID; + const announceCreatedAction = buildOptimisticCreatedReportAction(CONST.POLICY.OWNER_EMAIL_FAKE); + const announceReportActionData = { + [announceCreatedAction.reportActionID]: announceCreatedAction, + }; + const pendingChatMembers = getPendingChatMembers(currentUserAccountID ? [currentUserAccountID] : [], [], CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + const adminsChatData = { + ...buildOptimisticChatReport( + [currentUserAccountID ?? -1], + CONST.REPORT.WORKSPACE_CHAT_ROOMS.ADMINS, + CONST.REPORT.CHAT_TYPE.POLICY_ADMINS, + policyID, + CONST.POLICY.OWNER_ACCOUNT_ID_FAKE, + false, + policyName, + ), + pendingChatMembers, + }; + const adminsChatReportID = adminsChatData.reportID; + const adminsCreatedAction = buildOptimisticCreatedReportAction(CONST.POLICY.OWNER_EMAIL_FAKE); + const adminsReportActionData = { + [adminsCreatedAction.reportActionID]: adminsCreatedAction, + }; + + const expenseChatData = buildOptimisticChatReport( + [currentUserAccountID ?? -1], + '', + CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, + policyID, + currentUserAccountID, + true, + policyName, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + expenseReportId, + ); + const expenseChatReportID = expenseChatData.reportID; + const expenseReportCreatedAction = buildOptimisticCreatedReportAction(currentUserEmail ?? ''); + const expenseReportActionData = { + [expenseReportCreatedAction.reportActionID]: expenseReportCreatedAction, + }; + + return { + announceChatReportID, + announceChatData, + announceReportActionData, + announceCreatedReportActionID: announceCreatedAction.reportActionID, + adminsChatReportID, + adminsChatData, + adminsReportActionData, + adminsCreatedReportActionID: adminsCreatedAction.reportActionID, + expenseChatReportID, + expenseChatData, + expenseReportActionData, + expenseCreatedReportActionID: expenseReportCreatedAction.reportActionID, + }; +} + +/** + * Builds an optimistic Task Report with a randomly generated reportID + * + * @param ownerAccountID - Account ID of the person generating the Task. + * @param assigneeAccountID - AccountID of the other person participating in the Task. + * @param parentReportID - Report ID of the chat where the Task is. + * @param title - Task title. + * @param description - Task description. + * @param policyID - PolicyID of the parent report + */ + +function buildOptimisticTaskReport( + ownerAccountID: number, + assigneeAccountID = 0, + parentReportID?: string, + title?: string, + description?: string, + policyID: string = CONST.POLICY.OWNER_EMAIL_FAKE, + notificationPreference: NotificationPreference = CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, +): OptimisticTaskReport { + const participants: Participants = { + [ownerAccountID]: { + notificationPreference, + }, + }; + + if (assigneeAccountID) { + participants[assigneeAccountID] = {notificationPreference}; + } + + return { + reportID: generateReportID(), + reportName: title, + description: getParsedComment(description ?? ''), + ownerAccountID, + participants, + managerID: assigneeAccountID, + type: CONST.REPORT.TYPE.TASK, + parentReportID, + policyID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + lastVisibleActionCreated: DateUtils.getDBTime(), + hasParentAccess: true, + }; +} + +/** + * Builds an optimistic EXPORTED_TO_INTEGRATION report action + * + * @param integration - The connectionName of the integration + * @param markedManually - Whether the integration was marked as manually exported + */ +function buildOptimisticExportIntegrationAction(integration: ConnectionName, markedManually = false): OptimisticExportIntegrationAction { + const label = CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]; + return { + reportActionID: NumberUtils.rand64(), + actionName: CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_INTEGRATION, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + actorAccountID: currentUserAccountID, + message: [], + person: [ + { + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + style: 'strong', + text: getCurrentUserDisplayNameOrEmail(), + }, + ], + automatic: false, + avatar: getCurrentUserAvatar(), + created: DateUtils.getDBTime(), + shouldShow: true, + originalMessage: { + label, + lastModified: DateUtils.getDBTime(), + markedManually, + inProgress: true, + }, + }; +} + +/** + * A helper method to create transaction thread + * + * @param reportAction - the parent IOU report action from which to create the thread + * @param moneyRequestReport - the report which the report action belongs to + */ +function buildTransactionThread( + reportAction: OnyxEntry, + moneyRequestReport: OnyxEntry, + existingTransactionThreadReportID?: string, +): OptimisticChatReport { + const participantAccountIDs = [...new Set([currentUserAccountID, Number(reportAction?.actorAccountID)])].filter(Boolean) as number[]; + const existingTransactionThreadReport = getReportOrDraftReport(existingTransactionThreadReportID); + + if (existingTransactionThreadReportID && existingTransactionThreadReport) { + return { + ...existingTransactionThreadReport, + isOptimisticReport: true, + parentReportActionID: reportAction?.reportActionID, + parentReportID: moneyRequestReport?.reportID, + reportName: getTransactionReportName(reportAction), + policyID: moneyRequestReport?.policyID, + }; + } + + return buildOptimisticChatReport( + participantAccountIDs, + getTransactionReportName(reportAction), + undefined, + moneyRequestReport?.policyID ?? '-1', + CONST.POLICY.OWNER_ACCOUNT_ID_FAKE, + false, + '', + undefined, + undefined, + CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, + reportAction?.reportActionID, + moneyRequestReport?.reportID, + ); +} + +/** + * Build optimistic expense entities: + * + * 1. CREATED action for the chatReport + * 2. CREATED action for the iouReport + * 3. IOU action for the iouReport linked to the transaction thread via `childReportID` + * 4. Transaction Thread linked to the IOU action via `parentReportActionID` + * 5. CREATED action for the Transaction Thread + */ +function buildOptimisticMoneyRequestEntities( + iouReport: Report, + type: ValueOf, + amount: number, + currency: string, + comment: string, + payeeEmail: string, + participants: Participant[], + transactionID: string, + paymentType?: PaymentMethodType, + isSettlingUp = false, + isSendMoneyFlow = false, + isOwnPolicyExpenseChat = false, + isPersonalTrackingExpense?: boolean, + existingTransactionThreadReportID?: string, + linkedTrackedExpenseReportAction?: ReportAction, +): [OptimisticCreatedReportAction, OptimisticCreatedReportAction, OptimisticIOUReportAction, OptimisticChatReport, OptimisticCreatedReportAction | null] { + const createdActionForChat = buildOptimisticCreatedReportAction(payeeEmail); + + // The `CREATED` action must be optimistically generated before the IOU action so that it won't appear after the IOU action in the chat. + const iouActionCreationTime = DateUtils.getDBTime(); + const createdActionForIOUReport = buildOptimisticCreatedReportAction(payeeEmail, DateUtils.subtractMillisecondsFromDateTime(iouActionCreationTime, 1)); + + const iouAction = buildOptimisticIOUReportAction( + type, + amount, + currency, + comment, + participants, + transactionID, + paymentType, + isPersonalTrackingExpense ? '0' : iouReport.reportID, + isSettlingUp, + isSendMoneyFlow, + isOwnPolicyExpenseChat, + iouActionCreationTime, + linkedTrackedExpenseReportAction, + ); + + // Create optimistic transactionThread and the `CREATED` action for it, if existingTransactionThreadReportID is undefined + const transactionThread = buildTransactionThread(iouAction, iouReport, existingTransactionThreadReportID); + const createdActionForTransactionThread = existingTransactionThreadReportID ? null : buildOptimisticCreatedReportAction(payeeEmail); + + // The IOU action and the transactionThread are co-dependent as parent-child, so we need to link them together + iouAction.childReportID = existingTransactionThreadReportID ?? transactionThread.reportID; + + return [createdActionForChat, createdActionForIOUReport, iouAction, transactionThread, createdActionForTransactionThread]; +} + +// Check if the report is empty, meaning it has no visible messages (i.e. only a "created" report action). +function isEmptyReport(report: OnyxEntry): boolean { + if (!report) { + return true; + } + + if (report.lastMessageText ?? report.lastMessageTranslationKey) { + return false; + } + + const lastVisibleMessage = getLastVisibleMessage(report.reportID); + return !lastVisibleMessage.lastMessageText && !lastVisibleMessage.lastMessageTranslationKey; +} + +function isUnread(report: OnyxEntry): boolean { + if (!report) { + return false; + } + + if (isEmptyReport(report) && !isSelfDM(report)) { + return false; + } + // lastVisibleActionCreated and lastReadTime are both datetime strings and can be compared directly + const lastVisibleActionCreated = report.lastVisibleActionCreated ?? ''; + const lastReadTime = report.lastReadTime ?? ''; + const lastMentionedTime = report.lastMentionedTime ?? ''; + + // If the user was mentioned and the comment got deleted the lastMentionedTime will be more recent than the lastVisibleActionCreated + return lastReadTime < lastVisibleActionCreated || lastReadTime < lastMentionedTime; +} + +function isIOUOwnedByCurrentUser(report: OnyxEntry, allReportsDict?: OnyxCollection): boolean { + const allAvailableReports = allReportsDict ?? ReportConnection.getAllReports(); + if (!report || !allAvailableReports) { + return false; + } + + let reportToLook = report; + if (report.iouReportID) { + const iouReport = allAvailableReports[`${ONYXKEYS.COLLECTION.REPORT}${report.iouReportID}`]; + if (iouReport) { + reportToLook = iouReport; + } + } + + return reportToLook.ownerAccountID === currentUserAccountID; +} + +/** + * Assuming the passed in report is a default room, lets us know whether we can see it or not, based on permissions and + * the various subsets of users we've allowed to use default rooms. + */ +function canSeeDefaultRoom(report: OnyxEntry, policies: OnyxCollection, betas: OnyxEntry): boolean { + // Include archived rooms + if (isArchivedRoom(report, getReportNameValuePairs(report?.reportID))) { + return true; + } + + // Include default rooms for free plan policies (domain rooms aren't included in here because they do not belong to a policy) + if (getPolicyType(report, policies) === CONST.POLICY.TYPE.FREE) { + return true; + } + + // If the room has an assigned guide, it can be seen. + if (hasExpensifyGuidesEmails(Object.keys(report?.participants ?? {}).map(Number))) { + return true; + } + + // Include any admins and announce rooms, since only non partner-managed domain rooms are on the beta now. + if (isAdminRoom(report) || isAnnounceRoom(report)) { + return true; + } + + // For all other cases, just check that the user belongs to the default rooms beta + return Permissions.canUseDefaultRooms(betas ?? []); +} + +function canAccessReport(report: OnyxEntry, policies: OnyxCollection, betas: OnyxEntry): boolean { + // We hide default rooms (it's basically just domain rooms now) from people who aren't on the defaultRooms beta. + if (isDefaultRoom(report) && !canSeeDefaultRoom(report, policies, betas)) { + return false; + } + + if (report?.errorFields?.notFound) { + return false; + } + + return true; +} + +// eslint-disable-next-line rulesdir/no-negated-variables +function isReportNotFound(report: OnyxEntry): boolean { + return !!report?.errorFields?.notFound; +} + +/** + * Check if the report is the parent report of the currently viewed report or at least one child report has report action + */ +function shouldHideReport(report: OnyxEntry, currentReportId: string): boolean { + const currentReport = getReportOrDraftReport(currentReportId); + const parentReport = getParentReport(!isEmptyObject(currentReport) ? currentReport : undefined); + const reportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`] ?? {}; + const isChildReportHasComment = Object.values(reportActions ?? {})?.some((reportAction) => (reportAction?.childVisibleActionCount ?? 0) > 0); + return parentReport?.reportID !== report?.reportID && !isChildReportHasComment; +} + +/** + * Checks to see if a report's parentAction is an expense that contains a violation type of either violation or warning + */ +function doesTransactionThreadHaveViolations( + report: OnyxInputOrEntry, + transactionViolations: OnyxCollection, + parentReportAction: OnyxInputOrEntry, +): boolean { + if (!ReportActionsUtils.isMoneyRequestAction(parentReportAction)) { + return false; + } + const {IOUTransactionID, IOUReportID} = ReportActionsUtils.getOriginalMessage(parentReportAction) ?? {}; + if (!IOUTransactionID || !IOUReportID) { + return false; + } + if (!isCurrentUserSubmitter(IOUReportID)) { + return false; + } + if (report?.stateNum !== CONST.REPORT.STATE_NUM.OPEN && report?.stateNum !== CONST.REPORT.STATE_NUM.SUBMITTED) { + return false; + } + return ( + TransactionUtils.hasViolation(IOUTransactionID, transactionViolations) || + TransactionUtils.hasWarningTypeViolation(IOUTransactionID, transactionViolations) || + (isPaidGroupPolicy(report) && TransactionUtils.hasModifiedAmountOrDateViolation(IOUTransactionID, transactionViolations)) + ); +} + +/** + * Checks if we should display violation - we display violations when the expense has violation and it is not settled + */ +function shouldDisplayTransactionThreadViolations( + report: OnyxEntry, + transactionViolations: OnyxCollection, + parentReportAction: OnyxEntry, +): boolean { + if (!ReportActionsUtils.isMoneyRequestAction(parentReportAction)) { + return false; + } + const {IOUReportID} = ReportActionsUtils.getOriginalMessage(parentReportAction) ?? {}; + if (isSettled(IOUReportID) || isReportApproved(IOUReportID?.toString())) { + return false; + } + return doesTransactionThreadHaveViolations(report, transactionViolations, parentReportAction); +} + +/** + * Checks to see if a report contains a violation + */ +function hasViolations(reportID: string, transactionViolations: OnyxCollection): boolean { + const transactions = reportsTransactions[reportID] ?? []; + return transactions.some((transaction) => TransactionUtils.hasViolation(transaction.transactionID, transactionViolations)); +} + +/** + * Checks to see if a report contains a violation of type `warning` + */ +function hasWarningTypeViolations(reportID: string, transactionViolations: OnyxCollection): boolean { + const transactions = reportsTransactions[reportID] ?? []; + return transactions.some((transaction) => TransactionUtils.hasWarningTypeViolation(transaction.transactionID, transactionViolations)); +} + +function hasReportViolations(reportID: string) { + const reportViolations = allReportsViolations?.[`${ONYXKEYS.COLLECTION.REPORT_VIOLATIONS}${reportID}`]; + return Object.values(reportViolations ?? {}).some((violations) => !isEmptyObject(violations)); +} + +/** + * Checks if #admins room chan be shown + * We show #admin rooms when a) More than one admin exists or b) There exists policy audit log for review. + */ +function shouldAdminsRoomBeVisible(report: OnyxEntry): boolean { + const accountIDs = Object.entries(report?.participants ?? {}).map(([accountID]) => Number(accountID)); + const adminAccounts = PersonalDetailsUtils.getLoginsByAccountIDs(accountIDs).filter((login) => !PolicyUtils.isExpensifyTeam(login)); + const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(report?.reportID ?? ''); + if ((lastVisibleAction ? ReportActionsUtils.isCreatedAction(lastVisibleAction) : report?.lastActionType === CONST.REPORT.ACTIONS.TYPE.CREATED) && adminAccounts.length <= 1) { + return false; + } + return true; +} + +/** + * Takes several pieces of data from Onyx and evaluates if a report should be shown in the option list (either when searching + * for reports or the reports shown in the LHN). + * + * This logic is very specific and the order of the logic is very important. It should fail quickly in most cases and also + * filter out the majority of reports before filtering out very specific minority of reports. + */ +function shouldReportBeInOptionList({ + report, + currentReportId, + isInFocusMode, + betas, + policies, + excludeEmptyChats, + doesReportHaveViolations, + includeSelfDM = false, + login, + includeDomainEmail = false, +}: { + report: OnyxEntry; + currentReportId: string; + isInFocusMode: boolean; + betas: OnyxEntry; + policies: OnyxCollection; + excludeEmptyChats: boolean; + doesReportHaveViolations: boolean; + includeSelfDM?: boolean; + login?: string; + includeDomainEmail?: boolean; +}) { + const isInDefaultMode = !isInFocusMode; + // Exclude reports that have no data because there wouldn't be anything to show in the option item. + // This can happen if data is currently loading from the server or a report is in various stages of being created. + // This can also happen for anyone accessing a public room or archived room for which they don't have access to the underlying policy. + // Optionally exclude reports that do not belong to currently active workspace + + const parentReportAction = isThread(report) ? allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`]?.[report.parentReportActionID] : undefined; + + if ( + !report?.reportID || + !report?.type || + report?.reportName === undefined || + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + report?.isHidden || + (!report?.participants && + // We omit sending back participants for chat rooms when searching for reports since they aren't needed to display the results and can get very large. + // So we allow showing rooms with no participants–in any other circumstances we should never have these reports with no participants in Onyx. + !isChatRoom(report) && + !isChatThread(report) && + !isArchivedRoom(report, getReportNameValuePairs(report?.reportID)) && + !isMoneyRequestReport(report) && + !isTaskReport(report) && + !isSelfDM(report) && + !isSystemChat(report) && + !isGroupChat(report)) + ) { + return false; + } + + // We used to use the system DM for A/B testing onboarding tasks, but now only create them in the Concierge chat. We + // still need to allow existing users who have tasks in the system DM to see them, but otherwise we don't need to + // show that chat + if (report?.participants?.[CONST.ACCOUNT_ID.NOTIFICATIONS] && isEmptyReport(report)) { + return false; + } + + if (!canAccessReport(report, policies, betas)) { + return false; + } + + // If this is a transaction thread associated with a report that only has one transaction, omit it + if (isOneTransactionThread(report.reportID, report.parentReportID ?? '-1', parentReportAction)) { + return false; + } + + if ((Object.values(CONST.REPORT.UNSUPPORTED_TYPE) as string[]).includes(report?.type ?? '')) { + return false; + } + + // Include the currently viewed report. If we excluded the currently viewed report, then there + // would be no way to highlight it in the options list and it would be confusing to users because they lose + // a sense of context. + if (report.reportID === currentReportId) { + return true; + } + + // Retrieve the draft comment for the report and convert it to a boolean + const hasDraftComment = hasValidDraftComment(report.reportID); + + // Include reports that are relevant to the user in any view mode. Criteria include having a draft or having a GBR showing. + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (hasDraftComment || requiresAttentionFromCurrentUser(report)) { + return true; + } + + const isEmptyChat = isEmptyReport(report); + const canHideReport = shouldHideReport(report, currentReportId); + + // Include reports if they are pinned + if (report.isPinned) { + return true; + } + + const reportIsSettled = report.statusNum === CONST.REPORT.STATUS_NUM.REIMBURSED; + + // Always show IOU reports with violations unless they are reimbursed + if (isExpenseRequest(report) && doesReportHaveViolations && !reportIsSettled) { + return true; + } + + // Hide only chat threads that haven't been commented on (other threads are actionable) + if (isChatThread(report) && canHideReport && isEmptyChat) { + return false; + } + + // Show #admins room only when it has some value to the user. + if (isAdminRoom(report) && !shouldAdminsRoomBeVisible(report)) { + return false; + } + + // Include reports that have errors from trying to add a workspace + // If we excluded it, then the red-brock-road pattern wouldn't work for the user to resolve the error + if (report.errorFields?.addWorkspaceRoom) { + return true; + } + + // All unread chats (even archived ones) in GSD mode will be shown. This is because GSD mode is specifically for focusing the user on the most relevant chats, primarily, the unread ones + if (isInFocusMode) { + return isUnread(report) && getReportNotificationPreference(report) !== CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE; + } + + // Archived reports should always be shown when in default (most recent) mode. This is because you should still be able to access and search for the chats to find them. + if (isInDefaultMode && isArchivedRoom(report, getReportNameValuePairs(report?.reportID))) { + return true; + } + + // Hide chats between two users that haven't been commented on from the LNH + if (excludeEmptyChats && isEmptyChat && isChatReport(report) && !isChatRoom(report) && !isPolicyExpenseChat(report) && !isSystemChat(report) && !isGroupChat(report) && canHideReport) { + return false; + } + + if (isSelfDM(report)) { + return includeSelfDM; + } + + if (Str.isDomainEmail(login ?? '') && !includeDomainEmail) { + return false; + } + + // Hide chat threads where the parent message is pending removal + if ( + !isEmptyObject(parentReportAction) && + ReportActionsUtils.isPendingRemove(parentReportAction) && + ReportActionsUtils.isThreadParentMessage(parentReportAction, report?.reportID ?? '') + ) { + return false; + } + + return true; +} + +/** + * Returns the system report from the list of reports. + */ +function getSystemChat(): OnyxEntry { + const allReports = ReportConnection.getAllReports(); + if (!allReports) { + return undefined; + } + + return Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.SYSTEM); +} + +/** + * Attempts to find a report in onyx with the provided list of participants. Does not include threads, task, expense, room, and policy expense chat. + */ +function getChatByParticipants(newParticipantList: number[], reports: OnyxCollection = ReportConnection.getAllReports(), shouldIncludeGroupChats = false): OnyxEntry { + const sortedNewParticipantList = newParticipantList.sort(); + return Object.values(reports ?? {}).find((report) => { + const participantAccountIDs = Object.keys(report?.participants ?? {}); + + // Skip if it's not a 1:1 chat + if (!shouldIncludeGroupChats && !isOneOnOneChat(report) && !isSystemChat(report)) { + return false; + } + + // If we are looking for a group chat, then skip non-group chat report + if (shouldIncludeGroupChats && !isGroupChat(report)) { + return false; + } + + const sortedParticipantsAccountIDs = participantAccountIDs.map(Number).sort(); + + // Only return the chat if it has all the participants + return lodashIsEqual(sortedNewParticipantList, sortedParticipantsAccountIDs); + }); +} + +/** + * Attempts to find an invoice chat report in onyx with the provided policyID and receiverID. + */ +function getInvoiceChatByParticipants(policyID: string, receiverID: string | number, reports: OnyxCollection = ReportConnection.getAllReports()): OnyxEntry { + return Object.values(reports ?? {}).find((report) => { + if (!report || !isInvoiceRoom(report) || isArchivedRoom(report)) { + return false; + } + + const isSameReceiver = + report.invoiceReceiver && + (('accountID' in report.invoiceReceiver && report.invoiceReceiver.accountID === receiverID) || + ('policyID' in report.invoiceReceiver && report.invoiceReceiver.policyID === receiverID)); + + return report.policyID === policyID && isSameReceiver; + }); +} + +/** + * Attempts to find a policy expense report in onyx that is owned by ownerAccountID in a given policy + */ +function getPolicyExpenseChat(ownerAccountID: number, policyID: string): OnyxEntry { + return Object.values(ReportConnection.getAllReports() ?? {}).find((report: OnyxEntry) => { + // If the report has been deleted, then skip it + if (!report) { + return false; + } + + return report.policyID === policyID && isPolicyExpenseChat(report) && report.ownerAccountID === ownerAccountID; + }); +} + +function getAllPolicyReports(policyID: string): Array> { + return Object.values(ReportConnection.getAllReports() ?? {}).filter((report) => report?.policyID === policyID); +} + +/** + * Returns true if Chronos is one of the chat participants (1:1) + */ +function chatIncludesChronos(report: OnyxInputOrEntry): boolean { + const participantAccountIDs = Object.keys(report?.participants ?? {}).map(Number); + return participantAccountIDs.includes(CONST.ACCOUNT_ID.CHRONOS); +} + +function chatIncludesChronosWithID(reportID?: string): boolean { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const report = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID || -1}`]; + return chatIncludesChronos(report); +} + +/** + * Can only flag if: + * + * - It was written by someone else and isn't a whisper + * - It's a welcome message whisper + * - It's an ADD_COMMENT that is not an attachment + */ +function canFlagReportAction(reportAction: OnyxInputOrEntry, reportID: string | undefined): boolean { + let report = getReportOrDraftReport(reportID); + + // If the childReportID exists in reportAction and is equal to the reportID, + // the report action being evaluated is the parent report action in a thread, and we should get the parent report to evaluate instead. + if (reportAction?.childReportID?.toString() === reportID?.toString()) { + report = getReportOrDraftReport(report?.parentReportID); + } + const isCurrentUserAction = reportAction?.actorAccountID === currentUserAccountID; + if (ReportActionsUtils.isWhisperAction(reportAction)) { + // Allow flagging whispers that are sent by other users + if (!isCurrentUserAction && reportAction?.actorAccountID !== CONST.ACCOUNT_ID.CONCIERGE) { + return true; + } + + // Disallow flagging the rest of whisper as they are sent by us + return false; + } + + return !!( + !isCurrentUserAction && + reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT && + !ReportActionsUtils.isDeletedAction(reportAction) && + !ReportActionsUtils.isCreatedTaskReportAction(reportAction) && + !isEmptyObject(report) && + report && + isAllowedToComment(report) + ); +} + +/** + * Whether flag comment page should show + */ +function shouldShowFlagComment(reportAction: OnyxInputOrEntry, report: OnyxInputOrEntry): boolean { + return ( + canFlagReportAction(reportAction, report?.reportID) && + !isArchivedRoom(report, getReportNameValuePairs(report?.reportID)) && + !chatIncludesChronos(report) && + !isConciergeChatReport(report) && + reportAction?.actorAccountID !== CONST.ACCOUNT_ID.CONCIERGE + ); +} + +/** + * @param sortedAndFilteredReportActions - reportActions for the report, sorted newest to oldest, and filtered for only those that should be visible + */ +function getNewMarkerReportActionID(report: OnyxEntry, sortedAndFilteredReportActions: ReportAction[]): string { + if (!isUnread(report)) { + return ''; + } + + const newMarkerIndex = lodashFindLastIndex(sortedAndFilteredReportActions, (reportAction) => (reportAction.created ?? '') > (report?.lastReadTime ?? '')); + + return 'reportActionID' in sortedAndFilteredReportActions[newMarkerIndex] ? sortedAndFilteredReportActions[newMarkerIndex].reportActionID : ''; +} + +/** + * Performs the markdown conversion, and replaces code points > 127 with C escape sequences + * Used for compatibility with the backend auth validator for AddComment, and to account for MD in comments + * @returns The comment's total length as seen from the backend + */ +function getCommentLength(textComment: string, parsingDetails?: ParsingDetails): number { + return getParsedComment(textComment, parsingDetails) + .replace(/[^ -~]/g, '\\u????') + .trim().length; +} + +function getRouteFromLink(url: string | null): string { + if (!url) { + return ''; + } + + // Get the reportID from URL + let route = url; + const localWebAndroidRegEx = /^(https:\/\/([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3}))/; + linkingConfig.prefixes.forEach((prefix) => { + if (route.startsWith(prefix)) { + route = route.replace(prefix, ''); + } else if (localWebAndroidRegEx.test(route)) { + route = route.replace(localWebAndroidRegEx, ''); + } else { + return; + } + + // Remove the port if it's a localhost URL + if (/^:\d+/.test(route)) { + route = route.replace(/:\d+/, ''); + } + + // Remove the leading slash if exists + if (route.startsWith('/')) { + route = route.replace('/', ''); + } + }); + return route; +} + +function parseReportRouteParams(route: string): ReportRouteParams { + let parsingRoute = route; + if (parsingRoute.at(0) === '/') { + // remove the first slash + parsingRoute = parsingRoute.slice(1); + } + + if (!parsingRoute.startsWith(Url.addTrailingForwardSlash(ROUTES.REPORT))) { + return {reportID: '', isSubReportPageRoute: false}; + } + + const pathSegments = parsingRoute.split('/'); + + const reportIDSegment = pathSegments[1]; + const hasRouteReportActionID = !Number.isNaN(Number(pathSegments[2])); + + // Check for "undefined" or any other unwanted string values + if (!reportIDSegment || reportIDSegment === 'undefined') { + return {reportID: '', isSubReportPageRoute: false}; + } + + return { + reportID: reportIDSegment, + isSubReportPageRoute: pathSegments.length > 2 && !hasRouteReportActionID, + }; +} + +function getReportIDFromLink(url: string | null): string { + const route = getRouteFromLink(url); + const {reportID, isSubReportPageRoute} = parseReportRouteParams(route); + if (isSubReportPageRoute) { + // We allow the Sub-Report deep link routes (settings, details, etc.) to be handled by their respective component pages + return ''; + } + return reportID; +} + +/** + * Check if the chat report is linked to an iou that is waiting for the current user to add a credit bank account. + */ +function hasIOUWaitingOnCurrentUserBankAccount(chatReport: OnyxInputOrEntry): boolean { + if (chatReport?.iouReportID) { + const iouReport = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReport?.iouReportID}`]; + if (iouReport?.isWaitingOnBankAccount && iouReport?.ownerAccountID === currentUserAccountID) { + return true; + } + } + + return false; +} + +/** + * Users can submit an expense: + * - in policy expense chats only if they are in a role of a member in the chat (in other words, if it's their policy expense chat) + * - in an open or submitted expense report tied to a policy expense chat the user owns + * - employee can submit expenses in a submitted expense report only if the policy has Instant Submit settings turned on + * - in an IOU report, which is not settled yet + * - in a 1:1 DM chat + */ +function canRequestMoney(report: OnyxEntry, policy: OnyxEntry, otherParticipants: number[]): boolean { + // User cannot submit expenses in a chat thread, task report or in a chat room + if (isChatThread(report) || isTaskReport(report) || isChatRoom(report) || isSelfDM(report) || isGroupChat(report)) { + return false; + } + + // Users can only submit expenses in DMs if they are a 1:1 DM + if (isDM(report)) { + return otherParticipants.length === 1; + } + + // Prevent requesting money if pending IOU report waiting for their bank account already exists + if (hasIOUWaitingOnCurrentUserBankAccount(report)) { + return false; + } + + let isOwnPolicyExpenseChat = report?.isOwnPolicyExpenseChat ?? false; + if (isExpenseReport(report) && getParentReport(report)) { + isOwnPolicyExpenseChat = !!getParentReport(report)?.isOwnPolicyExpenseChat; + } + + // In case there are no other participants than the current user and it's not user's own policy expense chat, they can't submit expenses from such report + if (otherParticipants.length === 0 && !isOwnPolicyExpenseChat) { + return false; + } + + // Current user must be a manager or owner of this IOU + if (isIOUReport(report) && currentUserAccountID !== report?.managerID && currentUserAccountID !== report?.ownerAccountID) { + return false; + } + + // User can submit expenses in any IOU report, unless paid, but the user can only submit expenses in an expense report + // which is tied to their workspace chat. + if (isMoneyRequestReport(report)) { + const canAddTransactions = canAddTransaction(report); + return isReportInGroupPolicy(report) ? isOwnPolicyExpenseChat && canAddTransactions : canAddTransactions; + } + + // In the case of policy expense chat, users can only submit expenses from their own policy expense chat + return !isPolicyExpenseChat(report) || isOwnPolicyExpenseChat; +} + +function isGroupChatAdmin(report: OnyxEntry, accountID: number) { + if (!report?.participants) { + return false; + } + + const reportParticipants = report.participants ?? {}; + const participant = reportParticipants[accountID]; + return participant?.role === CONST.REPORT.ROLE.ADMIN; +} + +/** + * Helper method to define what expense options we want to show for particular method. + * There are 4 expense options: Submit, Split, Pay and Track expense: + * - Submit option should show for: + * - DMs + * - own policy expense chats + * - open and processing expense reports tied to own policy expense chat + * - unsettled IOU reports + * - Pay option should show for: + * - DMs + * - Split options should show for: + * - DMs + * - chat/policy rooms with more than 1 participant + * - groups chats with 2 and more participants + * - corporate workspace chats + * - Track expense option should show for: + * - Self DMs + * - own policy expense chats + * - open and processing expense reports tied to own policy expense chat + * - Send invoice option should show for: + * - invoice rooms if the user is an admin of the sender workspace + * None of the options should show in chat threads or if there is some special Expensify account + * as a participant of the report. + */ +function getMoneyRequestOptions(report: OnyxEntry, policy: OnyxEntry, reportParticipants: number[], filterDeprecatedTypes = false): IOUType[] { + // In any thread or task report, we do not allow any new expenses yet + if (isChatThread(report) || isTaskReport(report) || isInvoiceReport(report) || isSystemChat(report)) { + return []; + } + + if (isInvoiceRoom(report)) { + // TODO: Uncomment the following line when the invoices screen is ready - https://github.com/Expensify/App/issues/45175. + // if (PolicyUtils.canSendInvoiceFromWorkspace(policy?.id) && isPolicyAdmin(report?.policyID ?? '-1', allPolicies)) { + if (isPolicyAdmin(report?.policyID ?? '-1', allPolicies)) { + return [CONST.IOU.TYPE.INVOICE]; + } + return []; + } + + // We don't allow IOU actions if an Expensify account is a participant of the report, unless the policy that the report is on is owned by an Expensify account + const doParticipantsIncludeExpensifyAccounts = lodashIntersection(reportParticipants, CONST.EXPENSIFY_ACCOUNT_IDS).length > 0; + const isPolicyOwnedByExpensifyAccounts = report?.policyID ? CONST.EXPENSIFY_ACCOUNT_IDS.includes(getPolicy(report?.policyID ?? '-1')?.ownerAccountID ?? -1) : false; + if (doParticipantsIncludeExpensifyAccounts && !isPolicyOwnedByExpensifyAccounts) { + return []; + } + + const otherParticipants = reportParticipants.filter((accountID) => currentUserPersonalDetails?.accountID !== accountID); + const hasSingleParticipantInReport = otherParticipants.length === 1; + let options: IOUType[] = []; + + if (isSelfDM(report)) { + options = [CONST.IOU.TYPE.TRACK]; + } + + // User created policy rooms and default rooms like #admins or #announce will always have the Split Expense option + // unless there are no other participants at all (e.g. #admins room for a policy with only 1 admin) + // DM chats will have the Split Expense option. + // Your own workspace chats will have the split expense option. + if ( + (isChatRoom(report) && !isAnnounceRoom(report) && otherParticipants.length > 0) || + (isDM(report) && otherParticipants.length > 0) || + (isGroupChat(report) && otherParticipants.length > 0) || + (isPolicyExpenseChat(report) && report?.isOwnPolicyExpenseChat) + ) { + options = [CONST.IOU.TYPE.SPLIT]; + } + + if (canRequestMoney(report, policy, otherParticipants)) { + options = [...options, CONST.IOU.TYPE.SUBMIT]; + if (!filterDeprecatedTypes) { + options = [...options, CONST.IOU.TYPE.REQUEST]; + } + + // If the user can request money from the workspace report, they can also track expenses + if (isPolicyExpenseChat(report) || isExpenseReport(report)) { + options = [...options, CONST.IOU.TYPE.TRACK]; + } + } + + // Pay someone option should be visible only in 1:1 DMs + if (isDM(report) && hasSingleParticipantInReport) { + options = [...options, CONST.IOU.TYPE.PAY]; + if (!filterDeprecatedTypes) { + options = [...options, CONST.IOU.TYPE.SEND]; + } + } + + return options; +} + +/** + * This is a temporary function to help with the smooth transition with the oldDot. + * This function will be removed once the transition occurs in oldDot to new links. + */ +// eslint-disable-next-line @typescript-eslint/naming-convention +function temporary_getMoneyRequestOptions( + report: OnyxEntry, + policy: OnyxEntry, + reportParticipants: number[], +): Array> { + return getMoneyRequestOptions(report, policy, reportParticipants, true) as Array>; +} + +/** + * Invoice sender, invoice receiver and auto-invited admins cannot leave + */ +function canLeaveInvoiceRoom(report: OnyxEntry): boolean { + if (!report || !report?.invoiceReceiver) { + return false; + } + + if (report?.statusNum === CONST.REPORT.STATUS_NUM.CLOSED) { + return false; + } + + const isSenderPolicyAdmin = getPolicy(report.policyID)?.role === CONST.POLICY.ROLE.ADMIN; + + if (isSenderPolicyAdmin) { + return false; + } + + if (report.invoiceReceiver.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL) { + return report?.invoiceReceiver?.accountID !== currentUserAccountID; + } + + const isReceiverPolicyAdmin = getPolicy(report.invoiceReceiver.policyID)?.role === CONST.POLICY.ROLE.ADMIN; + + if (isReceiverPolicyAdmin) { + return false; + } + + return true; +} + +/** + * Allows a user to leave a policy room according to the following conditions of the visibility or chatType rNVP: + * `public` - Anyone can leave (because anybody can join) + * `public_announce` - Only non-policy members can leave (it's auto-shared with policy members) + * `policy_admins` - Nobody can leave (it's auto-shared with all policy admins) + * `policy_announce` - Nobody can leave (it's auto-shared with all policy members) + * `policyExpenseChat` - Nobody can leave (it's auto-shared with all policy members) + * `policy` - Anyone can leave (though only policy members can join) + * `domain` - Nobody can leave (it's auto-shared with domain members) + * `dm` - Nobody can leave (it's auto-shared with users) + * `private` - Anybody can leave (though you can only be invited to join) + * `invoice` - Invoice sender, invoice receiver and auto-invited admins cannot leave + */ +function canLeaveRoom(report: OnyxEntry, isPolicyEmployee: boolean): boolean { + if (isInvoiceRoom(report)) { + if (isArchivedRoom(report, getReportNameValuePairs(report?.reportID))) { + return false; + } + + const invoiceReport = getReportOrDraftReport(report?.iouReportID ?? '-1'); + + if (invoiceReport?.ownerAccountID === currentUserAccountID) { + return false; + } + + if (invoiceReport?.managerID === currentUserAccountID) { + return false; + } + + const isSenderPolicyAdmin = getPolicy(report?.policyID)?.role === CONST.POLICY.ROLE.ADMIN; + + if (isSenderPolicyAdmin) { + return false; + } + + const isReceiverPolicyAdmin = + report?.invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.BUSINESS ? getPolicy(report?.invoiceReceiver?.policyID)?.role === CONST.POLICY.ROLE.ADMIN : false; + + if (isReceiverPolicyAdmin) { + return false; + } + + return true; + } + + if (!report?.visibility) { + if ( + report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_ADMINS || + report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE || + report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT || + report?.chatType === CONST.REPORT.CHAT_TYPE.DOMAIN_ALL || + report?.chatType === CONST.REPORT.CHAT_TYPE.SELF_DM || + !report?.chatType + ) { + // DM chats don't have a chatType + return false; + } + } else if (isPublicAnnounceRoom(report) && isPolicyEmployee) { + return false; + } + return true; +} + +function isCurrentUserTheOnlyParticipant(participantAccountIDs?: number[]): boolean { + return !!(participantAccountIDs?.length === 1 && participantAccountIDs?.[0] === currentUserAccountID); +} + +/** + * Returns display names for those that can see the whisper. + * However, it returns "you" if the current user is the only one who can see it besides the person that sent it. + */ +function getWhisperDisplayNames(participantAccountIDs?: number[]): string | undefined { + const isWhisperOnlyVisibleToCurrentUser = isCurrentUserTheOnlyParticipant(participantAccountIDs); + + // When the current user is the only participant, the display name needs to be "you" because that's the only person reading it + if (isWhisperOnlyVisibleToCurrentUser) { + return Localize.translateLocal('common.youAfterPreposition'); + } + + return participantAccountIDs?.map((accountID) => getDisplayNameForParticipant(accountID, !isWhisperOnlyVisibleToCurrentUser)).join(', '); +} + +/** + * Show subscript on workspace chats / threads and expense requests + */ +function shouldReportShowSubscript(report: OnyxEntry): boolean { + if (isArchivedRoom(report, getReportNameValuePairs(report?.reportID)) && !isWorkspaceThread(report)) { + return false; + } + + if (isPolicyExpenseChat(report) && !isChatThread(report) && !isTaskReport(report) && !report?.isOwnPolicyExpenseChat) { + return true; + } + + if (isPolicyExpenseChat(report) && !isThread(report) && !isTaskReport(report)) { + return true; + } + + if (isExpenseRequest(report)) { + return true; + } + + if (isExpenseReport(report) && isOneTransactionReport(report?.reportID ?? '-1')) { + return true; + } + + if (isWorkspaceTaskReport(report)) { + return true; + } + + if (isWorkspaceThread(report)) { + return true; + } + + if (isInvoiceRoom(report)) { + return true; + } + + return false; +} + +/** + * Return true if reports data exists + */ +function isReportDataReady(): boolean { + const allReports = ReportConnection.getAllReports(); + return !isEmptyObject(allReports) && Object.keys(allReports ?? {}).some((key) => allReports?.[key]?.reportID); +} + +/** + * Return true if reportID from path is valid + */ +function isValidReportIDFromPath(reportIDFromPath: string): boolean { + return !['', 'null', '0', '-1'].includes(reportIDFromPath); +} + +/** + * Return the errors we have when creating a chat or a workspace room + */ +function getAddWorkspaceRoomOrChatReportErrors(report: OnyxEntry): Errors | null | undefined { + // We are either adding a workspace room, or we're creating a chat, it isn't possible for both of these to have errors for the same report at the same time, so + // simply looking up the first truthy value will get the relevant property if it's set. + return report?.errorFields?.addWorkspaceRoom ?? report?.errorFields?.createChat; +} + +/** + * Return true if the expense report is marked for deletion. + */ +function isMoneyRequestReportPendingDeletion(reportOrID: OnyxEntry | string): boolean { + const report = typeof reportOrID === 'string' ? ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportOrID}`] : reportOrID; + if (!isMoneyRequestReport(report)) { + return false; + } + + const parentReportAction = ReportActionsUtils.getReportAction(report?.parentReportID ?? '-1', report?.parentReportActionID ?? '-1'); + return parentReportAction?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; +} + +function canUserPerformWriteAction(report: OnyxEntry) { + const reportErrors = getAddWorkspaceRoomOrChatReportErrors(report); + + // If the expense report is marked for deletion, let us prevent any further write action. + if (isMoneyRequestReportPendingDeletion(report)) { + return false; + } + + const reportNameValuePairs = getReportNameValuePairs(report?.reportID); + return !isArchivedRoom(report, reportNameValuePairs) && isEmptyObject(reportErrors) && report && isAllowedToComment(report) && !isAnonymousUser && canWriteInReport(report); +} + +/** + * Returns ID of the original report from which the given reportAction is first created. + */ +function getOriginalReportID(reportID: string, reportAction: OnyxInputOrEntry): string | undefined { + const reportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`]; + const currentReportAction = reportActions?.[reportAction?.reportActionID ?? '-1'] ?? null; + const transactionThreadReportID = ReportActionsUtils.getOneTransactionThreadReportID(reportID, reportActions ?? ([] as ReportAction[])); + if (Object.keys(currentReportAction ?? {}).length === 0) { + return isThreadFirstChat(reportAction, reportID) + ? ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]?.parentReportID + : transactionThreadReportID ?? reportID; + } + return reportID; +} + +/** + * Return the pendingAction and the errors resulting from either + * + * - creating a workspace room + * - starting a chat + * - paying the expense + * + * while being offline + */ +function getReportOfflinePendingActionAndErrors(report: OnyxEntry): ReportOfflinePendingActionAndErrors { + // It shouldn't be possible for all of these actions to be pending (or to have errors) for the same report at the same time, so just take the first that exists + const reportPendingAction = report?.pendingFields?.addWorkspaceRoom ?? report?.pendingFields?.createChat ?? report?.pendingFields?.reimbursed; + + const reportErrors = getAddWorkspaceRoomOrChatReportErrors(report); + return {reportPendingAction, reportErrors}; +} + +/** + * Check if the report can create the expense with type is iouType + */ +function canCreateRequest(report: OnyxEntry, policy: OnyxEntry, iouType: ValueOf): boolean { + const participantAccountIDs = Object.keys(report?.participants ?? {}).map(Number); + if (!canUserPerformWriteAction(report)) { + return false; + } + return getMoneyRequestOptions(report, policy, participantAccountIDs).includes(iouType); +} + +function getWorkspaceChats(policyID: string, accountIDs: number[]): Array> { + const allReports = ReportConnection.getAllReports(); + return Object.values(allReports ?? {}).filter((report) => isPolicyExpenseChat(report) && (report?.policyID ?? '-1') === policyID && accountIDs.includes(report?.ownerAccountID ?? -1)); +} + +/** + * Gets all reports that relate to the policy + * + * @param policyID - the workspace ID to get all associated reports + */ +function getAllWorkspaceReports(policyID: string): Array> { + const allReports = ReportConnection.getAllReports(); + return Object.values(allReports ?? {}).filter((report) => (report?.policyID ?? '-1') === policyID); +} + +/** + * @param policy - the workspace the report is on, null if the user isn't a member of the workspace + */ +function shouldDisableRename(report: OnyxEntry): boolean { + if ( + isDefaultRoom(report) || + isArchivedRoom(report, getReportNameValuePairs(report?.reportID)) || + isPublicRoom(report) || + isThread(report) || + isMoneyRequest(report) || + isMoneyRequestReport(report) || + isPolicyExpenseChat(report) || + isInvoiceRoom(report) || + isInvoiceReport(report) || + isSystemChat(report) + ) { + return true; + } + + if (isGroupChat(report)) { + return false; + } + + if (isDeprecatedGroupDM(report) || isTaskReport(report)) { + return true; + } + + return false; +} + +/** + * @param policy - the workspace the report is on, null if the user isn't a member of the workspace + */ +function canEditWriteCapability(report: OnyxEntry, policy: OnyxEntry): boolean { + return PolicyUtils.isPolicyAdmin(policy) && !isAdminRoom(report) && !isArchivedRoom(report, getReportNameValuePairs(report?.reportID)) && !isThread(report) && !isInvoiceRoom(report); +} + +/** + * @param policy - the workspace the report is on, null if the user isn't a member of the workspace + */ +function canEditRoomVisibility(report: OnyxEntry, policy: OnyxEntry): boolean { + return PolicyUtils.isPolicyAdmin(policy) && !isArchivedRoom(report, getReportNameValuePairs(report?.reportID)); +} + +/** + * Returns the onyx data needed for the task assignee chat + */ +function getTaskAssigneeChatOnyxData( + accountID: number, + assigneeAccountID: number, + taskReportID: string, + assigneeChatReportID: string, + parentReportID: string, + title: string, + assigneeChatReport: OnyxEntry, +): OnyxDataTaskAssigneeChat { + // Set if we need to add a comment to the assignee chat notifying them that they have been assigned a task + let optimisticAssigneeAddComment: OptimisticReportAction | undefined; + // Set if this is a new chat that needs to be created for the assignee + let optimisticChatCreatedReportAction: OptimisticCreatedReportAction | undefined; + const currentTime = DateUtils.getDBTime(); + const optimisticData: OnyxUpdate[] = []; + const successData: OnyxUpdate[] = []; + const failureData: OnyxUpdate[] = []; + + // You're able to assign a task to someone you haven't chatted with before - so we need to optimistically create the chat and the chat reportActions + // Only add the assignee chat report to onyx if we haven't already set it optimistically + if (assigneeChatReport?.isOptimisticReport && assigneeChatReport.pendingFields?.createChat !== CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) { + optimisticChatCreatedReportAction = buildOptimisticCreatedReportAction(assigneeChatReportID); + optimisticData.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${assigneeChatReportID}`, + value: { + pendingFields: { + createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + isHidden: false, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${assigneeChatReportID}`, + value: {[optimisticChatCreatedReportAction.reportActionID]: optimisticChatCreatedReportAction as Partial}, + }, + ); + + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${assigneeChatReportID}`, + value: { + pendingFields: { + createChat: null, + }, + isOptimisticReport: false, + // BE will send a different participant. We clear the optimistic one to avoid duplicated entries + participants: {[assigneeAccountID]: null}, + }, + }); + + failureData.push( + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${assigneeChatReportID}`, + value: null, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${assigneeChatReportID}`, + value: {[optimisticChatCreatedReportAction.reportActionID]: {pendingAction: null}}, + }, + // If we failed, we want to remove the optimistic personal details as it was likely due to an invalid login + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + value: { + [assigneeAccountID]: null, + }, + }, + ); + } + + // If you're choosing to share the task in the same DM as the assignee then we don't need to create another reportAction indicating that you've been assigned + if (assigneeChatReportID !== parentReportID) { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const displayname = allPersonalDetails?.[assigneeAccountID]?.displayName || allPersonalDetails?.[assigneeAccountID]?.login || ''; + optimisticAssigneeAddComment = buildOptimisticTaskCommentReportAction(taskReportID, title, assigneeAccountID, `assigned to ${displayname}`, parentReportID); + const lastAssigneeCommentText = formatReportLastMessageText(ReportActionsUtils.getReportActionText(optimisticAssigneeAddComment.reportAction as ReportAction)); + const optimisticAssigneeReport = { + lastVisibleActionCreated: currentTime, + lastMessageText: lastAssigneeCommentText, + lastActorAccountID: accountID, + lastReadTime: currentTime, + }; + + optimisticData.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${assigneeChatReportID}`, + value: {[optimisticAssigneeAddComment.reportAction.reportActionID ?? '-1']: optimisticAssigneeAddComment.reportAction as ReportAction}, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${assigneeChatReportID}`, + value: optimisticAssigneeReport, + }, + ); + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${assigneeChatReportID}`, + value: {[optimisticAssigneeAddComment.reportAction.reportActionID ?? '-1']: {isOptimisticAction: null}}, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${assigneeChatReportID}`, + value: {[optimisticAssigneeAddComment.reportAction.reportActionID ?? '-1']: {pendingAction: null}}, + }); + } + + return { + optimisticData, + successData, + failureData, + optimisticAssigneeAddComment, + optimisticChatCreatedReportAction, + }; +} + +/** + * Return iou report action display message + */ +function getIOUReportActionDisplayMessage(reportAction: OnyxEntry, transaction?: OnyxEntry): string { + if (!ReportActionsUtils.isMoneyRequestAction(reportAction)) { + return ''; + } + const originalMessage = ReportActionsUtils.getOriginalMessage(reportAction); + const {IOUReportID} = originalMessage ?? {}; + const iouReport = getReportOrDraftReport(IOUReportID); + let translationKey: TranslationPaths; + if (originalMessage?.type === CONST.IOU.REPORT_ACTION_TYPE.PAY) { + // The `REPORT_ACTION_TYPE.PAY` action type is used for both fulfilling existing requests and sending money. To + // differentiate between these two scenarios, we check if the `originalMessage` contains the `IOUDetails` + // property. If it does, it indicates that this is a 'Pay someone' action. + const {amount, currency} = originalMessage?.IOUDetails ?? originalMessage ?? {}; + const formattedAmount = CurrencyUtils.convertToDisplayString(Math.abs(amount), currency) ?? ''; + + switch (originalMessage.paymentType) { + case CONST.IOU.PAYMENT_TYPE.ELSEWHERE: + translationKey = hasMissingInvoiceBankAccount(IOUReportID ?? '-1') ? 'iou.payerSettledWithMissingBankAccount' : 'iou.paidElsewhereWithAmount'; + break; + case CONST.IOU.PAYMENT_TYPE.EXPENSIFY: + case CONST.IOU.PAYMENT_TYPE.VBBA: + translationKey = 'iou.paidWithExpensifyWithAmount'; + break; + default: + translationKey = 'iou.payerPaidAmount'; + break; + } + return Localize.translateLocal(translationKey, {amount: formattedAmount, payer: ''}); + } + + const amount = TransactionUtils.getAmount(transaction, !isEmptyObject(iouReport) && isExpenseReport(iouReport)) ?? 0; + const formattedAmount = CurrencyUtils.convertToDisplayString(amount, TransactionUtils.getCurrency(transaction)) ?? ''; + const isRequestSettled = isSettled(originalMessage?.IOUReportID); + const isApproved = isReportApproved(iouReport); + if (isRequestSettled) { + return Localize.translateLocal('iou.payerSettled', { + amount: formattedAmount, + }); + } + if (isApproved) { + return Localize.translateLocal('iou.approvedAmount', { + amount: formattedAmount, + }); + } + if (ReportActionsUtils.isSplitBillAction(reportAction)) { + translationKey = 'iou.didSplitAmount'; + } else if (ReportActionsUtils.isTrackExpenseAction(reportAction)) { + translationKey = 'iou.trackedAmount'; + } else { + translationKey = 'iou.submittedAmount'; + } + return Localize.translateLocal(translationKey, { + formattedAmount, + comment: TransactionUtils.getDescription(transaction) ?? '', + }); +} + +/** + * Checks if a report is a group chat. + * + * A report is a group chat if it meets the following conditions: + * - Not a chat thread. + * - Not a task report. + * - Not an expense / IOU report. + * - Not an archived room. + * - Not a public / admin / announce chat room (chat type doesn't match any of the specified types). + * - More than 2 participants. + * + */ +function isDeprecatedGroupDM(report: OnyxEntry): boolean { + return !!( + report && + !isChatThread(report) && + !isTaskReport(report) && + !isInvoiceReport(report) && + !isMoneyRequestReport(report) && + !isArchivedRoom(report, getReportNameValuePairs(report?.reportID)) && + !Object.values(CONST.REPORT.CHAT_TYPE).some((chatType) => chatType === getChatType(report)) && + Object.keys(report.participants ?? {}) + .map(Number) + .filter((accountID) => accountID !== currentUserAccountID).length > 1 + ); +} + +/** + * A "root" group chat is the top level group chat and does not refer to any threads off of a Group Chat + */ +function isRootGroupChat(report: OnyxEntry): boolean { + return !isChatThread(report) && (isGroupChat(report) || isDeprecatedGroupDM(report)); +} + +/** + * Assume any report without a reportID is unusable. + */ +function isValidReport(report?: OnyxEntry): boolean { + return !!report?.reportID; +} + +/** + * Check to see if we are a participant of this report. + */ +function isReportParticipant(accountID: number, report: OnyxEntry): boolean { + if (!accountID) { + return false; + } + + const possibleAccountIDs = Object.keys(report?.participants ?? {}).map(Number); + if (report?.ownerAccountID) { + possibleAccountIDs.push(report?.ownerAccountID); + } + if (report?.managerID) { + possibleAccountIDs.push(report?.managerID); + } + return possibleAccountIDs.includes(accountID); +} + +/** + * Check to see if the current user has access to view the report. + */ +function canCurrentUserOpenReport(report: OnyxEntry): boolean { + return (isReportParticipant(currentUserAccountID ?? 0, report) || isPublicRoom(report)) && canAccessReport(report, allPolicies, allBetas); +} + +function shouldUseFullTitleToDisplay(report: OnyxEntry): boolean { + return ( + isMoneyRequestReport(report) || isPolicyExpenseChat(report) || isChatRoom(report) || isChatThread(report) || isTaskReport(report) || isGroupChat(report) || isInvoiceReport(report) + ); +} + +function getRoom(type: ValueOf, policyID: string): OnyxEntry { + const room = Object.values(ReportConnection.getAllReports() ?? {}).find((report) => report?.policyID === policyID && report?.chatType === type && !isThread(report)); + return room; +} + +/** + * We only want policy members who are members of the report to be able to modify the report description, but not in thread chat. + */ +function canEditReportDescription(report: OnyxEntry, policy: OnyxEntry): boolean { + return ( + !isMoneyRequestReport(report) && + !isArchivedRoom(report, getReportNameValuePairs(report?.reportID)) && + isChatRoom(report) && + !isChatThread(report) && + !isEmpty(policy) && + hasParticipantInArray(report, [currentUserAccountID ?? 0]) && + !isAuditor(report) + ); +} + +function canEditPolicyDescription(policy: OnyxEntry): boolean { + return PolicyUtils.isPolicyAdmin(policy); +} + +/** + * Checks if report action has error when smart scanning + */ +function hasSmartscanError(reportActions: ReportAction[]) { + return reportActions.some((action) => { + const isReportPreview = ReportActionsUtils.isReportPreviewAction(action); + const isSplitReportAction = ReportActionsUtils.isSplitBillAction(action); + if (!isSplitReportAction && !isReportPreview) { + return false; + } + const IOUReportID = ReportActionsUtils.getIOUReportIDFromReportActionPreview(action); + const isReportPreviewError = isReportPreview && shouldShowRBRForMissingSmartscanFields(IOUReportID) && !isSettled(IOUReportID); + if (isReportPreviewError) { + return true; + } + + const transactionID = ReportActionsUtils.isMoneyRequestAction(action) ? ReportActionsUtils.getOriginalMessage(action)?.IOUTransactionID ?? '-1' : '-1'; + const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] ?? {}; + const isSplitBillError = isSplitReportAction && TransactionUtils.hasMissingSmartscanFields(transaction as Transaction); + + return isSplitBillError; + }); +} + +function shouldAutoFocusOnKeyPress(event: KeyboardEvent): boolean { + if (event.key.length > 1) { + return false; + } + + // If a key is pressed in combination with Meta, Control or Alt do not focus + if (event.ctrlKey || event.metaKey) { + return false; + } + + if (event.code === 'Space') { + return false; + } + + return true; +} + +/** + * Navigates to the appropriate screen based on the presence of a private note for the current user. + */ +function navigateToPrivateNotes(report: OnyxEntry, session: OnyxEntry) { + if (isEmpty(report) || isEmpty(session) || !session.accountID) { + return; + } + const currentUserPrivateNote = report.privateNotes?.[session.accountID]?.note ?? ''; + if (isEmpty(currentUserPrivateNote)) { + Navigation.navigate(ROUTES.PRIVATE_NOTES_EDIT.getRoute(report.reportID, session.accountID)); + return; + } + Navigation.navigate(ROUTES.PRIVATE_NOTES_LIST.getRoute(report.reportID)); +} + +/** + * Get all held transactions of a iouReport + */ +function getAllHeldTransactions(iouReportID?: string): Transaction[] { + const transactions = reportsTransactions[iouReportID ?? ''] ?? []; + return transactions.filter((transaction) => TransactionUtils.isOnHold(transaction)); +} + +/** + * Check if Report has any held expenses + */ +function hasHeldExpenses(iouReportID?: string): boolean { + const transactions = reportsTransactions[iouReportID ?? ''] ?? []; + return transactions.some((transaction) => TransactionUtils.isOnHold(transaction)); +} + +/** + * Check if all expenses in the Report are on hold + */ +function hasOnlyHeldExpenses(iouReportID: string): boolean { + const reportTransactions = reportsTransactions[iouReportID ?? ''] ?? []; + return reportTransactions.length > 0 && !reportTransactions.some((transaction) => !TransactionUtils.isOnHold(transaction)); +} + +/** + * Checks if thread replies should be displayed + */ +function shouldDisplayThreadReplies(reportAction: OnyxInputOrEntry, reportID: string): boolean { + const hasReplies = (reportAction?.childVisibleActionCount ?? 0) > 0; + return hasReplies && !!reportAction?.childCommenterCount && !isThreadFirstChat(reportAction, reportID); +} + +/** + * Check if money report has any transactions updated optimistically + */ +function hasUpdatedTotal(report: OnyxInputOrEntry, policy: OnyxInputOrEntry): boolean { + if (!report) { + return true; + } + + const allReportTransactions = reportsTransactions[report.reportID] ?? []; + + const hasPendingTransaction = allReportTransactions.some((transaction) => !!transaction.pendingAction); + const hasTransactionWithDifferentCurrency = allReportTransactions.some((transaction) => transaction.currency !== report.currency); + const hasDifferentWorkspaceCurrency = report.pendingFields?.createChat && isExpenseReport(report) && report.currency !== policy?.outputCurrency; + const hasOptimisticHeldExpense = hasHeldExpenses(report.reportID) && report?.unheldTotal === undefined; + + return !(hasPendingTransaction && (hasTransactionWithDifferentCurrency || hasDifferentWorkspaceCurrency)) && !hasOptimisticHeldExpense; +} + +/** + * Return held and full amount formatted with used currency + */ +function getNonHeldAndFullAmount(iouReport: OnyxEntry, policy: OnyxEntry): string[] { + const reportTransactions = reportsTransactions[iouReport?.reportID ?? ''] ?? []; + const hasPendingTransaction = reportTransactions.some((transaction) => !!transaction.pendingAction); + + // if the report is an expense report, the total amount should be negated + const coefficient = isExpenseReport(iouReport) ? -1 : 1; + + if (hasUpdatedTotal(iouReport, policy) && hasPendingTransaction) { + const unheldTotal = reportTransactions.reduce((currentVal, transaction) => currentVal + (!TransactionUtils.isOnHold(transaction) ? transaction.amount : 0), 0); + + return [ + CurrencyUtils.convertToDisplayString(unheldTotal * coefficient, iouReport?.currency), + CurrencyUtils.convertToDisplayString((iouReport?.total ?? 0) * coefficient, iouReport?.currency), + ]; + } + + return [ + CurrencyUtils.convertToDisplayString((iouReport?.unheldTotal ?? 0) * coefficient, iouReport?.currency), + CurrencyUtils.convertToDisplayString((iouReport?.total ?? 0) * coefficient, iouReport?.currency), + ]; +} + +/** + * Disable reply in thread action if: + * + * - The action is listed in the thread-disabled list + * - The action is a split expense action + * - The action is deleted and is not threaded + * - The report is archived and the action is not threaded + * - The action is a whisper action and it's neither a report preview nor IOU action + * - The action is the thread's first chat + */ +function shouldDisableThread(reportAction: OnyxInputOrEntry, reportID: string): boolean { + const isSplitBillAction = ReportActionsUtils.isSplitBillAction(reportAction); + const isDeletedAction = ReportActionsUtils.isDeletedAction(reportAction); + const isReportPreviewAction = ReportActionsUtils.isReportPreviewAction(reportAction); + const isIOUAction = ReportActionsUtils.isMoneyRequestAction(reportAction); + const isWhisperAction = ReportActionsUtils.isWhisperAction(reportAction) || ReportActionsUtils.isActionableTrackExpense(reportAction); + const isArchivedReport = isArchivedRoom(getReportOrDraftReport(reportID), getReportNameValuePairs(reportID)); + const isActionDisabled = CONST.REPORT.ACTIONS.THREAD_DISABLED.some((action: string) => action === reportAction?.actionName); + + return ( + isActionDisabled || + isSplitBillAction || + (isDeletedAction && !reportAction?.childVisibleActionCount) || + (isArchivedReport && !reportAction?.childVisibleActionCount) || + (isWhisperAction && !isReportPreviewAction && !isIOUAction) || + isThreadFirstChat(reportAction, reportID) + ); +} + +function getAllAncestorReportActions(report: Report | null | undefined, currentUpdatedReport?: OnyxEntry): Ancestor[] { + if (!report) { + return []; + } + const allAncestors: Ancestor[] = []; + let parentReportID = report.parentReportID; + let parentReportActionID = report.parentReportActionID; + + while (parentReportID) { + const parentReport = currentUpdatedReport && currentUpdatedReport.reportID === parentReportID ? currentUpdatedReport : getReportOrDraftReport(parentReportID); + const parentReportAction = ReportActionsUtils.getReportAction(parentReportID, parentReportActionID ?? '-1'); + + if ( + !parentReport || + !parentReportAction || + (ReportActionsUtils.isTransactionThread(parentReportAction) && !ReportActionsUtils.isSentMoneyReportAction(parentReportAction)) || + ReportActionsUtils.isReportPreviewAction(parentReportAction) + ) { + break; + } + + const isParentReportActionUnread = ReportActionsUtils.isCurrentActionUnread(parentReport, parentReportAction); + allAncestors.push({ + report: parentReport, + reportAction: parentReportAction, + shouldDisplayNewMarker: isParentReportActionUnread, + }); + + parentReportID = parentReport?.parentReportID; + parentReportActionID = parentReport?.parentReportActionID; + } + + return allAncestors.reverse(); +} + +function getAllAncestorReportActionIDs(report: Report | null | undefined, includeTransactionThread = false): AncestorIDs { + if (!report) { + return { + reportIDs: [], + reportActionsIDs: [], + }; + } + + const allAncestorIDs: AncestorIDs = { + reportIDs: [], + reportActionsIDs: [], + }; + let parentReportID = report.parentReportID; + let parentReportActionID = report.parentReportActionID; + + while (parentReportID) { + const parentReport = getReportOrDraftReport(parentReportID); + const parentReportAction = ReportActionsUtils.getReportAction(parentReportID, parentReportActionID ?? '-1'); + + if ( + !parentReportAction || + (!includeTransactionThread && + ((ReportActionsUtils.isTransactionThread(parentReportAction) && !ReportActionsUtils.isSentMoneyReportAction(parentReportAction)) || + ReportActionsUtils.isReportPreviewAction(parentReportAction))) + ) { + break; + } + + allAncestorIDs.reportIDs.push(parentReportID ?? '-1'); + allAncestorIDs.reportActionsIDs.push(parentReportActionID ?? '-1'); + + if (!parentReport) { + break; + } + + parentReportID = parentReport?.parentReportID; + parentReportActionID = parentReport?.parentReportActionID; + } + + return allAncestorIDs; +} + +/** + * Get optimistic data of parent report action + * @param reportID The reportID of the report that is updated + * @param lastVisibleActionCreated Last visible action created of the child report + * @param type The type of action in the child report + */ +function getOptimisticDataForParentReportAction(reportID: string, lastVisibleActionCreated: string, type: string): Array { + const report = getReportOrDraftReport(reportID); + + if (!report || isEmptyObject(report)) { + return []; + } + + const ancestors = getAllAncestorReportActionIDs(report, true); + const totalAncestor = ancestors.reportIDs.length; + + return Array.from(Array(totalAncestor), (_, index) => { + const ancestorReport = getReportOrDraftReport(ancestors.reportIDs[index]); + + if (!ancestorReport || isEmptyObject(ancestorReport)) { + return null; + } + + const ancestorReportAction = ReportActionsUtils.getReportAction(ancestorReport.reportID, ancestors.reportActionsIDs[index]); + + if (!ancestorReportAction || isEmptyObject(ancestorReportAction)) { + return null; + } + + return { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${ancestorReport.reportID}`, + value: { + [ancestorReportAction?.reportActionID ?? '-1']: updateOptimisticParentReportAction(ancestorReportAction, lastVisibleActionCreated, type), + }, + }; + }); +} + +function canBeAutoReimbursed(report: OnyxInputOrEntry, policy: OnyxInputOrEntry): boolean { + if (isEmptyObject(policy)) { + return false; + } + type CurrencyType = TupleToUnion; + const reimbursableTotal = getMoneyRequestSpendBreakdown(report).totalDisplaySpend; + const autoReimbursementLimit = policy?.autoReimbursement?.limit ?? policy?.autoReimbursementLimit ?? 0; + const isAutoReimbursable = + isReportInGroupPolicy(report) && + policy.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES && + autoReimbursementLimit >= reimbursableTotal && + reimbursableTotal > 0 && + CONST.DIRECT_REIMBURSEMENT_CURRENCIES.includes(report?.currency as CurrencyType); + return isAutoReimbursable; +} + +/** Check if the current user is an owner of the report */ +function isReportOwner(report: OnyxInputOrEntry): boolean { + return report?.ownerAccountID === currentUserPersonalDetails?.accountID; +} + +function isAllowedToApproveExpenseReport(report: OnyxEntry, approverAccountID?: number): boolean { + const policy = getPolicy(report?.policyID); + const isOwner = (approverAccountID ?? currentUserAccountID) === report?.ownerAccountID; + return !(policy?.preventSelfApproval && isOwner); +} + +function isAllowedToSubmitDraftExpenseReport(report: OnyxEntry): boolean { + const policy = getPolicy(report?.policyID); + const submitToAccountID = PolicyUtils.getSubmitToAccountID(policy, report?.ownerAccountID ?? -1); + + return isAllowedToApproveExpenseReport(report, submitToAccountID); +} + +/** + * What missing payment method does this report action indicate, if any? + */ +function getIndicatedMissingPaymentMethod(userWallet: OnyxEntry, reportId: string, reportAction: ReportAction): MissingPaymentMethod | undefined { + const isSubmitterOfUnsettledReport = isCurrentUserSubmitter(reportId) && !isSettled(reportId); + if (!isSubmitterOfUnsettledReport || !ReportActionsUtils.isReimbursementQueuedAction(reportAction)) { + return undefined; + } + const paymentType = ReportActionsUtils.getOriginalMessage(reportAction)?.paymentType; + if (paymentType === CONST.IOU.PAYMENT_TYPE.EXPENSIFY) { + return isEmpty(userWallet) || userWallet.tierName === CONST.WALLET.TIER_NAME.SILVER ? 'wallet' : undefined; + } + + return !store.hasCreditBankAccount() ? 'bankAccount' : undefined; +} + +/** + * Checks if report chat contains missing payment method + */ +function hasMissingPaymentMethod(userWallet: OnyxEntry, iouReportID: string): boolean { + const reportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`] ?? {}; + return Object.values(reportActions) + .filter(Boolean) + .some((action) => getIndicatedMissingPaymentMethod(userWallet, iouReportID, action) !== undefined); +} + +/** + * Used from expense actions to decide if we need to build an optimistic expense report. + * Create a new report if: + * - we don't have an iouReport set in the chatReport + * - we have one, but it's waiting on the payee adding a bank account + * - we have one, but we can't add more transactions to it due to: report is approved or settled, or report is processing and policy isn't on Instant submit reporting frequency + */ +function shouldCreateNewMoneyRequestReport(existingIOUReport: OnyxInputOrEntry | undefined, chatReport: OnyxInputOrEntry): boolean { + return !existingIOUReport || hasIOUWaitingOnCurrentUserBankAccount(chatReport) || !canAddTransaction(existingIOUReport); +} + +function getTripTransactions(tripRoomReportID: string | undefined, reportFieldToCompare: 'parentReportID' | 'reportID' = 'parentReportID'): Transaction[] { + const tripTransactionReportIDs = Object.values(ReportConnection.getAllReports() ?? {}) + .filter((report) => report && report?.[reportFieldToCompare] === tripRoomReportID) + .map((report) => report?.reportID); + return tripTransactionReportIDs.flatMap((reportID) => reportsTransactions[reportID ?? ''] ?? []); +} + +function getTripIDFromTransactionParentReport(transactionParentReport: OnyxEntry | undefined | null): string | undefined { + return getReportOrDraftReport(transactionParentReport?.parentReportID)?.tripData?.tripID; +} + +/** + * Checks if report contains actions with errors + */ +function hasActionsWithErrors(reportID: string): boolean { + const reportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] ?? {}; + return Object.values(reportActions) + .filter(Boolean) + .some((action) => !isEmptyObject(action.errors)); +} + +function isNonAdminOrOwnerOfPolicyExpenseChat(report: OnyxInputOrEntry, policy: OnyxInputOrEntry): boolean { + return isPolicyExpenseChat(report) && !(PolicyUtils.isPolicyAdmin(policy) || PolicyUtils.isPolicyOwner(policy, currentUserAccountID ?? -1) || isReportOwner(report)); +} + +function isAdminOwnerApproverOrReportOwner(report: OnyxEntry, policy: OnyxEntry): boolean { + const isApprover = isMoneyRequestReport(report) && report?.managerID !== null && currentUserPersonalDetails?.accountID === report?.managerID; + + return PolicyUtils.isPolicyAdmin(policy) || PolicyUtils.isPolicyOwner(policy, currentUserAccountID ?? -1) || isReportOwner(report) || isApprover; +} + +/** + * Whether the user can join a report + */ +function canJoinChat(report: OnyxEntry, parentReportAction: OnyxInputOrEntry, policy: OnyxInputOrEntry): boolean { + // We disabled thread functions for whisper action + // So we should not show join option for existing thread on whisper message that has already been left, or manually leave it + if (ReportActionsUtils.isWhisperAction(parentReportAction)) { + return false; + } + + // If the notification preference of the chat is not hidden that means we have already joined the chat + if (getReportNotificationPreference(report) !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN) { + return false; + } + + const isExpenseChat = isMoneyRequestReport(report) || isMoneyRequest(report) || isInvoiceReport(report) || isTrackExpenseReport(report); + // Anyone viewing these chat types is already a participant and therefore cannot join + if (isRootGroupChat(report) || isSelfDM(report) || isInvoiceRoom(report) || isSystemChat(report) || isExpenseChat) { + return false; + } + + // The user who is a member of the workspace has already joined the public announce room. + if (isPublicAnnounceRoom(report) && !isEmptyObject(policy)) { + return false; + } + + return isChatThread(report) || isUserCreatedPolicyRoom(report) || isNonAdminOrOwnerOfPolicyExpenseChat(report, policy); +} + +/** + * Whether the user can leave a report + */ +function canLeaveChat(report: OnyxEntry, policy: OnyxEntry): boolean { + if (isRootGroupChat(report)) { + return true; + } + + if (isPolicyExpenseChat(report) && !report?.isOwnPolicyExpenseChat && !PolicyUtils.isPolicyAdmin(policy)) { + return true; + } + + if (isPublicRoom(report) && SessionUtils.isAnonymousUser()) { + return false; + } + + if (getReportNotificationPreference(report) === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN) { + return false; + } + + // Anyone viewing these chat types is already a participant and therefore cannot leave + if (isSelfDM(report)) { + return false; + } + + // The user who is a member of the workspace cannot leave the public announce room. + if (isPublicAnnounceRoom(report) && !isEmptyObject(policy)) { + return false; + } + + if (isInvoiceRoom(report)) { + return canLeaveInvoiceRoom(report); + } + + return (isChatThread(report) && !!getReportNotificationPreference(report)) || isUserCreatedPolicyRoom(report) || isNonAdminOrOwnerOfPolicyExpenseChat(report, policy); +} + +function getReportActionActorAccountID(reportAction: OnyxInputOrEntry, iouReport: OnyxInputOrEntry | undefined): number | undefined { + switch (reportAction?.actionName) { + case CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW: + return !isEmptyObject(iouReport) ? iouReport.managerID : reportAction?.childManagerAccountID; + + case CONST.REPORT.ACTIONS.TYPE.SUBMITTED: + return reportAction?.adminAccountID ?? reportAction?.actorAccountID; + + default: + return reportAction?.actorAccountID; + } +} + +function createDraftWorkspaceAndNavigateToConfirmationScreen(transactionID: string, actionName: IOUAction): void { + const isCategorizing = actionName === CONST.IOU.ACTION.CATEGORIZE; + const {expenseChatReportID, policyID, policyName} = PolicyActions.createDraftWorkspace(); + IOU.setMoneyRequestParticipants(transactionID, [ + { + selected: true, + accountID: 0, + isPolicyExpenseChat: true, + reportID: expenseChatReportID, + policyID, + searchText: policyName, + }, + ]); + if (isCategorizing) { + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(actionName, CONST.IOU.TYPE.SUBMIT, transactionID, expenseChatReportID)); + } else { + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(actionName, CONST.IOU.TYPE.SUBMIT, transactionID, expenseChatReportID, true)); + } +} + +function createDraftTransactionAndNavigateToParticipantSelector(transactionID: string, reportID: string, actionName: IOUAction, reportActionID: string): void { + const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] ?? ({} as Transaction); + const reportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] ?? ([] as ReportAction[]); + + if (!transaction || !reportActions) { + return; + } + + const linkedTrackedExpenseReportAction = Object.values(reportActions) + .filter(Boolean) + .find((action) => ReportActionsUtils.isMoneyRequestAction(action) && ReportActionsUtils.getOriginalMessage(action)?.IOUTransactionID === transactionID); + + const {created, amount, currency, merchant, mccGroup} = getTransactionDetails(transaction) ?? {}; + const comment = getTransactionCommentObject(transaction); + + IOU.createDraftTransaction({ + ...transaction, + actionableWhisperReportActionID: reportActionID, + linkedTrackedExpenseReportAction, + linkedTrackedExpenseReportID: reportID, + created, + modifiedCreated: undefined, + amount, + currency, + comment, + merchant, + modifiedMerchant: '', + mccGroup, + } as Transaction); + + const filteredPolicies = Object.values(allPolicies ?? {}).filter( + (policy) => policy && policy.type !== CONST.POLICY.TYPE.PERSONAL && policy.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + ); + + if (actionName === CONST.IOU.ACTION.SUBMIT || (allPolicies && filteredPolicies.length > 0)) { + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(CONST.IOU.TYPE.SUBMIT, transactionID, reportID, undefined, actionName)); + return; + } + + return createDraftWorkspaceAndNavigateToConfirmationScreen(transactionID, actionName); +} + +/** + * @returns the object to update `report.hasOutstandingChildRequest` + */ +function getOutstandingChildRequest(iouReport: OnyxInputOrEntry): OutstandingChildRequest { + if (!iouReport || isEmptyObject(iouReport)) { + return {}; + } + + if (!isExpenseReport(iouReport)) { + const {reimbursableSpend} = getMoneyRequestSpendBreakdown(iouReport); + return { + hasOutstandingChildRequest: iouReport.managerID === currentUserAccountID && reimbursableSpend !== 0, + }; + } + + const policy = getPolicy(iouReport.policyID); + const shouldBeManuallySubmitted = PolicyUtils.isPaidGroupPolicy(policy) && !policy?.harvesting?.enabled; + const isOwnFreePolicy = PolicyUtils.isFreeGroupPolicy(policy) && PolicyUtils.isPolicyAdmin(policy); + if (shouldBeManuallySubmitted || isOwnFreePolicy) { + return { + hasOutstandingChildRequest: true, + }; + } + + // We don't need to update hasOutstandingChildRequest in this case + return {}; +} + +function canReportBeMentionedWithinPolicy(report: OnyxEntry, policyID: string): boolean { + if (report?.policyID !== policyID) { + return false; + } + + return isChatRoom(report) && !isInvoiceRoom(report) && !isThread(report); +} + +function shouldShowMerchantColumn(transactions: Transaction[]) { + const allReports = ReportConnection.getAllReports(); + return transactions.some((transaction) => isExpenseReport(allReports?.[transaction.reportID] ?? null)); +} + +/** + * Whether a given report is used for onboarding tasks. In the past, it could be either the Concierge chat or the system + * DM, and we saved the report ID in the user's `onboarding` NVP. As a fallback for users who don't have the NVP, we now + * only use the Concierge chat. + */ +function isChatUsedForOnboarding(optionOrReport: OnyxEntry | OptionData): boolean { + // onboarding can be an array for old accounts and accounts created from olddot + if (onboarding && !Array.isArray(onboarding) && onboarding.chatReportID) { + return onboarding.chatReportID === optionOrReport?.reportID; + } + + return (optionOrReport as OptionData)?.isConciergeChat ?? isConciergeChatReport(optionOrReport); +} + +/** + * Get the report used for the user's onboarding process. For most users it is the Concierge chat, however in the past + * we also used the system DM for A/B tests. + */ +function getChatUsedForOnboarding(): OnyxEntry { + return Object.values(ReportConnection.getAllReports() ?? {}).find(isChatUsedForOnboarding); +} + +/** + * Checks if given field has any violations and returns name of the first encountered one + */ +function getFieldViolation(violations: OnyxEntry, reportField: PolicyReportField): ReportViolationName | undefined { + if (!violations || !reportField) { + return undefined; + } + + return Object.values(CONST.REPORT_VIOLATIONS).find((violation) => !!violations[violation] && violations[violation][reportField.fieldID]); +} + +/** + * Returns translation for given field violation + */ +function getFieldViolationTranslation(reportField: PolicyReportField, violation?: ReportViolationName): string { + if (!violation) { + return ''; + } + + switch (violation) { + case 'fieldRequired': + return Localize.translateLocal('reportViolations.fieldRequired', reportField.name); + default: + return ''; + } +} + +/** + * Returns all violations for report + */ +function getReportViolations(reportID: string): ReportViolations | undefined { + if (!allReportsViolations) { + return undefined; + } + + return allReportsViolations[`${ONYXKEYS.COLLECTION.REPORT_VIOLATIONS}${reportID}`]; +} + +function findPolicyExpenseChatByPolicyID(policyID: string): OnyxEntry { + return Object.values(ReportConnection.getAllReports() ?? {}).find((report) => isPolicyExpenseChat(report) && report?.policyID === policyID); +} + +/** + * A function to get the report last message. This is usually used to restore the report message preview in LHN after report actions change. + * @param reportID + * @param actionsToMerge + * @returns containing the calculated message preview data of the report + */ +function getReportLastMessage(reportID: string, actionsToMerge?: ReportActions) { + let result: Partial = { + lastMessageTranslationKey: '', + lastMessageText: '', + lastVisibleActionCreated: '', + }; + + const {lastMessageText = '', lastMessageTranslationKey = ''} = getLastVisibleMessage(reportID, actionsToMerge); + + if (lastMessageText || lastMessageTranslationKey) { + const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(reportID, actionsToMerge); + const lastVisibleActionCreated = lastVisibleAction?.created; + const lastActorAccountID = lastVisibleAction?.actorAccountID; + result = { + lastMessageTranslationKey, + lastMessageText, + lastVisibleActionCreated, + lastActorAccountID, + }; + } + + return result; +} + +function getSourceIDFromReportAction(reportAction: OnyxEntry): string { + const message = Array.isArray(reportAction?.message) ? reportAction?.message?.at(-1) ?? null : reportAction?.message ?? null; + const html = message?.html ?? ''; + const {sourceURL} = getAttachmentDetails(html); + const sourceID = (sourceURL?.match(CONST.REGEX.ATTACHMENT_ID) ?? [])[1]; + return sourceID; +} + +function getIntegrationIcon(connectionName?: ConnectionName) { + if (connectionName === CONST.POLICY.CONNECTIONS.NAME.XERO) { + return XeroCircle; + } + if (connectionName === CONST.POLICY.CONNECTIONS.NAME.QBO) { + return QBOCircle; + } + return undefined; +} + +function canBeExported(report: OnyxEntry) { + if (!report?.statusNum) { + return false; + } + const isCorrectState = [CONST.REPORT.STATUS_NUM.APPROVED, CONST.REPORT.STATUS_NUM.CLOSED, CONST.REPORT.STATUS_NUM.REIMBURSED].some((status) => status === report.statusNum); + return isExpenseReport(report) && isCorrectState; +} + +function isExported(reportActions: OnyxEntry) { + if (!reportActions) { + return false; + } + return Object.values(reportActions).some((action) => ReportActionsUtils.isExportIntegrationAction(action)); +} + +function getApprovalChain(policy: OnyxEntry, employeeAccountID: number, reportTotal: number): string[] { + const approvalChain: string[] = []; + + // If the policy is not on advanced approval mode, we should not use the approval chain even if it exists. + if (!PolicyUtils.isControlOnAdvancedApprovalMode(policy)) { + return approvalChain; + } + + let nextApproverEmail = PolicyUtils.getSubmitToEmail(policy, employeeAccountID); + + while (nextApproverEmail && !approvalChain.includes(nextApproverEmail)) { + approvalChain.push(nextApproverEmail); + nextApproverEmail = PolicyUtils.getForwardsToAccount(policy, nextApproverEmail, reportTotal); + } + return approvalChain; +} + +/** + * Checks if the user has missing bank account for the invoice room. + */ +function hasMissingInvoiceBankAccount(iouReportID: string): boolean { + const invoiceReport = getReport(iouReportID); + + if (!isInvoiceReport(invoiceReport)) { + return false; + } + + return invoiceReport?.ownerAccountID === currentUserAccountID && isEmptyObject(getPolicy(invoiceReport?.policyID)?.invoice?.bankAccount ?? {}) && isSettled(iouReportID); +} + +function isExpenseReportManagerWithoutParentAccess(report: OnyxEntry) { + return isExpenseReport(report) && report?.hasParentAccess === false && report?.managerID === currentUserAccountID; +} + +export { + addDomainToShortMention, + completeShortMention, + areAllRequestsBeingSmartScanned, + buildOptimisticAddCommentReportAction, + buildOptimisticApprovedReportAction, + buildOptimisticUnapprovedReportAction, + buildOptimisticCancelPaymentReportAction, + buildOptimisticChangedTaskAssigneeReportAction, + buildOptimisticChatReport, + buildOptimisticClosedReportAction, + buildOptimisticCreatedReportAction, + buildOptimisticDismissedViolationReportAction, + buildOptimisticEditedTaskFieldReportAction, + buildOptimisticExpenseReport, + buildOptimisticGroupChatReport, + buildOptimisticHoldReportAction, + buildOptimisticHoldReportActionComment, + buildOptimisticIOUReport, + buildOptimisticIOUReportAction, + buildOptimisticModifiedExpenseReportAction, + buildOptimisticMoneyRequestEntities, + buildOptimisticMovedReportAction, + buildOptimisticMovedTrackedExpenseModifiedReportAction, + buildOptimisticRenamedRoomReportAction, + buildOptimisticRoomDescriptionUpdatedReportAction, + buildOptimisticReportPreview, + buildOptimisticActionableTrackExpenseWhisper, + buildOptimisticSubmittedReportAction, + buildOptimisticTaskCommentReportAction, + buildOptimisticTaskReport, + buildOptimisticTaskReportAction, + buildOptimisticUnHoldReportAction, + buildOptimisticWorkspaceChats, + buildParticipantsFromAccountIDs, + buildTransactionThread, + canAccessReport, + isReportNotFound, + canAddTransaction, + canDeleteTransaction, + canBeAutoReimbursed, + canCreateRequest, + canCreateTaskInReport, + canCurrentUserOpenReport, + canDeleteReportAction, + canHoldUnholdReportAction, + canEditFieldOfMoneyRequest, + canEditMoneyRequest, + canEditPolicyDescription, + canEditReportAction, + canEditReportDescription, + canEditRoomVisibility, + canEditWriteCapability, + canFlagReportAction, + isNonAdminOrOwnerOfPolicyExpenseChat, + canLeaveRoom, + canJoinChat, + canLeaveChat, + canReportBeMentionedWithinPolicy, + canRequestMoney, + canSeeDefaultRoom, + canShowReportRecipientLocalTime, + canUserPerformWriteAction, + chatIncludesChronos, + chatIncludesChronosWithID, + chatIncludesConcierge, + createDraftTransactionAndNavigateToParticipantSelector, + doesReportBelongToWorkspace, + doesTransactionThreadHaveViolations, + findLastAccessedReport, + findSelfDMReportID, + formatReportLastMessageText, + generateReportID, + getAddWorkspaceRoomOrChatReportErrors, + getAllAncestorReportActionIDs, + getAllAncestorReportActions, + getAllHeldTransactions, + getAllPolicyReports, + getAllWorkspaceReports, + getAvailableReportFields, + getBankAccountRoute, + getChatByParticipants, + getChatRoomSubtitle, + getChildReportNotificationPreference, + getCommentLength, + getDefaultGroupAvatar, + getDefaultWorkspaceAvatar, + getDefaultWorkspaceAvatarTestID, + getDeletedParentActionMessageForChatReport, + getDisplayNameForParticipant, + getDisplayNamesWithTooltips, + getGroupChatName, + getIOUReportActionDisplayMessage, + getIOUReportActionMessage, + getIOUApprovedMessage, + getIOUForwardedMessage, + getRejectedReportMessage, + getWorkspaceNameUpdatedMessage, + getIOUSubmittedMessage, + getIcons, + getIconsForParticipants, + getIndicatedMissingPaymentMethod, + getLastVisibleMessage, + getMoneyRequestOptions, + getMoneyRequestSpendBreakdown, + getNewMarkerReportActionID, + getNonHeldAndFullAmount, + getOptimisticDataForParentReportAction, + getOriginalReportID, + getOutstandingChildRequest, + getParentNavigationSubtitle, + getParsedComment, + getParticipantsAccountIDsForDisplay, + getParticipantsList, + getParticipants, + getPendingChatMembers, + getPersonalDetailsForAccountID, + getPolicyDescriptionText, + getPolicyExpenseChat, + getPolicyName, + getPolicyType, + getReimbursementDeQueuedActionMessage, + getReimbursementQueuedActionMessage, + getReportActionActorAccountID, + getReportDescriptionText, + getReportFieldKey, + getReportIDFromLink, + getReportName, + getReportNotificationPreference, + getReportOfflinePendingActionAndErrors, + getReportParticipantsTitle, + getReportPreviewMessage, + getReportRecipientAccountIDs, + getReportOrDraftReport, + getRoom, + getRootParentReport, + getRouteFromLink, + getSystemChat, + getTaskAssigneeChatOnyxData, + getTransactionDetails, + getTransactionReportName, + getTransactionsWithReceipts, + getUserDetailTooltipText, + getWhisperDisplayNames, + getWorkspaceChats, + getWorkspaceIcon, + goBackToDetailsPage, + goBackFromPrivateNotes, + getInvoicePayerName, + getInvoicesChatName, + getPayeeName, + hasActionsWithErrors, + hasAutomatedExpensifyAccountIDs, + hasExpensifyGuidesEmails, + hasHeldExpenses, + hasIOUWaitingOnCurrentUserBankAccount, + hasMissingPaymentMethod, + hasMissingSmartscanFields, + hasNonReimbursableTransactions, + hasOnlyHeldExpenses, + hasOnlyTransactionsWithPendingRoutes, + hasReportNameError, + hasSmartscanError, + hasUpdatedTotal, + hasViolations, + hasWarningTypeViolations, + isActionCreator, + isAdminRoom, + isAdminsOnlyPostingRoom, + isAllowedToApproveExpenseReport, + isAllowedToComment, + isAllowedToSubmitDraftExpenseReport, + isAnnounceRoom, + isArchivedRoom, + isArchivedRoomWithID, + isClosedReport, + isCanceledTaskReport, + isChatReport, + isChatRoom, + isTripRoom, + isChatThread, + isChildReport, + isClosedExpenseReportWithNoExpenses, + isCompletedTaskReport, + isConciergeChatReport, + isControlPolicyExpenseChat, + isControlPolicyExpenseReport, + isCurrentUserSubmitter, + isCurrentUserTheOnlyParticipant, + isDM, + isDefaultRoom, + isDeprecatedGroupDM, + isEmptyReport, + isRootGroupChat, + isExpenseReport, + isExpenseReportManagerWithoutParentAccess, + isExpenseRequest, + isExpensifyOnlyParticipantInReport, + isGroupChat, + isGroupChatAdmin, + isGroupPolicy, + isReportInGroupPolicy, + isHoldCreator, + isIOUOwnedByCurrentUser, + isIOUReport, + isIOUReportUsingReport, + isJoinRequestInAdminRoom, + isDomainRoom, + isMoneyRequest, + isMoneyRequestReport, + isMoneyRequestReportPendingDeletion, + isOneOnOneChat, + isOneTransactionThread, + isOpenExpenseReport, + isOpenTaskReport, + isOptimisticPersonalDetail, + isPaidGroupPolicy, + isPaidGroupPolicyExpenseChat, + isPaidGroupPolicyExpenseReport, + isPayer, + isPolicyAdmin, + isPolicyExpenseChat, + isPolicyExpenseChatAdmin, + isProcessingReport, + isPublicAnnounceRoom, + isPublicRoom, + isReportApproved, + isReportManuallyReimbursed, + isReportDataReady, + isReportFieldDisabled, + isReportFieldOfTypeTitle, + isReportManager, + isReportMessageAttachment, + isReportOwner, + isReportParticipant, + isSelfDM, + isSettled, + isSystemChat, + isTaskReport, + isThread, + isThreadFirstChat, + isTrackExpenseReport, + isUnread, + isUnreadWithMention, + isUserCreatedPolicyRoom, + isValidReport, + isValidReportIDFromPath, + isWaitingForAssigneeToCompleteAction, + isInvoiceRoom, + isInvoiceRoomWithID, + isInvoiceReport, + isOpenInvoiceReport, + getDefaultNotificationPreferenceForReport, + canWriteInReport, + navigateToDetailsPage, + navigateToPrivateNotes, + navigateBackAfterDeleteTransaction, + parseReportRouteParams, + parseReportActionHtmlToText, + requiresAttentionFromCurrentUser, + shouldAutoFocusOnKeyPress, + shouldCreateNewMoneyRequestReport, + shouldDisableDetailPage, + shouldDisableRename, + shouldDisableThread, + shouldDisplayThreadReplies, + shouldDisplayTransactionThreadViolations, + shouldReportBeInOptionList, + shouldReportShowSubscript, + shouldShowFlagComment, + shouldShowRBRForMissingSmartscanFields, + shouldUseFullTitleToDisplay, + updateOptimisticParentReportAction, + updateReportPreview, + temporary_getMoneyRequestOptions, + getTripTransactions, + getTripIDFromTransactionParentReport, + buildOptimisticInvoiceReport, + getInvoiceChatByParticipants, + shouldShowMerchantColumn, + isCurrentUserInvoiceReceiver, + isDraftReport, + changeMoneyRequestHoldStatus, + isAdminOwnerApproverOrReportOwner, + createDraftWorkspaceAndNavigateToConfirmationScreen, + isChatUsedForOnboarding, + buildOptimisticExportIntegrationAction, + getChatUsedForOnboarding, + getFieldViolationTranslation, + getFieldViolation, + getReportViolations, + findPolicyExpenseChatByPolicyID, + getIntegrationIcon, + canBeExported, + isExported, + hasOnlyNonReimbursableTransactions, + getReportLastMessage, + getMostRecentlyVisitedReport, + getSourceIDFromReportAction, + getReport, + getReportNameValuePairs, + hasReportViolations, + isPayAtEndExpenseReport, + getArchiveReason, + getApprovalChain, + isIndividualInvoiceRoom, + isAuditor, + hasMissingInvoiceBankAccount, +}; + +export type { + Ancestor, + DisplayNameWithTooltips, + OptimisticAddCommentReportAction, + OptimisticChatReport, + OptimisticClosedReportAction, + OptimisticCreatedReportAction, + OptimisticIOUReportAction, + OptimisticTaskReportAction, + OptionData, + TransactionDetails, + PartialReportAction, + ParsingDetails, +}; diff --git a/src/libs/SearchUtils.ts b/src/libs/SearchUtils.ts index 9760ff80ca19..2f7c88eb7401 100644 --- a/src/libs/SearchUtils.ts +++ b/src/libs/SearchUtils.ts @@ -362,7 +362,7 @@ function getSortedTransactionData(data: TransactionListItemType[], sortBy?: Sear } function getReportNewestTransactionDate(report: ReportListItemType) { - return report.transactions?.reduce((max, curr) => (curr.modifiedCreated ?? curr.created > max.created ? curr : max), report.transactions[0])?.created; + return report.transactions?.reduce((max, curr) => (curr.modifiedCreated ?? curr.created > (max?.created ?? '') ? curr : max), report.transactions.at(0))?.created; } function getSortedReportData(data: ReportListItemType[]) { @@ -735,7 +735,7 @@ function buildFilterString(filterName: string, queryFilters: QueryFilter[], deli let filterValueString = ''; queryFilters.forEach((queryFilter, index) => { // If the previous queryFilter has the same operator (this rule applies only to eq and neq operators) then append the current value - if ((queryFilter.operator === 'eq' && queryFilters[index - 1]?.operator === 'eq') || (queryFilter.operator === 'neq' && queryFilters[index - 1]?.operator === 'neq')) { + if ((queryFilter.operator === 'eq' && queryFilters?.at(index - 1)?.operator === 'eq') || (queryFilter.operator === 'neq' && queryFilters.at(index - 1)?.operator === 'neq')) { filterValueString += `${delimiter}${sanitizeString(queryFilter.value.toString())}`; } else { filterValueString += ` ${filterName}${operatorToSignMap[queryFilter.operator]}${sanitizeString(queryFilter.value.toString())}`; @@ -766,7 +766,7 @@ function getSearchHeaderTitle( .filter(([, taxRateKeys]) => taxRateKeys.some((taxID) => taxRateIDs.includes(taxID))) .map(([taxRate]) => taxRate); displayQueryFilters = taxRateNames.map((taxRate) => ({ - operator: queryFilter[0].operator, + operator: queryFilter.at(0)?.operator ?? 'and', value: taxRate, })); } else { diff --git a/src/libs/SelectionScraper/index.ts b/src/libs/SelectionScraper/index.ts index 88726aa633b6..060cbc613acf 100644 --- a/src/libs/SelectionScraper/index.ts +++ b/src/libs/SelectionScraper/index.ts @@ -117,15 +117,16 @@ const replaceNodes = (dom: Node, isChildOfEditorElement: boolean): Node => { } } else if (dom instanceof Element) { domName = dom.name; + const child = dom.children.at(0); if (dom.attribs?.[tagAttribute]) { // If it's a markdown element, rename it according to the value of data-testid, so ExpensiMark can parse it if (markdownElements.includes(dom.attribs[tagAttribute])) { domName = dom.attribs[tagAttribute]; } - } else if (dom.name === 'div' && dom.children.length === 1 && isChildOfEditorElement) { + } else if (dom.name === 'div' && dom.children.length === 1 && isChildOfEditorElement && child) { // We are excluding divs that are children of our editor element and have only one child to prevent // additional newlines from being added in the HTML to Markdown conversion process. - return replaceNodes(dom.children[0], isChildOfEditorElement); + return replaceNodes(child, isChildOfEditorElement); } // We need to preserve href attribute in order to copy links. diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index d056f111695e..2f8b4f7b1cbc 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -49,7 +49,11 @@ Onyx.connect({ (reportAction) => ReportActionsUtils.shouldReportActionBeVisibleAsLastAction(reportAction) && reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED, ); - visibleReportActionItems[reportID] = reportActionsForDisplay[reportActionsForDisplay.length - 1]; + const reportAction = reportActionsForDisplay.at(reportActionsForDisplay.length - 1); + if (!reportAction) { + return; + } + visibleReportActionItems[reportID] = reportAction; }, }); @@ -300,7 +304,7 @@ function getOptionData({ const visibleParticipantAccountIDs = ReportUtils.getParticipantsAccountIDsForDisplay(report, true); const participantPersonalDetailList = Object.values(OptionsListUtils.getPersonalDetailsForAccountIDs(participantAccountIDs, personalDetails)); - const personalDetail = participantPersonalDetailList[0] ?? {}; + const personalDetail = participantPersonalDetailList.at(0) ?? ({} as PersonalDetails); const hasErrors = Object.keys(result.allReportErrors ?? {}).length !== 0; result.isThread = ReportUtils.isChatThread(report); @@ -476,9 +480,9 @@ function getOptionData({ } if (!hasMultipleParticipants) { - result.accountID = personalDetail?.accountID; - result.login = personalDetail?.login; - result.phoneNumber = personalDetail?.phoneNumber; + result.accountID = personalDetail?.accountID ?? -1; + result.login = personalDetail?.login ?? ''; + result.phoneNumber = personalDetail?.phoneNumber ?? ''; } const reportName = ReportUtils.getReportName(report, policy, undefined, undefined, invoiceReceiverPolicy); @@ -487,7 +491,7 @@ function getOptionData({ result.subtitle = subtitle; result.participantsList = participantPersonalDetailList; - result.icons = ReportUtils.getIcons(report, personalDetails, personalDetail?.avatar, personalDetail?.login, personalDetail?.accountID, policy, invoiceReceiverPolicy); + result.icons = ReportUtils.getIcons(report, personalDetails, personalDetail?.avatar, personalDetail?.login, personalDetail?.accountID ?? -1, policy, invoiceReceiverPolicy); result.displayNamesWithTooltips = displayNamesWithTooltips; if (status) { diff --git a/src/libs/StringUtils.ts b/src/libs/StringUtils.ts index 10730150489c..b3fcd247284e 100644 --- a/src/libs/StringUtils.ts +++ b/src/libs/StringUtils.ts @@ -94,7 +94,7 @@ function lineBreaksToSpaces(text = '') { function getFirstLine(text = '') { // Split the input string by newline characters and return the first element of the resulting array const lines = text.split('\n'); - return lines[0]; + return lines.at(0); } export default {sanitizeString, isEmptyString, removeInvisibleCharacters, normalizeAccents, normalizeCRLF, lineBreaksToSpaces, getFirstLine}; diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 63c2f9aa9862..9feefed1d7cc 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -492,7 +492,7 @@ function getTagArrayFromName(tagName: string): string[] { function getTag(transaction: OnyxInputOrEntry, tagIndex?: number): string { if (tagIndex !== undefined) { const tagsArray = getTagArrayFromName(transaction?.tag ?? ''); - return tagsArray[tagIndex] ?? ''; + return tagsArray.at(tagIndex) ?? ''; } return transaction?.tag ?? ''; @@ -646,7 +646,7 @@ function getValidWaypoints(waypoints: WaypointCollection | undefined, reArrangeI let waypointIndex = -1; return waypointValues.reduce((acc, currentWaypoint, index) => { - const previousWaypoint = waypointValues[lastWaypointIndex]; + const previousWaypoint = waypointValues.at(lastWaypointIndex); // Check if the waypoint has a valid address if (!waypointHasValidAddress(currentWaypoint)) { @@ -987,7 +987,7 @@ function compareDuplicateTransactionFields(transactionID: string): {keep: Partia // Helper function to check if all fields are equal for a given key function areAllFieldsEqual(items: Array>, keyExtractor: (item: OnyxEntry) => string) { - const firstTransaction = transactions[0]; + const firstTransaction = transactions.at(0); return items.every((item) => keyExtractor(item) === keyExtractor(firstTransaction)); } @@ -1002,7 +1002,7 @@ function compareDuplicateTransactionFields(transactionID: string): {keep: Partia for (const fieldName in fieldsToCompare) { if (Object.prototype.hasOwnProperty.call(fieldsToCompare, fieldName)) { const keys = fieldsToCompare[fieldName]; - const firstTransaction = transactions[0]; + const firstTransaction = transactions.at(0); const isFirstTransactionCommentEmptyObject = typeof firstTransaction?.comment === 'object' && firstTransaction?.comment?.comment === ''; if (fieldName === 'description') { diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index adbc05460220..d5c93d382637 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -19,7 +19,7 @@ function getTagViolationsForSingleLevelTags( policyTagList: PolicyTagLists, ): TransactionViolation[] { const policyTagKeys = Object.keys(policyTagList); - const policyTagListName = policyTagKeys[0]; + const policyTagListName = policyTagKeys.at(0) ?? ''; const policyTags = policyTagList[policyTagListName]?.tags; const hasTagOutOfPolicyViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.TAG_OUT_OF_POLICY); const hasMissingTagViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.MISSING_TAG); @@ -93,8 +93,8 @@ function getTagViolationForIndependentTags(policyTagList: PolicyTagLists, transa const errorIndexes = []; for (let i = 0; i < policyTagKeys.length; i++) { const isTagRequired = policyTagList[policyTagKeys[i]].required ?? true; - const isTagSelected = !!selectedTags[i]; - if (isTagRequired && (!isTagSelected || (selectedTags.length === 1 && selectedTags[0] === ''))) { + const isTagSelected = !!selectedTags.at(i); + if (isTagRequired && (!isTagSelected || (selectedTags.length === 1 && selectedTags.at(0) === ''))) { errorIndexes.push(i); } } @@ -109,7 +109,7 @@ function getTagViolationForIndependentTags(policyTagList: PolicyTagLists, transa } else { let hasInvalidTag = false; for (let i = 0; i < policyTagKeys.length; i++) { - const selectedTag = selectedTags[i]; + const selectedTag = selectedTags.at(i); const tags = policyTagList[policyTagKeys[i]].tags; const isTagInPolicy = Object.values(tags).some((tag) => tag.name === selectedTag && !!tag.enabled); if (!isTagInPolicy && selectedTag) { @@ -117,7 +117,7 @@ function getTagViolationForIndependentTags(policyTagList: PolicyTagLists, transa name: CONST.VIOLATIONS.TAG_OUT_OF_POLICY, type: CONST.VIOLATION_TYPES.VIOLATION, data: { - tagName: policyTagKeys[i], + tagName: policyTagKeys.at(i), }, }); hasInvalidTag = true; diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index 7d936bff0b3b..67655e0f472b 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -157,7 +157,7 @@ function convertPolicyEmployeesToApprovalWorkflows({employees, defaultApprover, return 1; } - return (a.approvers[0]?.displayName ?? '-1').localeCompare(b.approvers[0]?.displayName ?? '-1'); + return (a.approvers.at(0)?.displayName ?? '-1').localeCompare(b.approvers.at(0)?.displayName ?? '-1'); }); // Add a default workflow if one doesn't exist (no employees submit to the default approver) diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index edee4a7fca6f..79c469841fad 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -3874,7 +3874,7 @@ function trackExpense( function getOrCreateOptimisticSplitChatReport(existingSplitChatReportID: string, participants: Participant[], participantAccountIDs: number[], currentUserAccountID: number) { // The existing chat report could be passed as reportID or exist on the sole "participant" (in this case a report option) - const existingChatReportID = existingSplitChatReportID || participants[0].reportID; + const existingChatReportID = existingSplitChatReportID || (participants.at(0)?.reportID ?? '-1'); // Check if the report is available locally if we do have one let existingSplitChatReport = existingChatReportID ? ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${existingChatReportID}`] : null; @@ -5152,7 +5152,7 @@ function createDistanceRequest( chatType: splitData.chatType, }; } else { - const participant = participants[0] ?? {}; + const participant = participants.at(0) ?? {}; const { iouReport, chatReport, @@ -5265,8 +5265,8 @@ function editRegularMoneyRequest( '', false, ); - updatedMoneyRequestReport.lastMessageText = ReportActionsUtils.getTextFromHtml(lastMessage[0].html); - updatedMoneyRequestReport.lastMessageHtml = lastMessage[0].html; + updatedMoneyRequestReport.lastMessageText = ReportActionsUtils.getTextFromHtml(lastMessage.at(0)?.html); + updatedMoneyRequestReport.lastMessageHtml = lastMessage.at(0)?.html; // Update the last message of the chat report const hasNonReimbursableTransactions = ReportUtils.hasNonReimbursableTransactions(iouReport?.reportID); @@ -5639,9 +5639,12 @@ function prepareToCleanUpMoneyRequest(transactionID: string, reportAction: OnyxT }); if (ReportActionsUtils.getReportActionMessage(updatedReportPreviewAction)) { - if (Array.isArray(updatedReportPreviewAction?.message) && updatedReportPreviewAction.message?.[0]) { - updatedReportPreviewAction.message[0].text = messageText; - updatedReportPreviewAction.message[0].deleted = shouldDeleteIOUReport ? DateUtils.getDBTime() : ''; + if (Array.isArray(updatedReportPreviewAction?.message)) { + const message = updatedReportPreviewAction.message.at(0); + if (message) { + message.text = messageText; + message.deleted = shouldDeleteIOUReport ? DateUtils.getDBTime() : ''; + } } else if (!Array.isArray(updatedReportPreviewAction.message) && updatedReportPreviewAction.message) { updatedReportPreviewAction.message.text = messageText; updatedReportPreviewAction.message.deleted = shouldDeleteIOUReport ? DateUtils.getDBTime() : ''; @@ -6443,7 +6446,7 @@ function getReportFromHoldRequestsOnyxData( failureData: OnyxUpdate[]; } { const {holdReportActions, holdTransactions} = getHoldReportActionsAndTransactions(iouReport.reportID); - const firstHoldTransaction = holdTransactions[0]; + const firstHoldTransaction = holdTransactions.at(0); const newParentReportActionID = rand64(); const optimisticExpenseReport = ReportUtils.buildOptimisticExpenseReport( @@ -7010,7 +7013,7 @@ function isLastApprover(approvalChain: string[]): boolean { if (approvalChain.length === 0) { return true; } - return approvalChain[approvalChain.length - 1] === currentUserEmail; + return approvalChain.at(approvalChain.length - 1) === currentUserEmail; } function approveMoneyRequest(expenseReport: OnyxEntry, full?: boolean) { @@ -7517,7 +7520,7 @@ function completePaymentOnboarding(paymentSelected: ValueOf>>>((val, reportAction) => { + const firstMessage = Array.isArray(reportAction.message) ? reportAction.message.at(0) : null; // eslint-disable-next-line no-param-reassign val[reportAction.reportActionID] = { originalMessage: { deleted: deletedTime, }, - ...(Array.isArray(reportAction.message) && - !!reportAction.message[0] && { - message: [ - { - ...reportAction.message[0], - deleted: deletedTime, - }, - ...reportAction.message.slice(1), - ], - }), + ...(firstMessage && { + message: [ + { + ...firstMessage, + deleted: deletedTime, + }, + ...(Array.isArray(reportAction.message) ? reportAction.message.slice(1) : []), + ], + }), ...(!Array.isArray(reportAction.message) && { message: { deleted: deletedTime, diff --git a/src/libs/actions/IOU.ts.orig b/src/libs/actions/IOU.ts.orig new file mode 100644 index 000000000000..edee4a7fca6f --- /dev/null +++ b/src/libs/actions/IOU.ts.orig @@ -0,0 +1,8250 @@ +import {format} from 'date-fns'; +import {fastMerge, Str} from 'expensify-common'; +import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxInputValue, OnyxUpdate} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; +import type {PartialDeep, SetRequired, ValueOf} from 'type-fest'; +import ReceiptGeneric from '@assets/images/receipt-generic.png'; +import * as API from '@libs/API'; +import type { + ApproveMoneyRequestParams, + CompleteSplitBillParams, + CreateDistanceRequestParams, + CreateWorkspaceParams, + DeleteMoneyRequestParams, + DetachReceiptParams, + EditMoneyRequestParams, + PayInvoiceParams, + PayMoneyRequestParams, + ReplaceReceiptParams, + RequestMoneyParams, + SendInvoiceParams, + SendMoneyParams, + SetNameValuePairParams, + SplitBillParams, + StartSplitBillParams, + SubmitReportParams, + TrackExpenseParams, + TransactionMergeParams, + UnapproveExpenseReportParams, + UpdateMoneyRequestParams, +} from '@libs/API/parameters'; +import {WRITE_COMMANDS} from '@libs/API/types'; +import * as CurrencyUtils from '@libs/CurrencyUtils'; +import DateUtils from '@libs/DateUtils'; +import DistanceRequestUtils from '@libs/DistanceRequestUtils'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import * as FileUtils from '@libs/fileDownload/FileUtils'; +import * as IOUUtils from '@libs/IOUUtils'; +import * as LocalePhoneNumber from '@libs/LocalePhoneNumber'; +import * as Localize from '@libs/Localize'; +import isSearchTopmostCentralPane from '@libs/Navigation/isSearchTopmostCentralPane'; +import Navigation from '@libs/Navigation/Navigation'; +import * as NextStepUtils from '@libs/NextStepUtils'; +import {rand64} from '@libs/NumberUtils'; +import * as OptionsListUtils from '@libs/OptionsListUtils'; +import * as PhoneNumber from '@libs/PhoneNumber'; +import * as PolicyUtils from '@libs/PolicyUtils'; +import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +import * as ReportConnection from '@libs/ReportConnection'; +import type {OptimisticChatReport, OptimisticCreatedReportAction, OptimisticIOUReportAction, TransactionDetails} from '@libs/ReportUtils'; +import * as ReportUtils from '@libs/ReportUtils'; +import * as SessionUtils from '@libs/SessionUtils'; +import * as SubscriptionUtils from '@libs/SubscriptionUtils'; +import * as TransactionUtils from '@libs/TransactionUtils'; +import {getCurrency, getTransaction} from '@libs/TransactionUtils'; +import ViolationsUtils from '@libs/Violations/ViolationsUtils'; +import type {IOUAction, IOUType} from '@src/CONST'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type * as OnyxTypes from '@src/types/onyx'; +import type {Participant, Split} from '@src/types/onyx/IOU'; +import type {ErrorFields, Errors} from '@src/types/onyx/OnyxCommon'; +import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; +import type ReportAction from '@src/types/onyx/ReportAction'; +import type {OnyxData} from '@src/types/onyx/Request'; +import type {Comment, Receipt, ReceiptSource, Routes, SplitShares, TransactionChanges, WaypointCollection} from '@src/types/onyx/Transaction'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import * as CachedPDFPaths from './CachedPDFPaths'; +import * as Category from './Policy/Category'; +import * as Policy from './Policy/Policy'; +import * as Tag from './Policy/Tag'; +import * as Report from './Report'; + +type IOURequestType = ValueOf; + +type OneOnOneIOUReport = OnyxTypes.Report | undefined | null; + +type MoneyRequestInformation = { + payerAccountID: number; + payerEmail: string; + iouReport: OnyxTypes.Report; + chatReport: OnyxTypes.Report; + transaction: OnyxTypes.Transaction; + iouAction: OptimisticIOUReportAction; + createdChatReportActionID: string; + createdIOUReportActionID: string; + reportPreviewAction: OnyxTypes.ReportAction; + transactionThreadReportID: string; + createdReportActionIDForThread: string; + onyxData: OnyxData; +}; + +type TrackExpenseInformation = { + createdWorkspaceParams?: CreateWorkspaceParams; + iouReport?: OnyxTypes.Report; + chatReport: OnyxTypes.Report; + transaction: OnyxTypes.Transaction; + iouAction: OptimisticIOUReportAction; + createdChatReportActionID: string; + createdIOUReportActionID?: string; + reportPreviewAction?: OnyxTypes.ReportAction; + transactionThreadReportID: string; + createdReportActionIDForThread: string; + actionableWhisperReportActionIDParam?: string; + onyxData: OnyxData; +}; + +type SendInvoiceInformation = { + senderWorkspaceID: string; + receiver: Partial; + invoiceRoom: OnyxTypes.Report; + createdChatReportActionID: string; + invoiceReportID: string; + reportPreviewReportActionID: string; + transactionID: string; + transactionThreadReportID: string; + createdIOUReportActionID: string; + createdReportActionIDForThread: string; + reportActionID: string; + onyxData: OnyxData; +}; + +type SplitData = { + chatReportID: string; + transactionID: string; + reportActionID: string; + policyID?: string; + createdReportActionID?: string; + chatType?: string; +}; + +type SplitsAndOnyxData = { + splitData: SplitData; + splits: Split[]; + onyxData: OnyxData; +}; + +type UpdateMoneyRequestData = { + params: UpdateMoneyRequestParams; + onyxData: OnyxData; +}; + +type PayMoneyRequestData = { + params: PayMoneyRequestParams & Partial; + optimisticData: OnyxUpdate[]; + successData: OnyxUpdate[]; + failureData: OnyxUpdate[]; +}; + +type SendMoneyParamsData = { + params: SendMoneyParams; + optimisticData: OnyxUpdate[]; + successData: OnyxUpdate[]; + failureData: OnyxUpdate[]; +}; + +type GPSPoint = { + lat: number; + long: number; +}; + +let allPersonalDetails: OnyxTypes.PersonalDetailsList = {}; +Onyx.connect({ + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + callback: (value) => { + allPersonalDetails = value ?? {}; + }, +}); + +let allTransactions: NonNullable> = {}; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (value) => { + if (!value) { + allTransactions = {}; + return; + } + + allTransactions = value; + }, +}); + +let allTransactionDrafts: NonNullable> = {}; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, + waitForCollectionCallback: true, + callback: (value) => { + allTransactionDrafts = value ?? {}; + }, +}); + +let allTransactionViolations: NonNullable> = {}; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, + waitForCollectionCallback: true, + callback: (value) => { + if (!value) { + allTransactionViolations = {}; + return; + } + + allTransactionViolations = value; + }, +}); + +let allDraftSplitTransactions: NonNullable> = {}; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT, + waitForCollectionCallback: true, + callback: (value) => { + allDraftSplitTransactions = value ?? {}; + }, +}); + +let allNextSteps: NonNullable> = {}; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.NEXT_STEP, + waitForCollectionCallback: true, + callback: (value) => { + allNextSteps = value ?? {}; + }, +}); + +let userAccountID = -1; +let currentUserEmail = ''; +Onyx.connect({ + key: ONYXKEYS.SESSION, + callback: (value) => { + currentUserEmail = value?.email ?? ''; + userAccountID = value?.accountID ?? -1; + }, +}); + +let currentUserPersonalDetails: OnyxEntry; +Onyx.connect({ + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + callback: (value) => { + currentUserPersonalDetails = value?.[userAccountID] ?? undefined; + }, +}); + +let currentDate: OnyxEntry = ''; +Onyx.connect({ + key: ONYXKEYS.CURRENT_DATE, + callback: (value) => { + currentDate = value; + }, +}); + +let quickAction: OnyxEntry = {}; +Onyx.connect({ + key: ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE, + callback: (value) => { + quickAction = value; + }, +}); + +let allReportActions: OnyxCollection; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, + waitForCollectionCallback: true, + callback: (actions) => { + if (!actions) { + return; + } + allReportActions = actions; + }, +}); + +let activePolicyID: OnyxEntry; +Onyx.connect({ + key: ONYXKEYS.NVP_ACTIVE_POLICY_ID, + callback: (value) => (activePolicyID = value), +}); + +let introSelected: OnyxEntry; +Onyx.connect({ + key: ONYXKEYS.NVP_INTRO_SELECTED, + callback: (value) => (introSelected = value), +}); + +let personalDetailsList: OnyxEntry; +Onyx.connect({ + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + callback: (value) => (personalDetailsList = value), +}); + +/** + * Find the report preview action from given chat report and iou report + */ +function getReportPreviewAction(chatReportID: string, iouReportID: string): OnyxInputValue> { + const reportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`] ?? {}; + + // Find the report preview action from the chat report + return ( + Object.values(reportActions).find( + (reportAction): reportAction is ReportAction => + reportAction && ReportActionsUtils.isReportPreviewAction(reportAction) && ReportActionsUtils.getOriginalMessage(reportAction)?.linkedReportID === iouReportID, + ) ?? null + ); +} + +/** + * Initialize expense info + * @param reportID to attach the transaction to + * @param policy + * @param isFromGlobalCreate + * @param iouRequestType one of manual/scan/distance + */ +function initMoneyRequest(reportID: string, policy: OnyxEntry, isFromGlobalCreate: boolean, iouRequestType: IOURequestType = CONST.IOU.REQUEST_TYPE.MANUAL) { + // Generate a brand new transactionID + const newTransactionID = CONST.IOU.OPTIMISTIC_TRANSACTION_ID; + const currency = policy?.outputCurrency ?? currentUserPersonalDetails?.localCurrencyCode ?? CONST.CURRENCY.USD; + // Disabling this line since currentDate can be an empty string + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const created = currentDate || format(new Date(), 'yyyy-MM-dd'); + + const currentTransaction = allTransactionDrafts?.[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${newTransactionID}`]; + + // in case we have to re-init money request, but the IOU request type is the same with the old draft transaction, + // we should keep most of the existing data by using the ONYX MERGE operation + if (currentTransaction?.iouRequestType === iouRequestType) { + // so, we just need to update the reportID, isFromGlobalCreate, created, currency + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${newTransactionID}`, { + reportID, + isFromGlobalCreate, + created, + currency, + transactionID: newTransactionID, + }); + return; + } + + const comment: Comment = {}; + + // Add initial empty waypoints when starting a distance expense + if (iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE) { + comment.waypoints = { + waypoint0: {keyForList: 'start_waypoint'}, + waypoint1: {keyForList: 'stop_waypoint'}, + }; + if (!isFromGlobalCreate) { + const customUnitRateID = DistanceRequestUtils.getCustomUnitRateID(reportID); + comment.customUnit = {customUnitRateID}; + } + } + + // Store the transaction in Onyx and mark it as not saved so it can be cleaned up later + // Use set() here so that there is no way that data will be leaked between objects when it gets reset + Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${newTransactionID}`, { + amount: 0, + comment, + created, + currency, + iouRequestType, + reportID, + transactionID: newTransactionID, + isFromGlobalCreate, + merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, + splitPayerAccountIDs: currentUserPersonalDetails ? [currentUserPersonalDetails.accountID] : undefined, + }); +} + +function createDraftTransaction(transaction: OnyxTypes.Transaction) { + if (!transaction) { + return; + } + + const newTransaction = { + ...transaction, + }; + + Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction.transactionID}`, newTransaction); +} + +function clearMoneyRequest(transactionID: string, skipConfirmation = false) { + Onyx.set(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID}`, skipConfirmation); + Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, null); +} + +function startMoneyRequest(iouType: ValueOf, reportID: string, requestType?: IOURequestType, skipConfirmation = false) { + clearMoneyRequest(CONST.IOU.OPTIMISTIC_TRANSACTION_ID, skipConfirmation); + switch (requestType) { + case CONST.IOU.REQUEST_TYPE.MANUAL: + Navigation.navigate(ROUTES.MONEY_REQUEST_CREATE_TAB_MANUAL.getRoute(CONST.IOU.ACTION.CREATE, iouType, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, reportID)); + return; + case CONST.IOU.REQUEST_TYPE.SCAN: + Navigation.navigate(ROUTES.MONEY_REQUEST_CREATE_TAB_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, reportID)); + return; + case CONST.IOU.REQUEST_TYPE.DISTANCE: + Navigation.navigate(ROUTES.MONEY_REQUEST_CREATE_TAB_DISTANCE.getRoute(CONST.IOU.ACTION.CREATE, iouType, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, reportID)); + return; + default: + Navigation.navigate(ROUTES.MONEY_REQUEST_CREATE.getRoute(CONST.IOU.ACTION.CREATE, iouType, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, reportID)); + } +} + +function setMoneyRequestAmount(transactionID: string, amount: number, currency: string, shouldShowOriginalAmount = false) { + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {amount, currency, shouldShowOriginalAmount}); +} + +function setMoneyRequestCreated(transactionID: string, created: string, isDraft: boolean) { + Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {created}); +} + +function setMoneyRequestCurrency(transactionID: string, currency: string, isEditing = false) { + const fieldToUpdate = isEditing ? 'modifiedCurrency' : 'currency'; + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {[fieldToUpdate]: currency}); +} + +function setMoneyRequestDescription(transactionID: string, comment: string, isDraft: boolean) { + Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {comment: {comment: comment.trim()}}); +} + +function setMoneyRequestMerchant(transactionID: string, merchant: string, isDraft: boolean) { + Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {merchant}); +} + +function setMoneyRequestPendingFields(transactionID: string, pendingFields: OnyxTypes.Transaction['pendingFields']) { + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {pendingFields}); +} + +function setMoneyRequestCategory(transactionID: string, category: string) { + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {category}); +} + +function setMoneyRequestTag(transactionID: string, tag: string) { + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {tag}); +} + +function setMoneyRequestBillable(transactionID: string, billable: boolean) { + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {billable}); +} + +function setMoneyRequestParticipants(transactionID: string, participants: Participant[] = []) { + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {participants}); +} + +function setSplitPayer(transactionID: string, payerAccountID: number) { + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {splitPayerAccountIDs: [payerAccountID]}); +} + +function setMoneyRequestReceipt(transactionID: string, source: string, filename: string, isDraft: boolean, type?: string) { + Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { + receipt: {source, type: type ?? ''}, + filename, + }); +} + +/** + * Set custom unit rateID for the transaction draft + */ +function setCustomUnitRateID(transactionID: string, customUnitRateID: string) { + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {comment: {customUnit: {customUnitRateID}}}); +} + +/** Set the distance rate of a new transaction */ +function setMoneyRequestDistanceRate(transactionID: string, rateID: string, policyID: string, isDraft: boolean) { + Onyx.merge(ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES, {[policyID]: rateID}); + Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {comment: {customUnit: {customUnitRateID: rateID}}}); +} + +/** Helper function to get the receipt error for expenses, or the generic error if there's no receipt */ +function getReceiptError(receipt: OnyxEntry, filename?: string, isScanRequest = true, errorKey?: number): Errors | ErrorFields { + return isEmptyObject(receipt) || !isScanRequest + ? ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericCreateFailureMessage', errorKey) + : ErrorUtils.getMicroSecondOnyxErrorObject({error: CONST.IOU.RECEIPT_ERROR, source: receipt.source?.toString() ?? '', filename: filename ?? ''}, errorKey); +} + +/** Helper function to get optimistic fields violations onyx data */ +function getFieldViolationsOnyxData(iouReport: OnyxTypes.Report): SetRequired { + const missingFields: OnyxTypes.ReportFieldsViolations = {}; + const excludedFields = Object.values(CONST.REPORT_VIOLATIONS_EXCLUDED_FIELDS) as string[]; + + Object.values(iouReport.fieldList ?? {}).forEach((field) => { + if (excludedFields.includes(field.fieldID) || !!field.value || !!field.defaultValue) { + return; + } + // in case of missing field violation the empty object is indicator. + missingFields[field.fieldID] = {}; + }); + + return { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_VIOLATIONS}${iouReport.reportID}`, + value: { + fieldRequired: missingFields, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_VIOLATIONS}${iouReport.reportID}`, + value: null, + }, + ], + }; +} + +/** Builds the Onyx data for an expense */ +function buildOnyxDataForMoneyRequest( + chatReport: OnyxTypes.OnyxInputOrEntry, + iouReport: OnyxTypes.Report, + transaction: OnyxTypes.Transaction, + chatCreatedAction: OptimisticCreatedReportAction, + iouCreatedAction: OptimisticCreatedReportAction, + iouAction: OptimisticIOUReportAction, + optimisticPersonalDetailListAction: OnyxTypes.PersonalDetailsList, + reportPreviewAction: ReportAction, + optimisticPolicyRecentlyUsedCategories: string[], + optimisticPolicyRecentlyUsedTags: OnyxTypes.RecentlyUsedTags, + isNewChatReport: boolean, + transactionThreadReport: OptimisticChatReport | null, + transactionThreadCreatedReportAction: OptimisticCreatedReportAction | null, + shouldCreateNewMoneyRequestReport: boolean, + policy?: OnyxTypes.OnyxInputOrEntry, + policyTagList?: OnyxTypes.OnyxInputOrEntry, + policyCategories?: OnyxTypes.OnyxInputOrEntry, + optimisticNextStep?: OnyxTypes.ReportNextStep | null, + isOneOnOneSplit = false, + existingTransactionThreadReportID?: string, + optimisticRecentlyUsedCurrencies?: string[], +): [OnyxUpdate[], OnyxUpdate[], OnyxUpdate[]] { + const isScanRequest = TransactionUtils.isScanRequest(transaction); + const outstandingChildRequest = ReportUtils.getOutstandingChildRequest(iouReport); + const clearedPendingFields = Object.fromEntries(Object.keys(transaction.pendingFields ?? {}).map((key) => [key, null])); + const optimisticData: OnyxUpdate[] = []; + const successData: OnyxUpdate[] = []; + let newQuickAction: ValueOf = isScanRequest ? CONST.QUICK_ACTIONS.REQUEST_SCAN : CONST.QUICK_ACTIONS.REQUEST_MANUAL; + if (TransactionUtils.isDistanceRequest(transaction)) { + newQuickAction = CONST.QUICK_ACTIONS.REQUEST_DISTANCE; + } + const existingTransactionThreadReport = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${existingTransactionThreadReportID}`] ?? null; + + if (chatReport) { + optimisticData.push({ + // Use SET for new reports because it doesn't exist yet, is faster and we need the data to be available when we navigate to the chat page + onyxMethod: isNewChatReport ? Onyx.METHOD.SET : Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, + value: { + ...chatReport, + lastReadTime: DateUtils.getDBTime(), + lastMessageTranslationKey: '', + iouReportID: iouReport.reportID, + ...outstandingChildRequest, + ...(isNewChatReport ? {pendingFields: {createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}} : {}), + }, + }); + } + + optimisticData.push( + { + onyxMethod: shouldCreateNewMoneyRequestReport ? Onyx.METHOD.SET : Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, + value: { + ...iouReport, + lastMessageText: ReportActionsUtils.getReportActionText(iouAction), + lastMessageHtml: ReportActionsUtils.getReportActionHtml(iouAction), + lastVisibleActionCreated: iouAction.created, + pendingFields: { + ...(shouldCreateNewMoneyRequestReport ? {createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD} : {preview: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), + }, + }, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: transaction, + }, + isNewChatReport + ? { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: { + [chatCreatedAction.reportActionID]: chatCreatedAction, + [reportPreviewAction.reportActionID]: reportPreviewAction, + }, + } + : { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: { + [reportPreviewAction.reportActionID]: reportPreviewAction, + }, + }, + shouldCreateNewMoneyRequestReport + ? { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, + value: { + [iouCreatedAction.reportActionID]: iouCreatedAction as OnyxTypes.ReportAction, + [iouAction.reportActionID]: iouAction as OnyxTypes.ReportAction, + }, + } + : { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, + value: { + [iouAction.reportActionID]: iouAction as OnyxTypes.ReportAction, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport?.reportID}`, + value: { + ...transactionThreadReport, + pendingFields: {createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}, + }, + }, + // Remove the temporary transaction used during the creation flow + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`, + value: null, + }, + ); + + if (!isEmptyObject(transactionThreadCreatedReportAction)) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport?.reportID}`, + value: { + [transactionThreadCreatedReportAction.reportActionID]: transactionThreadCreatedReportAction, + }, + }); + } + + if (!isOneOnOneSplit) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE, + value: { + action: newQuickAction, + chatReportID: chatReport?.reportID, + isFirstQuickAction: isEmptyObject(quickAction), + }, + }); + } + + if (optimisticPolicyRecentlyUsedCategories.length) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${iouReport.policyID}`, + value: optimisticPolicyRecentlyUsedCategories, + }); + } + + if (optimisticRecentlyUsedCurrencies?.length) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.RECENTLY_USED_CURRENCIES, + value: optimisticRecentlyUsedCurrencies, + }); + } + + if (!isEmptyObject(optimisticPolicyRecentlyUsedTags)) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${iouReport.policyID}`, + value: optimisticPolicyRecentlyUsedTags, + }); + } + + const redundantParticipants: Record = {}; + if (!isEmptyObject(optimisticPersonalDetailListAction)) { + const successPersonalDetailListAction: Record = {}; + + // BE will send different participants. We clear the optimistic ones to avoid duplicated entries + Object.keys(optimisticPersonalDetailListAction).forEach((accountIDKey) => { + const accountID = Number(accountIDKey); + successPersonalDetailListAction[accountID] = null; + redundantParticipants[accountID] = null; + }); + + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + value: optimisticPersonalDetailListAction, + }); + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + value: successPersonalDetailListAction, + }); + } + + if (!isEmptyObject(optimisticNextStep)) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${iouReport.reportID}`, + value: optimisticNextStep, + }); + } + + if (isNewChatReport) { + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport?.reportID}`, + value: { + participants: redundantParticipants, + pendingFields: null, + errorFields: null, + isOptimisticReport: false, + }, + }); + } + + successData.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, + value: { + participants: redundantParticipants, + pendingFields: null, + errorFields: null, + isOptimisticReport: false, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport?.reportID}`, + value: { + participants: redundantParticipants, + pendingFields: null, + errorFields: null, + isOptimisticReport: false, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: { + pendingAction: null, + pendingFields: clearedPendingFields, + // The routes contains the distance in meters. Clearing the routes ensures we use the distance + // in the correct unit stored under the transaction customUnit once the request is created. + // The route is also not saved in the backend, so we can't rely on it. + routes: null, + }, + }, + + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: { + ...(isNewChatReport + ? { + [chatCreatedAction.reportActionID]: { + pendingAction: null, + errors: null, + }, + } + : {}), + [reportPreviewAction.reportActionID]: { + pendingAction: null, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, + value: { + ...(shouldCreateNewMoneyRequestReport + ? { + [iouCreatedAction.reportActionID]: { + pendingAction: null, + errors: null, + }, + } + : {}), + [iouAction.reportActionID]: { + pendingAction: null, + errors: null, + }, + }, + }, + ); + + if (!isEmptyObject(transactionThreadCreatedReportAction)) { + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport?.reportID}`, + value: { + [transactionThreadCreatedReportAction.reportActionID]: { + pendingAction: null, + errors: null, + }, + }, + }); + } + + const errorKey = DateUtils.getMicroseconds(); + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport?.reportID}`, + value: { + iouReportID: chatReport?.iouReportID, + lastReadTime: chatReport?.lastReadTime, + pendingFields: null, + hasOutstandingChildRequest: chatReport?.hasOutstandingChildRequest, + ...(isNewChatReport + ? { + errorFields: { + createChat: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('report.genericCreateReportFailureMessage'), + }, + } + : {}), + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, + value: { + pendingFields: null, + errorFields: { + ...(shouldCreateNewMoneyRequestReport ? {createChat: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('report.genericCreateReportFailureMessage')} : {}), + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport?.reportID}`, + value: { + pendingFields: null, + errorFields: existingTransactionThreadReport + ? null + : { + createChat: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('report.genericCreateReportFailureMessage'), + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: { + // Disabling this line since transaction.filename can be an empty string + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + errors: getReceiptError(transaction.receipt, transaction.filename || transaction.receipt?.filename, isScanRequest, errorKey), + pendingFields: clearedPendingFields, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, + value: { + ...(shouldCreateNewMoneyRequestReport + ? { + [iouCreatedAction.reportActionID]: { + // Disabling this line since transaction.filename can be an empty string + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + errors: getReceiptError(transaction.receipt, transaction.filename || transaction.receipt?.filename, isScanRequest, errorKey), + }, + [iouAction.reportActionID]: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericCreateFailureMessage'), + }, + } + : { + [iouAction.reportActionID]: { + // Disabling this line since transaction.filename can be an empty string + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + errors: getReceiptError(transaction.receipt, transaction.filename || transaction.receipt?.filename, isScanRequest, errorKey), + }, + }), + }, + }, + ]; + + if (!isEmptyObject(transactionThreadCreatedReportAction)) { + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport?.reportID}`, + value: { + [transactionThreadCreatedReportAction.reportActionID]: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericCreateFailureMessage'), + }, + }, + }); + } + + // We don't need to compute violations unless we're on a paid policy + if (!policy || !PolicyUtils.isPaidGroupPolicy(policy)) { + return [optimisticData, successData, failureData]; + } + + const violationsOnyxData = ViolationsUtils.getViolationsOnyxData( + transaction, + [], + policy, + policyTagList ?? {}, + policyCategories ?? {}, + PolicyUtils.hasDependentTags(policy, policyTagList ?? {}), + ); + + if (violationsOnyxData) { + optimisticData.push(violationsOnyxData); + failureData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}`, + value: [], + }); + } + + return [optimisticData, successData, failureData]; +} + +/** Builds the Onyx data for an invoice */ +function buildOnyxDataForInvoice( + chatReport: OnyxEntry, + iouReport: OnyxTypes.Report, + transaction: OnyxTypes.Transaction, + chatCreatedAction: OptimisticCreatedReportAction, + iouCreatedAction: OptimisticCreatedReportAction, + iouAction: OptimisticIOUReportAction, + optimisticPersonalDetailListAction: OnyxTypes.PersonalDetailsList, + reportPreviewAction: ReportAction, + optimisticPolicyRecentlyUsedCategories: string[], + optimisticPolicyRecentlyUsedTags: OnyxTypes.RecentlyUsedTags, + isNewChatReport: boolean, + transactionThreadReport: OptimisticChatReport, + transactionThreadCreatedReportAction: OptimisticCreatedReportAction | null, + policy?: OnyxEntry, + policyTagList?: OnyxEntry, + policyCategories?: OnyxEntry, + optimisticRecentlyUsedCurrencies?: string[], + companyName?: string, + companyWebsite?: string, +): [OnyxUpdate[], OnyxUpdate[], OnyxUpdate[]] { + const clearedPendingFields = Object.fromEntries(Object.keys(transaction.pendingFields ?? {}).map((key) => [key, null])); + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, + value: { + ...iouReport, + lastMessageText: ReportActionsUtils.getReportActionText(iouAction), + lastMessageHtml: ReportActionsUtils.getReportActionHtml(iouAction), + pendingFields: { + createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: transaction, + }, + isNewChatReport + ? { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: { + [chatCreatedAction.reportActionID]: chatCreatedAction, + [reportPreviewAction.reportActionID]: reportPreviewAction, + }, + } + : { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: { + [reportPreviewAction.reportActionID]: reportPreviewAction, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, + value: { + [iouCreatedAction.reportActionID]: iouCreatedAction as OnyxTypes.ReportAction, + [iouAction.reportActionID]: iouAction as OnyxTypes.ReportAction, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport.reportID}`, + value: transactionThreadReport, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport.reportID}`, + value: { + [transactionThreadCreatedReportAction?.reportActionID ?? '-1']: transactionThreadCreatedReportAction, + }, + }, + // Remove the temporary transaction used during the creation flow + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`, + value: null, + }, + ]; + const successData: OnyxUpdate[] = []; + + if (chatReport) { + optimisticData.push({ + // Use SET for new reports because it doesn't exist yet, is faster and we need the data to be available when we navigate to the chat page + onyxMethod: isNewChatReport ? Onyx.METHOD.SET : Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, + value: { + ...chatReport, + lastReadTime: DateUtils.getDBTime(), + lastMessageTranslationKey: '', + iouReportID: iouReport.reportID, + ...(isNewChatReport ? {pendingFields: {createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}} : {}), + }, + }); + } + + if (optimisticPolicyRecentlyUsedCategories.length) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${iouReport.policyID}`, + value: optimisticPolicyRecentlyUsedCategories, + }); + } + + if (optimisticRecentlyUsedCurrencies?.length) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.RECENTLY_USED_CURRENCIES, + value: optimisticRecentlyUsedCurrencies, + }); + } + + if (!isEmptyObject(optimisticPolicyRecentlyUsedTags)) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${iouReport.policyID}`, + value: optimisticPolicyRecentlyUsedTags, + }); + } + + const redundantParticipants: Record = {}; + if (!isEmptyObject(optimisticPersonalDetailListAction)) { + const successPersonalDetailListAction: Record = {}; + + // BE will send different participants. We clear the optimistic ones to avoid duplicated entries + Object.keys(optimisticPersonalDetailListAction).forEach((accountIDKey) => { + const accountID = Number(accountIDKey); + successPersonalDetailListAction[accountID] = null; + redundantParticipants[accountID] = null; + }); + + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + value: optimisticPersonalDetailListAction, + }); + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + value: successPersonalDetailListAction, + }); + } + + successData.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, + value: { + participants: redundantParticipants, + pendingFields: null, + errorFields: null, + isOptimisticReport: false, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport.reportID}`, + value: { + participants: redundantParticipants, + pendingFields: null, + errorFields: null, + isOptimisticReport: false, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: { + pendingAction: null, + pendingFields: clearedPendingFields, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: { + ...(isNewChatReport + ? { + [chatCreatedAction.reportActionID]: { + pendingAction: null, + errors: null, + }, + } + : {}), + [reportPreviewAction.reportActionID]: { + pendingAction: null, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, + value: { + [iouCreatedAction.reportActionID]: { + pendingAction: null, + errors: null, + }, + [iouAction.reportActionID]: { + pendingAction: null, + errors: null, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport.reportID}`, + value: { + [transactionThreadCreatedReportAction?.reportActionID ?? '-1']: { + pendingAction: null, + errors: null, + }, + }, + }, + ); + + if (isNewChatReport) { + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport?.reportID}`, + value: { + participants: redundantParticipants, + pendingFields: null, + errorFields: null, + isOptimisticReport: false, + }, + }); + } + + const errorKey = DateUtils.getMicroseconds(); + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport?.reportID}`, + value: { + iouReportID: chatReport?.iouReportID, + lastReadTime: chatReport?.lastReadTime, + pendingFields: null, + hasOutstandingChildRequest: chatReport?.hasOutstandingChildRequest, + ...(isNewChatReport + ? { + errorFields: { + createChat: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('report.genericCreateReportFailureMessage'), + }, + } + : {}), + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, + value: { + pendingFields: null, + errorFields: { + createChat: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('report.genericCreateReportFailureMessage'), + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport.reportID}`, + value: { + errorFields: { + createChat: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('report.genericCreateReportFailureMessage'), + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericCreateInvoiceFailureMessage'), + pendingAction: null, + pendingFields: clearedPendingFields, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, + value: { + [iouCreatedAction.reportActionID]: { + // Disabling this line since transaction.filename can be an empty string + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + errors: getReceiptError(transaction.receipt, transaction.filename || transaction.receipt?.filename, false, errorKey), + }, + [iouAction.reportActionID]: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericCreateInvoiceFailureMessage'), + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport.reportID}`, + value: { + [transactionThreadCreatedReportAction?.reportActionID ?? '-1']: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericCreateInvoiceFailureMessage', errorKey), + }, + }, + }, + ]; + + if (companyName && companyWebsite) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policy?.id}`, + value: { + invoice: { + companyName, + companyWebsite, + pendingFields: { + companyName: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + companyWebsite: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + }); + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policy?.id}`, + value: { + invoice: { + pendingFields: { + companyName: null, + companyWebsite: null, + }, + }, + }, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policy?.id}`, + value: { + invoice: { + companyName: undefined, + companyWebsite: undefined, + pendingFields: { + companyName: null, + companyWebsite: null, + }, + }, + }, + }); + } + + // We don't need to compute violations unless we're on a paid policy + if (!policy || !PolicyUtils.isPaidGroupPolicy(policy)) { + return [optimisticData, successData, failureData]; + } + + const violationsOnyxData = ViolationsUtils.getViolationsOnyxData( + transaction, + [], + policy, + policyTagList ?? {}, + policyCategories ?? {}, + PolicyUtils.hasDependentTags(policy, policyTagList ?? {}), + ); + + if (violationsOnyxData) { + optimisticData.push(violationsOnyxData); + failureData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}`, + value: [], + }); + } + + return [optimisticData, successData, failureData]; +} + +/** Builds the Onyx data for track expense */ +function buildOnyxDataForTrackExpense( + chatReport: OnyxInputValue, + iouReport: OnyxInputValue, + transaction: OnyxTypes.Transaction, + iouCreatedAction: OptimisticCreatedReportAction, + iouAction: OptimisticIOUReportAction, + reportPreviewAction: OnyxInputValue, + transactionThreadReport: OptimisticChatReport | null, + transactionThreadCreatedReportAction: OptimisticCreatedReportAction | null, + shouldCreateNewMoneyRequestReport: boolean, + policy?: OnyxInputValue, + policyTagList?: OnyxInputValue, + policyCategories?: OnyxInputValue, + existingTransactionThreadReportID?: string, + actionableTrackExpenseWhisper?: OnyxInputValue, +): [OnyxUpdate[], OnyxUpdate[], OnyxUpdate[]] { + const isScanRequest = TransactionUtils.isScanRequest(transaction); + const isDistanceRequest = TransactionUtils.isDistanceRequest(transaction); + const clearedPendingFields = Object.fromEntries(Object.keys(transaction.pendingFields ?? {}).map((key) => [key, null])); + const optimisticData: OnyxUpdate[] = []; + const successData: OnyxUpdate[] = []; + const failureData: OnyxUpdate[] = []; + + let newQuickAction: ValueOf = CONST.QUICK_ACTIONS.TRACK_MANUAL; + if (isScanRequest) { + newQuickAction = CONST.QUICK_ACTIONS.TRACK_SCAN; + } else if (isDistanceRequest) { + newQuickAction = CONST.QUICK_ACTIONS.TRACK_DISTANCE; + } + const existingTransactionThreadReport = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${existingTransactionThreadReportID}`] ?? null; + + if (chatReport) { + optimisticData.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, + value: { + ...chatReport, + lastMessageText: ReportActionsUtils.getReportActionText(iouAction), + lastMessageHtml: ReportActionsUtils.getReportActionHtml(iouAction), + lastReadTime: DateUtils.getDBTime(), + iouReportID: iouReport?.reportID, + }, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE, + value: { + action: newQuickAction, + chatReportID: chatReport.reportID, + isFirstQuickAction: isEmptyObject(quickAction), + }, + }, + ); + + if (actionableTrackExpenseWhisper && !iouReport) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: { + [actionableTrackExpenseWhisper.reportActionID]: actionableTrackExpenseWhisper, + }, + }); + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, + value: { + lastVisibleActionCreated: actionableTrackExpenseWhisper.created, + lastMessageText: CONST.ACTIONABLE_TRACK_EXPENSE_WHISPER_MESSAGE, + }, + }); + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: { + [actionableTrackExpenseWhisper.reportActionID]: {pendingAction: null, errors: null}, + }, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: {[actionableTrackExpenseWhisper.reportActionID]: {} as ReportAction}, + }); + } + } + + if (iouReport) { + optimisticData.push( + { + onyxMethod: shouldCreateNewMoneyRequestReport ? Onyx.METHOD.SET : Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, + value: { + ...iouReport, + lastMessageText: ReportActionsUtils.getReportActionText(iouAction), + lastMessageHtml: ReportActionsUtils.getReportActionHtml(iouAction), + pendingFields: { + ...(shouldCreateNewMoneyRequestReport ? {createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD} : {preview: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), + }, + }, + }, + shouldCreateNewMoneyRequestReport + ? { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, + value: { + [iouCreatedAction.reportActionID]: iouCreatedAction as OnyxTypes.ReportAction, + [iouAction.reportActionID]: iouAction as OnyxTypes.ReportAction, + }, + } + : { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, + value: { + [iouAction.reportActionID]: iouAction as OnyxTypes.ReportAction, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: { + ...(reportPreviewAction && {[reportPreviewAction.reportActionID]: reportPreviewAction}), + }, + }, + ); + } else { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: { + [iouAction.reportActionID]: iouAction as OnyxTypes.ReportAction, + }, + }); + } + + optimisticData.push( + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: transaction, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport?.reportID}`, + value: { + ...transactionThreadReport, + pendingFields: {createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}, + }, + }, + // Remove the temporary transaction used during the creation flow + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`, + value: null, + }, + ); + + if (!isEmptyObject(transactionThreadCreatedReportAction)) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport?.reportID}`, + value: { + [transactionThreadCreatedReportAction.reportActionID]: transactionThreadCreatedReportAction, + }, + }); + } + + if (iouReport) { + successData.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, + value: { + pendingFields: null, + errorFields: null, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`, + value: { + ...(shouldCreateNewMoneyRequestReport + ? { + [iouCreatedAction.reportActionID]: { + pendingAction: null, + errors: null, + }, + } + : {}), + [iouAction.reportActionID]: { + pendingAction: null, + errors: null, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: { + ...(reportPreviewAction && {[reportPreviewAction.reportActionID]: {pendingAction: null}}), + }, + }, + ); + } else { + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: { + [iouAction.reportActionID]: { + pendingAction: null, + errors: null, + }, + ...(reportPreviewAction && {[reportPreviewAction.reportActionID]: {pendingAction: null}}), + }, + }); + } + + successData.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport?.reportID}`, + value: { + pendingFields: null, + errorFields: null, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: { + pendingAction: null, + pendingFields: clearedPendingFields, + routes: null, + }, + }, + ); + + if (!isEmptyObject(transactionThreadCreatedReportAction)) { + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport?.reportID}`, + value: { + [transactionThreadCreatedReportAction.reportActionID]: { + pendingAction: null, + errors: null, + }, + }, + }); + } + + failureData.push({ + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE, + value: quickAction ?? null, + }); + + if (iouReport) { + failureData.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, + value: { + pendingFields: null, + errorFields: { + ...(shouldCreateNewMoneyRequestReport ? {createChat: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('report.genericCreateReportFailureMessage')} : {}), + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, + value: { + ...(shouldCreateNewMoneyRequestReport + ? { + [iouCreatedAction.reportActionID]: { + // Disabling this line since transaction.filename can be an empty string + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + errors: getReceiptError(transaction.receipt, transaction.filename || transaction.receipt?.filename, isScanRequest), + }, + [iouAction.reportActionID]: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericCreateFailureMessage'), + }, + } + : { + [iouAction.reportActionID]: { + // Disabling this line since transaction.filename can be an empty string + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + errors: getReceiptError(transaction.receipt, transaction.filename || transaction.receipt?.filename, isScanRequest), + }, + }), + }, + }, + ); + } else { + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: { + [iouAction.reportActionID]: { + // Disabling this line since transaction.filename can be an empty string + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + errors: getReceiptError(transaction.receipt, transaction.filename || transaction.receipt?.filename, isScanRequest), + }, + }, + }); + } + + failureData.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport?.reportID}`, + value: { + lastReadTime: chatReport?.lastReadTime, + lastMessageText: chatReport?.lastMessageText, + lastMessageHtml: chatReport?.lastMessageHtml, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport?.reportID}`, + value: { + pendingFields: null, + errorFields: existingTransactionThreadReport + ? null + : { + createChat: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('report.genericCreateReportFailureMessage'), + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: { + // Disabling this line since transaction.filename can be an empty string + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + errors: getReceiptError(transaction.receipt, transaction.filename || transaction.receipt?.filename, isScanRequest), + pendingFields: clearedPendingFields, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport?.reportID}`, + value: { + [transactionThreadCreatedReportAction?.reportActionID ?? '-1']: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericCreateFailureMessage'), + }, + }, + }, + ); + + // We don't need to compute violations unless we're on a paid policy + if (!policy || !PolicyUtils.isPaidGroupPolicy(policy)) { + return [optimisticData, successData, failureData]; + } + + const violationsOnyxData = ViolationsUtils.getViolationsOnyxData( + transaction, + [], + policy, + policyTagList ?? {}, + policyCategories ?? {}, + PolicyUtils.hasDependentTags(policy, policyTagList ?? {}), + ); + + if (violationsOnyxData) { + optimisticData.push(violationsOnyxData); + failureData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}`, + value: [], + }); + } + + // Show field violations only for control policies + if (PolicyUtils.isControlPolicy(policy) && iouReport) { + const {optimisticData: fieldViolationsOptimisticData, failureData: fieldViolationsFailureData} = getFieldViolationsOnyxData(iouReport); + optimisticData.push(...fieldViolationsOptimisticData); + failureData.push(...fieldViolationsFailureData); + } + + return [optimisticData, successData, failureData]; +} + +function getDeleteTrackExpenseInformation( + chatReportID: string, + transactionID: string, + reportAction: OnyxTypes.ReportAction, + shouldDeleteTransactionFromOnyx = true, + isMovingTransactionFromTrackExpense = false, + actionableWhisperReportActionID = '', + resolution = '', +) { + const allReports = ReportConnection.getAllReports(); + // STEP 1: Get all collections we're updating + const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`] ?? null; + const transaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + const transactionViolations = allTransactionViolations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`]; + const transactionThreadID = reportAction.childReportID; + let transactionThread = null; + if (transactionThreadID) { + transactionThread = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadID}`] ?? null; + } + + // STEP 2: Decide if we need to: + // 1. Delete the transactionThread - delete if there are no visible comments in the thread and we're not moving the transaction + // 2. Update the moneyRequestPreview to show [Deleted expense] - update if the transactionThread exists AND it isn't being deleted and we're not moving the transaction + const shouldDeleteTransactionThread = !isMovingTransactionFromTrackExpense && (transactionThreadID ? (reportAction?.childVisibleActionCount ?? 0) === 0 : false); + + const shouldShowDeletedRequestMessage = !isMovingTransactionFromTrackExpense && !!transactionThreadID && !shouldDeleteTransactionThread; + + // STEP 3: Update the IOU reportAction. + const updatedReportAction = { + [reportAction.reportActionID]: { + pendingAction: shouldShowDeletedRequestMessage ? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE : CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + previousMessage: reportAction.message, + message: [ + { + type: 'COMMENT', + html: '', + text: '', + isEdited: true, + isDeletedParentAction: shouldShowDeletedRequestMessage, + }, + ], + originalMessage: { + IOUTransactionID: null, + }, + errors: undefined, + }, + ...(actionableWhisperReportActionID && {[actionableWhisperReportActionID]: {originalMessage: {resolution}}}), + } as OnyxTypes.ReportActions; + const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(chatReport?.reportID ?? '-1', updatedReportAction); + const {lastMessageText = '', lastMessageHtml = ''} = ReportActionsUtils.getLastVisibleMessage(chatReport?.reportID ?? '-1', updatedReportAction); + + // STEP 4: Build Onyx data + const optimisticData: OnyxUpdate[] = []; + + if (shouldDeleteTransactionFromOnyx) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: null, + }); + } + + optimisticData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, + value: null, + }); + + if (shouldDeleteTransactionThread) { + optimisticData.push( + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadID}`, + value: null, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadID}`, + value: null, + }, + ); + } + + optimisticData.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: updatedReportAction, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport?.reportID}`, + value: { + lastMessageText, + lastVisibleActionCreated: lastVisibleAction?.created, + lastMessageHtml: !lastMessageHtml ? lastMessageText : lastMessageHtml, + }, + }, + ); + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: { + [reportAction.reportActionID]: { + pendingAction: null, + errors: null, + }, + }, + }, + ]; + + const failureData: OnyxUpdate[] = []; + + if (shouldDeleteTransactionFromOnyx) { + failureData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: transaction ?? null, + }); + } + + failureData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, + value: transactionViolations ?? null, + }); + + if (shouldDeleteTransactionThread) { + failureData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadID}`, + value: transactionThread, + }); + } + + if (actionableWhisperReportActionID) { + const actionableWhisperReportAction = ReportActionsUtils.getReportAction(chatReportID, actionableWhisperReportActionID); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: { + [actionableWhisperReportActionID]: { + originalMessage: { + resolution: ReportActionsUtils.isActionableTrackExpense(actionableWhisperReportAction) + ? ReportActionsUtils.getOriginalMessage(actionableWhisperReportAction)?.resolution ?? null + : null, + }, + }, + }, + }); + } + failureData.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: { + [reportAction.reportActionID]: { + ...reportAction, + pendingAction: null, + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericDeleteFailureMessage'), + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport?.reportID}`, + value: chatReport, + }, + ); + + const parameters: DeleteMoneyRequestParams = { + transactionID, + reportActionID: reportAction.reportActionID, + }; + + return {parameters, optimisticData, successData, failureData, shouldDeleteTransactionThread, chatReport}; +} + +/** Gathers all the data needed to create an invoice. */ +function getSendInvoiceInformation( + transaction: OnyxEntry, + currentUserAccountID: number, + invoiceChatReport?: OnyxEntry, + receipt?: Receipt, + policy?: OnyxEntry, + policyTagList?: OnyxEntry, + policyCategories?: OnyxEntry, + companyName?: string, + companyWebsite?: string, +): SendInvoiceInformation { + const {amount = 0, currency = '', created = '', merchant = '', category = '', tag = '', taxCode = '', taxAmount = 0, billable, comment, participants} = transaction ?? {}; + const trimmedComment = (comment?.comment ?? '').trim(); + const senderWorkspaceID = participants?.find((participant) => participant?.isSender)?.policyID ?? '-1'; + const receiverParticipant = participants?.find((participant) => participant?.accountID) ?? invoiceChatReport?.invoiceReceiver; + const receiverAccountID = receiverParticipant && 'accountID' in receiverParticipant && receiverParticipant.accountID ? receiverParticipant.accountID : -1; + let receiver = ReportUtils.getPersonalDetailsForAccountID(receiverAccountID); + let optimisticPersonalDetailListAction = {}; + + // STEP 1: Get existing chat report OR build a new optimistic one + let isNewChatReport = false; + let chatReport = !isEmptyObject(invoiceChatReport) && invoiceChatReport?.reportID ? invoiceChatReport : null; + + if (!chatReport) { + chatReport = ReportUtils.getInvoiceChatByParticipants(senderWorkspaceID, receiverAccountID) ?? null; + } + + if (!chatReport) { + isNewChatReport = true; + chatReport = ReportUtils.buildOptimisticChatReport([receiverAccountID, currentUserAccountID], CONST.REPORT.DEFAULT_REPORT_NAME, CONST.REPORT.CHAT_TYPE.INVOICE, senderWorkspaceID); + } + + // STEP 2: Create a new optimistic invoice report. + const optimisticInvoiceReport = ReportUtils.buildOptimisticInvoiceReport(chatReport.reportID, senderWorkspaceID, receiverAccountID, receiver.displayName ?? '', amount, currency); + + // STEP 3: Build optimistic receipt and transaction + const receiptObject: Receipt = {}; + let filename; + if (receipt?.source) { + receiptObject.source = receipt.source; + receiptObject.state = receipt.state ?? CONST.IOU.RECEIPT_STATE.SCANREADY; + filename = receipt.name; + } + const optimisticTransaction = TransactionUtils.buildOptimisticTransaction( + amount, + currency, + optimisticInvoiceReport.reportID, + trimmedComment, + created, + '', + '', + merchant, + receiptObject, + filename, + undefined, + category, + tag, + taxCode, + taxAmount, + billable, + ); + + const optimisticPolicyRecentlyUsedCategories = Category.buildOptimisticPolicyRecentlyUsedCategories(optimisticInvoiceReport.policyID, category); + const optimisticPolicyRecentlyUsedTags = Tag.buildOptimisticPolicyRecentlyUsedTags(optimisticInvoiceReport.policyID, tag); + const optimisticRecentlyUsedCurrencies = Policy.buildOptimisticRecentlyUsedCurrencies(currency); + + // STEP 4: Add optimistic personal details for participant + const shouldCreateOptimisticPersonalDetails = isNewChatReport && !allPersonalDetails[receiverAccountID]; + if (shouldCreateOptimisticPersonalDetails) { + const receiverLogin = receiverParticipant && 'login' in receiverParticipant && receiverParticipant.login ? receiverParticipant.login : ''; + receiver = { + accountID: receiverAccountID, + displayName: LocalePhoneNumber.formatPhoneNumber(receiverLogin), + login: receiverLogin, + isOptimisticPersonalDetail: true, + }; + + optimisticPersonalDetailListAction = {[receiverAccountID]: receiver}; + } + + // STEP 5: Build optimistic reportActions. + const reportPreviewAction = ReportUtils.buildOptimisticReportPreview(chatReport, optimisticInvoiceReport, trimmedComment, optimisticTransaction); + optimisticInvoiceReport.parentReportActionID = reportPreviewAction.reportActionID; + chatReport.lastVisibleActionCreated = reportPreviewAction.created; + const [optimisticCreatedActionForChat, optimisticCreatedActionForIOUReport, iouAction, optimisticTransactionThread, optimisticCreatedActionForTransactionThread] = + ReportUtils.buildOptimisticMoneyRequestEntities( + optimisticInvoiceReport, + CONST.IOU.REPORT_ACTION_TYPE.CREATE, + amount, + currency, + trimmedComment, + receiver.login ?? '', + [receiver], + optimisticTransaction.transactionID, + undefined, + false, + false, + false, + ); + + // STEP 6: Build Onyx Data + const [optimisticData, successData, failureData] = buildOnyxDataForInvoice( + chatReport, + optimisticInvoiceReport, + optimisticTransaction, + optimisticCreatedActionForChat, + optimisticCreatedActionForIOUReport, + iouAction, + optimisticPersonalDetailListAction, + reportPreviewAction, + optimisticPolicyRecentlyUsedCategories, + optimisticPolicyRecentlyUsedTags, + isNewChatReport, + optimisticTransactionThread, + optimisticCreatedActionForTransactionThread, + policy, + policyTagList, + policyCategories, + optimisticRecentlyUsedCurrencies, + companyName, + companyWebsite, + ); + + return { + createdIOUReportActionID: optimisticCreatedActionForIOUReport.reportActionID, + createdReportActionIDForThread: optimisticCreatedActionForTransactionThread?.reportActionID ?? '-1', + reportActionID: iouAction.reportActionID, + senderWorkspaceID, + receiver, + invoiceRoom: chatReport, + createdChatReportActionID: optimisticCreatedActionForChat.reportActionID, + invoiceReportID: optimisticInvoiceReport.reportID, + reportPreviewReportActionID: reportPreviewAction.reportActionID, + transactionID: optimisticTransaction.transactionID, + transactionThreadReportID: optimisticTransactionThread.reportID, + onyxData: { + optimisticData, + successData, + failureData, + }, + }; +} + +/** + * Gathers all the data needed to submit an expense. It attempts to find existing reports, iouReports, and receipts. If it doesn't find them, then + * it creates optimistic versions of them and uses those instead + */ +function getMoneyRequestInformation( + parentChatReport: OnyxEntry, + participant: Participant, + comment: string, + amount: number, + currency: string, + created: string, + merchant: string, + receipt: Receipt | undefined, + existingTransactionID: string | undefined, + category: string | undefined, + tag: string | undefined, + taxCode: string | undefined, + taxAmount: number | undefined, + billable: boolean | undefined, + policy: OnyxEntry | undefined, + policyTagList: OnyxEntry | undefined, + policyCategories: OnyxEntry | undefined, + payeeAccountID = userAccountID, + payeeEmail = currentUserEmail, + moneyRequestReportID = '', + linkedTrackedExpenseReportAction?: OnyxTypes.ReportAction, +): MoneyRequestInformation { + const payerEmail = PhoneNumber.addSMSDomainIfPhoneNumber(participant.login ?? ''); + const payerAccountID = Number(participant.accountID); + const isPolicyExpenseChat = participant.isPolicyExpenseChat; + + // STEP 1: Get existing chat report OR build a new optimistic one + let isNewChatReport = false; + let chatReport = !isEmptyObject(parentChatReport) && parentChatReport?.reportID ? parentChatReport : null; + + const allReports = ReportConnection.getAllReports(); + // If this is a policyExpenseChat, the chatReport must exist and we can get it from Onyx. + // report is null if the flow is initiated from the global create menu. However, participant always stores the reportID if it exists, which is the case for policyExpenseChats + if (!chatReport && isPolicyExpenseChat) { + chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${participant.reportID}`] ?? null; + } + + if (!chatReport) { + chatReport = ReportUtils.getChatByParticipants([payerAccountID, payeeAccountID]) ?? null; + } + + // If we still don't have a report, it likely doens't exist and we need to build an optimistic one + if (!chatReport) { + isNewChatReport = true; + chatReport = ReportUtils.buildOptimisticChatReport([payerAccountID, payeeAccountID]); + } + + // STEP 2: Get the Expense/IOU report. If the moneyRequestReportID has been provided, we want to add the transaction to this specific report. + // If no such reportID has been provided, let's use the chatReport.iouReportID property. In case that is not present, build a new optimistic Expense/IOU report. + let iouReport: OnyxInputValue = null; + if (moneyRequestReportID) { + iouReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${moneyRequestReportID}`] ?? null; + } else { + iouReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReport.iouReportID}`] ?? null; + } + + const shouldCreateNewMoneyRequestReport = ReportUtils.shouldCreateNewMoneyRequestReport(iouReport, chatReport); + + if (!iouReport || shouldCreateNewMoneyRequestReport) { + iouReport = isPolicyExpenseChat + ? ReportUtils.buildOptimisticExpenseReport(chatReport.reportID, chatReport.policyID ?? '-1', payeeAccountID, amount, currency) + : ReportUtils.buildOptimisticIOUReport(payeeAccountID, payerAccountID, amount, chatReport.reportID, currency); + } else if (isPolicyExpenseChat) { + iouReport = {...iouReport}; + if (iouReport?.currency === currency && typeof iouReport.total === 'number') { + // Because of the Expense reports are stored as negative values, we subtract the total from the amount + iouReport.total -= amount; + } + } else { + iouReport = IOUUtils.updateIOUOwnerAndTotal(iouReport, payeeAccountID, amount, currency); + } + + // STEP 3: Build an optimistic transaction with the receipt + const existingTransaction = allTransactionDrafts[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${existingTransactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`]; + const isDistanceRequest = existingTransaction && existingTransaction.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE; + let optimisticTransaction = TransactionUtils.buildOptimisticTransaction( + ReportUtils.isExpenseReport(iouReport) ? -amount : amount, + currency, + iouReport.reportID, + comment, + created, + '', + '', + merchant, + receipt, + '', + existingTransactionID, + category, + tag, + taxCode, + ReportUtils.isExpenseReport(iouReport) ? -(taxAmount ?? 0) : taxAmount, + billable, + isDistanceRequest ? {waypoints: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD} : undefined, + ); + + const optimisticPolicyRecentlyUsedCategories = Category.buildOptimisticPolicyRecentlyUsedCategories(iouReport.policyID, category); + const optimisticPolicyRecentlyUsedTags = Tag.buildOptimisticPolicyRecentlyUsedTags(iouReport.policyID, tag); + const optimisticPolicyRecentluUsedCurrencies = Policy.buildOptimisticRecentlyUsedCurrencies(currency); + + // If there is an existing transaction (which is the case for distance requests), then the data from the existing transaction + // needs to be manually merged into the optimistic transaction. This is because buildOnyxDataForMoneyRequest() uses `Onyx.set()` for the transaction + // data. This is a big can of worms to change it to `Onyx.merge()` as explored in https://expensify.slack.com/archives/C05DWUDHVK7/p1692139468252109. + // I want to clean this up at some point, but it's possible this will live in the code for a while so I've created https://github.com/Expensify/App/issues/25417 + // to remind me to do this. + if (isDistanceRequest) { + optimisticTransaction = fastMerge(existingTransaction, optimisticTransaction, false); + } + + // STEP 4: Build optimistic reportActions. We need: + // 1. CREATED action for the chatReport + // 2. CREATED action for the iouReport + // 3. IOU action for the iouReport + // 4. The transaction thread, which requires the iouAction, and CREATED action for the transaction thread + // 5. REPORT_PREVIEW action for the chatReport + // Note: The CREATED action for the IOU report must be optimistically generated before the IOU action so there's no chance that it appears after the IOU action in the chat + const [optimisticCreatedActionForChat, optimisticCreatedActionForIOUReport, iouAction, optimisticTransactionThread, optimisticCreatedActionForTransactionThread] = + ReportUtils.buildOptimisticMoneyRequestEntities( + iouReport, + CONST.IOU.REPORT_ACTION_TYPE.CREATE, + amount, + currency, + comment, + payeeEmail, + [participant], + optimisticTransaction.transactionID, + undefined, + false, + false, + false, + undefined, + linkedTrackedExpenseReportAction?.childReportID, + linkedTrackedExpenseReportAction, + ); + + let reportPreviewAction = shouldCreateNewMoneyRequestReport ? null : getReportPreviewAction(chatReport.reportID, iouReport.reportID); + + if (reportPreviewAction) { + reportPreviewAction = ReportUtils.updateReportPreview(iouReport, reportPreviewAction, false, comment, optimisticTransaction); + } else { + reportPreviewAction = ReportUtils.buildOptimisticReportPreview(chatReport, iouReport, comment, optimisticTransaction); + chatReport.lastVisibleActionCreated = reportPreviewAction.created; + + // Generated ReportPreview action is a parent report action of the iou report. + // We are setting the iou report's parentReportActionID to display subtitle correctly in IOU page when offline. + iouReport.parentReportActionID = reportPreviewAction.reportActionID; + } + + const shouldCreateOptimisticPersonalDetails = isNewChatReport && !allPersonalDetails[payerAccountID]; + // Add optimistic personal details for participant + const optimisticPersonalDetailListAction = shouldCreateOptimisticPersonalDetails + ? { + [payerAccountID]: { + accountID: payerAccountID, + // Disabling this line since participant.displayName can be an empty string + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + displayName: LocalePhoneNumber.formatPhoneNumber(participant.displayName || payerEmail), + login: participant.login, + isOptimisticPersonalDetail: true, + }, + } + : {}; + + const optimisticNextStep = NextStepUtils.buildNextStep(iouReport, CONST.REPORT.STATUS_NUM.OPEN); + + // STEP 5: Build Onyx Data + const [optimisticData, successData, failureData] = buildOnyxDataForMoneyRequest( + chatReport, + iouReport, + optimisticTransaction, + optimisticCreatedActionForChat, + optimisticCreatedActionForIOUReport, + iouAction, + optimisticPersonalDetailListAction, + reportPreviewAction, + optimisticPolicyRecentlyUsedCategories, + optimisticPolicyRecentlyUsedTags, + isNewChatReport, + optimisticTransactionThread ?? {}, + optimisticCreatedActionForTransactionThread, + shouldCreateNewMoneyRequestReport, + policy, + policyTagList, + policyCategories, + optimisticNextStep, + undefined, + undefined, + optimisticPolicyRecentluUsedCurrencies, + ); + + return { + payerAccountID, + payerEmail, + iouReport, + chatReport, + transaction: optimisticTransaction, + iouAction, + createdChatReportActionID: isNewChatReport ? optimisticCreatedActionForChat.reportActionID : '-1', + createdIOUReportActionID: shouldCreateNewMoneyRequestReport ? optimisticCreatedActionForIOUReport.reportActionID : '-1', + reportPreviewAction, + transactionThreadReportID: optimisticTransactionThread?.reportID ?? '-1', + createdReportActionIDForThread: optimisticCreatedActionForTransactionThread?.reportActionID ?? '-1', + onyxData: { + optimisticData, + successData, + failureData, + }, + }; +} + +/** + * Gathers all the data needed to make an expense. It attempts to find existing reports, iouReports, and receipts. If it doesn't find them, then + * it creates optimistic versions of them and uses those instead + */ +function getTrackExpenseInformation( + parentChatReport: OnyxEntry, + participant: Participant, + comment: string, + amount: number, + currency: string, + created: string, + merchant: string, + receipt: OnyxEntry, + category: string | undefined, + tag: string | undefined, + taxCode: string | undefined, + taxAmount: number | undefined, + billable: boolean | undefined, + policy: OnyxEntry | undefined, + policyTagList: OnyxEntry | undefined, + policyCategories: OnyxEntry | undefined, + payeeEmail = currentUserEmail, + payeeAccountID = userAccountID, + moneyRequestReportID = '', + linkedTrackedExpenseReportAction?: OnyxTypes.ReportAction, + existingTransactionID?: string, +): TrackExpenseInformation | null { + const optimisticData: OnyxUpdate[] = []; + const successData: OnyxUpdate[] = []; + const failureData: OnyxUpdate[] = []; + + const isPolicyExpenseChat = participant.isPolicyExpenseChat; + + // STEP 1: Get existing chat report + let chatReport = !isEmptyObject(parentChatReport) && parentChatReport?.reportID ? parentChatReport : null; + const allReports = ReportConnection.getAllReports(); + // The chatReport always exists, and we can get it from Onyx if chatReport is null. + if (!chatReport) { + chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${participant.reportID}`] ?? null; + } + + // If we still don't have a report, it likely doesn't exist, and we will early return here as it should not happen + // Maybe later, we can build an optimistic selfDM chat. + if (!chatReport) { + return null; + } + + // Check if the report is a draft + const isDraftReport = ReportUtils.isDraftReport(chatReport?.reportID); + + let createdWorkspaceParams: CreateWorkspaceParams | undefined; + + if (isDraftReport) { + const workspaceData = Policy.buildPolicyData(undefined, policy?.makeMeAdmin, policy?.name, policy?.id, chatReport?.reportID); + createdWorkspaceParams = workspaceData.params; + optimisticData.push(...workspaceData.optimisticData); + successData.push(...workspaceData.successData); + failureData.push(...workspaceData.failureData); + } + + // STEP 2: If not in the self-DM flow, we need to use the expense report. + // For this, first use the chatReport.iouReportID property. Build a new optimistic expense report if needed. + const shouldUseMoneyReport = !!isPolicyExpenseChat; + + let iouReport: OnyxInputValue = null; + let shouldCreateNewMoneyRequestReport = false; + + if (shouldUseMoneyReport) { + if (moneyRequestReportID) { + iouReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${moneyRequestReportID}`] ?? null; + } else { + iouReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReport.iouReportID}`] ?? null; + } + + shouldCreateNewMoneyRequestReport = ReportUtils.shouldCreateNewMoneyRequestReport(iouReport, chatReport); + if (!iouReport || shouldCreateNewMoneyRequestReport) { + iouReport = ReportUtils.buildOptimisticExpenseReport(chatReport.reportID, chatReport.policyID ?? '-1', payeeAccountID, amount, currency, false); + } else { + iouReport = {...iouReport}; + if (iouReport?.currency === currency && typeof iouReport.total === 'number' && typeof iouReport.nonReimbursableTotal === 'number') { + // Because of the Expense reports are stored as negative values, we subtract the total from the amount + iouReport.total -= amount; + iouReport.nonReimbursableTotal -= amount; + } + } + } + + // If shouldUseMoneyReport is true, the iouReport was defined. + // But we'll use the `shouldUseMoneyReport && iouReport` check further instead of `shouldUseMoneyReport` to avoid TS errors. + + // STEP 3: Build optimistic receipt and transaction + const receiptObject: Receipt = {}; + let filename; + if (receipt?.source) { + receiptObject.source = receipt.source; + receiptObject.state = receipt.state ?? CONST.IOU.RECEIPT_STATE.SCANREADY; + filename = receipt.name; + } + const existingTransaction = allTransactionDrafts[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${existingTransactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`]; + if (!filename) { + filename = existingTransaction?.filename; + } + const isDistanceRequest = existingTransaction && existingTransaction.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE; + let optimisticTransaction = TransactionUtils.buildOptimisticTransaction( + ReportUtils.isExpenseReport(iouReport) ? -amount : amount, + currency, + shouldUseMoneyReport && iouReport ? iouReport.reportID : '-1', + comment, + created, + '', + '', + merchant, + receiptObject, + filename, + existingTransactionID ?? null, + category, + tag, + taxCode, + taxAmount, + billable, + isDistanceRequest ? {waypoints: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD} : undefined, + false, + ); + + // If there is an existing transaction (which is the case for distance requests), then the data from the existing transaction + // needs to be manually merged into the optimistic transaction. This is because buildOnyxDataForMoneyRequest() uses `Onyx.set()` for the transaction + // data. This is a big can of worms to change it to `Onyx.merge()` as explored in https://expensify.slack.com/archives/C05DWUDHVK7/p1692139468252109. + // I want to clean this up at some point, but it's possible this will live in the code for a while so I've created https://github.com/Expensify/App/issues/25417 + // to remind me to do this. + if (isDistanceRequest) { + optimisticTransaction = fastMerge(existingTransaction, optimisticTransaction, false); + } + + // STEP 4: Build optimistic reportActions. We need: + // 1. CREATED action for the iouReport (if tracking in the Expense chat) + // 2. IOU action for the iouReport (if tracking in the Expense chat), otherwise – for chatReport + // 3. The transaction thread, which requires the iouAction, and CREATED action for the transaction thread + // 4. REPORT_PREVIEW action for the chatReport (if tracking in the Expense chat) + const [, optimisticCreatedActionForIOUReport, iouAction, optimisticTransactionThread, optimisticCreatedActionForTransactionThread] = ReportUtils.buildOptimisticMoneyRequestEntities( + shouldUseMoneyReport && iouReport ? iouReport : chatReport, + CONST.IOU.REPORT_ACTION_TYPE.TRACK, + amount, + currency, + comment, + payeeEmail, + [participant], + optimisticTransaction.transactionID, + undefined, + false, + false, + false, + !shouldUseMoneyReport, + linkedTrackedExpenseReportAction?.childReportID, + linkedTrackedExpenseReportAction, + ); + + let reportPreviewAction: OnyxInputValue> = null; + if (shouldUseMoneyReport && iouReport) { + reportPreviewAction = shouldCreateNewMoneyRequestReport ? null : getReportPreviewAction(chatReport.reportID, iouReport.reportID); + + if (reportPreviewAction) { + reportPreviewAction = ReportUtils.updateReportPreview(iouReport, reportPreviewAction, false, comment, optimisticTransaction); + } else { + reportPreviewAction = ReportUtils.buildOptimisticReportPreview(chatReport, iouReport, comment, optimisticTransaction); + // Generated ReportPreview action is a parent report action of the iou report. + // We are setting the iou report's parentReportActionID to display subtitle correctly in IOU page when offline. + iouReport.parentReportActionID = reportPreviewAction.reportActionID; + } + } + + let actionableTrackExpenseWhisper: OnyxInputValue = null; + if (!isPolicyExpenseChat) { + actionableTrackExpenseWhisper = ReportUtils.buildOptimisticActionableTrackExpenseWhisper(iouAction, optimisticTransaction.transactionID); + } + + // STEP 5: Build Onyx Data + const trackExpenseOnyxData = buildOnyxDataForTrackExpense( + chatReport, + iouReport, + optimisticTransaction, + optimisticCreatedActionForIOUReport, + iouAction, + reportPreviewAction, + optimisticTransactionThread ?? {}, + optimisticCreatedActionForTransactionThread, + shouldCreateNewMoneyRequestReport, + policy, + policyTagList, + policyCategories, + undefined, + actionableTrackExpenseWhisper, + ); + + return { + createdWorkspaceParams, + chatReport, + iouReport: iouReport ?? undefined, + transaction: optimisticTransaction, + iouAction, + createdChatReportActionID: '-1', + createdIOUReportActionID: shouldCreateNewMoneyRequestReport ? optimisticCreatedActionForIOUReport.reportActionID : '-1', + reportPreviewAction: reportPreviewAction ?? undefined, + transactionThreadReportID: optimisticTransactionThread.reportID, + createdReportActionIDForThread: optimisticCreatedActionForTransactionThread?.reportActionID ?? '-1', + actionableWhisperReportActionIDParam: actionableTrackExpenseWhisper?.reportActionID ?? '', + onyxData: { + optimisticData: optimisticData.concat(trackExpenseOnyxData[0]), + successData: successData.concat(trackExpenseOnyxData[1]), + failureData: failureData.concat(trackExpenseOnyxData[2]), + }, + }; +} + +/** + * Compute the diff amount when we update the transaction + */ +function calculateDiffAmount( + iouReport: OnyxTypes.OnyxInputOrEntry, + updatedTransaction: OnyxTypes.OnyxInputOrEntry, + transaction: OnyxEntry, +): number { + if (!iouReport) { + return 0; + } + const isExpenseReport = ReportUtils.isExpenseReport(iouReport); + const updatedCurrency = TransactionUtils.getCurrency(updatedTransaction); + const currentCurrency = TransactionUtils.getCurrency(transaction); + + const currentAmount = TransactionUtils.getAmount(transaction, isExpenseReport); + const updatedAmount = TransactionUtils.getAmount(updatedTransaction, isExpenseReport); + + if (updatedCurrency === iouReport?.currency && currentCurrency !== iouReport?.currency) { + // Add the diff to the total if we change the currency from a different currency to the currency of the IOU report + return updatedAmount; + } + if (updatedCurrency !== iouReport?.currency && currentCurrency === iouReport?.currency) { + // Subtract the diff from the total if we change the currency from the currency of IOU report to a different currency + return -updatedAmount; + } + if (updatedCurrency === iouReport?.currency && updatedAmount !== currentAmount) { + // Calculate the diff between the updated amount and the current amount if we change the amount and the currency of the transaction is the currency of the report + return updatedAmount - currentAmount; + } + + return 0; +} + +/** + * @param transactionID + * @param transactionThreadReportID + * @param transactionChanges + * @param [transactionChanges.created] Present when updated the date field + * @param policy May be undefined, an empty object, or an object matching the Policy type (src/types/onyx/Policy.ts) + * @param policyTagList + * @param policyCategories + * @param onlyIncludeChangedFields + * When 'true', then the returned params will only include the transaction details for the fields that were changed. + * When `false`, then the returned params will include all the transaction details, regardless of which fields were changed. + * This setting is necessary while the UpdateDistanceRequest API is refactored to be fully 1:1:1 in https://github.com/Expensify/App/issues/28358 + */ +function getUpdateMoneyRequestParams( + transactionID: string, + transactionThreadReportID: string, + transactionChanges: TransactionChanges, + policy: OnyxTypes.OnyxInputOrEntry, + policyTagList: OnyxTypes.OnyxInputOrEntry, + policyCategories: OnyxTypes.OnyxInputOrEntry, + onlyIncludeChangedFields: boolean, +): UpdateMoneyRequestData { + const optimisticData: OnyxUpdate[] = []; + const successData: OnyxUpdate[] = []; + const failureData: OnyxUpdate[] = []; + + // Step 1: Set any "pending fields" (ones updated while the user was offline) to have error messages in the failureData + const pendingFields = Object.fromEntries(Object.keys(transactionChanges).map((key) => [key, CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE])); + const clearedPendingFields = Object.fromEntries(Object.keys(transactionChanges).map((key) => [key, null])); + const errorFields = Object.fromEntries(Object.keys(pendingFields).map((key) => [key, {[DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericEditFailureMessage')}])); + + const allReports = ReportConnection.getAllReports(); + // Step 2: Get all the collections being updated + const transactionThread = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`] ?? null; + const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + const iouReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThread?.parentReportID}`] ?? null; + const isFromExpenseReport = ReportUtils.isExpenseReport(iouReport); + const isScanning = TransactionUtils.hasReceipt(transaction) && TransactionUtils.isReceiptBeingScanned(transaction); + let updatedTransaction: OnyxTypes.OnyxInputOrEntry = transaction + ? TransactionUtils.getUpdatedTransaction(transaction, transactionChanges, isFromExpenseReport) + : null; + const transactionDetails = ReportUtils.getTransactionDetails(updatedTransaction); + + if (transactionDetails?.waypoints) { + // This needs to be a JSON string since we're sending this to the MapBox API + transactionDetails.waypoints = JSON.stringify(transactionDetails.waypoints); + } + + const dataToIncludeInParams: Partial | undefined = onlyIncludeChangedFields + ? Object.fromEntries(Object.entries(transactionDetails ?? {}).filter(([key]) => Object.keys(transactionChanges).includes(key))) + : transactionDetails; + + const params: UpdateMoneyRequestParams = { + ...dataToIncludeInParams, + reportID: iouReport?.reportID, + transactionID, + }; + + const hasPendingWaypoints = 'waypoints' in transactionChanges; + const hasModifiedDistanceRate = 'customUnitRateID' in transactionChanges; + if (transaction && updatedTransaction && (hasPendingWaypoints || hasModifiedDistanceRate)) { + updatedTransaction = { + ...updatedTransaction, + ...TransactionUtils.calculateAmountForUpdatedWaypointOrRate(updatedTransaction, transactionChanges, policy, ReportUtils.isExpenseReport(iouReport)), + }; + + // Delete the draft transaction when editing waypoints when the server responds successfully and there are no errors + successData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, + value: null, + }); + + // Revert the transaction's amount to the original value on failure. + // The IOU Report will be fully reverted in the failureData further below. + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + amount: transaction.amount, + modifiedAmount: transaction.modifiedAmount, + modifiedMerchant: transaction.modifiedMerchant, + }, + }); + } + + // Step 3: Build the modified expense report actions + // We don't create a modified report action if: + // - we're updating the waypoints + // - we're updating the distance rate while the waypoints are still pending + // In these cases, there isn't a valid optimistic mileage data we can use, + // and the report action is created on the server with the distance-related response from the MapBox API + const updatedReportAction = ReportUtils.buildOptimisticModifiedExpenseReportAction(transactionThread, transaction, transactionChanges, isFromExpenseReport, policy, updatedTransaction); + if (!hasPendingWaypoints && !(hasModifiedDistanceRate && TransactionUtils.isFetchingWaypointsFromServer(transaction))) { + params.reportActionID = updatedReportAction.reportActionID; + + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThread?.reportID}`, + value: { + [updatedReportAction.reportActionID]: updatedReportAction as OnyxTypes.ReportAction, + }, + }); + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThread?.reportID}`, + value: { + lastVisibleActionCreated: updatedReportAction.created, + lastReadTime: updatedReportAction.created, + }, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThread?.reportID}`, + value: { + lastVisibleActionCreated: transactionThread?.lastVisibleActionCreated, + lastReadTime: transactionThread?.lastReadTime, + }, + }); + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThread?.reportID}`, + value: { + [updatedReportAction.reportActionID]: {pendingAction: null}, + }, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThread?.reportID}`, + value: { + [updatedReportAction.reportActionID]: { + ...(updatedReportAction as OnyxTypes.ReportAction), + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericEditFailureMessage'), + }, + }, + }); + } + + // Step 4: Compute the IOU total and update the report preview message (and report header) so LHN amount owed is correct. + const diff = calculateDiffAmount(iouReport, updatedTransaction, transaction); + + let updatedMoneyRequestReport: OnyxTypes.OnyxInputOrEntry; + if (!iouReport) { + updatedMoneyRequestReport = null; + } else if ((ReportUtils.isExpenseReport(iouReport) || ReportUtils.isInvoiceReport(iouReport)) && typeof iouReport.total === 'number') { + // For expense report, the amount is negative, so we should subtract total from diff + updatedMoneyRequestReport = { + ...iouReport, + total: iouReport.total - diff, + }; + if (!transaction?.reimbursable && typeof updatedMoneyRequestReport.nonReimbursableTotal === 'number') { + updatedMoneyRequestReport.nonReimbursableTotal -= diff; + } + } else { + updatedMoneyRequestReport = IOUUtils.updateIOUOwnerAndTotal(iouReport, updatedReportAction.actorAccountID ?? -1, diff, TransactionUtils.getCurrency(transaction), false, true); + } + + if (updatedMoneyRequestReport) { + updatedMoneyRequestReport.cachedTotal = CurrencyUtils.convertToDisplayString(updatedMoneyRequestReport.total, transactionDetails?.currency); + } + + optimisticData.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, + value: updatedMoneyRequestReport, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.parentReportID}`, + value: ReportUtils.getOutstandingChildRequest(updatedMoneyRequestReport), + }, + ); + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, + value: {pendingAction: null}, + }); + + // Optimistically modify the transaction and the transaction thread + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + ...updatedTransaction, + pendingFields, + isLoading: hasPendingWaypoints, + errorFields: null, + }, + }); + + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, + value: { + lastActorAccountID: updatedReportAction.actorAccountID, + }, + }); + + if (isScanning && ('amount' in transactionChanges || 'currency' in transactionChanges)) { + optimisticData.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`, + value: { + [transactionThread?.parentReportActionID ?? '-1']: { + originalMessage: { + whisperedTo: [], + }, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.parentReportID}`, + value: { + [iouReport?.parentReportActionID ?? '-1']: { + originalMessage: { + whisperedTo: [], + }, + }, + }, + }, + ); + } + + // Update recently used categories if the category is changed + const hasModifiedCategory = 'category' in transactionChanges; + if (hasModifiedCategory) { + const optimisticPolicyRecentlyUsedCategories = Category.buildOptimisticPolicyRecentlyUsedCategories(iouReport?.policyID, transactionChanges.category); + if (optimisticPolicyRecentlyUsedCategories.length) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${iouReport?.policyID}`, + value: optimisticPolicyRecentlyUsedCategories, + }); + } + } + + // Update recently used currencies if the currency is changed + if ('currency' in transactionChanges) { + const optimisticRecentlyUsedCurrencies = Policy.buildOptimisticRecentlyUsedCurrencies(transactionChanges.currency); + if (optimisticRecentlyUsedCurrencies.length) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.RECENTLY_USED_CURRENCIES, + value: optimisticRecentlyUsedCurrencies, + }); + } + } + + // Update recently used categories if the tag is changed + const hasModifiedTag = 'tag' in transactionChanges; + if (hasModifiedTag) { + const optimisticPolicyRecentlyUsedTags = Tag.buildOptimisticPolicyRecentlyUsedTags(iouReport?.policyID, transactionChanges.tag); + if (!isEmptyObject(optimisticPolicyRecentlyUsedTags)) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${iouReport?.policyID}`, + value: optimisticPolicyRecentlyUsedTags, + }); + } + } + + // Clear out the error fields and loading states on success + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + pendingFields: clearedPendingFields, + isLoading: false, + errorFields: null, + routes: null, + }, + }); + + // Clear out loading states, pending fields, and add the error fields + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + pendingFields: clearedPendingFields, + isLoading: false, + errorFields, + }, + }); + + if (iouReport) { + // Reset the iouReport to its original state + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, + value: iouReport, + }); + } + + if (policy && PolicyUtils.isPaidGroupPolicy(policy) && updatedTransaction && (hasModifiedTag || hasModifiedCategory || hasModifiedDistanceRate)) { + const currentTransactionViolations = allTransactionViolations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`] ?? []; + optimisticData.push( + ViolationsUtils.getViolationsOnyxData( + updatedTransaction, + currentTransactionViolations, + policy, + policyTagList ?? {}, + policyCategories ?? {}, + PolicyUtils.hasDependentTags(policy, policyTagList ?? {}), + ), + ); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, + value: currentTransactionViolations, + }); + } + + // Reset the transaction thread to its original state + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, + value: transactionThread, + }); + + return { + params, + onyxData: {optimisticData, successData, failureData}, + }; +} + +/** + * @param transactionID + * @param transactionThreadReportID + * @param transactionChanges + * @param [transactionChanges.created] Present when updated the date field + * @param onlyIncludeChangedFields + * When 'true', then the returned params will only include the transaction details for the fields that were changed. + * When `false`, then the returned params will include all the transaction details, regardless of which fields were changed. + * This setting is necessary while the UpdateDistanceRequest API is refactored to be fully 1:1:1 in https://github.com/Expensify/App/issues/28358 + * @param policy May be undefined, an empty object, or an object matching the Policy type (src/types/onyx/Policy.ts) + */ +function getUpdateTrackExpenseParams( + transactionID: string, + transactionThreadReportID: string, + transactionChanges: TransactionChanges, + onlyIncludeChangedFields: boolean, + policy: OnyxTypes.OnyxInputOrEntry, +): UpdateMoneyRequestData { + const optimisticData: OnyxUpdate[] = []; + const successData: OnyxUpdate[] = []; + const failureData: OnyxUpdate[] = []; + + // Step 1: Set any "pending fields" (ones updated while the user was offline) to have error messages in the failureData + const pendingFields = Object.fromEntries(Object.keys(transactionChanges).map((key) => [key, CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE])); + const clearedPendingFields = Object.fromEntries(Object.keys(transactionChanges).map((key) => [key, null])); + const errorFields = Object.fromEntries(Object.keys(pendingFields).map((key) => [key, {[DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericEditFailureMessage')}])); + + const allReports = ReportConnection.getAllReports(); + // Step 2: Get all the collections being updated + const transactionThread = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`] ?? null; + const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThread?.parentReportID}`] ?? null; + const isScanning = TransactionUtils.hasReceipt(transaction) && TransactionUtils.isReceiptBeingScanned(transaction); + let updatedTransaction = transaction ? TransactionUtils.getUpdatedTransaction(transaction, transactionChanges, false) : null; + const transactionDetails = ReportUtils.getTransactionDetails(updatedTransaction); + + if (transactionDetails?.waypoints) { + // This needs to be a JSON string since we're sending this to the MapBox API + transactionDetails.waypoints = JSON.stringify(transactionDetails.waypoints); + } + + const dataToIncludeInParams: Partial | undefined = onlyIncludeChangedFields + ? Object.fromEntries(Object.entries(transactionDetails ?? {}).filter(([key]) => Object.keys(transactionChanges).includes(key))) + : transactionDetails; + + const params: UpdateMoneyRequestParams = { + ...dataToIncludeInParams, + reportID: chatReport?.reportID, + transactionID, + }; + + const hasPendingWaypoints = 'waypoints' in transactionChanges; + const hasModifiedDistanceRate = 'customUnitRateID' in transactionChanges; + if (transaction && updatedTransaction && (hasPendingWaypoints || hasModifiedDistanceRate)) { + updatedTransaction = { + ...updatedTransaction, + ...TransactionUtils.calculateAmountForUpdatedWaypointOrRate(transaction, transactionChanges, policy, false), + }; + + // Delete the draft transaction when editing waypoints when the server responds successfully and there are no errors + successData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, + value: null, + }); + + // Revert the transaction's amount to the original value on failure. + // The IOU Report will be fully reverted in the failureData further below. + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + amount: transaction.amount, + modifiedAmount: transaction.modifiedAmount, + modifiedMerchant: transaction.modifiedMerchant, + }, + }); + } + + // Step 3: Build the modified expense report actions + // We don't create a modified report action if: + // - we're updating the waypoints + // - we're updating the distance rate while the waypoints are still pending + // In these cases, there isn't a valid optimistic mileage data we can use, + // and the report action is created on the server with the distance-related response from the MapBox API + const updatedReportAction = ReportUtils.buildOptimisticModifiedExpenseReportAction(transactionThread, transaction, transactionChanges, false, policy); + if (!hasPendingWaypoints && !(hasModifiedDistanceRate && TransactionUtils.isFetchingWaypointsFromServer(transaction))) { + params.reportActionID = updatedReportAction.reportActionID; + + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThread?.reportID}`, + value: { + [updatedReportAction.reportActionID]: updatedReportAction as OnyxTypes.ReportAction, + }, + }); + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThread?.reportID}`, + value: { + [updatedReportAction.reportActionID]: {pendingAction: null}, + }, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThread?.reportID}`, + value: { + [updatedReportAction.reportActionID]: { + ...(updatedReportAction as OnyxTypes.ReportAction), + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericEditFailureMessage'), + }, + }, + }); + } + + // Step 4: Update the report preview message (and report header) so LHN amount tracked is correct. + // Optimistically modify the transaction and the transaction thread + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + ...updatedTransaction, + pendingFields, + isLoading: hasPendingWaypoints, + errorFields: null, + }, + }); + + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, + value: { + lastActorAccountID: updatedReportAction.actorAccountID, + }, + }); + + if (isScanning && ('amount' in transactionChanges || 'currency' in transactionChanges)) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: { + [transactionThread?.parentReportActionID ?? '-1']: { + originalMessage: { + whisperedTo: [], + }, + }, + }, + }); + } + + // Clear out the error fields and loading states on success + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + pendingFields: clearedPendingFields, + isLoading: false, + errorFields: null, + routes: null, + }, + }); + + // Clear out loading states, pending fields, and add the error fields + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + pendingFields: clearedPendingFields, + isLoading: false, + errorFields, + }, + }); + + // Reset the transaction thread to its original state + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, + value: transactionThread, + }); + + return { + params, + onyxData: {optimisticData, successData, failureData}, + }; +} + +/** Updates the created date of an expense */ +function updateMoneyRequestDate( + transactionID: string, + transactionThreadReportID: string, + value: string, + policy: OnyxEntry, + policyTags: OnyxEntry, + policyCategories: OnyxEntry, +) { + const transactionChanges: TransactionChanges = { + created: value, + }; + const allReports = ReportConnection.getAllReports(); + const transactionThreadReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`] ?? null; + const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport?.parentReportID}`] ?? null; + let data: UpdateMoneyRequestData; + if (ReportUtils.isTrackExpenseReport(transactionThreadReport) && ReportUtils.isSelfDM(parentReport)) { + data = getUpdateTrackExpenseParams(transactionID, transactionThreadReportID, transactionChanges, true, policy); + } else { + data = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTags, policyCategories, true); + } + const {params, onyxData} = data; + API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DATE, params, onyxData); +} + +/** Updates the billable field of an expense */ +function updateMoneyRequestBillable( + transactionID: string, + transactionThreadReportID: string, + value: boolean, + policy: OnyxEntry, + policyTagList: OnyxEntry, + policyCategories: OnyxEntry, +) { + const transactionChanges: TransactionChanges = { + billable: value, + }; + const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories, true); + API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_BILLABLE, params, onyxData); +} + +/** Updates the merchant field of an expense */ +function updateMoneyRequestMerchant( + transactionID: string, + transactionThreadReportID: string, + value: string, + policy: OnyxEntry, + policyTagList: OnyxEntry, + policyCategories: OnyxEntry, +) { + const transactionChanges: TransactionChanges = { + merchant: value, + }; + const allReports = ReportConnection.getAllReports(); + const transactionThreadReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`] ?? null; + const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport?.parentReportID}`] ?? null; + let data: UpdateMoneyRequestData; + if (ReportUtils.isTrackExpenseReport(transactionThreadReport) && ReportUtils.isSelfDM(parentReport)) { + data = getUpdateTrackExpenseParams(transactionID, transactionThreadReportID, transactionChanges, true, policy); + } else { + data = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories, true); + } + const {params, onyxData} = data; + API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_MERCHANT, params, onyxData); +} + +/** Updates the tag of an expense */ +function updateMoneyRequestTag( + transactionID: string, + transactionThreadReportID: string, + tag: string, + policy: OnyxEntry, + policyTagList: OnyxEntry, + policyCategories: OnyxEntry, +) { + const transactionChanges: TransactionChanges = { + tag, + }; + const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories, true); + API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_TAG, params, onyxData); +} + +/** Updates the created tax amount of an expense */ +function updateMoneyRequestTaxAmount( + transactionID: string, + optimisticReportActionID: string, + taxAmount: number, + policy: OnyxEntry, + policyTagList: OnyxEntry, + policyCategories: OnyxEntry, +) { + const transactionChanges = { + taxAmount, + }; + const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, optimisticReportActionID, transactionChanges, policy, policyTagList, policyCategories, true); + API.write('UpdateMoneyRequestTaxAmount', params, onyxData); +} + +type UpdateMoneyRequestTaxRateParams = { + transactionID: string; + optimisticReportActionID: string; + taxCode: string; + taxAmount: number; + policy: OnyxEntry; + policyTagList: OnyxEntry; + policyCategories: OnyxEntry; +}; + +/** Updates the created tax rate of an expense */ +function updateMoneyRequestTaxRate({transactionID, optimisticReportActionID, taxCode, taxAmount, policy, policyTagList, policyCategories}: UpdateMoneyRequestTaxRateParams) { + const transactionChanges = { + taxCode, + taxAmount, + }; + const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, optimisticReportActionID, transactionChanges, policy, policyTagList, policyCategories, true); + API.write('UpdateMoneyRequestTaxRate', params, onyxData); +} + +type UpdateMoneyRequestDistanceParams = { + transactionID: string; + transactionThreadReportID: string; + waypoints: WaypointCollection; + routes?: Routes; + policy?: OnyxEntry; + policyTagList?: OnyxEntry; + policyCategories?: OnyxEntry; +}; + +/** Updates the waypoints of a distance expense */ +function updateMoneyRequestDistance({ + transactionID, + transactionThreadReportID, + waypoints, + routes = undefined, + policy = {} as OnyxTypes.Policy, + policyTagList = {}, + policyCategories = {}, +}: UpdateMoneyRequestDistanceParams) { + const transactionChanges: TransactionChanges = { + waypoints, + routes, + }; + const allReports = ReportConnection.getAllReports(); + const transactionThreadReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`] ?? null; + const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport?.parentReportID}`] ?? null; + let data: UpdateMoneyRequestData; + if (ReportUtils.isTrackExpenseReport(transactionThreadReport) && ReportUtils.isSelfDM(parentReport)) { + data = getUpdateTrackExpenseParams(transactionID, transactionThreadReportID, transactionChanges, true, policy); + } else { + data = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories, true); + } + const {params, onyxData} = data; + API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DISTANCE, params, onyxData); +} + +/** Updates the category of an expense */ +function updateMoneyRequestCategory( + transactionID: string, + transactionThreadReportID: string, + category: string, + policy: OnyxEntry, + policyTagList: OnyxEntry, + policyCategories: OnyxEntry, +) { + const transactionChanges: TransactionChanges = { + category, + }; + const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories, true); + API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_CATEGORY, params, onyxData); +} + +/** Updates the description of an expense */ +function updateMoneyRequestDescription( + transactionID: string, + transactionThreadReportID: string, + comment: string, + policy: OnyxEntry, + policyTagList: OnyxEntry, + policyCategories: OnyxEntry, +) { + const transactionChanges: TransactionChanges = { + comment, + }; + const allReports = ReportConnection.getAllReports(); + const transactionThreadReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`] ?? null; + const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport?.parentReportID}`] ?? null; + let data: UpdateMoneyRequestData; + if (ReportUtils.isTrackExpenseReport(transactionThreadReport) && ReportUtils.isSelfDM(parentReport)) { + data = getUpdateTrackExpenseParams(transactionID, transactionThreadReportID, transactionChanges, true, policy); + } else { + data = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories, true); + } + const {params, onyxData} = data; + API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DESCRIPTION, params, onyxData); +} + +/** Updates the distance rate of an expense */ +function updateMoneyRequestDistanceRate( + transactionID: string, + transactionThreadReportID: string, + rateID: string, + policy: OnyxEntry, + policyTagList: OnyxEntry, + policyCategories: OnyxEntry, + updatedTaxAmount?: number, + updatedTaxCode?: string, +) { + const transactionChanges: TransactionChanges = { + customUnitRateID: rateID, + ...(typeof updatedTaxAmount === 'number' ? {taxAmount: updatedTaxAmount} : {}), + ...(updatedTaxCode ? {taxCode: updatedTaxCode} : {}), + }; + const allReports = ReportConnection.getAllReports(); + const transactionThreadReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`] ?? null; + const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport?.parentReportID}`] ?? null; + let data: UpdateMoneyRequestData; + if (ReportUtils.isTrackExpenseReport(transactionThreadReport) && ReportUtils.isSelfDM(parentReport)) { + data = getUpdateTrackExpenseParams(transactionID, transactionThreadReportID, transactionChanges, true, policy); + } else { + data = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories, true); + } + const {params, onyxData} = data; + // `taxAmount` & `taxCode` only needs to be updated in the optimistic data, so we need to remove them from the params + const {taxAmount, taxCode, ...paramsWithoutTaxUpdated} = params; + API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DISTANCE_RATE, paramsWithoutTaxUpdated, onyxData); +} + +/** Edits an existing distance expense */ +function updateDistanceRequest( + transactionID: string, + transactionThreadReportID: string, + transactionChanges: TransactionChanges, + policy: OnyxTypes.Policy, + policyTagList: OnyxTypes.PolicyTagLists, + policyCategories: OnyxTypes.PolicyCategories, +) { + const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories, false); + API.write(WRITE_COMMANDS.UPDATE_DISTANCE_REQUEST, params, onyxData); +} + +const getConvertTrackedExpenseInformation = ( + transactionID: string, + actionableWhisperReportActionID: string, + moneyRequestReportID: string, + linkedTrackedExpenseReportAction: OnyxTypes.ReportAction, + linkedTrackedExpenseReportID: string, + transactionThreadReportID: string, + resolution: IOUAction, +) => { + const optimisticData: OnyxUpdate[] = []; + const successData: OnyxUpdate[] = []; + const failureData: OnyxUpdate[] = []; + + // Delete the transaction from the track expense report + const { + optimisticData: deleteOptimisticData, + successData: deleteSuccessData, + failureData: deleteFailureData, + } = getDeleteTrackExpenseInformation(linkedTrackedExpenseReportID, transactionID, linkedTrackedExpenseReportAction, false, true, actionableWhisperReportActionID, resolution); + + optimisticData?.push(...deleteOptimisticData); + successData?.push(...deleteSuccessData); + failureData?.push(...deleteFailureData); + + // Build modified expense report action with the transaction changes + const modifiedExpenseReportAction = ReportUtils.buildOptimisticMovedTrackedExpenseModifiedReportAction(transactionThreadReportID, moneyRequestReportID); + + optimisticData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, + value: { + [modifiedExpenseReportAction.reportActionID]: modifiedExpenseReportAction as OnyxTypes.ReportAction, + }, + }); + successData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, + value: { + [modifiedExpenseReportAction.reportActionID]: {pendingAction: null}, + }, + }); + failureData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, + value: { + [modifiedExpenseReportAction.reportActionID]: { + ...(modifiedExpenseReportAction as OnyxTypes.ReportAction), + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericEditFailureMessage'), + }, + }, + }); + + return {optimisticData, successData, failureData, modifiedExpenseReportActionID: modifiedExpenseReportAction.reportActionID}; +}; + +function convertTrackedExpenseToRequest( + payerAccountID: number, + payerEmail: string, + chatReportID: string, + transactionID: string, + actionableWhisperReportActionID: string, + createdChatReportActionID: string, + moneyRequestReportID: string, + moneyRequestCreatedReportActionID: string, + moneyRequestPreviewReportActionID: string, + linkedTrackedExpenseReportAction: OnyxTypes.ReportAction, + linkedTrackedExpenseReportID: string, + transactionThreadReportID: string, + reportPreviewReportActionID: string, + onyxData: OnyxData, + amount: number, + currency: string, + comment: string, + merchant: string, + created: string, + receipt?: Receipt, +) { + const {optimisticData, successData, failureData} = onyxData; + + const { + optimisticData: moveTransactionOptimisticData, + successData: moveTransactionSuccessData, + failureData: moveTransactionFailureData, + modifiedExpenseReportActionID, + } = getConvertTrackedExpenseInformation( + transactionID, + actionableWhisperReportActionID, + moneyRequestReportID, + linkedTrackedExpenseReportAction, + linkedTrackedExpenseReportID, + transactionThreadReportID, + CONST.IOU.ACTION.SUBMIT, + ); + + optimisticData?.push(...moveTransactionOptimisticData); + successData?.push(...moveTransactionSuccessData); + failureData?.push(...moveTransactionFailureData); + + const parameters = { + amount, + currency, + comment, + created, + merchant, + receipt, + payerAccountID, + payerEmail, + chatReportID, + transactionID, + actionableWhisperReportActionID, + createdChatReportActionID, + moneyRequestReportID, + moneyRequestCreatedReportActionID, + moneyRequestPreviewReportActionID, + transactionThreadReportID, + modifiedExpenseReportActionID, + reportPreviewReportActionID, + }; + API.write(WRITE_COMMANDS.CONVERT_TRACKED_EXPENSE_TO_REQUEST, parameters, {optimisticData, successData, failureData}); +} + +function categorizeTrackedExpense( + policyID: string, + transactionID: string, + moneyRequestPreviewReportActionID: string, + moneyRequestReportID: string, + moneyRequestCreatedReportActionID: string, + actionableWhisperReportActionID: string, + linkedTrackedExpenseReportAction: OnyxTypes.ReportAction, + linkedTrackedExpenseReportID: string, + transactionThreadReportID: string, + reportPreviewReportActionID: string, + onyxData: OnyxData | undefined, + amount: number, + currency: string, + comment: string, + merchant: string, + created: string, + category?: string, + tag?: string, + taxCode = '', + taxAmount = 0, + billable?: boolean, + receipt?: Receipt, + createdWorkspaceParams?: CreateWorkspaceParams, +) { + const {optimisticData, successData, failureData} = onyxData ?? {}; + + const { + optimisticData: moveTransactionOptimisticData, + successData: moveTransactionSuccessData, + failureData: moveTransactionFailureData, + modifiedExpenseReportActionID, + } = getConvertTrackedExpenseInformation( + transactionID, + actionableWhisperReportActionID, + moneyRequestReportID, + linkedTrackedExpenseReportAction, + linkedTrackedExpenseReportID, + transactionThreadReportID, + CONST.IOU.ACTION.CATEGORIZE, + ); + + optimisticData?.push(...moveTransactionOptimisticData); + successData?.push(...moveTransactionSuccessData); + failureData?.push(...moveTransactionFailureData); + + const parameters = { + policyID, + transactionID, + moneyRequestPreviewReportActionID, + moneyRequestReportID, + moneyRequestCreatedReportActionID, + actionableWhisperReportActionID, + modifiedExpenseReportActionID, + reportPreviewReportActionID, + amount, + currency, + comment, + merchant, + category, + tag, + taxCode, + taxAmount, + billable, + created, + receipt, + policyExpenseChatReportID: createdWorkspaceParams?.expenseChatReportID, + policyExpenseCreatedReportActionID: createdWorkspaceParams?.expenseCreatedReportActionID, + announceChatReportID: createdWorkspaceParams?.announceChatReportID, + announceCreatedReportActionID: createdWorkspaceParams?.announceCreatedReportActionID, + adminsChatReportID: createdWorkspaceParams?.adminsChatReportID, + adminsCreatedReportActionID: createdWorkspaceParams?.adminsCreatedReportActionID, + }; + + API.write(WRITE_COMMANDS.CATEGORIZE_TRACKED_EXPENSE, parameters, {optimisticData, successData, failureData}); +} + +function shareTrackedExpense( + policyID: string, + transactionID: string, + moneyRequestPreviewReportActionID: string, + moneyRequestReportID: string, + moneyRequestCreatedReportActionID: string, + actionableWhisperReportActionID: string, + linkedTrackedExpenseReportAction: OnyxTypes.ReportAction, + linkedTrackedExpenseReportID: string, + transactionThreadReportID: string, + reportPreviewReportActionID: string, + onyxData: OnyxData | undefined, + amount: number, + currency: string, + comment: string, + merchant: string, + created: string, + category?: string, + tag?: string, + taxCode = '', + taxAmount = 0, + billable?: boolean, + receipt?: Receipt, + createdWorkspaceParams?: CreateWorkspaceParams, +) { + const {optimisticData, successData, failureData} = onyxData ?? {}; + + const { + optimisticData: moveTransactionOptimisticData, + successData: moveTransactionSuccessData, + failureData: moveTransactionFailureData, + modifiedExpenseReportActionID, + } = getConvertTrackedExpenseInformation( + transactionID, + actionableWhisperReportActionID, + moneyRequestReportID, + linkedTrackedExpenseReportAction, + linkedTrackedExpenseReportID, + transactionThreadReportID, + CONST.IOU.ACTION.SHARE, + ); + + optimisticData?.push(...moveTransactionOptimisticData); + successData?.push(...moveTransactionSuccessData); + failureData?.push(...moveTransactionFailureData); + + const parameters = { + policyID, + transactionID, + moneyRequestPreviewReportActionID, + moneyRequestReportID, + moneyRequestCreatedReportActionID, + actionableWhisperReportActionID, + modifiedExpenseReportActionID, + reportPreviewReportActionID, + amount, + currency, + comment, + merchant, + created, + category, + tag, + taxCode, + taxAmount, + billable, + receipt, + policyExpenseChatReportID: createdWorkspaceParams?.expenseChatReportID, + policyExpenseCreatedReportActionID: createdWorkspaceParams?.expenseCreatedReportActionID, + announceChatReportID: createdWorkspaceParams?.announceChatReportID, + announceCreatedReportActionID: createdWorkspaceParams?.announceCreatedReportActionID, + adminsChatReportID: createdWorkspaceParams?.adminsChatReportID, + adminsCreatedReportActionID: createdWorkspaceParams?.adminsCreatedReportActionID, + }; + + API.write(WRITE_COMMANDS.SHARE_TRACKED_EXPENSE, parameters, {optimisticData, successData, failureData}); +} + +/** + * Submit expense to another user + */ +function requestMoney( + report: OnyxEntry, + amount: number, + currency: string, + created: string, + merchant: string, + payeeEmail: string | undefined, + payeeAccountID: number, + participant: Participant, + comment: string, + receipt: Receipt | undefined, + category?: string, + tag?: string, + taxCode = '', + taxAmount = 0, + billable?: boolean, + policy?: OnyxEntry, + policyTagList?: OnyxEntry, + policyCategories?: OnyxEntry, + gpsPoints?: GPSPoint, + action?: IOUAction, + actionableWhisperReportActionID?: string, + linkedTrackedExpenseReportAction?: OnyxTypes.ReportAction, + linkedTrackedExpenseReportID?: string, + reimbursible?: boolean, +) { + // If the report is iou or expense report, we should get the linked chat report to be passed to the getMoneyRequestInformation function + const isMoneyRequestReport = ReportUtils.isMoneyRequestReport(report); + const currentChatReport = isMoneyRequestReport ? ReportUtils.getReportOrDraftReport(report?.chatReportID) : report; + const moneyRequestReportID = isMoneyRequestReport ? report?.reportID : ''; + const isMovingTransactionFromTrackExpense = IOUUtils.isMovingTransactionFromTrackExpense(action); + + const { + payerAccountID, + payerEmail, + iouReport, + chatReport, + transaction, + iouAction, + createdChatReportActionID, + createdIOUReportActionID, + reportPreviewAction, + transactionThreadReportID, + createdReportActionIDForThread, + onyxData, + } = getMoneyRequestInformation( + isMovingTransactionFromTrackExpense ? undefined : currentChatReport, + participant, + comment, + amount, + currency, + created, + merchant, + receipt, + isMovingTransactionFromTrackExpense && linkedTrackedExpenseReportAction && ReportActionsUtils.isMoneyRequestAction(linkedTrackedExpenseReportAction) + ? ReportActionsUtils.getOriginalMessage(linkedTrackedExpenseReportAction)?.IOUTransactionID + : undefined, + category, + tag, + taxCode, + taxAmount, + billable, + policy, + policyTagList, + policyCategories, + payeeAccountID, + payeeEmail, + moneyRequestReportID, + linkedTrackedExpenseReportAction, + ); + const activeReportID = isMoneyRequestReport ? report?.reportID : chatReport.reportID; + + switch (action) { + case CONST.IOU.ACTION.SUBMIT: { + if (!linkedTrackedExpenseReportAction || !actionableWhisperReportActionID || !linkedTrackedExpenseReportID) { + return; + } + + convertTrackedExpenseToRequest( + payerAccountID, + payerEmail, + chatReport.reportID, + transaction.transactionID, + actionableWhisperReportActionID, + createdChatReportActionID, + iouReport.reportID, + createdIOUReportActionID, + iouAction.reportActionID, + linkedTrackedExpenseReportAction, + linkedTrackedExpenseReportID, + transactionThreadReportID, + reportPreviewAction.reportActionID, + onyxData, + amount, + currency, + comment, + merchant, + created, + receipt, + ); + break; + } + default: { + const parameters: RequestMoneyParams = { + debtorEmail: payerEmail, + debtorAccountID: payerAccountID, + amount, + currency, + comment, + created, + merchant, + iouReportID: iouReport.reportID, + chatReportID: chatReport.reportID, + transactionID: transaction.transactionID, + reportActionID: iouAction.reportActionID, + createdChatReportActionID, + createdIOUReportActionID, + reportPreviewReportActionID: reportPreviewAction.reportActionID, + receipt, + receiptState: receipt?.state, + category, + tag, + taxCode, + taxAmount, + billable, + // This needs to be a string of JSON because of limitations with the fetch() API and nested objects + receiptGpsPoints: gpsPoints ? JSON.stringify(gpsPoints) : undefined, + transactionThreadReportID, + createdReportActionIDForThread, + reimbursible, + }; + + // eslint-disable-next-line rulesdir/no-multiple-api-calls + API.write(WRITE_COMMANDS.REQUEST_MONEY, parameters, onyxData); + } + } + + Navigation.dismissModal(isSearchTopmostCentralPane() ? undefined : activeReportID); + if (activeReportID) { + Report.notifyNewAction(activeReportID, payeeAccountID); + } +} + +function sendInvoice( + currentUserAccountID: number, + transaction: OnyxEntry, + invoiceChatReport?: OnyxEntry, + receiptFile?: Receipt, + policy?: OnyxEntry, + policyTagList?: OnyxEntry, + policyCategories?: OnyxEntry, + companyName?: string, + companyWebsite?: string, +) { + const { + senderWorkspaceID, + receiver, + invoiceRoom, + createdChatReportActionID, + invoiceReportID, + reportPreviewReportActionID, + transactionID, + transactionThreadReportID, + createdIOUReportActionID, + createdReportActionIDForThread, + reportActionID, + onyxData, + } = getSendInvoiceInformation(transaction, currentUserAccountID, invoiceChatReport, receiptFile, policy, policyTagList, policyCategories, companyName, companyWebsite); + + const parameters: SendInvoiceParams = { + createdIOUReportActionID, + createdReportActionIDForThread, + reportActionID, + senderWorkspaceID, + accountID: currentUserAccountID, + amount: transaction?.amount ?? 0, + currency: transaction?.currency ?? '', + comment: transaction?.comment?.comment?.trim() ?? '', + merchant: transaction?.merchant ?? '', + category: transaction?.category, + date: transaction?.created ?? '', + invoiceRoomReportID: invoiceRoom.reportID, + createdChatReportActionID, + invoiceReportID, + reportPreviewReportActionID, + transactionID, + transactionThreadReportID, + companyName, + companyWebsite, + ...(invoiceChatReport?.reportID ? {receiverInvoiceRoomID: invoiceChatReport.reportID} : {receiverEmail: receiver.login ?? ''}), + }; + + API.write(WRITE_COMMANDS.SEND_INVOICE, parameters, onyxData); + + if (isSearchTopmostCentralPane()) { + Navigation.dismissModal(); + } else { + Navigation.dismissModalWithReport(invoiceRoom); + } + + Report.notifyNewAction(invoiceRoom.reportID, receiver.accountID); +} + +/** + * Track an expense + */ +function trackExpense( + report: OnyxTypes.Report, + amount: number, + currency: string, + created: string, + merchant: string, + payeeEmail: string | undefined, + payeeAccountID: number, + participant: Participant, + comment: string, + receipt?: Receipt, + category?: string, + tag?: string, + taxCode = '', + taxAmount = 0, + billable?: boolean, + policy?: OnyxEntry, + policyTagList?: OnyxEntry, + policyCategories?: OnyxEntry, + gpsPoints?: GPSPoint, + validWaypoints?: WaypointCollection, + action?: IOUAction, + actionableWhisperReportActionID?: string, + linkedTrackedExpenseReportAction?: OnyxTypes.ReportAction, + linkedTrackedExpenseReportID?: string, + customUnitRateID?: string, +) { + const isMoneyRequestReport = ReportUtils.isMoneyRequestReport(report); + const currentChatReport = isMoneyRequestReport ? ReportUtils.getReportOrDraftReport(report.chatReportID) : report; + const moneyRequestReportID = isMoneyRequestReport ? report.reportID : ''; + const isMovingTransactionFromTrackExpense = IOUUtils.isMovingTransactionFromTrackExpense(action); + + // Pass an open receipt so the distance expense will show a map with the route optimistically + const trackedReceipt = validWaypoints ? {source: ReceiptGeneric as ReceiptSource, state: CONST.IOU.RECEIPT_STATE.OPEN} : receipt; + + const { + createdWorkspaceParams, + iouReport, + chatReport, + transaction, + iouAction, + createdChatReportActionID, + createdIOUReportActionID, + reportPreviewAction, + transactionThreadReportID, + createdReportActionIDForThread, + actionableWhisperReportActionIDParam, + onyxData, + } = + getTrackExpenseInformation( + currentChatReport, + participant, + comment, + amount, + currency, + created, + merchant, + trackedReceipt, + category, + tag, + taxCode, + taxAmount, + billable, + policy, + policyTagList, + policyCategories, + payeeEmail, + payeeAccountID, + moneyRequestReportID, + linkedTrackedExpenseReportAction, + isMovingTransactionFromTrackExpense && linkedTrackedExpenseReportAction && ReportActionsUtils.isMoneyRequestAction(linkedTrackedExpenseReportAction) + ? ReportActionsUtils.getOriginalMessage(linkedTrackedExpenseReportAction)?.IOUTransactionID + : undefined, + ) ?? {}; + const activeReportID = isMoneyRequestReport ? report.reportID : chatReport?.reportID; + + switch (action) { + case CONST.IOU.ACTION.CATEGORIZE: { + if (!linkedTrackedExpenseReportAction || !actionableWhisperReportActionID || !linkedTrackedExpenseReportID) { + return; + } + categorizeTrackedExpense( + chatReport?.policyID ?? '-1', + transaction?.transactionID ?? '-1', + iouAction?.reportActionID ?? '-1', + iouReport?.reportID ?? '-1', + createdIOUReportActionID ?? '-1', + actionableWhisperReportActionID, + linkedTrackedExpenseReportAction, + linkedTrackedExpenseReportID, + transactionThreadReportID ?? '-1', + reportPreviewAction?.reportActionID ?? '-1', + onyxData, + amount, + currency, + comment, + merchant, + created, + category, + tag, + taxCode, + taxAmount, + billable, + trackedReceipt, + createdWorkspaceParams, + ); + break; + } + case CONST.IOU.ACTION.SHARE: { + if (!linkedTrackedExpenseReportAction || !actionableWhisperReportActionID || !linkedTrackedExpenseReportID) { + return; + } + shareTrackedExpense( + chatReport?.policyID ?? '-1', + transaction?.transactionID ?? '-1', + iouAction?.reportActionID ?? '-1', + iouReport?.reportID ?? '-1', + createdIOUReportActionID ?? '-1', + actionableWhisperReportActionID, + linkedTrackedExpenseReportAction, + linkedTrackedExpenseReportID, + transactionThreadReportID ?? '-1', + reportPreviewAction?.reportActionID ?? '-1', + onyxData, + amount, + currency, + comment, + merchant, + created, + category, + tag, + taxCode, + taxAmount, + billable, + trackedReceipt, + createdWorkspaceParams, + ); + break; + } + default: { + const parameters: TrackExpenseParams = { + amount, + currency, + comment, + created, + merchant, + iouReportID: iouReport?.reportID, + chatReportID: chatReport?.reportID ?? '-1', + transactionID: transaction?.transactionID ?? '-1', + reportActionID: iouAction?.reportActionID ?? '-1', + createdChatReportActionID: createdChatReportActionID ?? '-1', + createdIOUReportActionID, + reportPreviewReportActionID: reportPreviewAction?.reportActionID, + receipt: trackedReceipt, + receiptState: trackedReceipt?.state, + category, + tag, + taxCode, + taxAmount, + billable, + // This needs to be a string of JSON because of limitations with the fetch() API and nested objects + receiptGpsPoints: gpsPoints ? JSON.stringify(gpsPoints) : undefined, + transactionThreadReportID: transactionThreadReportID ?? '-1', + createdReportActionIDForThread: createdReportActionIDForThread ?? '-1', + waypoints: validWaypoints ? JSON.stringify(validWaypoints) : undefined, + customUnitRateID, + }; + if (actionableWhisperReportActionIDParam) { + parameters.actionableWhisperReportActionID = actionableWhisperReportActionIDParam; + } + API.write(WRITE_COMMANDS.TRACK_EXPENSE, parameters, onyxData); + } + } + Navigation.dismissModal(isSearchTopmostCentralPane() ? undefined : activeReportID); + + if (action === CONST.IOU.ACTION.SHARE) { + Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.navigate(ROUTES.ROOM_INVITE.getRoute(activeReportID ?? '-1', CONST.IOU.SHARE.ROLE.ACCOUNTANT))); + } + + Report.notifyNewAction(activeReportID ?? '', payeeAccountID); +} + +function getOrCreateOptimisticSplitChatReport(existingSplitChatReportID: string, participants: Participant[], participantAccountIDs: number[], currentUserAccountID: number) { + // The existing chat report could be passed as reportID or exist on the sole "participant" (in this case a report option) + const existingChatReportID = existingSplitChatReportID || participants[0].reportID; + + // Check if the report is available locally if we do have one + let existingSplitChatReport = existingChatReportID ? ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${existingChatReportID}`] : null; + + const allParticipantsAccountIDs = [...participantAccountIDs, currentUserAccountID]; + if (!existingSplitChatReport) { + existingSplitChatReport = ReportUtils.getChatByParticipants(allParticipantsAccountIDs, undefined, participantAccountIDs.length > 1); + } + + // We found an existing chat report we are done... + if (existingSplitChatReport) { + // Yes, these are the same, but give the caller a way to identify if we created a new report or not + return {existingSplitChatReport, splitChatReport: existingSplitChatReport}; + } + + // Create a Group Chat if we have multiple participants + if (participants.length > 1) { + const splitChatReport = ReportUtils.buildOptimisticChatReport( + allParticipantsAccountIDs, + '', + CONST.REPORT.CHAT_TYPE.GROUP, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, + ); + return {existingSplitChatReport: null, splitChatReport}; + } + + // Otherwise, create a new 1:1 chat report + const splitChatReport = ReportUtils.buildOptimisticChatReport(participantAccountIDs); + return {existingSplitChatReport: null, splitChatReport}; +} + +/** + * Build the Onyx data and IOU split necessary for splitting a bill with 3+ users. + * 1. Build the optimistic Onyx data for the group chat, i.e. chatReport and iouReportAction creating the former if it doesn't yet exist. + * 2. Loop over the group chat participant list, building optimistic or updating existing chatReports, iouReports and iouReportActions between the user and each participant. + * We build both Onyx data and the IOU split that is sent as a request param and is used by Auth to create the chatReports, iouReports and iouReportActions in the database. + * The IOU split has the following shape: + * [ + * {email: 'currentUser', amount: 100}, + * {email: 'user2', amount: 100, iouReportID: '100', chatReportID: '110', transactionID: '120', reportActionID: '130'}, + * {email: 'user3', amount: 100, iouReportID: '200', chatReportID: '210', transactionID: '220', reportActionID: '230'} + * ] + * @param amount - always in the smallest unit of the currency + * @param existingSplitChatReportID - the report ID where the split expense happens, could be a group chat or a workspace chat + */ +function createSplitsAndOnyxData( + participants: Participant[], + currentUserLogin: string, + currentUserAccountID: number, + amount: number, + comment: string, + currency: string, + merchant: string, + created: string, + category: string, + tag: string, + splitShares: SplitShares = {}, + existingSplitChatReportID = '', + billable = false, + iouRequestType: IOURequestType = CONST.IOU.REQUEST_TYPE.MANUAL, + taxCode = '', + taxAmount = 0, +): SplitsAndOnyxData { + const currentUserEmailForIOUSplit = PhoneNumber.addSMSDomainIfPhoneNumber(currentUserLogin); + const participantAccountIDs = participants.map((participant) => Number(participant.accountID)); + + const {splitChatReport, existingSplitChatReport} = getOrCreateOptimisticSplitChatReport(existingSplitChatReportID, participants, participantAccountIDs, currentUserAccountID); + const isOwnPolicyExpenseChat = !!splitChatReport.isOwnPolicyExpenseChat; + + // Pass an open receipt so the distance expense will show a map with the route optimistically + const receipt: Receipt | undefined = iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE ? {source: ReceiptGeneric as ReceiptSource, state: CONST.IOU.RECEIPT_STATE.OPEN} : undefined; + + const existingTransaction = allTransactionDrafts[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`]; + const isDistanceRequest = existingTransaction && existingTransaction.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE; + let splitTransaction = TransactionUtils.buildOptimisticTransaction( + amount, + currency, + CONST.REPORT.SPLIT_REPORTID, + comment, + created, + '', + '', + merchant || Localize.translateLocal('iou.expense'), + receipt, + undefined, + undefined, + category, + tag, + taxCode, + taxAmount, + billable, + isDistanceRequest ? {waypoints: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD} : undefined, + ); + + // Important data is set on the draft distance transaction, such as the iouRequestType marking it as a distance request, so merge it into the optimistic split transaction + if (isDistanceRequest) { + splitTransaction = fastMerge(existingTransaction, splitTransaction, false); + } + + // Note: The created action must be optimistically generated before the IOU action so there's no chance that the created action appears after the IOU action in the chat + const splitCreatedReportAction = ReportUtils.buildOptimisticCreatedReportAction(currentUserEmailForIOUSplit); + const splitIOUReportAction = ReportUtils.buildOptimisticIOUReportAction( + CONST.IOU.REPORT_ACTION_TYPE.SPLIT, + amount, + currency, + comment, + participants, + splitTransaction.transactionID, + undefined, + '', + false, + false, + isOwnPolicyExpenseChat, + ); + + splitChatReport.lastReadTime = DateUtils.getDBTime(); + splitChatReport.lastMessageText = ReportActionsUtils.getReportActionText(splitIOUReportAction); + splitChatReport.lastMessageHtml = ReportActionsUtils.getReportActionHtml(splitIOUReportAction); + splitChatReport.lastActorAccountID = currentUserAccountID; + splitChatReport.lastVisibleActionCreated = splitIOUReportAction.created; + + // If we have an existing splitChatReport (group chat or workspace) use it's pending fields, otherwise indicate that we are adding a chat + if (!existingSplitChatReport) { + splitChatReport.pendingFields = { + createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }; + } + + const optimisticData: OnyxUpdate[] = [ + { + // Use set for new reports because it doesn't exist yet, is faster, + // and we need the data to be available when we navigate to the chat page + onyxMethod: existingSplitChatReport ? Onyx.METHOD.MERGE : Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${splitChatReport.reportID}`, + value: splitChatReport, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE, + value: { + action: iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE ? CONST.QUICK_ACTIONS.SPLIT_DISTANCE : CONST.QUICK_ACTIONS.SPLIT_MANUAL, + chatReportID: splitChatReport.reportID, + isFirstQuickAction: isEmptyObject(quickAction), + }, + }, + existingSplitChatReport + ? { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${splitChatReport.reportID}`, + value: { + [splitIOUReportAction.reportActionID]: splitIOUReportAction as OnyxTypes.ReportAction, + }, + } + : { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${splitChatReport.reportID}`, + value: { + [splitCreatedReportAction.reportActionID]: splitCreatedReportAction as OnyxTypes.ReportAction, + [splitIOUReportAction.reportActionID]: splitIOUReportAction as OnyxTypes.ReportAction, + }, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransaction.transactionID}`, + value: splitTransaction, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`, + value: null, + }, + ]; + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${splitChatReport.reportID}`, + value: { + ...(existingSplitChatReport ? {} : {[splitCreatedReportAction.reportActionID]: {pendingAction: null}}), + [splitIOUReportAction.reportActionID]: {pendingAction: null}, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransaction.transactionID}`, + value: {pendingAction: null, pendingFields: null}, + }, + ]; + + const redundantParticipants: Record = {}; + if (!existingSplitChatReport) { + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${splitChatReport.reportID}`, + value: {pendingFields: {createChat: null}, participants: redundantParticipants}, + }); + } + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransaction.transactionID}`, + value: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericCreateFailureMessage'), + pendingAction: null, + pendingFields: null, + }, + }, + ]; + + if (existingSplitChatReport) { + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${splitChatReport.reportID}`, + value: { + [splitIOUReportAction.reportActionID]: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericCreateFailureMessage'), + }, + }, + }); + } else { + failureData.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${splitChatReport.reportID}`, + value: { + errorFields: { + createChat: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('report.genericCreateReportFailureMessage'), + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${splitChatReport.reportID}`, + value: { + [splitIOUReportAction.reportActionID]: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericCreateFailureMessage'), + }, + }, + }, + ); + } + + // Loop through participants creating individual chats, iouReports and reportActionIDs as needed + const currentUserAmount = splitShares?.[currentUserAccountID]?.amount ?? IOUUtils.calculateAmount(participants.length, amount, currency, true); + const currentUserTaxAmount = IOUUtils.calculateAmount(participants.length, taxAmount, currency, true); + + const splits: Split[] = [{email: currentUserEmailForIOUSplit, accountID: currentUserAccountID, amount: currentUserAmount, taxAmount: currentUserTaxAmount}]; + + const hasMultipleParticipants = participants.length > 1; + participants.forEach((participant) => { + // In a case when a participant is a workspace, even when a current user is not an owner of the workspace + const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(participant); + const splitAmount = splitShares?.[participant.accountID ?? -1]?.amount ?? IOUUtils.calculateAmount(participants.length, amount, currency, false); + const splitTaxAmount = IOUUtils.calculateAmount(participants.length, taxAmount, currency, false); + + // To exclude someone from a split, the amount can be 0. The scenario for this is when creating a split from a group chat, we have remove the option to deselect users to exclude them. + // We can input '0' next to someone we want to exclude. + if (splitAmount === 0) { + return; + } + + // In case the participant is a workspace, email & accountID should remain undefined and won't be used in the rest of this code + // participant.login is undefined when the request is initiated from a group DM with an unknown user, so we need to add a default + const email = isOwnPolicyExpenseChat || isPolicyExpenseChat ? '' : PhoneNumber.addSMSDomainIfPhoneNumber(participant.login ?? '').toLowerCase(); + const accountID = isOwnPolicyExpenseChat || isPolicyExpenseChat ? 0 : Number(participant.accountID); + if (email === currentUserEmailForIOUSplit) { + return; + } + + // STEP 1: Get existing chat report OR build a new optimistic one + // If we only have one participant and the request was initiated from the global create menu, i.e. !existingGroupChatReportID, the oneOnOneChatReport is the groupChatReport + let oneOnOneChatReport: OnyxTypes.Report | OptimisticChatReport; + let isNewOneOnOneChatReport = false; + let shouldCreateOptimisticPersonalDetails = false; + const personalDetailExists = accountID in allPersonalDetails; + + // If this is a split between two people only and the function + // wasn't provided with an existing group chat report id + // or, if the split is being made from the workspace chat, then the oneOnOneChatReport is the same as the splitChatReport + // in this case existingSplitChatReport will belong to the policy expense chat and we won't be + // entering code that creates optimistic personal details + if ((!hasMultipleParticipants && !existingSplitChatReportID) || isOwnPolicyExpenseChat) { + oneOnOneChatReport = splitChatReport; + shouldCreateOptimisticPersonalDetails = !existingSplitChatReport && !personalDetailExists; + } else { + const existingChatReport = ReportUtils.getChatByParticipants([accountID, currentUserAccountID]); + isNewOneOnOneChatReport = !existingChatReport; + shouldCreateOptimisticPersonalDetails = isNewOneOnOneChatReport && !personalDetailExists; + oneOnOneChatReport = existingChatReport ?? ReportUtils.buildOptimisticChatReport([accountID, currentUserAccountID]); + } + + // STEP 2: Get existing IOU/Expense report and update its total OR build a new optimistic one + let oneOnOneIOUReport: OneOnOneIOUReport = oneOnOneChatReport.iouReportID + ? ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${oneOnOneChatReport.iouReportID}`] + : null; + const shouldCreateNewOneOnOneIOUReport = ReportUtils.shouldCreateNewMoneyRequestReport(oneOnOneIOUReport, oneOnOneChatReport); + + if (!oneOnOneIOUReport || shouldCreateNewOneOnOneIOUReport) { + oneOnOneIOUReport = isOwnPolicyExpenseChat + ? ReportUtils.buildOptimisticExpenseReport(oneOnOneChatReport.reportID, oneOnOneChatReport.policyID ?? '-1', currentUserAccountID, splitAmount, currency) + : ReportUtils.buildOptimisticIOUReport(currentUserAccountID, accountID, splitAmount, oneOnOneChatReport.reportID, currency); + } else if (isOwnPolicyExpenseChat) { + if (typeof oneOnOneIOUReport?.total === 'number') { + // Because of the Expense reports are stored as negative values, we subtract the total from the amount + oneOnOneIOUReport.total -= splitAmount; + } + } else { + oneOnOneIOUReport = IOUUtils.updateIOUOwnerAndTotal(oneOnOneIOUReport, currentUserAccountID, splitAmount, currency); + } + + // STEP 3: Build optimistic transaction + const oneOnOneTransaction = TransactionUtils.buildOptimisticTransaction( + ReportUtils.isExpenseReport(oneOnOneIOUReport) ? -splitAmount : splitAmount, + currency, + oneOnOneIOUReport.reportID, + comment, + created, + CONST.IOU.TYPE.SPLIT, + splitTransaction.transactionID, + merchant || Localize.translateLocal('iou.expense'), + undefined, + undefined, + undefined, + category, + tag, + taxCode, + ReportUtils.isExpenseReport(oneOnOneIOUReport) ? -splitTaxAmount : splitTaxAmount, + billable, + ); + + // STEP 4: Build optimistic reportActions. We need: + // 1. CREATED action for the chatReport + // 2. CREATED action for the iouReport + // 3. IOU action for the iouReport + // 4. Transaction Thread and the CREATED action for it + // 5. REPORT_PREVIEW action for the chatReport + const [oneOnOneCreatedActionForChat, oneOnOneCreatedActionForIOU, oneOnOneIOUAction, optimisticTransactionThread, optimisticCreatedActionForTransactionThread] = + ReportUtils.buildOptimisticMoneyRequestEntities( + oneOnOneIOUReport, + CONST.IOU.REPORT_ACTION_TYPE.CREATE, + splitAmount, + currency, + comment, + currentUserEmailForIOUSplit, + [participant], + oneOnOneTransaction.transactionID, + ); + + // Add optimistic personal details for new participants + const oneOnOnePersonalDetailListAction: OnyxTypes.PersonalDetailsList = shouldCreateOptimisticPersonalDetails + ? { + [accountID]: { + accountID, + // Disabling this line since participant.displayName can be an empty string + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + displayName: LocalePhoneNumber.formatPhoneNumber(participant.displayName || email), + login: participant.login, + isOptimisticPersonalDetail: true, + }, + } + : {}; + + if (shouldCreateOptimisticPersonalDetails) { + // BE will send different participants. We clear the optimistic ones to avoid duplicated entries + redundantParticipants[accountID] = null; + } + + let oneOnOneReportPreviewAction = getReportPreviewAction(oneOnOneChatReport.reportID, oneOnOneIOUReport.reportID); + if (oneOnOneReportPreviewAction) { + oneOnOneReportPreviewAction = ReportUtils.updateReportPreview(oneOnOneIOUReport, oneOnOneReportPreviewAction); + } else { + oneOnOneReportPreviewAction = ReportUtils.buildOptimisticReportPreview(oneOnOneChatReport, oneOnOneIOUReport); + } + + // Add category to optimistic policy recently used categories when a participant is a workspace + const optimisticPolicyRecentlyUsedCategories = isPolicyExpenseChat ? Category.buildOptimisticPolicyRecentlyUsedCategories(participant.policyID, category) : []; + + const optimisticRecentlyUsedCurrencies = Policy.buildOptimisticRecentlyUsedCurrencies(currency); + + // Add tag to optimistic policy recently used tags when a participant is a workspace + const optimisticPolicyRecentlyUsedTags = isPolicyExpenseChat ? Tag.buildOptimisticPolicyRecentlyUsedTags(participant.policyID, tag) : {}; + + // STEP 5: Build Onyx Data + const [oneOnOneOptimisticData, oneOnOneSuccessData, oneOnOneFailureData] = buildOnyxDataForMoneyRequest( + oneOnOneChatReport, + oneOnOneIOUReport, + oneOnOneTransaction, + oneOnOneCreatedActionForChat, + oneOnOneCreatedActionForIOU, + oneOnOneIOUAction, + oneOnOnePersonalDetailListAction, + oneOnOneReportPreviewAction, + optimisticPolicyRecentlyUsedCategories, + optimisticPolicyRecentlyUsedTags, + isNewOneOnOneChatReport, + optimisticTransactionThread, + optimisticCreatedActionForTransactionThread, + shouldCreateNewOneOnOneIOUReport, + null, + null, + null, + null, + true, + undefined, + optimisticRecentlyUsedCurrencies, + ); + + const individualSplit = { + email, + accountID, + isOptimisticAccount: ReportUtils.isOptimisticPersonalDetail(accountID), + amount: splitAmount, + iouReportID: oneOnOneIOUReport.reportID, + chatReportID: oneOnOneChatReport.reportID, + transactionID: oneOnOneTransaction.transactionID, + reportActionID: oneOnOneIOUAction.reportActionID, + createdChatReportActionID: oneOnOneCreatedActionForChat.reportActionID, + createdIOUReportActionID: oneOnOneCreatedActionForIOU.reportActionID, + reportPreviewReportActionID: oneOnOneReportPreviewAction.reportActionID, + transactionThreadReportID: optimisticTransactionThread.reportID, + createdReportActionIDForThread: optimisticCreatedActionForTransactionThread?.reportActionID, + taxAmount: splitTaxAmount, + }; + + splits.push(individualSplit); + optimisticData.push(...oneOnOneOptimisticData); + successData.push(...oneOnOneSuccessData); + failureData.push(...oneOnOneFailureData); + }); + + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransaction.transactionID}`, + value: { + comment: { + splits: splits.map((split) => ({accountID: split.accountID, amount: split.amount})), + }, + }, + }); + + const splitData: SplitData = { + chatReportID: splitChatReport.reportID, + transactionID: splitTransaction.transactionID, + reportActionID: splitIOUReportAction.reportActionID, + policyID: splitChatReport.policyID, + chatType: splitChatReport.chatType, + }; + + if (!existingSplitChatReport) { + splitData.createdReportActionID = splitCreatedReportAction.reportActionID; + } + + return { + splitData, + splits, + onyxData: {optimisticData, successData, failureData}, + }; +} + +type SplitBillActionsParams = { + participants: Participant[]; + currentUserLogin: string; + currentUserAccountID: number; + amount: number; + comment: string; + currency: string; + merchant: string; + created: string; + category?: string; + tag?: string; + billable?: boolean; + iouRequestType?: IOURequestType; + existingSplitChatReportID?: string; + splitShares?: SplitShares; + splitPayerAccountIDs?: number[]; + taxCode?: string; + taxAmount?: number; +}; + +/** + * @param amount - always in smallest currency unit + * @param existingSplitChatReportID - Either a group DM or a workspace chat + */ +function splitBill({ + participants, + currentUserLogin, + currentUserAccountID, + amount, + comment, + currency, + merchant, + created, + category = '', + tag = '', + billable = false, + iouRequestType = CONST.IOU.REQUEST_TYPE.MANUAL, + existingSplitChatReportID = '', + splitShares = {}, + splitPayerAccountIDs = [], + taxCode = '', + taxAmount = 0, +}: SplitBillActionsParams) { + const {splitData, splits, onyxData} = createSplitsAndOnyxData( + participants, + currentUserLogin, + currentUserAccountID, + amount, + comment, + currency, + merchant, + created, + category, + tag, + splitShares, + existingSplitChatReportID, + billable, + iouRequestType, + taxCode, + taxAmount, + ); + + const parameters: SplitBillParams = { + reportID: splitData.chatReportID, + amount, + splits: JSON.stringify(splits), + currency, + comment, + category, + merchant, + created, + tag, + billable, + transactionID: splitData.transactionID, + reportActionID: splitData.reportActionID, + createdReportActionID: splitData.createdReportActionID, + policyID: splitData.policyID, + chatType: splitData.chatType, + splitPayerAccountIDs, + taxCode, + taxAmount, + }; + + API.write(WRITE_COMMANDS.SPLIT_BILL, parameters, onyxData); + + Navigation.dismissModal(isSearchTopmostCentralPane() ? undefined : existingSplitChatReportID); + Report.notifyNewAction(splitData.chatReportID, currentUserAccountID); +} + +/** + * @param amount - always in the smallest currency unit + */ +function splitBillAndOpenReport({ + participants, + currentUserLogin, + currentUserAccountID, + amount, + comment, + currency, + merchant, + created, + category = '', + tag = '', + billable = false, + iouRequestType = CONST.IOU.REQUEST_TYPE.MANUAL, + splitShares = {}, + splitPayerAccountIDs = [], + taxCode = '', + taxAmount = 0, +}: SplitBillActionsParams) { + const {splitData, splits, onyxData} = createSplitsAndOnyxData( + participants, + currentUserLogin, + currentUserAccountID, + amount, + comment, + currency, + merchant, + created, + category, + tag, + splitShares, + '', + billable, + iouRequestType, + taxCode, + taxAmount, + ); + + const parameters: SplitBillParams = { + reportID: splitData.chatReportID, + amount, + splits: JSON.stringify(splits), + currency, + merchant, + created, + comment, + category, + tag, + billable, + transactionID: splitData.transactionID, + reportActionID: splitData.reportActionID, + createdReportActionID: splitData.createdReportActionID, + policyID: splitData.policyID, + chatType: splitData.chatType, + splitPayerAccountIDs, + taxCode, + taxAmount, + }; + + API.write(WRITE_COMMANDS.SPLIT_BILL_AND_OPEN_REPORT, parameters, onyxData); + + Navigation.dismissModal(isSearchTopmostCentralPane() ? undefined : splitData.chatReportID); + Report.notifyNewAction(splitData.chatReportID, currentUserAccountID); +} + +type StartSplitBilActionParams = { + participants: Participant[]; + currentUserLogin: string; + currentUserAccountID: number; + comment: string; + receipt: Receipt; + existingSplitChatReportID?: string; + billable?: boolean; + category: string | undefined; + tag: string | undefined; + currency: string; + taxCode: string; + taxAmount: number; +}; + +/** Used exclusively for starting a split expense request that contains a receipt, the split request will be completed once the receipt is scanned + * or user enters details manually. + * + * @param existingSplitChatReportID - Either a group DM or a workspace chat + */ +function startSplitBill({ + participants, + currentUserLogin, + currentUserAccountID, + comment, + receipt, + existingSplitChatReportID = '', + billable = false, + category = '', + tag = '', + currency, + taxCode = '', + taxAmount = 0, +}: StartSplitBilActionParams) { + const currentUserEmailForIOUSplit = PhoneNumber.addSMSDomainIfPhoneNumber(currentUserLogin); + const participantAccountIDs = participants.map((participant) => Number(participant.accountID)); + const {splitChatReport, existingSplitChatReport} = getOrCreateOptimisticSplitChatReport(existingSplitChatReportID, participants, participantAccountIDs, currentUserAccountID); + const isOwnPolicyExpenseChat = !!splitChatReport.isOwnPolicyExpenseChat; + + const {name: filename, source, state = CONST.IOU.RECEIPT_STATE.SCANREADY} = receipt; + const receiptObject: Receipt = {state, source}; + + // ReportID is -2 (aka "deleted") on the group transaction + const splitTransaction = TransactionUtils.buildOptimisticTransaction( + 0, + currency, + CONST.REPORT.SPLIT_REPORTID, + comment, + '', + '', + '', + CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, + receiptObject, + filename, + undefined, + category, + tag, + taxCode, + taxAmount, + billable, + ); + + // Note: The created action must be optimistically generated before the IOU action so there's no chance that the created action appears after the IOU action in the chat + const splitChatCreatedReportAction = ReportUtils.buildOptimisticCreatedReportAction(currentUserEmailForIOUSplit); + const splitIOUReportAction = ReportUtils.buildOptimisticIOUReportAction( + CONST.IOU.REPORT_ACTION_TYPE.SPLIT, + 0, + CONST.CURRENCY.USD, + comment, + participants, + splitTransaction.transactionID, + undefined, + '', + false, + false, + isOwnPolicyExpenseChat, + ); + + splitChatReport.lastReadTime = DateUtils.getDBTime(); + splitChatReport.lastMessageText = ReportActionsUtils.getReportActionText(splitIOUReportAction); + splitChatReport.lastMessageHtml = ReportActionsUtils.getReportActionHtml(splitIOUReportAction); + + // If we have an existing splitChatReport (group chat or workspace) use it's pending fields, otherwise indicate that we are adding a chat + if (!existingSplitChatReport) { + splitChatReport.pendingFields = { + createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }; + } + + const optimisticData: OnyxUpdate[] = [ + { + // Use set for new reports because it doesn't exist yet, is faster, + // and we need the data to be available when we navigate to the chat page + onyxMethod: existingSplitChatReport ? Onyx.METHOD.MERGE : Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${splitChatReport.reportID}`, + value: splitChatReport, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE, + value: { + action: CONST.QUICK_ACTIONS.SPLIT_SCAN, + chatReportID: splitChatReport.reportID, + isFirstQuickAction: isEmptyObject(quickAction), + }, + }, + existingSplitChatReport + ? { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${splitChatReport.reportID}`, + value: { + [splitIOUReportAction.reportActionID]: splitIOUReportAction as OnyxTypes.ReportAction, + }, + } + : { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${splitChatReport.reportID}`, + value: { + [splitChatCreatedReportAction.reportActionID]: splitChatCreatedReportAction, + [splitIOUReportAction.reportActionID]: splitIOUReportAction as OnyxTypes.ReportAction, + }, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransaction.transactionID}`, + value: splitTransaction, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${splitChatReport.reportID}`, + value: { + ...(existingSplitChatReport ? {} : {[splitChatCreatedReportAction.reportActionID]: {pendingAction: null}}), + [splitIOUReportAction.reportActionID]: {pendingAction: null}, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransaction.transactionID}`, + value: {pendingAction: null}, + }, + ]; + + const redundantParticipants: Record = {}; + if (!existingSplitChatReport) { + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${splitChatReport.reportID}`, + value: {pendingFields: {createChat: null}, participants: redundantParticipants}, + }); + } + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransaction.transactionID}`, + value: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericCreateFailureMessage'), + }, + }, + ]; + + if (existingSplitChatReport) { + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${splitChatReport.reportID}`, + value: { + [splitIOUReportAction.reportActionID]: { + errors: getReceiptError(receipt, filename), + }, + }, + }); + } else { + failureData.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${splitChatReport.reportID}`, + value: { + errorFields: { + createChat: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('report.genericCreateReportFailureMessage'), + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${splitChatReport.reportID}`, + value: { + [splitChatCreatedReportAction.reportActionID]: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('report.genericCreateReportFailureMessage'), + }, + [splitIOUReportAction.reportActionID]: { + errors: getReceiptError(receipt, filename), + }, + }, + }, + ); + } + + const splits: Split[] = [{email: currentUserEmailForIOUSplit, accountID: currentUserAccountID}]; + + participants.forEach((participant) => { + // Disabling this line since participant.login can be an empty string + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const email = participant.isOwnPolicyExpenseChat ? '' : PhoneNumber.addSMSDomainIfPhoneNumber(participant.login || participant.text || '').toLowerCase(); + const accountID = participant.isOwnPolicyExpenseChat ? 0 : Number(participant.accountID); + if (email === currentUserEmailForIOUSplit) { + return; + } + + // When splitting with a workspace chat, we only need to supply the policyID and the workspace reportID as it's needed so we can update the report preview + if (participant.isOwnPolicyExpenseChat) { + splits.push({ + policyID: participant.policyID, + chatReportID: splitChatReport.reportID, + }); + return; + } + + const participantPersonalDetails = allPersonalDetails[participant?.accountID ?? -1]; + if (!participantPersonalDetails) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + value: { + [accountID]: { + accountID, + // Disabling this line since participant.displayName can be an empty string + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + displayName: LocalePhoneNumber.formatPhoneNumber(participant.displayName || email), + // Disabling this line since participant.login can be an empty string + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + login: participant.login || participant.text, + isOptimisticPersonalDetail: true, + }, + }, + }); + // BE will send different participants. We clear the optimistic ones to avoid duplicated entries + redundantParticipants[accountID] = null; + } + + splits.push({ + email, + accountID, + }); + }); + + participants.forEach((participant) => { + const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(participant); + if (!isPolicyExpenseChat) { + return; + } + + const optimisticPolicyRecentlyUsedCategories = Category.buildOptimisticPolicyRecentlyUsedCategories(participant.policyID, category); + const optimisticPolicyRecentlyUsedTags = Tag.buildOptimisticPolicyRecentlyUsedTags(participant.policyID, tag); + const optimisticRecentlyUsedCurrencies = Policy.buildOptimisticRecentlyUsedCurrencies(currency); + + if (optimisticPolicyRecentlyUsedCategories.length > 0) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${participant.policyID}`, + value: optimisticPolicyRecentlyUsedCategories, + }); + } + + if (optimisticRecentlyUsedCurrencies.length > 0) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.RECENTLY_USED_CURRENCIES, + value: optimisticRecentlyUsedCurrencies, + }); + } + + if (!isEmptyObject(optimisticPolicyRecentlyUsedTags)) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${participant.policyID}`, + value: optimisticPolicyRecentlyUsedTags, + }); + } + }); + + // Save the new splits array into the transaction's comment in case the user calls CompleteSplitBill while offline + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransaction.transactionID}`, + value: { + comment: { + splits, + }, + }, + }); + + const parameters: StartSplitBillParams = { + chatReportID: splitChatReport.reportID, + reportActionID: splitIOUReportAction.reportActionID, + transactionID: splitTransaction.transactionID, + splits: JSON.stringify(splits), + receipt, + comment, + category, + tag, + currency, + isFromGroupDM: !existingSplitChatReport, + billable, + ...(existingSplitChatReport ? {} : {createdReportActionID: splitChatCreatedReportAction.reportActionID}), + chatType: splitChatReport?.chatType, + taxCode, + taxAmount, + }; + + API.write(WRITE_COMMANDS.START_SPLIT_BILL, parameters, {optimisticData, successData, failureData}); + + Navigation.dismissModalWithReport(splitChatReport); + Report.notifyNewAction(splitChatReport.reportID ?? '-1', currentUserAccountID); +} + +/** Used for editing a split expense while it's still scanning or when SmartScan fails, it completes a split expense started by startSplitBill above. + * + * @param chatReportID - The group chat or workspace reportID + * @param reportAction - The split action that lives in the chatReport above + * @param updatedTransaction - The updated **draft** split transaction + * @param sessionAccountID - accountID of the current user + * @param sessionEmail - email of the current user + */ +function completeSplitBill(chatReportID: string, reportAction: OnyxTypes.ReportAction, updatedTransaction: OnyxEntry, sessionAccountID: number, sessionEmail: string) { + const currentUserEmailForIOUSplit = PhoneNumber.addSMSDomainIfPhoneNumber(sessionEmail); + const transactionID = updatedTransaction?.transactionID ?? '-1'; + const unmodifiedTransaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + + // Save optimistic updated transaction and action + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + ...updatedTransaction, + receipt: { + state: CONST.IOU.RECEIPT_STATE.OPEN, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, + value: { + [reportAction.reportActionID]: { + lastModified: DateUtils.getDBTime(), + originalMessage: { + whisperedTo: [], + }, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: {pendingAction: null}, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, + value: {pendingAction: null}, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + ...unmodifiedTransaction, + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericCreateFailureMessage'), + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, + value: { + [reportAction.reportActionID]: { + ...reportAction, + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericCreateFailureMessage'), + }, + }, + }, + ]; + + const splitParticipants: Split[] = updatedTransaction?.comment?.splits ?? []; + const amount = updatedTransaction?.modifiedAmount; + const currency = updatedTransaction?.modifiedCurrency; + console.debug(updatedTransaction); + + // Exclude the current user when calculating the split amount, `calculateAmount` takes it into account + const splitAmount = IOUUtils.calculateAmount(splitParticipants.length - 1, amount ?? 0, currency ?? '', false); + const splitTaxAmount = IOUUtils.calculateAmount(splitParticipants.length - 1, updatedTransaction?.taxAmount ?? 0, currency ?? '', false); + + const splits: Split[] = [{email: currentUserEmailForIOUSplit}]; + splitParticipants.forEach((participant) => { + // Skip creating the transaction for the current user + if (participant.email === currentUserEmailForIOUSplit) { + return; + } + const isPolicyExpenseChat = !!participant.policyID; + + if (!isPolicyExpenseChat) { + // In case this is still the optimistic accountID saved in the splits array, return early as we cannot know + // if there is an existing chat between the split creator and this participant + // Instead, we will rely on Auth generating the report IDs and the user won't see any optimistic chats or reports created + const participantPersonalDetails: OnyxTypes.PersonalDetails | null = allPersonalDetails[participant?.accountID ?? -1]; + if (!participantPersonalDetails || participantPersonalDetails.isOptimisticPersonalDetail) { + splits.push({ + email: participant.email, + }); + return; + } + } + + let oneOnOneChatReport: OnyxEntry; + let isNewOneOnOneChatReport = false; + const allReports = ReportConnection.getAllReports(); + if (isPolicyExpenseChat) { + // The workspace chat reportID is saved in the splits array when starting a split expense with a workspace + oneOnOneChatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${participant.chatReportID}`]; + } else { + const existingChatReport = ReportUtils.getChatByParticipants(participant.accountID ? [participant.accountID, sessionAccountID] : []); + isNewOneOnOneChatReport = !existingChatReport; + oneOnOneChatReport = existingChatReport ?? ReportUtils.buildOptimisticChatReport(participant.accountID ? [participant.accountID, sessionAccountID] : []); + } + + let oneOnOneIOUReport: OneOnOneIOUReport = oneOnOneChatReport?.iouReportID ? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${oneOnOneChatReport.iouReportID}`] : null; + const shouldCreateNewOneOnOneIOUReport = ReportUtils.shouldCreateNewMoneyRequestReport(oneOnOneIOUReport, oneOnOneChatReport); + + if (!oneOnOneIOUReport || shouldCreateNewOneOnOneIOUReport) { + oneOnOneIOUReport = isPolicyExpenseChat + ? ReportUtils.buildOptimisticExpenseReport(oneOnOneChatReport?.reportID ?? '-1', participant.policyID ?? '-1', sessionAccountID, splitAmount, currency ?? '') + : ReportUtils.buildOptimisticIOUReport(sessionAccountID, participant.accountID ?? -1, splitAmount, oneOnOneChatReport?.reportID ?? '-1', currency ?? ''); + } else if (isPolicyExpenseChat) { + if (typeof oneOnOneIOUReport?.total === 'number') { + // Because of the Expense reports are stored as negative values, we subtract the total from the amount + oneOnOneIOUReport.total -= splitAmount; + } + } else { + oneOnOneIOUReport = IOUUtils.updateIOUOwnerAndTotal(oneOnOneIOUReport, sessionAccountID, splitAmount, currency ?? ''); + } + + const oneOnOneTransaction = TransactionUtils.buildOptimisticTransaction( + isPolicyExpenseChat ? -splitAmount : splitAmount, + currency ?? '', + oneOnOneIOUReport?.reportID ?? '-1', + updatedTransaction?.comment?.comment, + updatedTransaction?.modifiedCreated, + CONST.IOU.TYPE.SPLIT, + transactionID, + updatedTransaction?.modifiedMerchant, + {...updatedTransaction?.receipt, state: CONST.IOU.RECEIPT_STATE.OPEN}, + updatedTransaction?.filename, + undefined, + updatedTransaction?.category, + updatedTransaction?.tag, + updatedTransaction?.taxCode, + isPolicyExpenseChat ? -splitTaxAmount : splitAmount, + updatedTransaction?.billable, + ); + + const [oneOnOneCreatedActionForChat, oneOnOneCreatedActionForIOU, oneOnOneIOUAction, optimisticTransactionThread, optimisticCreatedActionForTransactionThread] = + ReportUtils.buildOptimisticMoneyRequestEntities( + oneOnOneIOUReport, + CONST.IOU.REPORT_ACTION_TYPE.CREATE, + splitAmount, + currency ?? '', + updatedTransaction?.comment?.comment ?? '', + currentUserEmailForIOUSplit, + [participant], + oneOnOneTransaction.transactionID, + undefined, + ); + + let oneOnOneReportPreviewAction = getReportPreviewAction(oneOnOneChatReport?.reportID ?? '-1', oneOnOneIOUReport?.reportID ?? '-1'); + if (oneOnOneReportPreviewAction) { + oneOnOneReportPreviewAction = ReportUtils.updateReportPreview(oneOnOneIOUReport, oneOnOneReportPreviewAction); + } else { + oneOnOneReportPreviewAction = ReportUtils.buildOptimisticReportPreview(oneOnOneChatReport, oneOnOneIOUReport, '', oneOnOneTransaction); + } + + const [oneOnOneOptimisticData, oneOnOneSuccessData, oneOnOneFailureData] = buildOnyxDataForMoneyRequest( + oneOnOneChatReport, + oneOnOneIOUReport, + oneOnOneTransaction, + oneOnOneCreatedActionForChat, + oneOnOneCreatedActionForIOU, + oneOnOneIOUAction, + {}, + oneOnOneReportPreviewAction, + [], + {}, + isNewOneOnOneChatReport, + optimisticTransactionThread, + optimisticCreatedActionForTransactionThread, + shouldCreateNewOneOnOneIOUReport, + null, + null, + null, + null, + true, + ); + + splits.push({ + email: participant.email, + accountID: participant.accountID, + policyID: participant.policyID, + iouReportID: oneOnOneIOUReport?.reportID, + chatReportID: oneOnOneChatReport?.reportID, + transactionID: oneOnOneTransaction.transactionID, + reportActionID: oneOnOneIOUAction.reportActionID, + createdChatReportActionID: oneOnOneCreatedActionForChat.reportActionID, + createdIOUReportActionID: oneOnOneCreatedActionForIOU.reportActionID, + reportPreviewReportActionID: oneOnOneReportPreviewAction.reportActionID, + transactionThreadReportID: optimisticTransactionThread.reportID, + createdReportActionIDForThread: optimisticCreatedActionForTransactionThread?.reportActionID, + }); + + optimisticData.push(...oneOnOneOptimisticData); + successData.push(...oneOnOneSuccessData); + failureData.push(...oneOnOneFailureData); + }); + + const { + amount: transactionAmount, + currency: transactionCurrency, + created: transactionCreated, + merchant: transactionMerchant, + comment: transactionComment, + category: transactionCategory, + tag: transactionTag, + taxCode: transactionTaxCode, + taxAmount: transactionTaxAmount, + billable: transactionBillable, + } = ReportUtils.getTransactionDetails(updatedTransaction) ?? {}; + + const parameters: CompleteSplitBillParams = { + transactionID, + amount: transactionAmount, + currency: transactionCurrency, + created: transactionCreated, + merchant: transactionMerchant, + comment: transactionComment, + category: transactionCategory, + tag: transactionTag, + splits: JSON.stringify(splits), + taxCode: transactionTaxCode, + taxAmount: transactionTaxAmount, + billable: transactionBillable, + }; + + API.write(WRITE_COMMANDS.COMPLETE_SPLIT_BILL, parameters, {optimisticData, successData, failureData}); + Navigation.dismissModal(isSearchTopmostCentralPane() ? undefined : chatReportID); + Report.notifyNewAction(chatReportID, sessionAccountID); +} + +function setDraftSplitTransaction(transactionID: string, transactionChanges: TransactionChanges = {}) { + let draftSplitTransaction = allDraftSplitTransactions[`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`]; + + if (!draftSplitTransaction) { + draftSplitTransaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + } + + const updatedTransaction = draftSplitTransaction ? TransactionUtils.getUpdatedTransaction(draftSplitTransaction, transactionChanges, false, false) : null; + + Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, updatedTransaction); +} + +/** Requests money based on a distance (e.g. mileage from a map) */ +function createDistanceRequest( + report: OnyxEntry, + participants: Participant[], + comment: string, + created: string, + category: string | undefined, + tag: string | undefined, + taxCode: string | undefined, + taxAmount: number | undefined, + amount: number, + currency: string, + merchant: string, + billable: boolean | undefined, + validWaypoints: WaypointCollection, + policy?: OnyxEntry, + policyTagList?: OnyxEntry, + policyCategories?: OnyxEntry, + customUnitRateID = '', + currentUserLogin = '', + currentUserAccountID = -1, + splitShares: SplitShares = {}, + iouType: ValueOf = CONST.IOU.TYPE.SUBMIT, +) { + // If the report is an iou or expense report, we should get the linked chat report to be passed to the getMoneyRequestInformation function + const isMoneyRequestReport = ReportUtils.isMoneyRequestReport(report); + const currentChatReport = isMoneyRequestReport ? ReportUtils.getReportOrDraftReport(report?.chatReportID) : report; + const moneyRequestReportID = isMoneyRequestReport ? report?.reportID : ''; + + const optimisticReceipt: Receipt = { + source: ReceiptGeneric as ReceiptSource, + state: CONST.IOU.RECEIPT_STATE.OPEN, + }; + + let parameters: CreateDistanceRequestParams; + let onyxData: OnyxData; + if (iouType === CONST.IOU.TYPE.SPLIT) { + const { + splitData, + splits, + onyxData: splitOnyxData, + } = createSplitsAndOnyxData( + participants, + currentUserLogin ?? '', + currentUserAccountID, + amount, + comment, + currency, + merchant, + created, + category ?? '', + tag ?? '', + splitShares, + report?.reportID ?? '', + billable, + CONST.IOU.REQUEST_TYPE.DISTANCE, + taxCode, + taxAmount, + ); + onyxData = splitOnyxData; + + // Splits don't use the IOU report param. The split transaction isn't linked to a report shown in the UI, it's linked to a special default reportID of -2. + // Therefore, any params related to the IOU report are irrelevant and omitted below. + parameters = { + transactionID: splitData.transactionID, + chatReportID: splitData.chatReportID, + createdChatReportActionID: splitData.createdReportActionID ?? '', + reportActionID: splitData.reportActionID, + waypoints: JSON.stringify(validWaypoints), + customUnitRateID, + comment, + created, + category, + tag, + taxCode, + taxAmount, + billable, + splits: JSON.stringify(splits), + chatType: splitData.chatType, + }; + } else { + const participant = participants[0] ?? {}; + const { + iouReport, + chatReport, + transaction, + iouAction, + createdChatReportActionID, + createdIOUReportActionID, + reportPreviewAction, + transactionThreadReportID, + createdReportActionIDForThread, + payerEmail, + onyxData: moneyRequestOnyxData, + } = getMoneyRequestInformation( + currentChatReport, + participant, + comment, + amount, + currency, + created, + merchant, + optimisticReceipt, + undefined, + category, + tag, + taxCode, + taxAmount, + billable, + policy, + policyTagList, + policyCategories, + userAccountID, + currentUserEmail, + moneyRequestReportID, + ); + onyxData = moneyRequestOnyxData; + + parameters = { + comment, + iouReportID: iouReport.reportID, + chatReportID: chatReport.reportID, + transactionID: transaction.transactionID, + reportActionID: iouAction.reportActionID, + createdChatReportActionID, + createdIOUReportActionID, + reportPreviewReportActionID: reportPreviewAction.reportActionID, + waypoints: JSON.stringify(validWaypoints), + created, + category, + tag, + taxCode, + taxAmount, + billable, + transactionThreadReportID, + createdReportActionIDForThread, + payerEmail, + customUnitRateID, + }; + } + + API.write(WRITE_COMMANDS.CREATE_DISTANCE_REQUEST, parameters, onyxData); + const activeReportID = isMoneyRequestReport ? report?.reportID ?? '-1' : parameters.chatReportID; + Navigation.dismissModal(isSearchTopmostCentralPane() ? undefined : activeReportID); + Report.notifyNewAction(activeReportID, userAccountID); +} + +function editRegularMoneyRequest( + transactionID: string, + transactionThreadReportID: string, + transactionChanges: TransactionChanges, + policy: OnyxTypes.Policy, + policyTags: OnyxTypes.PolicyTagLists, + policyCategories: OnyxTypes.PolicyCategories, +) { + const allReports = ReportConnection.getAllReports(); + // STEP 1: Get all collections we're updating + const transactionThread = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`] ?? null; + const transaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + const iouReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThread?.parentReportID}`] ?? null; + const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${iouReport?.chatReportID}`] ?? null; + const isFromExpenseReport = ReportUtils.isExpenseReport(iouReport); + + // STEP 2: Build new modified expense report action. + const updatedReportAction = ReportUtils.buildOptimisticModifiedExpenseReportAction(transactionThread, transaction, transactionChanges, isFromExpenseReport, policy); + const updatedTransaction = transaction ? TransactionUtils.getUpdatedTransaction(transaction, transactionChanges, isFromExpenseReport) : null; + + // STEP 3: Compute the IOU total and update the report preview message so LHN amount owed is correct + // Should only update if the transaction matches the currency of the report, else we wait for the update + // from the server with the currency conversion + let updatedMoneyRequestReport = {...iouReport}; + const updatedChatReport = {...chatReport}; + const diff = TransactionUtils.getAmount(transaction, true) - TransactionUtils.getAmount(updatedTransaction, true); + if (updatedTransaction?.currency === iouReport?.currency && updatedTransaction?.modifiedAmount && diff !== 0) { + if (ReportUtils.isExpenseReport(iouReport) && typeof updatedMoneyRequestReport.total === 'number') { + updatedMoneyRequestReport.total += diff; + } else { + updatedMoneyRequestReport = iouReport + ? IOUUtils.updateIOUOwnerAndTotal(iouReport, updatedReportAction.actorAccountID ?? -1, diff, TransactionUtils.getCurrency(transaction), false) + : {}; + } + + updatedMoneyRequestReport.cachedTotal = CurrencyUtils.convertToDisplayString(updatedMoneyRequestReport.total, updatedTransaction.currency); + + // Update the last message of the IOU report + const lastMessage = ReportUtils.getIOUReportActionMessage( + iouReport?.reportID ?? '-1', + CONST.IOU.REPORT_ACTION_TYPE.CREATE, + updatedMoneyRequestReport.total ?? 0, + '', + updatedTransaction.currency, + '', + false, + ); + updatedMoneyRequestReport.lastMessageText = ReportActionsUtils.getTextFromHtml(lastMessage[0].html); + updatedMoneyRequestReport.lastMessageHtml = lastMessage[0].html; + + // Update the last message of the chat report + const hasNonReimbursableTransactions = ReportUtils.hasNonReimbursableTransactions(iouReport?.reportID); + const messageText = Localize.translateLocal(hasNonReimbursableTransactions ? 'iou.payerSpentAmount' : 'iou.payerOwesAmount', { + payer: ReportUtils.getPersonalDetailsForAccountID(updatedMoneyRequestReport.managerID ?? -1).login ?? '', + amount: CurrencyUtils.convertToDisplayString(updatedMoneyRequestReport.total, updatedMoneyRequestReport.currency), + }); + updatedChatReport.lastMessageText = messageText; + updatedChatReport.lastMessageHtml = messageText; + } + + const isScanning = TransactionUtils.hasReceipt(updatedTransaction) && TransactionUtils.isReceiptBeingScanned(updatedTransaction); + + // STEP 4: Compose the optimistic data + const currentTime = DateUtils.getDBTime(); + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThread?.reportID}`, + value: { + [updatedReportAction.reportActionID]: updatedReportAction as OnyxTypes.ReportAction, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: updatedTransaction, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, + value: updatedMoneyRequestReport, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.chatReportID}`, + value: updatedChatReport, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, + value: { + lastReadTime: currentTime, + lastVisibleActionCreated: currentTime, + }, + }, + ]; + + if (!isScanning) { + optimisticData.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`, + value: { + [transactionThread?.parentReportActionID ?? '-1']: { + originalMessage: { + whisperedTo: [], + }, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.parentReportID}`, + value: { + [iouReport?.parentReportActionID ?? '-1']: { + originalMessage: { + whisperedTo: [], + }, + }, + }, + }, + ); + } + + // Update recently used categories if the category is changed + if ('category' in transactionChanges) { + const optimisticPolicyRecentlyUsedCategories = Category.buildOptimisticPolicyRecentlyUsedCategories(iouReport?.policyID, transactionChanges.category); + if (optimisticPolicyRecentlyUsedCategories.length) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${iouReport?.policyID}`, + value: optimisticPolicyRecentlyUsedCategories, + }); + } + } + + // Update recently used currencies if the currency is changed + if ('currency' in transactionChanges) { + const optimisticRecentlyUsedCurrencies = Policy.buildOptimisticRecentlyUsedCurrencies(transactionChanges.currency); + if (optimisticRecentlyUsedCurrencies.length) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.RECENTLY_USED_CURRENCIES, + value: optimisticRecentlyUsedCurrencies, + }); + } + } + + // Update recently used categories if the tag is changed + if ('tag' in transactionChanges) { + const optimisticPolicyRecentlyUsedTags = Tag.buildOptimisticPolicyRecentlyUsedTags(iouReport?.policyID, transactionChanges.tag); + if (!isEmptyObject(optimisticPolicyRecentlyUsedTags)) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${iouReport?.policyID}`, + value: optimisticPolicyRecentlyUsedTags, + }); + } + } + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThread?.reportID}`, + value: { + [updatedReportAction.reportActionID]: {pendingAction: null}, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + pendingFields: { + comment: null, + amount: null, + created: null, + currency: null, + merchant: null, + billable: null, + category: null, + tag: null, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, + value: {pendingAction: null}, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThread?.reportID}`, + value: { + [updatedReportAction.reportActionID]: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericEditFailureMessage'), + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + ...transaction, + modifiedCreated: transaction?.modifiedCreated ? transaction.modifiedCreated : null, + modifiedAmount: transaction?.modifiedAmount ? transaction.modifiedAmount : null, + modifiedCurrency: transaction?.modifiedCurrency ? transaction.modifiedCurrency : null, + modifiedMerchant: transaction?.modifiedMerchant ? transaction.modifiedMerchant : null, + modifiedWaypoints: transaction?.modifiedWaypoints ? transaction.modifiedWaypoints : null, + pendingFields: null, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, + value: { + ...iouReport, + cachedTotal: iouReport?.cachedTotal ? iouReport?.cachedTotal : null, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.chatReportID}`, + value: chatReport, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, + value: { + lastReadTime: transactionThread?.lastReadTime, + lastVisibleActionCreated: transactionThread?.lastVisibleActionCreated, + }, + }, + ]; + + // Add transaction violations if we have a paid policy and an updated transaction + if (policy && PolicyUtils.isPaidGroupPolicy(policy) && updatedTransaction) { + const currentTransactionViolations = allTransactionViolations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`] ?? []; + const updatedViolationsOnyxData = ViolationsUtils.getViolationsOnyxData( + updatedTransaction, + currentTransactionViolations, + policy, + policyTags, + policyCategories, + PolicyUtils.hasDependentTags(policy, policyTags), + ); + optimisticData.push(updatedViolationsOnyxData); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, + value: currentTransactionViolations, + }); + } + + // STEP 6: Call the API endpoint + const {created, amount, currency, comment, merchant, category, billable, tag} = ReportUtils.getTransactionDetails(updatedTransaction) ?? {}; + + const parameters: EditMoneyRequestParams = { + transactionID, + reportActionID: updatedReportAction.reportActionID, + created, + amount, + currency, + comment, + merchant, + category, + billable, + tag, + }; + + API.write(WRITE_COMMANDS.EDIT_MONEY_REQUEST, parameters, {optimisticData, successData, failureData}); +} + +function editMoneyRequest( + transaction: OnyxTypes.Transaction, + transactionThreadReportID: string, + transactionChanges: TransactionChanges, + policy: OnyxTypes.Policy, + policyTags: OnyxTypes.PolicyTagLists, + policyCategories: OnyxTypes.PolicyCategories, +) { + if (TransactionUtils.isDistanceRequest(transaction)) { + updateDistanceRequest(transaction.transactionID, transactionThreadReportID, transactionChanges, policy, policyTags, policyCategories); + } else { + editRegularMoneyRequest(transaction.transactionID, transactionThreadReportID, transactionChanges, policy, policyTags, policyCategories); + } +} + +type UpdateMoneyRequestAmountAndCurrencyParams = { + transactionID: string; + transactionThreadReportID: string; + currency: string; + amount: number; + taxAmount: number; + policy?: OnyxEntry; + policyTagList?: OnyxEntry; + policyCategories?: OnyxEntry; + taxCode: string; +}; + +/** Updates the amount and currency fields of an expense */ +function updateMoneyRequestAmountAndCurrency({ + transactionID, + transactionThreadReportID, + currency, + amount, + taxAmount, + policy, + policyTagList, + policyCategories, + taxCode, +}: UpdateMoneyRequestAmountAndCurrencyParams) { + const transactionChanges = { + amount, + currency, + taxCode, + taxAmount, + }; + const allReports = ReportConnection.getAllReports(); + const transactionThreadReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`] ?? null; + const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport?.parentReportID}`] ?? null; + let data: UpdateMoneyRequestData; + if (ReportUtils.isTrackExpenseReport(transactionThreadReport) && ReportUtils.isSelfDM(parentReport)) { + data = getUpdateTrackExpenseParams(transactionID, transactionThreadReportID, transactionChanges, true, policy ?? null); + } else { + data = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy ?? null, policyTagList ?? null, policyCategories ?? null, true); + } + const {params, onyxData} = data; + API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_AMOUNT_AND_CURRENCY, params, onyxData); +} + +/** + * + * @param transactionID - The transactionID of IOU + * @param reportAction - The reportAction of the transaction in the IOU report + * @param isSingleTransactionView - whether we are in the transaction thread report + * @return the url to navigate back once the money request is deleted + */ +function prepareToCleanUpMoneyRequest(transactionID: string, reportAction: OnyxTypes.ReportAction, isSingleTransactionView = false) { + // STEP 1: Get all collections we're updating + const allReports = ReportConnection.getAllReports(); + const iouReportID = ReportActionsUtils.isMoneyRequestAction(reportAction) ? ReportActionsUtils.getOriginalMessage(reportAction)?.IOUReportID : '-1'; + const iouReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`] ?? null; + const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${iouReport?.chatReportID}`]; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const reportPreviewAction = getReportPreviewAction(iouReport?.chatReportID ?? '-1', iouReport?.reportID ?? '-1')!; + const transaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + const transactionViolations = allTransactionViolations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`]; + const transactionThreadID = reportAction.childReportID; + let transactionThread = null; + if (transactionThreadID) { + transactionThread = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadID}`] ?? null; + } + + // STEP 2: Decide if we need to: + // 1. Delete the transactionThread - delete if there are no visible comments in the thread + // 2. Update the moneyRequestPreview to show [Deleted expense] - update if the transactionThread exists AND it isn't being deleted + const shouldDeleteTransactionThread = transactionThreadID ? (reportAction?.childVisibleActionCount ?? 0) === 0 : false; + const shouldShowDeletedRequestMessage = !!transactionThreadID && !shouldDeleteTransactionThread; + + // STEP 3: Update the IOU reportAction and decide if the iouReport should be deleted. We delete the iouReport if there are no visible comments left in the report. + const updatedReportAction = { + [reportAction.reportActionID]: { + pendingAction: shouldShowDeletedRequestMessage ? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE : CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + previousMessage: reportAction.message, + message: [ + { + type: 'COMMENT', + html: '', + text: '', + isEdited: true, + isDeletedParentAction: shouldShowDeletedRequestMessage, + }, + ], + originalMessage: { + IOUTransactionID: null, + }, + errors: null, + }, + } as Record>; + + const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(iouReport?.reportID ?? '-1', updatedReportAction); + const iouReportLastMessageText = ReportActionsUtils.getLastVisibleMessage(iouReport?.reportID ?? '-1', updatedReportAction).lastMessageText; + const shouldDeleteIOUReport = + iouReportLastMessageText.length === 0 && !ReportActionsUtils.isDeletedParentAction(lastVisibleAction) && (!transactionThreadID || shouldDeleteTransactionThread); + + // STEP 4: Update the iouReport and reportPreview with new totals and messages if it wasn't deleted + let updatedIOUReport: OnyxInputValue; + const currency = TransactionUtils.getCurrency(transaction); + const updatedReportPreviewAction: OnyxTypes.ReportAction = {...reportPreviewAction}; + updatedReportPreviewAction.pendingAction = shouldDeleteIOUReport ? CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE : CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE; + if (iouReport && ReportUtils.isExpenseReport(iouReport)) { + updatedIOUReport = {...iouReport}; + + if (typeof updatedIOUReport.total === 'number' && currency === iouReport?.currency) { + // Because of the Expense reports are stored as negative values, we add the total from the amount + const amountDiff = TransactionUtils.getAmount(transaction, true); + updatedIOUReport.total += amountDiff; + + if (!transaction?.reimbursable && typeof updatedIOUReport.nonReimbursableTotal === 'number') { + updatedIOUReport.nonReimbursableTotal += amountDiff; + } + } + } else { + updatedIOUReport = IOUUtils.updateIOUOwnerAndTotal(iouReport, reportAction.actorAccountID ?? -1, TransactionUtils.getAmount(transaction, false), currency, true); + } + + if (updatedIOUReport) { + updatedIOUReport.lastMessageText = iouReportLastMessageText; + updatedIOUReport.lastVisibleActionCreated = lastVisibleAction?.created; + } + + const hasNonReimbursableTransactions = ReportUtils.hasNonReimbursableTransactions(iouReport?.reportID); + const messageText = Localize.translateLocal(hasNonReimbursableTransactions ? 'iou.payerSpentAmount' : 'iou.payerOwesAmount', { + payer: ReportUtils.getPersonalDetailsForAccountID(updatedIOUReport?.managerID ?? -1).login ?? '', + amount: CurrencyUtils.convertToDisplayString(updatedIOUReport?.total, updatedIOUReport?.currency), + }); + + if (ReportActionsUtils.getReportActionMessage(updatedReportPreviewAction)) { + if (Array.isArray(updatedReportPreviewAction?.message) && updatedReportPreviewAction.message?.[0]) { + updatedReportPreviewAction.message[0].text = messageText; + updatedReportPreviewAction.message[0].deleted = shouldDeleteIOUReport ? DateUtils.getDBTime() : ''; + } else if (!Array.isArray(updatedReportPreviewAction.message) && updatedReportPreviewAction.message) { + updatedReportPreviewAction.message.text = messageText; + updatedReportPreviewAction.message.deleted = shouldDeleteIOUReport ? DateUtils.getDBTime() : ''; + } + } + + if (updatedReportPreviewAction && reportPreviewAction?.childMoneyRequestCount && reportPreviewAction?.childMoneyRequestCount > 0) { + updatedReportPreviewAction.childMoneyRequestCount = reportPreviewAction.childMoneyRequestCount - 1; + } + + // STEP 5: Calculate the url that the user will be navigated back to + // This depends on which page they are on and which resources were deleted + let reportIDToNavigateBack: string | undefined; + if (iouReport && isSingleTransactionView && shouldDeleteTransactionThread && !shouldDeleteIOUReport) { + reportIDToNavigateBack = iouReport.reportID; + } + + if (iouReport?.chatReportID && shouldDeleteIOUReport) { + reportIDToNavigateBack = iouReport.chatReportID; + } + + const urlToNavigateBack = reportIDToNavigateBack ? ROUTES.REPORT_WITH_ID.getRoute(reportIDToNavigateBack) : undefined; + + return { + shouldDeleteTransactionThread, + shouldDeleteIOUReport, + updatedReportAction, + updatedIOUReport, + updatedReportPreviewAction, + transactionThreadID, + transactionThread, + chatReport, + transaction, + transactionViolations, + reportPreviewAction, + iouReport, + urlToNavigateBack, + }; +} + +/** + * + * @param transactionID - The transactionID of IOU + * @param reportAction - The reportAction of the transaction in the IOU report + * @param isSingleTransactionView - whether we are in the transaction thread report + * @return the url to navigate back once the money request is deleted + */ +function cleanUpMoneyRequest(transactionID: string, reportAction: OnyxTypes.ReportAction, isSingleTransactionView = false) { + const { + shouldDeleteTransactionThread, + shouldDeleteIOUReport, + updatedReportAction, + updatedIOUReport, + updatedReportPreviewAction, + transactionThreadID, + chatReport, + iouReport, + reportPreviewAction, + urlToNavigateBack, + } = prepareToCleanUpMoneyRequest(transactionID, reportAction, isSingleTransactionView); + + // build Onyx data + + // Onyx operations to delete the transaction, update the IOU report action and chat report action + const onyxUpdates: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: null, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`, + value: { + [reportAction.reportActionID]: shouldDeleteIOUReport + ? null + : { + pendingAction: null, + }, + }, + }, + ]; + + if (reportPreviewAction?.reportActionID) { + onyxUpdates.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: { + [reportPreviewAction.reportActionID]: { + ...updatedReportPreviewAction, + pendingAction: null, + errors: null, + }, + }, + }); + } + + // added the operation to delete associated transaction violations + onyxUpdates.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, + value: null, + }); + + // added the operation to delete transaction thread + if (shouldDeleteTransactionThread) { + onyxUpdates.push( + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadID}`, + value: null, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadID}`, + value: null, + }, + ); + } + + // added operations to update IOU report and chat report + onyxUpdates.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`, + value: updatedReportAction, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, + value: updatedIOUReport, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport?.reportID}`, + value: ReportUtils.getOutstandingChildRequest(updatedIOUReport), + }, + ); + + if (!shouldDeleteIOUReport && updatedReportPreviewAction.childMoneyRequestCount === 0) { + onyxUpdates.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport?.reportID}`, + value: { + hasOutstandingChildRequest: false, + }, + }); + } + + if (shouldDeleteIOUReport) { + onyxUpdates.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport?.reportID}`, + value: { + hasOutstandingChildRequest: false, + iouReportID: null, + lastMessageText: ReportActionsUtils.getLastVisibleMessage(iouReport?.chatReportID ?? '-1', {[reportPreviewAction?.reportActionID ?? '-1']: null})?.lastMessageText, + lastVisibleActionCreated: ReportActionsUtils.getLastVisibleAction(iouReport?.chatReportID ?? '-1', {[reportPreviewAction?.reportActionID ?? '-1']: null})?.created, + }, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, + value: null, + }, + ); + } + + Onyx.update(onyxUpdates); + + return urlToNavigateBack; +} + +/** + * + * @param transactionID - The transactionID of IOU + * @param reportAction - The reportAction of the transaction in the IOU report + * @param isSingleTransactionView - whether we are in the transaction thread report + * @return the url to navigate back once the money request is deleted + */ +function deleteMoneyRequest(transactionID: string, reportAction: OnyxTypes.ReportAction, isSingleTransactionView = false) { + // STEP 1: Calculate and prepare the data + const { + shouldDeleteTransactionThread, + shouldDeleteIOUReport, + updatedReportAction, + updatedIOUReport, + updatedReportPreviewAction, + transactionThreadID, + transactionThread, + chatReport, + transaction, + transactionViolations, + iouReport, + reportPreviewAction, + urlToNavigateBack, + } = prepareToCleanUpMoneyRequest(transactionID, reportAction, isSingleTransactionView); + + // STEP 2: Build Onyx data + // The logic mostly resembles the cleanUpMoneyRequest function + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: null, + }, + ]; + + optimisticData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, + value: null, + }); + + if (shouldDeleteTransactionThread) { + optimisticData.push( + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadID}`, + value: null, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadID}`, + value: null, + }, + ); + } + + optimisticData.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`, + value: updatedReportAction, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, + value: updatedIOUReport, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: { + [reportPreviewAction?.reportActionID ?? '-1']: updatedReportPreviewAction, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport?.reportID}`, + value: ReportUtils.getOutstandingChildRequest(updatedIOUReport), + }, + ); + + if (!shouldDeleteIOUReport && updatedReportPreviewAction?.childMoneyRequestCount === 0) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport?.reportID}`, + value: { + hasOutstandingChildRequest: false, + }, + }); + } + + if (shouldDeleteIOUReport) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport?.reportID}`, + value: { + hasOutstandingChildRequest: false, + iouReportID: null, + lastMessageText: ReportActionsUtils.getLastVisibleMessage(iouReport?.chatReportID ?? '-1', {[reportPreviewAction?.reportActionID ?? '-1']: null})?.lastMessageText, + lastVisibleActionCreated: ReportActionsUtils.getLastVisibleAction(iouReport?.chatReportID ?? '-1', {[reportPreviewAction?.reportActionID ?? '-1']: null})?.created, + }, + }); + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, + value: { + pendingFields: { + preview: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + }, + }, + }); + } + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`, + value: { + [reportAction.reportActionID]: shouldDeleteIOUReport + ? null + : { + pendingAction: null, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: { + [reportPreviewAction?.reportActionID ?? '-1']: { + pendingAction: null, + errors: null, + }, + }, + }, + ]; + + if (shouldDeleteIOUReport) { + successData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, + value: null, + }); + } + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: transaction ?? null, + }, + ]; + + failureData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, + value: transactionViolations ?? null, + }); + + if (shouldDeleteTransactionThread) { + failureData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadID}`, + value: transactionThread, + }); + } + + const errorKey = DateUtils.getMicroseconds(); + + failureData.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`, + value: { + [reportAction.reportActionID]: { + ...reportAction, + pendingAction: null, + errors: { + [errorKey]: Localize.translateLocal('iou.error.genericDeleteFailureMessage'), + }, + }, + }, + }, + shouldDeleteIOUReport + ? { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, + value: iouReport, + } + : { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, + value: iouReport, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: { + [reportPreviewAction?.reportActionID ?? '-1']: { + ...reportPreviewAction, + pendingAction: null, + errors: { + [errorKey]: Localize.translateLocal('iou.error.genericDeleteFailureMessage'), + }, + }, + }, + }, + ); + + if (chatReport && shouldDeleteIOUReport) { + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, + value: chatReport, + }); + } + + if (!shouldDeleteIOUReport && updatedReportPreviewAction?.childMoneyRequestCount === 0) { + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport?.reportID}`, + value: { + hasOutstandingChildRequest: true, + }, + }); + } + + const parameters: DeleteMoneyRequestParams = { + transactionID, + reportActionID: reportAction.reportActionID, + }; + + // STEP 3: Make the API request + API.write(WRITE_COMMANDS.DELETE_MONEY_REQUEST, parameters, {optimisticData, successData, failureData}); + CachedPDFPaths.clearByKey(transactionID); + + return urlToNavigateBack; +} + +function deleteTrackExpense(chatReportID: string, transactionID: string, reportAction: OnyxTypes.ReportAction, isSingleTransactionView = false) { + // STEP 1: Get all collections we're updating + const chatReport = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`] ?? null; + if (!ReportUtils.isSelfDM(chatReport)) { + return deleteMoneyRequest(transactionID, reportAction, isSingleTransactionView); + } + + const whisperAction = ReportActionsUtils.getTrackExpenseActionableWhisper(transactionID, chatReportID); + const actionableWhisperReportActionID = whisperAction?.reportActionID; + const {parameters, optimisticData, successData, failureData, shouldDeleteTransactionThread} = getDeleteTrackExpenseInformation( + chatReportID, + transactionID, + reportAction, + undefined, + undefined, + actionableWhisperReportActionID, + CONST.REPORT.ACTIONABLE_TRACK_EXPENSE_WHISPER_RESOLUTION.NOTHING, + ); + + // STEP 6: Make the API request + API.write(WRITE_COMMANDS.DELETE_MONEY_REQUEST, parameters, {optimisticData, successData, failureData}); + CachedPDFPaths.clearByKey(transactionID); + + // STEP 7: Navigate the user depending on which page they are on and which resources were deleted + if (isSingleTransactionView && shouldDeleteTransactionThread) { + // Pop the deleted report screen before navigating. This prevents navigating to the Concierge chat due to the missing report. + return ROUTES.REPORT_WITH_ID.getRoute(chatReport?.reportID ?? '-1'); + } +} + +/** + * @param managerID - Account ID of the person sending the money + * @param recipient - The user receiving the money + */ +function getSendMoneyParams( + report: OnyxEntry, + amount: number, + currency: string, + comment: string, + paymentMethodType: PaymentMethodType, + managerID: number, + recipient: Participant, +): SendMoneyParamsData { + const recipientEmail = PhoneNumber.addSMSDomainIfPhoneNumber(recipient.login ?? ''); + const recipientAccountID = Number(recipient.accountID); + const newIOUReportDetails = JSON.stringify({ + amount, + currency, + requestorEmail: recipientEmail, + requestorAccountID: recipientAccountID, + comment, + idempotencyKey: Str.guid(), + }); + + let chatReport = !isEmptyObject(report) && report?.reportID ? report : ReportUtils.getChatByParticipants([recipientAccountID, managerID]); + let isNewChat = false; + if (!chatReport) { + chatReport = ReportUtils.buildOptimisticChatReport([recipientAccountID, managerID]); + isNewChat = true; + } + const optimisticIOUReport = ReportUtils.buildOptimisticIOUReport(recipientAccountID, managerID, amount, chatReport.reportID, currency, true); + + const optimisticTransaction = TransactionUtils.buildOptimisticTransaction(amount, currency, optimisticIOUReport.reportID, comment); + const optimisticTransactionData: OnyxUpdate = { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${optimisticTransaction.transactionID}`, + value: optimisticTransaction, + }; + + const [optimisticCreatedActionForChat, optimisticCreatedActionForIOUReport, optimisticIOUReportAction, optimisticTransactionThread, optimisticCreatedActionForTransactionThread] = + ReportUtils.buildOptimisticMoneyRequestEntities( + optimisticIOUReport, + CONST.IOU.REPORT_ACTION_TYPE.PAY, + amount, + currency, + comment, + recipientEmail, + [recipient], + optimisticTransaction.transactionID, + paymentMethodType, + false, + true, + ); + + const reportPreviewAction = ReportUtils.buildOptimisticReportPreview(chatReport, optimisticIOUReport); + + // Change the method to set for new reports because it doesn't exist yet, is faster, + // and we need the data to be available when we navigate to the chat page + const optimisticChatReportData: OnyxUpdate = isNewChat + ? { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, + value: { + ...chatReport, + // Set and clear pending fields on the chat report + pendingFields: {createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}, + lastReadTime: DateUtils.getDBTime(), + lastVisibleActionCreated: reportPreviewAction.created, + }, + } + : { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, + value: { + ...chatReport, + lastReadTime: DateUtils.getDBTime(), + lastVisibleActionCreated: reportPreviewAction.created, + }, + }; + const optimisticQuickActionData: OnyxUpdate = { + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE, + value: { + action: CONST.QUICK_ACTIONS.SEND_MONEY, + chatReportID: chatReport.reportID, + isFirstQuickAction: isEmptyObject(quickAction), + }, + }; + const optimisticIOUReportData: OnyxUpdate = { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticIOUReport.reportID}`, + value: { + ...optimisticIOUReport, + lastMessageText: ReportActionsUtils.getReportActionText(optimisticIOUReportAction), + lastMessageHtml: ReportActionsUtils.getReportActionHtml(optimisticIOUReportAction), + }, + }; + const optimisticTransactionThreadData: OnyxUpdate = { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticTransactionThread.reportID}`, + value: optimisticTransactionThread, + }; + const optimisticIOUReportActionsData: OnyxUpdate = { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticIOUReport.reportID}`, + value: { + [optimisticCreatedActionForIOUReport.reportActionID]: optimisticCreatedActionForIOUReport, + [optimisticIOUReportAction.reportActionID]: { + ...(optimisticIOUReportAction as OnyxTypes.ReportAction), + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + }, + }; + const optimisticChatReportActionsData: OnyxUpdate = { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`, + value: { + [reportPreviewAction.reportActionID]: reportPreviewAction, + }, + }; + const optimisticTransactionThreadReportActionsData: OnyxUpdate = { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticTransactionThread.reportID}`, + value: { + [optimisticCreatedActionForTransactionThread?.reportActionID ?? '-1']: optimisticCreatedActionForTransactionThread, + }, + }; + + const successData: OnyxUpdate[] = []; + + // Add optimistic personal details for recipient + let optimisticPersonalDetailListData: OnyxUpdate | null = null; + const optimisticPersonalDetailListAction = isNewChat + ? { + [recipientAccountID]: { + accountID: recipientAccountID, + // Disabling this line since participant.displayName can be an empty string + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + displayName: recipient.displayName || recipient.login, + login: recipient.login, + }, + } + : {}; + + const redundantParticipants: Record = {}; + if (!isEmptyObject(optimisticPersonalDetailListAction)) { + const successPersonalDetailListAction: Record = {}; + + // BE will send different participants. We clear the optimistic ones to avoid duplicated entries + Object.keys(optimisticPersonalDetailListAction).forEach((accountIDKey) => { + const accountID = Number(accountIDKey); + successPersonalDetailListAction[accountID] = null; + redundantParticipants[accountID] = null; + }); + + optimisticPersonalDetailListData = { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + value: optimisticPersonalDetailListAction, + }; + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + value: successPersonalDetailListAction, + }); + } + + successData.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticIOUReport.reportID}`, + value: { + participants: redundantParticipants, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticTransactionThread.reportID}`, + value: { + participants: redundantParticipants, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticIOUReport.reportID}`, + value: { + [optimisticIOUReportAction.reportActionID]: { + pendingAction: null, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${optimisticTransaction.transactionID}`, + value: {pendingAction: null}, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`, + value: { + [reportPreviewAction.reportActionID]: { + pendingAction: null, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticTransactionThread.reportID}`, + value: { + [optimisticCreatedActionForTransactionThread?.reportActionID ?? '-1']: { + pendingAction: null, + }, + }, + }, + ); + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${optimisticTransaction.transactionID}`, + value: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.other'), + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticTransactionThread.reportID}`, + value: { + errorFields: { + createChat: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('report.genericCreateReportFailureMessage'), + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticTransactionThread.reportID}`, + value: { + [optimisticCreatedActionForTransactionThread?.reportActionID ?? '-1']: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericCreateFailureMessage'), + }, + }, + }, + ]; + + // Now, let's add the data we need just when we are creating a new chat report + if (isNewChat) { + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, + value: {pendingFields: null, participants: redundantParticipants}, + }); + failureData.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, + value: { + errorFields: { + createChat: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('report.genericCreateReportFailureMessage'), + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticIOUReport.reportID}`, + value: { + [optimisticIOUReportAction.reportActionID]: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericCreateFailureMessage'), + }, + }, + }, + ); + + if (optimisticChatReportActionsData.value) { + // Add an optimistic created action to the optimistic chat reportActions data + optimisticChatReportActionsData.value[optimisticCreatedActionForChat.reportActionID] = optimisticCreatedActionForChat; + } + } else { + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticIOUReport.reportID}`, + value: { + [optimisticIOUReportAction.reportActionID]: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.other'), + }, + }, + }); + } + + const optimisticData: OnyxUpdate[] = [ + optimisticChatReportData, + optimisticQuickActionData, + optimisticIOUReportData, + optimisticChatReportActionsData, + optimisticIOUReportActionsData, + optimisticTransactionData, + optimisticTransactionThreadData, + optimisticTransactionThreadReportActionsData, + ]; + + if (!isEmptyObject(optimisticPersonalDetailListData)) { + optimisticData.push(optimisticPersonalDetailListData); + } + + return { + params: { + iouReportID: optimisticIOUReport.reportID, + chatReportID: chatReport.reportID, + reportActionID: optimisticIOUReportAction.reportActionID, + paymentMethodType, + transactionID: optimisticTransaction.transactionID, + newIOUReportDetails, + createdReportActionID: isNewChat ? optimisticCreatedActionForChat.reportActionID : '-1', + reportPreviewReportActionID: reportPreviewAction.reportActionID, + createdIOUReportActionID: optimisticCreatedActionForIOUReport.reportActionID, + transactionThreadReportID: optimisticTransactionThread.reportID, + createdReportActionIDForThread: optimisticCreatedActionForTransactionThread?.reportActionID ?? '-1', + }, + optimisticData, + successData, + failureData, + }; +} + +type OptimisticHoldReportExpenseActionID = { + optimisticReportActionID: string; + oldReportActionID: string; +}; + +function getHoldReportActionsAndTransactions(reportID: string) { + const iouReportActions = ReportActionsUtils.getAllReportActions(reportID); + const holdReportActions: Array> = []; + const holdTransactions: OnyxTypes.Transaction[] = []; + + Object.values(iouReportActions).forEach((action) => { + const transactionID = ReportActionsUtils.isMoneyRequestAction(action) ? ReportActionsUtils.getOriginalMessage(action)?.IOUTransactionID ?? null : null; + const transaction = getTransaction(transactionID ?? '-1'); + + if (transaction?.comment?.hold) { + holdReportActions.push(action as OnyxTypes.ReportAction); + holdTransactions.push(transaction); + } + }); + + return {holdReportActions, holdTransactions}; +} + +function getReportFromHoldRequestsOnyxData( + chatReport: OnyxTypes.Report, + iouReport: OnyxTypes.Report, + recipient: Participant, +): { + optimisticHoldReportID: string; + optimisticHoldActionID: string; + optimisticHoldReportExpenseActionIDs: OptimisticHoldReportExpenseActionID[]; + optimisticData: OnyxUpdate[]; + failureData: OnyxUpdate[]; +} { + const {holdReportActions, holdTransactions} = getHoldReportActionsAndTransactions(iouReport.reportID); + const firstHoldTransaction = holdTransactions[0]; + const newParentReportActionID = rand64(); + + const optimisticExpenseReport = ReportUtils.buildOptimisticExpenseReport( + chatReport.reportID, + chatReport.policyID ?? iouReport.policyID ?? '', + recipient.accountID ?? 1, + holdTransactions.reduce((acc, transaction) => acc + transaction.amount, 0) * -1, + getCurrency(firstHoldTransaction), + false, + newParentReportActionID, + ); + const optimisticExpenseReportPreview = ReportUtils.buildOptimisticReportPreview( + chatReport, + optimisticExpenseReport, + '', + firstHoldTransaction, + optimisticExpenseReport.reportID, + newParentReportActionID, + ); + + const updateHeldReports: Record> = {}; + const addHoldReportActions: OnyxTypes.ReportActions = {}; + const deleteHoldReportActions: Record> = {}; + const optimisticHoldReportExpenseActionIDs: OptimisticHoldReportExpenseActionID[] = []; + + holdReportActions.forEach((holdReportAction) => { + const originalMessage = ReportActionsUtils.getOriginalMessage(holdReportAction); + + deleteHoldReportActions[holdReportAction.reportActionID] = { + message: [ + { + deleted: DateUtils.getDBTime(), + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + text: '', + }, + ], + }; + + const reportActionID = rand64(); + addHoldReportActions[reportActionID] = { + ...holdReportAction, + reportActionID, + originalMessage: { + ...originalMessage, + IOUReportID: optimisticExpenseReport.reportID, + }, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }; + + const heldReport = ReportUtils.getReportOrDraftReport(holdReportAction.childReportID); + if (heldReport) { + optimisticHoldReportExpenseActionIDs.push({optimisticReportActionID: reportActionID, oldReportActionID: holdReportAction.reportActionID}); + + updateHeldReports[`${ONYXKEYS.COLLECTION.REPORT}${heldReport.reportID}`] = { + parentReportActionID: reportActionID, + parentReportID: optimisticExpenseReport.reportID, + chatReportID: optimisticExpenseReport.reportID, + }; + } + }); + + const updateHeldTransactions: Record> = {}; + holdTransactions.forEach((transaction) => { + updateHeldTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`] = { + reportID: optimisticExpenseReport.reportID, + }; + }); + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, + value: { + iouReportID: optimisticExpenseReport.reportID, + }, + }, + // add new optimistic expense report + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticExpenseReport.reportID}`, + value: optimisticExpenseReport, + }, + // add preview report action to main chat + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`, + value: { + [optimisticExpenseReportPreview.reportActionID]: optimisticExpenseReportPreview, + }, + }, + // remove hold report actions from old iou report + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, + value: deleteHoldReportActions, + }, + // add hold report actions to new iou report + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticExpenseReport.reportID}`, + value: addHoldReportActions, + }, + // update held reports with new parentReportActionID + { + onyxMethod: Onyx.METHOD.MERGE_COLLECTION, + key: `${ONYXKEYS.COLLECTION.REPORT}`, + value: updateHeldReports, + }, + // update transactions with new iouReportID + { + onyxMethod: Onyx.METHOD.MERGE_COLLECTION, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}`, + value: updateHeldTransactions, + }, + ]; + + const bringReportActionsBack: Record = {}; + holdReportActions.forEach((reportAction) => { + bringReportActionsBack[reportAction.reportActionID] = reportAction; + }); + + const bringHeldTransactionsBack: Record = {}; + holdTransactions.forEach((transaction) => { + bringHeldTransactionsBack[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`] = transaction; + }); + + const failureData: OnyxUpdate[] = [ + // remove added optimistic expense report + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticExpenseReport.reportID}`, + value: null, + }, + // remove preview report action from the main chat + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`, + value: { + [optimisticExpenseReportPreview.reportActionID]: null, + }, + }, + // add hold report actions back to old iou report + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, + value: bringReportActionsBack, + }, + // remove hold report actions from the new iou report + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticExpenseReport.reportID}`, + value: null, + }, + // add hold transactions back to old iou report + { + onyxMethod: Onyx.METHOD.MERGE_COLLECTION, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}`, + value: bringHeldTransactionsBack, + }, + ]; + + return { + optimisticData, + optimisticHoldActionID: optimisticExpenseReportPreview.reportActionID, + failureData, + optimisticHoldReportID: optimisticExpenseReport.reportID, + optimisticHoldReportExpenseActionIDs, + }; +} + +function getPayMoneyRequestParams( + initialChatReport: OnyxTypes.Report, + iouReport: OnyxTypes.Report, + recipient: Participant, + paymentMethodType: PaymentMethodType, + full: boolean, + payAsBusiness?: boolean, +): PayMoneyRequestData { + const isInvoiceReport = ReportUtils.isInvoiceReport(iouReport); + const activePolicy = PolicyUtils.getPolicy(activePolicyID); + let payerPolicyID = activePolicyID; + let chatReport = initialChatReport; + let policyParams = {}; + const optimisticData: OnyxUpdate[] = []; + const successData: OnyxUpdate[] = []; + const failureData: OnyxUpdate[] = []; + const shouldCreatePolicy = !activePolicy || !PolicyUtils.isPolicyAdmin(activePolicy) || !PolicyUtils.isPaidGroupPolicy(activePolicy); + + if (ReportUtils.isIndividualInvoiceRoom(chatReport) && payAsBusiness && shouldCreatePolicy) { + payerPolicyID = Policy.generatePolicyID(); + const { + optimisticData: policyOptimisticData, + failureData: policyFailureData, + successData: policySuccessData, + params, + } = Policy.buildPolicyData(currentUserEmail, true, undefined, payerPolicyID); + const { + announceChatReportID, + announceCreatedReportActionID, + adminsChatReportID, + adminsCreatedReportActionID, + expenseChatReportID, + expenseCreatedReportActionID, + customUnitRateID, + customUnitID, + ownerEmail, + policyName, + } = params; + + policyParams = { + policyID: payerPolicyID, + announceChatReportID, + announceCreatedReportActionID, + adminsChatReportID, + adminsCreatedReportActionID, + expenseChatReportID, + expenseCreatedReportActionID, + customUnitRateID, + customUnitID, + ownerEmail, + policyName, + }; + + optimisticData.push(...policyOptimisticData, {onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.NVP_ACTIVE_POLICY_ID, value: payerPolicyID}); + successData.push(...policySuccessData); + failureData.push(...policyFailureData, {onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.NVP_ACTIVE_POLICY_ID, value: activePolicyID ?? null}); + } + + if (ReportUtils.isIndividualInvoiceRoom(chatReport) && payAsBusiness && activePolicyID) { + const existingB2BInvoiceRoom = ReportUtils.getInvoiceChatByParticipants(chatReport.policyID ?? '', activePolicyID); + if (existingB2BInvoiceRoom) { + chatReport = existingB2BInvoiceRoom; + } + } + + let total = (iouReport.total ?? 0) - (iouReport.nonReimbursableTotal ?? 0); + if (ReportUtils.hasHeldExpenses(iouReport.reportID) && !full && !!iouReport.unheldTotal) { + total = iouReport.unheldTotal; + } + + const optimisticIOUReportAction = ReportUtils.buildOptimisticIOUReportAction( + CONST.IOU.REPORT_ACTION_TYPE.PAY, + ReportUtils.isExpenseReport(iouReport) ? -total : total, + iouReport.currency ?? '', + '', + [recipient], + '', + paymentMethodType, + iouReport.reportID, + true, + ); + + // In some instances, the report preview action might not be available to the payer (only whispered to the requestor) + // hence we need to make the updates to the action safely. + let optimisticReportPreviewAction = null; + const reportPreviewAction = getReportPreviewAction(chatReport.reportID, iouReport.reportID); + if (reportPreviewAction) { + optimisticReportPreviewAction = ReportUtils.updateReportPreview(iouReport, reportPreviewAction, true); + } + let currentNextStep = null; + let optimisticNextStep = null; + if (!isInvoiceReport) { + currentNextStep = allNextSteps[`${ONYXKEYS.COLLECTION.NEXT_STEP}${iouReport.reportID}`] ?? null; + optimisticNextStep = NextStepUtils.buildNextStep(iouReport, CONST.REPORT.STATUS_NUM.REIMBURSED); + } + + const optimisticChatReport = { + ...chatReport, + lastReadTime: DateUtils.getDBTime(), + lastVisibleActionCreated: optimisticIOUReportAction.created, + hasOutstandingChildRequest: false, + iouReportID: null, + lastMessageText: ReportActionsUtils.getReportActionText(optimisticIOUReportAction), + lastMessageHtml: ReportActionsUtils.getReportActionHtml(optimisticIOUReportAction), + }; + if (ReportUtils.isIndividualInvoiceRoom(chatReport) && payAsBusiness && payerPolicyID) { + optimisticChatReport.invoiceReceiver = { + type: CONST.REPORT.INVOICE_RECEIVER_TYPE.BUSINESS, + policyID: payerPolicyID, + }; + } + + optimisticData.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, + value: optimisticChatReport, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, + value: { + [optimisticIOUReportAction.reportActionID]: { + ...(optimisticIOUReportAction as OnyxTypes.ReportAction), + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, + value: { + ...iouReport, + lastMessageText: ReportActionsUtils.getReportActionText(optimisticIOUReportAction), + lastMessageHtml: ReportActionsUtils.getReportActionHtml(optimisticIOUReportAction), + hasOutstandingChildRequest: false, + statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED, + pendingFields: { + preview: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + reimbursed: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + partial: full ? null : CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.NVP_LAST_PAYMENT_METHOD, + value: {[iouReport.policyID ?? '-1']: paymentMethodType}, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${iouReport.reportID}`, + value: optimisticNextStep, + }, + ); + + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, + value: { + pendingFields: { + preview: null, + reimbursed: null, + partial: null, + }, + }, + }); + + failureData.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, + value: { + [optimisticIOUReportAction.reportActionID]: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.other'), + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, + value: { + ...iouReport, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, + value: chatReport, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${iouReport.reportID}`, + value: currentNextStep, + }, + ); + + // In case the report preview action is loaded locally, let's update it. + if (optimisticReportPreviewAction) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`, + value: { + [optimisticReportPreviewAction.reportActionID]: optimisticReportPreviewAction, + }, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`, + value: { + [optimisticReportPreviewAction.reportActionID]: { + created: optimisticReportPreviewAction.created, + }, + }, + }); + } + + // Optimistically unhold all transactions if we pay all requests + if (full) { + const reportTransactions = TransactionUtils.getAllReportTransactions(iouReport.reportID); + for (const transaction of reportTransactions) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: { + comment: { + hold: null, + }, + }, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: { + comment: { + hold: transaction.comment?.hold, + }, + }, + }); + } + } + + let optimisticHoldReportID; + let optimisticHoldActionID; + let optimisticHoldReportExpenseActionIDs; + if (!full) { + const holdReportOnyxData = getReportFromHoldRequestsOnyxData(chatReport, iouReport, recipient); + + optimisticData.push(...holdReportOnyxData.optimisticData); + failureData.push(...holdReportOnyxData.failureData); + optimisticHoldReportID = holdReportOnyxData.optimisticHoldReportID; + optimisticHoldActionID = holdReportOnyxData.optimisticHoldActionID; + optimisticHoldReportExpenseActionIDs = JSON.stringify(holdReportOnyxData.optimisticHoldReportExpenseActionIDs); + } + + return { + params: { + iouReportID: iouReport.reportID, + chatReportID: chatReport.reportID, + reportActionID: optimisticIOUReportAction.reportActionID, + paymentMethodType, + full, + amount: Math.abs(total), + optimisticHoldReportID, + optimisticHoldActionID, + optimisticHoldReportExpenseActionIDs, + ...policyParams, + }, + optimisticData, + successData, + failureData, + }; +} + +/** + * @param managerID - Account ID of the person sending the money + * @param recipient - The user receiving the money + */ +function sendMoneyElsewhere(report: OnyxEntry, amount: number, currency: string, comment: string, managerID: number, recipient: Participant) { + const {params, optimisticData, successData, failureData} = getSendMoneyParams(report, amount, currency, comment, CONST.IOU.PAYMENT_TYPE.ELSEWHERE, managerID, recipient); + + API.write(WRITE_COMMANDS.SEND_MONEY_ELSEWHERE, params, {optimisticData, successData, failureData}); + + Navigation.dismissModal(isSearchTopmostCentralPane() ? undefined : params.chatReportID); + Report.notifyNewAction(params.chatReportID, managerID); +} + +/** + * @param managerID - Account ID of the person sending the money + * @param recipient - The user receiving the money + */ +function sendMoneyWithWallet(report: OnyxEntry, amount: number, currency: string, comment: string, managerID: number, recipient: Participant | ReportUtils.OptionData) { + const {params, optimisticData, successData, failureData} = getSendMoneyParams(report, amount, currency, comment, CONST.IOU.PAYMENT_TYPE.EXPENSIFY, managerID, recipient); + + API.write(WRITE_COMMANDS.SEND_MONEY_WITH_WALLET, params, {optimisticData, successData, failureData}); + + Navigation.dismissModal(isSearchTopmostCentralPane() ? undefined : params.chatReportID); + Report.notifyNewAction(params.chatReportID, managerID); +} + +function canApproveIOU(iouReport: OnyxTypes.OnyxInputOrEntry, policy: OnyxTypes.OnyxInputOrEntry) { + // Only expense reports can be approved + const isPaidGroupPolicy = policy && PolicyUtils.isPaidGroupPolicy(policy); + if (!isPaidGroupPolicy) { + return false; + } + + const isOnSubmitAndClosePolicy = PolicyUtils.isSubmitAndClose(policy); + if (isOnSubmitAndClosePolicy) { + return false; + } + + const managerID = iouReport?.managerID ?? -1; + const isCurrentUserManager = managerID === userAccountID; + const isOpenExpenseReport = ReportUtils.isOpenExpenseReport(iouReport); + const isApproved = ReportUtils.isReportApproved(iouReport); + const iouSettled = ReportUtils.isSettled(iouReport?.reportID); + const reportNameValuePairs = ReportUtils.getReportNameValuePairs(iouReport?.reportID); + const isArchivedReport = ReportUtils.isArchivedRoom(iouReport, reportNameValuePairs); + + return isCurrentUserManager && !isOpenExpenseReport && !isApproved && !iouSettled && !isArchivedReport; +} + +function canIOUBePaid( + iouReport: OnyxTypes.OnyxInputOrEntry, + chatReport: OnyxTypes.OnyxInputOrEntry, + policy: OnyxTypes.OnyxInputOrEntry, + transactions?: OnyxTypes.Transaction[], +) { + const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(chatReport); + const reportNameValuePairs = ReportUtils.getReportNameValuePairs(chatReport?.reportID); + const isChatReportArchived = ReportUtils.isArchivedRoom(chatReport, reportNameValuePairs); + const iouSettled = ReportUtils.isSettled(iouReport?.reportID); + + if (isEmptyObject(iouReport)) { + return false; + } + + if (policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO) { + return false; + } + + if (ReportUtils.isInvoiceReport(iouReport)) { + if (iouSettled) { + return false; + } + if (chatReport?.invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL) { + return chatReport?.invoiceReceiver?.accountID === userAccountID; + } + return PolicyUtils.getPolicy(chatReport?.invoiceReceiver?.policyID)?.role === CONST.POLICY.ROLE.ADMIN; + } + + const isPayer = ReportUtils.isPayer( + { + email: currentUserEmail, + accountID: userAccountID, + }, + iouReport, + ); + + const isOpenExpenseReport = isPolicyExpenseChat && ReportUtils.isOpenExpenseReport(iouReport); + + const {reimbursableSpend} = ReportUtils.getMoneyRequestSpendBreakdown(iouReport); + const isAutoReimbursable = policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES ? false : ReportUtils.canBeAutoReimbursed(iouReport, policy); + const shouldBeApproved = canApproveIOU(iouReport, policy); + + const isPayAtEndExpenseReport = ReportUtils.isPayAtEndExpenseReport(iouReport?.reportID, transactions); + + return ( + isPayer && + !isOpenExpenseReport && + !iouSettled && + !iouReport?.isWaitingOnBankAccount && + reimbursableSpend !== 0 && + !isChatReportArchived && + !isAutoReimbursable && + !shouldBeApproved && + !isPayAtEndExpenseReport + ); +} + +function hasIOUToApproveOrPay(chatReport: OnyxEntry, excludedIOUReportID: string): boolean { + const chatReportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`] ?? {}; + + return Object.values(chatReportActions).some((action) => { + const iouReport = ReportUtils.getReportOrDraftReport(action.childReportID ?? '-1'); + const policy = PolicyUtils.getPolicy(iouReport?.policyID); + const shouldShowSettlementButton = canIOUBePaid(iouReport, chatReport, policy) || canApproveIOU(iouReport, policy); + return action.childReportID?.toString() !== excludedIOUReportID && action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && shouldShowSettlementButton; + }); +} + +function isLastApprover(approvalChain: string[]): boolean { + if (approvalChain.length === 0) { + return true; + } + return approvalChain[approvalChain.length - 1] === currentUserEmail; +} + +function approveMoneyRequest(expenseReport: OnyxEntry, full?: boolean) { + if (expenseReport?.policyID && SubscriptionUtils.shouldRestrictUserBillableActions(expenseReport.policyID)) { + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(expenseReport.policyID)); + return; + } + + const currentNextStep = allNextSteps[`${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport?.reportID}`] ?? null; + let total = expenseReport?.total ?? 0; + const hasHeldExpenses = ReportUtils.hasHeldExpenses(expenseReport?.reportID); + if (hasHeldExpenses && !full && !!expenseReport?.unheldTotal) { + total = expenseReport?.unheldTotal; + } + const optimisticApprovedReportAction = ReportUtils.buildOptimisticApprovedReportAction(total, expenseReport?.currency ?? '', expenseReport?.reportID ?? '-1'); + + const approvalChain = ReportUtils.getApprovalChain(PolicyUtils.getPolicy(expenseReport?.policyID), expenseReport?.ownerAccountID ?? -1, expenseReport?.total ?? 0); + + const predictedNextStatus = isLastApprover(approvalChain) ? CONST.REPORT.STATUS_NUM.APPROVED : CONST.REPORT.STATUS_NUM.SUBMITTED; + const predictedNextState = isLastApprover(approvalChain) ? CONST.REPORT.STATE_NUM.APPROVED : CONST.REPORT.STATE_NUM.SUBMITTED; + + const optimisticNextStep = NextStepUtils.buildNextStep(expenseReport, predictedNextStatus); + const chatReport = ReportUtils.getReportOrDraftReport(expenseReport?.chatReportID); + + const optimisticReportActionsData: OnyxUpdate = { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`, + value: { + [optimisticApprovedReportAction.reportActionID]: { + ...(optimisticApprovedReportAction as OnyxTypes.ReportAction), + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + }, + }; + const optimisticIOUReportData: OnyxUpdate = { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport?.reportID}`, + value: { + ...expenseReport, + lastMessageText: ReportActionsUtils.getReportActionText(optimisticApprovedReportAction), + lastMessageHtml: ReportActionsUtils.getReportActionHtml(optimisticApprovedReportAction), + stateNum: predictedNextState, + statusNum: predictedNextStatus, + pendingFields: { + partial: full ? null : CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }; + + const optimisticChatReportData: OnyxUpdate = { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport?.chatReportID}`, + value: { + hasOutstandingChildRequest: hasIOUToApproveOrPay(chatReport, expenseReport?.reportID ?? '-1'), + }, + }; + + const optimisticNextStepData: OnyxUpdate = { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport?.reportID}`, + value: optimisticNextStep, + }; + const optimisticData: OnyxUpdate[] = [optimisticIOUReportData, optimisticReportActionsData, optimisticNextStepData, optimisticChatReportData]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`, + value: { + [optimisticApprovedReportAction.reportActionID]: { + pendingAction: null, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport?.reportID}`, + value: { + pendingFields: { + partial: null, + }, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`, + value: { + [optimisticApprovedReportAction.reportActionID]: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.other'), + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport?.chatReportID}`, + value: { + hasOutstandingChildRequest: chatReport?.hasOutstandingChildRequest, + pendingFields: { + partial: null, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport?.reportID}`, + value: currentNextStep, + }, + ]; + + // Clear hold reason of all transactions if we approve all requests + if (full && hasHeldExpenses) { + const heldTransactions = ReportUtils.getAllHeldTransactions(expenseReport?.reportID); + heldTransactions.forEach((heldTransaction) => { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${heldTransaction.transactionID}`, + value: { + comment: { + hold: '', + }, + }, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${heldTransaction.transactionID}`, + value: { + comment: { + hold: heldTransaction.comment?.hold, + }, + }, + }); + }); + } + + let optimisticHoldReportID; + let optimisticHoldActionID; + let optimisticHoldReportExpenseActionIDs; + if (!full && !!chatReport && !!expenseReport) { + const holdReportOnyxData = getReportFromHoldRequestsOnyxData(chatReport, expenseReport, {accountID: expenseReport.ownerAccountID}); + + optimisticData.push(...holdReportOnyxData.optimisticData); + failureData.push(...holdReportOnyxData.failureData); + optimisticHoldReportID = holdReportOnyxData.optimisticHoldReportID; + optimisticHoldActionID = holdReportOnyxData.optimisticHoldActionID; + optimisticHoldReportExpenseActionIDs = JSON.stringify(holdReportOnyxData.optimisticHoldReportExpenseActionIDs); + } + + const parameters: ApproveMoneyRequestParams = { + reportID: expenseReport?.reportID ?? '-1', + approvedReportActionID: optimisticApprovedReportAction.reportActionID, + full, + optimisticHoldReportID, + optimisticHoldActionID, + optimisticHoldReportExpenseActionIDs, + v2: PolicyUtils.isControlOnAdvancedApprovalMode(PolicyUtils.getPolicy(expenseReport?.policyID)), + }; + + API.write(WRITE_COMMANDS.APPROVE_MONEY_REQUEST, parameters, {optimisticData, successData, failureData}); +} + +function unapproveExpenseReport(expenseReport: OnyxEntry) { + if (isEmptyObject(expenseReport)) { + return; + } + + const currentNextStep = allNextSteps[`${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`] ?? null; + + const optimisticUnapprovedReportAction = ReportUtils.buildOptimisticUnapprovedReportAction(expenseReport.total ?? 0, expenseReport.currency ?? '', expenseReport.reportID); + const optimisticNextStep = NextStepUtils.buildNextStep(expenseReport, CONST.REPORT.STATUS_NUM.SUBMITTED); + + const optimisticReportActionData: OnyxUpdate = { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, + value: { + [optimisticUnapprovedReportAction.reportActionID]: { + ...(optimisticUnapprovedReportAction as OnyxTypes.ReportAction), + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + }, + }; + const optimisticIOUReportData: OnyxUpdate = { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, + value: { + ...expenseReport, + lastMessageText: ReportActionsUtils.getReportActionText(optimisticUnapprovedReportAction), + lastMessageHtml: ReportActionsUtils.getReportActionHtml(optimisticUnapprovedReportAction), + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + pendingFields: { + partial: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }; + + const optimisticNextStepData: OnyxUpdate = { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`, + value: optimisticNextStep, + }; + + const optimisticData: OnyxUpdate[] = [optimisticIOUReportData, optimisticReportActionData, optimisticNextStepData]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, + value: { + [optimisticUnapprovedReportAction.reportActionID]: { + pendingAction: null, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, + value: { + pendingFields: { + partial: null, + }, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, + value: { + [optimisticUnapprovedReportAction.reportActionID]: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.other'), + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`, + value: currentNextStep, + }, + ]; + + const parameters: UnapproveExpenseReportParams = { + reportID: expenseReport.reportID, + reportActionID: optimisticUnapprovedReportAction.reportActionID, + }; + + API.write(WRITE_COMMANDS.UNAPPROVE_EXPENSE_REPORT, parameters, {optimisticData, successData, failureData}); +} + +function submitReport(expenseReport: OnyxTypes.Report) { + if (expenseReport.policyID && SubscriptionUtils.shouldRestrictUserBillableActions(expenseReport.policyID)) { + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(expenseReport.policyID)); + return; + } + + const currentNextStep = allNextSteps[`${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`] ?? null; + const parentReport = ReportUtils.getReportOrDraftReport(expenseReport.parentReportID); + const policy = PolicyUtils.getPolicy(expenseReport.policyID); + const isCurrentUserManager = currentUserPersonalDetails?.accountID === expenseReport.managerID; + const isSubmitAndClosePolicy = PolicyUtils.isSubmitAndClose(policy); + const adminAccountID = policy?.role === CONST.POLICY.ROLE.ADMIN ? currentUserPersonalDetails?.accountID : undefined; + const optimisticSubmittedReportAction = ReportUtils.buildOptimisticSubmittedReportAction(expenseReport?.total ?? 0, expenseReport.currency ?? '', expenseReport.reportID, adminAccountID); + const optimisticNextStep = NextStepUtils.buildNextStep(expenseReport, isSubmitAndClosePolicy ? CONST.REPORT.STATUS_NUM.CLOSED : CONST.REPORT.STATUS_NUM.SUBMITTED); + + const optimisticData: OnyxUpdate[] = !isSubmitAndClosePolicy + ? [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, + value: { + [optimisticSubmittedReportAction.reportActionID]: { + ...(optimisticSubmittedReportAction as OnyxTypes.ReportAction), + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, + value: { + ...expenseReport, + lastMessageText: ReportActionsUtils.getReportActionText(optimisticSubmittedReportAction), + lastMessageHtml: ReportActionsUtils.getReportActionHtml(optimisticSubmittedReportAction), + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + }, + }, + ] + : [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, + value: { + ...expenseReport, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.CLOSED, + }, + }, + ]; + + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`, + value: optimisticNextStep, + }); + + if (parentReport?.reportID) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${parentReport.reportID}`, + value: { + ...parentReport, + // In case its a manager who force submitted the report, they are the next user who needs to take an action + hasOutstandingChildRequest: isCurrentUserManager, + iouReportID: null, + }, + }); + } + + const successData: OnyxUpdate[] = []; + if (!isSubmitAndClosePolicy) { + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, + value: { + [optimisticSubmittedReportAction.reportActionID]: { + pendingAction: null, + }, + }, + }); + } + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, + value: { + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`, + value: currentNextStep, + }, + ]; + if (!isSubmitAndClosePolicy) { + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, + value: { + [optimisticSubmittedReportAction.reportActionID]: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.other'), + }, + }, + }); + } + + if (parentReport?.reportID) { + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${parentReport.reportID}`, + value: { + hasOutstandingChildRequest: parentReport.hasOutstandingChildRequest, + iouReportID: expenseReport.reportID, + }, + }); + } + + const parameters: SubmitReportParams = { + reportID: expenseReport.reportID, + managerAccountID: PolicyUtils.getSubmitToAccountID(policy, expenseReport.ownerAccountID ?? -1) ?? expenseReport.managerID, + reportActionID: optimisticSubmittedReportAction.reportActionID, + }; + + API.write(WRITE_COMMANDS.SUBMIT_REPORT, parameters, {optimisticData, successData, failureData}); +} + +function cancelPayment(expenseReport: OnyxEntry, chatReport: OnyxTypes.Report) { + if (isEmptyObject(expenseReport)) { + return; + } + + const optimisticReportAction = ReportUtils.buildOptimisticCancelPaymentReportAction(expenseReport.reportID, -(expenseReport.total ?? 0), expenseReport.currency ?? ''); + const policy = PolicyUtils.getPolicy(chatReport.policyID); + const isFree = policy && policy.type === CONST.POLICY.TYPE.FREE; + const approvalMode = policy?.approvalMode ?? CONST.POLICY.APPROVAL_MODE.BASIC; + let stateNum: ValueOf = CONST.REPORT.STATE_NUM.SUBMITTED; + let statusNum: ValueOf = CONST.REPORT.STATUS_NUM.SUBMITTED; + if (!isFree) { + stateNum = approvalMode === CONST.POLICY.APPROVAL_MODE.OPTIONAL ? CONST.REPORT.STATE_NUM.SUBMITTED : CONST.REPORT.STATE_NUM.APPROVED; + statusNum = approvalMode === CONST.POLICY.APPROVAL_MODE.OPTIONAL ? CONST.REPORT.STATUS_NUM.CLOSED : CONST.REPORT.STATUS_NUM.APPROVED; + } + const optimisticNextStep = NextStepUtils.buildNextStep(expenseReport, statusNum); + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, + value: { + [optimisticReportAction.reportActionID]: { + ...(optimisticReportAction as OnyxTypes.ReportAction), + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, + value: { + ...expenseReport, + lastMessageText: ReportActionsUtils.getReportActionText(optimisticReportAction), + lastMessageHtml: ReportActionsUtils.getReportActionHtml(optimisticReportAction), + stateNum, + statusNum, + }, + }, + ]; + + if (!isFree) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`, + value: optimisticNextStep, + }); + } + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, + value: { + [optimisticReportAction.reportActionID]: { + pendingAction: null, + }, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, + value: { + [optimisticReportAction.reportActionID ?? '-1']: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.other'), + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, + value: { + statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED, + }, + }, + ]; + + if (chatReport?.reportID) { + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, + value: { + hasOutstandingChildRequest: true, + iouReportID: expenseReport.reportID, + }, + }); + } + if (!isFree) { + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`, + value: NextStepUtils.buildNextStep(expenseReport, CONST.REPORT.STATUS_NUM.REIMBURSED), + }); + } + + API.write( + WRITE_COMMANDS.CANCEL_PAYMENT, + { + iouReportID: expenseReport.reportID, + chatReportID: chatReport.reportID, + managerAccountID: expenseReport.managerID ?? -1, + reportActionID: optimisticReportAction.reportActionID, + }, + {optimisticData, successData, failureData}, + ); +} + +/** + * Completes onboarding for invite link flow based on the selected payment option + * + * @param paymentSelected based on which we choose the onboarding choice and concierge message + */ +function completePaymentOnboarding(paymentSelected: ValueOf) { + const isInviteOnboardingComplete = introSelected?.isInviteOnboardingComplete ?? false; + + if (isInviteOnboardingComplete || !introSelected?.choice) { + return; + } + + const session = SessionUtils.getSession(); + + const personalDetailsListValues = Object.values(OptionsListUtils.getPersonalDetailsForAccountIDs(session?.accountID ? [session.accountID] : [], personalDetailsList)); + const personalDetails = personalDetailsListValues[0] ?? {}; + + let onboardingPurpose = introSelected.choice; + if (introSelected.inviteType === CONST.ONBOARDING_INVITE_TYPES.IOU && paymentSelected === CONST.IOU.PAYMENT_SELECTED.BBA) { + onboardingPurpose = CONST.ONBOARDING_CHOICES.MANAGE_TEAM; + } + + if (introSelected.inviteType === CONST.ONBOARDING_INVITE_TYPES.INVOICE && paymentSelected !== CONST.IOU.PAYMENT_SELECTED.BBA) { + onboardingPurpose = CONST.ONBOARDING_CHOICES.CHAT_SPLIT; + } + + Report.completeOnboarding( + onboardingPurpose, + CONST.ONBOARDING_MESSAGES[onboardingPurpose], + { + firstName: personalDetails.firstName ?? '', + lastName: personalDetails.lastName ?? '', + }, + paymentSelected, + ); +} + +function payMoneyRequest(paymentType: PaymentMethodType, chatReport: OnyxTypes.Report, iouReport: OnyxTypes.Report, full = true) { + if (chatReport.policyID && SubscriptionUtils.shouldRestrictUserBillableActions(chatReport.policyID)) { + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(chatReport.policyID)); + return; + } + + const paymentSelected = paymentType === CONST.IOU.PAYMENT_TYPE.VBBA ? CONST.IOU.PAYMENT_SELECTED.BBA : CONST.IOU.PAYMENT_SELECTED.PBA; + completePaymentOnboarding(paymentSelected); + + const recipient = {accountID: iouReport.ownerAccountID}; + const {params, optimisticData, successData, failureData} = getPayMoneyRequestParams(chatReport, iouReport, recipient, paymentType, full); + + // For now, we need to call the PayMoneyRequestWithWallet API since PayMoneyRequest was not updated to work with + // Expensify Wallets. + const apiCommand = paymentType === CONST.IOU.PAYMENT_TYPE.EXPENSIFY ? WRITE_COMMANDS.PAY_MONEY_REQUEST_WITH_WALLET : WRITE_COMMANDS.PAY_MONEY_REQUEST; + + API.write(apiCommand, params, {optimisticData, successData, failureData}); +} + +function payInvoice(paymentMethodType: PaymentMethodType, chatReport: OnyxTypes.Report, invoiceReport: OnyxTypes.Report, payAsBusiness = false) { + const recipient = {accountID: invoiceReport.ownerAccountID}; + const { + optimisticData, + successData, + failureData, + params: { + reportActionID, + policyID, + announceChatReportID, + announceCreatedReportActionID, + adminsChatReportID, + adminsCreatedReportActionID, + expenseChatReportID, + expenseCreatedReportActionID, + customUnitRateID, + customUnitID, + ownerEmail, + policyName, + }, + } = getPayMoneyRequestParams(chatReport, invoiceReport, recipient, paymentMethodType, true, payAsBusiness); + + const paymentSelected = paymentMethodType === CONST.IOU.PAYMENT_TYPE.VBBA ? CONST.IOU.PAYMENT_SELECTED.BBA : CONST.IOU.PAYMENT_SELECTED.PBA; + completePaymentOnboarding(paymentSelected); + + let params: PayInvoiceParams = { + reportID: invoiceReport.reportID, + reportActionID, + paymentMethodType, + payAsBusiness, + }; + + if (policyID) { + params = { + ...params, + policyID, + announceChatReportID, + announceCreatedReportActionID, + adminsChatReportID, + adminsCreatedReportActionID, + expenseChatReportID, + expenseCreatedReportActionID, + customUnitRateID, + customUnitID, + ownerEmail, + policyName, + }; + } + + API.write(WRITE_COMMANDS.PAY_INVOICE, params, {optimisticData, successData, failureData}); +} + +function detachReceipt(transactionID: string) { + const transaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + const newTransaction = transaction + ? { + ...transaction, + filename: '', + receipt: { + source: '', + }, + } + : null; + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: newTransaction, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + ...(transaction ?? null), + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.receiptDeleteFailureError'), + }, + }, + ]; + + const parameters: DetachReceiptParams = {transactionID}; + + API.write(WRITE_COMMANDS.DETACH_RECEIPT, parameters, {optimisticData, failureData}); +} + +function replaceReceipt(transactionID: string, file: File, source: string) { + const transaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + const oldReceipt = transaction?.receipt ?? {}; + const receiptOptimistic = { + source, + state: CONST.IOU.RECEIPT_STATE.OPEN, + }; + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + receipt: receiptOptimistic, + filename: file.name, + }, + }, + ]; + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + receipt: !isEmptyObject(oldReceipt) ? oldReceipt : null, + filename: transaction?.filename, + errors: getReceiptError(receiptOptimistic, file.name), + }, + }, + ]; + + const parameters: ReplaceReceiptParams = { + transactionID, + receipt: file, + }; + + API.write(WRITE_COMMANDS.REPLACE_RECEIPT, parameters, {optimisticData, failureData}); +} + +/** + * Finds the participants for an IOU based on the attached report + * @param transactionID of the transaction to set the participants of + * @param report attached to the transaction + */ +function setMoneyRequestParticipantsFromReport(transactionID: string, report: OnyxEntry): Participant[] { + // If the report is iou or expense report, we should get the chat report to set participant for request money + const chatReport = ReportUtils.isMoneyRequestReport(report) ? ReportUtils.getReportOrDraftReport(report?.chatReportID) : report; + const currentUserAccountID = currentUserPersonalDetails?.accountID; + const shouldAddAsReport = !isEmptyObject(chatReport) && ReportUtils.isSelfDM(chatReport); + let participants: Participant[] = []; + + if (ReportUtils.isPolicyExpenseChat(chatReport) || shouldAddAsReport) { + participants = [{accountID: 0, reportID: chatReport?.reportID, isPolicyExpenseChat: ReportUtils.isPolicyExpenseChat(chatReport), selected: true}]; + } else if (ReportUtils.isInvoiceRoom(chatReport)) { + participants = [ + {reportID: chatReport?.reportID, selected: true}, + { + policyID: chatReport?.policyID, + isSender: true, + selected: false, + }, + ]; + } else { + const chatReportOtherParticipants = Object.keys(chatReport?.participants ?? {}) + .map(Number) + .filter((accountID) => accountID !== currentUserAccountID); + participants = chatReportOtherParticipants.map((accountID) => ({accountID, selected: true})); + } + + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {participants, participantsAutoAssigned: true}); + + return participants; +} + +function setMoneyRequestTaxRate(transactionID: string, taxCode: string) { + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {taxCode}); +} + +function setMoneyRequestTaxAmount(transactionID: string, taxAmount: number | null) { + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {taxAmount}); +} + +function dismissHoldUseExplanation() { + const parameters: SetNameValuePairParams = { + name: ONYXKEYS.NVP_DISMISSED_HOLD_USE_EXPLANATION, + value: true, + }; + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.NVP_DISMISSED_HOLD_USE_EXPLANATION, + value: true, + }, + ]; + + API.write(WRITE_COMMANDS.SET_NAME_VALUE_PAIR, parameters, { + optimisticData, + }); +} + +/** + * Sets the `splitShares` map that holds individual shares of a split bill + */ +function setSplitShares(transaction: OnyxEntry, amount: number, currency: string, newAccountIDs: number[]) { + if (!transaction) { + return; + } + const oldAccountIDs = Object.keys(transaction.splitShares ?? {}).map((key) => Number(key)); + + // Create an array containing unique IDs of the current transaction participants and the new ones + // The current userAccountID might not be included in newAccountIDs if this is called from the participants step using Global Create + // If this is called from an existing group chat, it'll be included. So we manually add them to account for both cases. + const accountIDs = [...new Set([userAccountID, ...newAccountIDs, ...oldAccountIDs])]; + + const splitShares: SplitShares = accountIDs.reduce((acc: SplitShares, accountID): SplitShares => { + // We want to replace the contents of splitShares to contain only `newAccountIDs` entries + // In the case of going back to the participants page and removing a participant + // a simple merge will have the previous participant still present in the splitshares object + // So we manually set their entry to null + if (!newAccountIDs.includes(accountID) && accountID !== userAccountID) { + acc[accountID] = null; + return acc; + } + + const isPayer = accountID === userAccountID; + const participantsLength = newAccountIDs.includes(userAccountID) ? newAccountIDs.length - 1 : newAccountIDs.length; + const splitAmount = IOUUtils.calculateAmount(participantsLength, amount, currency, isPayer); + acc[accountID] = { + amount: splitAmount, + isModified: false, + }; + return acc; + }, {}); + + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction.transactionID}`, {splitShares}); +} + +function resetSplitShares(transaction: OnyxEntry, newAmount?: number, currency?: string) { + if (!transaction) { + return; + } + const accountIDs = Object.keys(transaction.splitShares ?? {}).map((key) => Number(key)); + if (!accountIDs) { + return; + } + setSplitShares(transaction, newAmount ?? transaction.amount, currency ?? transaction.currency, accountIDs); +} + +/** + * Sets an individual split share of the participant accountID supplied + */ +function setIndividualShare(transactionID: string, participantAccountID: number, participantShare: number) { + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, { + splitShares: { + [participantAccountID]: {amount: participantShare, isModified: true}, + }, + }); +} + +/** + * Adjusts remaining unmodified shares when another share is modified + * E.g. if total bill is $100 and split between 3 participants, when the user changes the first share to $50, the remaining unmodified shares will become $25 each. + */ +function adjustRemainingSplitShares(transaction: NonNullable) { + const modifiedShares = Object.keys(transaction.splitShares ?? {}).filter((key: string) => transaction?.splitShares?.[Number(key)]?.isModified); + + if (!modifiedShares.length) { + return; + } + + const sumOfManualShares = modifiedShares + .map((key: string): number => transaction?.splitShares?.[Number(key)]?.amount ?? 0) + .reduce((prev: number, current: number): number => prev + current, 0); + + const unmodifiedSharesAccountIDs = Object.keys(transaction.splitShares ?? {}) + .filter((key: string) => !transaction?.splitShares?.[Number(key)]?.isModified) + .map((key: string) => Number(key)); + + const remainingTotal = transaction.amount - sumOfManualShares; + if (remainingTotal < 0) { + return; + } + + const splitShares: SplitShares = unmodifiedSharesAccountIDs.reduce((acc: SplitShares, accountID: number, index: number): SplitShares => { + const splitAmount = IOUUtils.calculateAmount(unmodifiedSharesAccountIDs.length - 1, remainingTotal, transaction.currency, index === 0); + acc[accountID] = { + amount: splitAmount, + }; + return acc; + }, {}); + + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction.transactionID}`, {splitShares}); +} + +/** + * Put expense on HOLD + */ +function putOnHold(transactionID: string, comment: string, reportID: string) { + const currentTime = DateUtils.getDBTime(); + const createdReportAction = ReportUtils.buildOptimisticHoldReportAction(currentTime); + const createdReportActionComment = ReportUtils.buildOptimisticHoldReportActionComment(comment, DateUtils.addMillisecondsFromDateTime(currentTime, 1)); + const newViolation = {name: CONST.VIOLATIONS.HOLD, type: CONST.VIOLATION_TYPES.VIOLATION}; + const transactionViolations = allTransactionViolations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`] ?? []; + const updatedViolations = [...transactionViolations, newViolation]; + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + value: { + [createdReportAction.reportActionID]: createdReportAction as ReportAction, + [createdReportActionComment.reportActionID]: createdReportActionComment as ReportAction, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + comment: { + hold: createdReportAction.reportActionID, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, + value: updatedViolations, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + pendingAction: null, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + pendingAction: null, + comment: { + hold: null, + }, + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericHoldExpenseFailureMessage'), + }, + }, + ]; + + API.write( + 'HoldRequest', + { + transactionID, + comment, + reportActionID: createdReportAction.reportActionID, + commentReportActionID: createdReportActionComment.reportActionID, + }, + {optimisticData, successData, failureData}, + ); +} + +/** + * Remove expense from HOLD + */ +function unholdRequest(transactionID: string, reportID: string) { + const createdReportAction = ReportUtils.buildOptimisticUnHoldReportAction(); + const transactionViolations = allTransactionViolations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`]; + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + value: { + [createdReportAction.reportActionID]: createdReportAction as ReportAction, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + comment: { + hold: null, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, + value: transactionViolations?.filter((violation) => violation.name !== CONST.VIOLATIONS.HOLD) ?? [], + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + pendingAction: null, + comment: { + hold: null, + }, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + pendingAction: null, + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericUnholdExpenseFailureMessage'), + }, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, + value: transactionViolations ?? null, + }, + ]; + + API.write( + 'UnHoldRequest', + { + transactionID, + reportActionID: createdReportAction.reportActionID, + }, + {optimisticData, successData, failureData}, + ); +} +// eslint-disable-next-line rulesdir/no-negated-variables +function navigateToStartStepIfScanFileCannotBeRead( + receiptFilename: string | undefined, + receiptPath: ReceiptSource | undefined, + onSuccess: (file: File) => void, + requestType: IOURequestType, + iouType: IOUType, + transactionID: string, + reportID: string, + receiptType: string | undefined, +) { + if (!receiptFilename || !receiptPath) { + return; + } + + const onFailure = () => { + setMoneyRequestReceipt(transactionID, '', '', true); + if (requestType === CONST.IOU.REQUEST_TYPE.MANUAL) { + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams())); + return; + } + IOUUtils.navigateToStartMoneyRequestStep(requestType, iouType, transactionID, reportID); + }; + FileUtils.readFileAsync(receiptPath.toString(), receiptFilename, onSuccess, onFailure, receiptType); +} + +/** Save the preferred payment method for a policy */ +function savePreferredPaymentMethod(policyID: string, paymentMethod: PaymentMethodType) { + Onyx.merge(`${ONYXKEYS.NVP_LAST_PAYMENT_METHOD}`, {[policyID]: paymentMethod}); +} + +/** Get report policy id of IOU request */ +function getIOURequestPolicyID(transaction: OnyxEntry, report: OnyxEntry): string { + // Workspace sender will exist for invoices + const workspaceSender = transaction?.participants?.find((participant) => participant.isSender); + return workspaceSender?.policyID ?? report?.policyID ?? '-1'; +} + +/** Merge several transactions into one by updating the fields of the one we want to keep and deleting the rest */ +function mergeDuplicates(params: TransactionMergeParams) { + const originalSelectedTransaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${params.transactionID}`]; + + const optimisticTransactionData: OnyxUpdate = { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${params.transactionID}`, + value: { + ...originalSelectedTransaction, + billable: params.billable, + comment: { + comment: params.comment, + }, + category: params.category, + created: params.created, + currency: params.currency, + modifiedMerchant: params.merchant, + reimbursable: params.reimbursable, + tag: params.tag, + }, + }; + + const failureTransactionData: OnyxUpdate = { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${params.transactionID}`, + // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style + value: originalSelectedTransaction as OnyxTypes.Transaction, + }; + + const optimisticTransactionDuplicatesData: OnyxUpdate[] = params.transactionIDList.map((id) => ({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${id}`, + value: null, + })); + + const failureTransactionDuplicatesData: OnyxUpdate[] = params.transactionIDList.map((id) => ({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${id}`, + // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style + value: allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${id}`] as OnyxTypes.Transaction, + })); + + const optimisticTransactionViolations: OnyxUpdate[] = [...params.transactionIDList, params.transactionID].map((id) => { + const violations = allTransactionViolations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${id}`] ?? []; + return { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${id}`, + value: violations.filter((violation) => violation.name !== CONST.VIOLATIONS.DUPLICATED_TRANSACTION), + }; + }); + + const failureTransactionViolations: OnyxUpdate[] = [...params.transactionIDList, params.transactionID].map((id) => { + const violations = allTransactionViolations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${id}`] ?? []; + return { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${id}`, + value: violations, + }; + }); + + const duplicateTransactionTotals = params.transactionIDList.reduce((total, id) => { + const duplicateTransaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${id}`]; + if (!duplicateTransaction) { + return total; + } + return total + duplicateTransaction.amount; + }, 0); + + const expenseReport = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${params.reportID}`]; + const expenseReportOptimisticData: OnyxUpdate = { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${params.reportID}`, + value: { + total: (expenseReport?.total ?? 0) - duplicateTransactionTotals, + }, + }; + const expenseReportFailureData: OnyxUpdate = { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${params.reportID}`, + value: { + total: expenseReport?.total, + }, + }; + + const iouActionsToDelete = Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${params.reportID}`] ?? {})?.filter( + (reportAction): reportAction is ReportAction => { + if (!ReportActionsUtils.isMoneyRequestAction(reportAction)) { + return false; + } + const message = ReportActionsUtils.getOriginalMessage(reportAction); + if (!message?.IOUTransactionID) { + return false; + } + return params.transactionIDList.includes(message.IOUTransactionID); + }, + ); + + const deletedTime = DateUtils.getDBTime(); + const expenseReportActionsOptimisticData: OnyxUpdate = { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${params.reportID}`, + value: iouActionsToDelete.reduce>>>((val, reportAction) => { + // eslint-disable-next-line no-param-reassign + val[reportAction.reportActionID] = { + originalMessage: { + deleted: deletedTime, + }, + ...(Array.isArray(reportAction.message) && + !!reportAction.message[0] && { + message: [ + { + ...reportAction.message[0], + deleted: deletedTime, + }, + ...reportAction.message.slice(1), + ], + }), + ...(!Array.isArray(reportAction.message) && { + message: { + deleted: deletedTime, + }, + }), + }; + return val; + }, {}), + }; + const expenseReportActionsFailureData: OnyxUpdate = { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${params.reportID}`, + value: iouActionsToDelete.reduce>>>>((val, reportAction) => { + // eslint-disable-next-line no-param-reassign + val[reportAction.reportActionID] = { + originalMessage: { + deleted: null, + }, + message: reportAction.message, + }; + return val; + }, {}), + }; + + const optimisticData: OnyxUpdate[] = []; + const failureData: OnyxUpdate[] = []; + + optimisticData.push( + optimisticTransactionData, + ...optimisticTransactionDuplicatesData, + ...optimisticTransactionViolations, + expenseReportOptimisticData, + expenseReportActionsOptimisticData, + ); + failureData.push(failureTransactionData, ...failureTransactionDuplicatesData, ...failureTransactionViolations, expenseReportFailureData, expenseReportActionsFailureData); + + API.write(WRITE_COMMANDS.TRANSACTION_MERGE, params, {optimisticData, failureData}); +} + +export { + adjustRemainingSplitShares, + approveMoneyRequest, + canApproveIOU, + cancelPayment, + canIOUBePaid, + cleanUpMoneyRequest, + clearMoneyRequest, + completeSplitBill, + createDistanceRequest, + createDraftTransaction, + deleteMoneyRequest, + deleteTrackExpense, + detachReceipt, + dismissHoldUseExplanation, + editMoneyRequest, + getIOURequestPolicyID, + initMoneyRequest, + navigateToStartStepIfScanFileCannotBeRead, + completePaymentOnboarding, + payInvoice, + payMoneyRequest, + putOnHold, + replaceReceipt, + requestMoney, + resetSplitShares, + savePreferredPaymentMethod, + sendInvoice, + sendMoneyElsewhere, + sendMoneyWithWallet, + setCustomUnitRateID, + setDraftSplitTransaction, + setIndividualShare, + setMoneyRequestAmount, + setMoneyRequestBillable, + setMoneyRequestCategory, + setMoneyRequestCreated, + setMoneyRequestCurrency, + setMoneyRequestDescription, + setMoneyRequestDistanceRate, + setMoneyRequestMerchant, + setMoneyRequestParticipants, + setMoneyRequestParticipantsFromReport, + setMoneyRequestPendingFields, + setMoneyRequestReceipt, + setMoneyRequestTag, + setMoneyRequestTaxAmount, + setMoneyRequestTaxRate, + setSplitPayer, + setSplitShares, + splitBill, + splitBillAndOpenReport, + startMoneyRequest, + startSplitBill, + submitReport, + trackExpense, + unapproveExpenseReport, + unholdRequest, + updateMoneyRequestAmountAndCurrency, + updateMoneyRequestBillable, + updateMoneyRequestCategory, + updateMoneyRequestDate, + updateMoneyRequestDescription, + updateMoneyRequestDistance, + updateMoneyRequestDistanceRate, + updateMoneyRequestMerchant, + updateMoneyRequestTag, + updateMoneyRequestTaxAmount, + updateMoneyRequestTaxRate, + mergeDuplicates, +}; +export type {GPSPoint as GpsPoint, IOURequestType}; diff --git a/src/libs/actions/ImportSpreadsheet.ts b/src/libs/actions/ImportSpreadsheet.ts index 2f3edeb3b1cb..9a597cf99bf9 100644 --- a/src/libs/actions/ImportSpreadsheet.ts +++ b/src/libs/actions/ImportSpreadsheet.ts @@ -3,15 +3,16 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; function setSpreadsheetData(data: string[][]): Promise { - if (!Array.isArray(data) || !Array.isArray(data[0])) { + if (!Array.isArray(data) || !Array.isArray(data.at(0))) { return Promise.reject(new Error('Invalid data format')); } - const transposedData = data[0].map((_, colIndex) => data.map((row) => row[colIndex])); - const columnNames: Record = data[0].reduce((acc: Record, _, colIndex) => { - acc[colIndex] = CONST.CSV_IMPORT_COLUMNS.IGNORE; - return acc; - }, {}); + const transposedData = data.at(0)?.map((_, colIndex) => data.map((row) => row.at(colIndex) ?? '')); + const columnNames: Record = + data.at(0)?.reduce((acc: Record, _, colIndex) => { + acc[colIndex] = CONST.CSV_IMPORT_COLUMNS.IGNORE; + return acc; + }, {}) ?? {}; return Onyx.merge(ONYXKEYS.IMPORTED_SPREADSHEET, {data: transposedData, columns: columnNames}); } diff --git a/src/libs/actions/OnyxUpdateManager/utils/index.ts b/src/libs/actions/OnyxUpdateManager/utils/index.ts index bb5dd599480e..bd3f5d33707b 100644 --- a/src/libs/actions/OnyxUpdateManager/utils/index.ts +++ b/src/libs/actions/OnyxUpdateManager/utils/index.ts @@ -93,7 +93,7 @@ function detectGapsAndSplit(lastUpdateIDFromClient: number): DetectGapAndSplitRe if (gapExists) { // If there is a gap and we didn't detect two chained updates, "firstUpdateToBeAppliedAfterGap" will always be the the last deferred update. // We will fetch all missing updates up to the previous update and can always apply the last deferred update. - const firstUpdateToBeAppliedAfterGap = firstUpdateIDAfterGaps ?? Number(updateValues[updateValues.length - 1].lastUpdateID); + const firstUpdateToBeAppliedAfterGap = firstUpdateIDAfterGaps ?? Number(updateValues.at(updateValues.length - 1)?.lastUpdateID); // Add all deferred updates after the gap(s) to "updatesAfterGaps". // If "firstUpdateToBeAppliedAfterGap" is set to the last deferred update, the array will be empty. diff --git a/src/libs/actions/Policy/Category.ts b/src/libs/actions/Policy/Category.ts index c342fe6eedb6..9fdc0b665faa 100644 --- a/src/libs/actions/Policy/Category.ts +++ b/src/libs/actions/Policy/Category.ts @@ -1212,7 +1212,10 @@ function setPolicyCategoryApprover(policyID: string, categoryName: string, appro newApprover = ''; } else { const indexToUpdate = updatedApprovalRules.indexOf(existingCategoryApproverRule); - updatedApprovalRules[indexToUpdate].approver = approver; + const approvalRule = updatedApprovalRules.at(indexToUpdate); + if (approvalRule) { + approvalRule.approver = approver; + } } const onyxData: OnyxData = { @@ -1274,7 +1277,11 @@ function setPolicyCategoryTax(policyID: string, categoryName: string, taxID: str }); } else { const indexToUpdate = updatedExpenseRules.indexOf(existingCategoryExpenseRule); - updatedExpenseRules[indexToUpdate].tax.field_id_TAX.externalID = taxID; + const expenseRule = updatedExpenseRules.at(indexToUpdate); + + if (expenseRule) { + expenseRule.tax.field_id_TAX.externalID = taxID; + } } const onyxData: OnyxData = { diff --git a/src/libs/actions/Policy/Category.ts.orig b/src/libs/actions/Policy/Category.ts.orig new file mode 100644 index 000000000000..c342fe6eedb6 --- /dev/null +++ b/src/libs/actions/Policy/Category.ts.orig @@ -0,0 +1,1337 @@ +import lodashCloneDeep from 'lodash/cloneDeep'; +import lodashUnion from 'lodash/union'; +import type {NullishDeep, OnyxCollection, OnyxUpdate} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; +import * as API from '@libs/API'; +import type { + EnablePolicyCategoriesParams, + OpenPolicyCategoriesPageParams, + RemovePolicyCategoryReceiptsRequiredParams, + SetPolicyCategoryApproverParams, + SetPolicyCategoryDescriptionRequiredParams, + SetPolicyCategoryMaxAmountParams, + SetPolicyCategoryReceiptsRequiredParams, + SetPolicyCategoryTaxParams, + SetPolicyDistanceRatesDefaultCategoryParams, + SetWorkspaceCategoryDescriptionHintParams, + UpdatePolicyCategoryGLCodeParams, +} from '@libs/API/parameters'; +import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import * as ApiUtils from '@libs/ApiUtils'; +import * as CategoryUtils from '@libs/CategoryUtils'; +import * as CurrencyUtils from '@libs/CurrencyUtils'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import fileDownload from '@libs/fileDownload'; +import getIsNarrowLayout from '@libs/getIsNarrowLayout'; +import {translateLocal} from '@libs/Localize'; +import Log from '@libs/Log'; +import enhanceParameters from '@libs/Network/enhanceParameters'; +import * as OptionsListUtils from '@libs/OptionsListUtils'; +import {navigateWhenEnableFeature, removePendingFieldsFromCustomUnit} from '@libs/PolicyUtils'; +import * as PolicyUtils from '@libs/PolicyUtils'; +import * as ReportUtils from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, PolicyCategories, PolicyCategory, RecentlyUsedCategories, Report} from '@src/types/onyx'; +import type {ApprovalRule, CustomUnit, ExpenseRule} from '@src/types/onyx/Policy'; +import type {PolicyCategoryExpenseLimitType} from '@src/types/onyx/PolicyCategory'; +import type {OnyxData} from '@src/types/onyx/Request'; + +const allPolicies: OnyxCollection = {}; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.POLICY, + callback: (val, key) => { + if (!key) { + return; + } + if (val === null || val === undefined) { + // If we are deleting a policy, we have to check every report linked to that policy + // and unset the draft indicator (pencil icon) alongside removing any draft comments. Clearing these values will keep the newly archived chats from being displayed in the LHN. + // More info: https://github.com/Expensify/App/issues/14260 + const policyID = key.replace(ONYXKEYS.COLLECTION.POLICY, ''); + const policyReports = ReportUtils.getAllPolicyReports(policyID); + const cleanUpMergeQueries: Record<`${typeof ONYXKEYS.COLLECTION.REPORT}${string}`, NullishDeep> = {}; + const cleanUpSetQueries: Record<`${typeof ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${string}` | `${typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${string}`, null> = {}; + policyReports.forEach((policyReport) => { + if (!policyReport) { + return; + } + const {reportID} = policyReport; + cleanUpSetQueries[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`] = null; + cleanUpSetQueries[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`] = null; + }); + Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT, cleanUpMergeQueries); + Onyx.multiSet(cleanUpSetQueries); + delete allPolicies[key]; + return; + } + + allPolicies[key] = val; + }, +}); + +let allRecentlyUsedCategories: OnyxCollection = {}; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES, + waitForCollectionCallback: true, + callback: (val) => (allRecentlyUsedCategories = val), +}); + +let allPolicyCategories: OnyxCollection = {}; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.POLICY_CATEGORIES, + waitForCollectionCallback: true, + callback: (val) => (allPolicyCategories = val), +}); + +function buildOptimisticPolicyCategories(policyID: string, categories: readonly string[]) { + const optimisticCategoryMap = categories.reduce>>((acc, category) => { + acc[category] = { + name: category, + enabled: true, + errors: null, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }; + return acc; + }, {}); + + const successCategoryMap = categories.reduce>>((acc, category) => { + acc[category] = { + errors: null, + pendingAction: null, + }; + return acc; + }, {}); + + const failureCategoryMap = categories.reduce>>((acc, category) => { + acc[category] = { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.categories.createFailureMessage'), + pendingAction: null, + }; + return acc; + }, {}); + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: optimisticCategoryMap, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES_DRAFT}${policyID}`, + value: null, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: successCategoryMap, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: failureCategoryMap, + }, + ], + }; + + return onyxData; +} + +function updateImportSpreadsheetData(categoriesLength: number) { + const onyxData: OnyxData = { + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.IMPORTED_SPREADSHEET, + value: { + shouldFinalModalBeOpened: true, + importFinalModal: { + title: translateLocal('spreadsheet.importSuccessfullTitle'), + prompt: translateLocal('spreadsheet.importCategoriesSuccessfullDescription', categoriesLength), + }, + }, + }, + ], + + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.IMPORTED_SPREADSHEET, + value: { + shouldFinalModalBeOpened: true, + importFinalModal: {title: translateLocal('spreadsheet.importFailedTitle'), prompt: translateLocal('spreadsheet.importFailedDescription')}, + }, + }, + ], + }; + + return onyxData; +} + +function openPolicyCategoriesPage(policyID: string) { + if (!policyID) { + Log.warn('openPolicyCategoriesPage invalid params', {policyID}); + return; + } + + const params: OpenPolicyCategoriesPageParams = { + policyID, + }; + + API.read(READ_COMMANDS.OPEN_POLICY_CATEGORIES_PAGE, params); +} + +function buildOptimisticPolicyRecentlyUsedCategories(policyID?: string, category?: string) { + if (!policyID || !category) { + return []; + } + + const policyRecentlyUsedCategories = allRecentlyUsedCategories?.[`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${policyID}`] ?? []; + + return lodashUnion([category], policyRecentlyUsedCategories); +} + +function setWorkspaceCategoryEnabled(policyID: string, categoriesToUpdate: Record) { + const policyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`] ?? {}; + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + const optimisticPolicyCategoriesData = { + ...Object.keys(categoriesToUpdate).reduce((acc, key) => { + acc[key] = { + ...policyCategories[key], + ...categoriesToUpdate[key], + errors: null, + pendingFields: { + enabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }; + + return acc; + }, {}), + }; + const shouldDisableRequiresCategory = !OptionsListUtils.hasEnabledOptions({...policyCategories, ...optimisticPolicyCategoriesData}); + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: optimisticPolicyCategoriesData, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + ...Object.keys(categoriesToUpdate).reduce((acc, key) => { + acc[key] = { + ...policyCategories[key], + ...categoriesToUpdate[key], + errors: null, + pendingFields: { + enabled: null, + }, + pendingAction: null, + }; + + return acc; + }, {}), + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + ...Object.keys(categoriesToUpdate).reduce((acc, key) => { + acc[key] = { + ...policyCategories[key], + ...categoriesToUpdate[key], + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.categories.updateFailureMessage'), + pendingFields: { + enabled: null, + }, + pendingAction: null, + }; + + return acc; + }, {}), + }, + }, + ], + }; + if (shouldDisableRequiresCategory) { + onyxData.optimisticData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + requiresCategory: false, + pendingFields: { + requiresCategory: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }); + onyxData.successData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + requiresCategory: null, + }, + }, + }); + onyxData.failureData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + requiresCategory: policy?.requiresCategory, + pendingFields: { + requiresCategory: null, + }, + }, + }); + } + + const parameters = { + policyID, + categories: JSON.stringify(Object.keys(categoriesToUpdate).map((key) => categoriesToUpdate[key])), + }; + + API.write(WRITE_COMMANDS.SET_WORKSPACE_CATEGORIES_ENABLED, parameters, onyxData); +} + +function setPolicyCategoryDescriptionRequired(policyID: string, categoryName: string, areCommentsRequired: boolean) { + const policyCategoryToUpdate = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]?.[categoryName]; + const originalAreCommentsRequired = policyCategoryToUpdate?.areCommentsRequired; + const originalCommentHint = policyCategoryToUpdate?.commentHint; + + // When areCommentsRequired is set to false, commentHint has to be reset + const updatedCommentHint = areCommentsRequired ? allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]?.[categoryName]?.commentHint : ''; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + pendingFields: { + areCommentsRequired: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + areCommentsRequired, + commentHint: updatedCommentHint, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + pendingAction: null, + pendingFields: { + areCommentsRequired: null, + }, + areCommentsRequired, + commentHint: updatedCommentHint, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + pendingAction: null, + pendingFields: { + areCommentsRequired: null, + }, + areCommentsRequired: originalAreCommentsRequired, + commentHint: originalCommentHint, + }, + }, + }, + ], + }; + + const parameters: SetPolicyCategoryDescriptionRequiredParams = { + policyID, + categoryName, + areCommentsRequired, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_DESCRIPTION_REQUIRED, parameters, onyxData); +} + +function setPolicyCategoryReceiptsRequired(policyID: string, categoryName: string, maxExpenseAmountNoReceipt: number) { + const originalMaxExpenseAmountNoReceipt = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]?.[categoryName]?.maxExpenseAmountNoReceipt; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + pendingFields: { + maxExpenseAmountNoReceipt: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + maxExpenseAmountNoReceipt, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + pendingAction: null, + pendingFields: { + maxExpenseAmountNoReceipt: null, + }, + maxExpenseAmountNoReceipt, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + pendingAction: null, + pendingFields: { + maxExpenseAmountNoReceipt: null, + }, + maxExpenseAmountNoReceipt: originalMaxExpenseAmountNoReceipt, + }, + }, + }, + ], + }; + + const parameters: SetPolicyCategoryReceiptsRequiredParams = { + policyID, + categoryName, + maxExpenseAmountNoReceipt, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_RECEIPTS_REQUIRED, parameters, onyxData); +} + +function removePolicyCategoryReceiptsRequired(policyID: string, categoryName: string) { + const originalMaxExpenseAmountNoReceipt = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]?.[categoryName]?.maxExpenseAmountNoReceipt; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + pendingFields: { + maxExpenseAmountNoReceipt: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + maxExpenseAmountNoReceipt: null, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + pendingAction: null, + pendingFields: { + maxExpenseAmountNoReceipt: null, + }, + maxExpenseAmountNoReceipt: null, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + pendingAction: null, + pendingFields: { + maxExpenseAmountNoReceipt: null, + }, + maxExpenseAmountNoReceipt: originalMaxExpenseAmountNoReceipt, + }, + }, + }, + ], + }; + + const parameters: RemovePolicyCategoryReceiptsRequiredParams = { + policyID, + categoryName, + }; + + API.write(WRITE_COMMANDS.REMOVE_POLICY_CATEGORY_RECEIPTS_REQUIRED, parameters, onyxData); +} + +function createPolicyCategory(policyID: string, categoryName: string) { + const onyxData = buildOptimisticPolicyCategories(policyID, [categoryName]); + + const parameters = { + policyID, + categories: JSON.stringify([{name: categoryName}]), + }; + + API.write(WRITE_COMMANDS.CREATE_WORKSPACE_CATEGORIES, parameters, onyxData); +} + +function importPolicyCategories(policyID: string, categories: PolicyCategory[]) { + const onyxData = updateImportSpreadsheetData(categories.length); + + const parameters = { + policyID, + // eslint-disable-next-line @typescript-eslint/naming-convention + categories: JSON.stringify([...categories.map((category) => ({name: category.name, enabled: category.enabled, 'GL Code': String(category['GL Code'])}))]), + }; + + API.write(WRITE_COMMANDS.IMPORT_CATEGORIES_SPREADSHEET, parameters, onyxData); +} + +function renamePolicyCategory(policyID: string, policyCategory: {oldName: string; newName: string}) { + const policy = PolicyUtils.getPolicy(policyID); + const policyCategoryToUpdate = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]?.[policyCategory.oldName]; + + const policyCategoryRule = CategoryUtils.getCategoryApproverRule(policy?.rules?.approvalRules ?? [], policyCategory.oldName); + const approvalRules = policy?.rules?.approvalRules ?? []; + const updatedApprovalRules: ApprovalRule[] = lodashCloneDeep(approvalRules); + + // Its related by name, so the corresponding rule has to be updated to handle offline scenario + if (policyCategoryRule) { + const indexToUpdate = updatedApprovalRules.findIndex((rule) => rule.id === policyCategoryRule.id); + policyCategoryRule.applyWhen = policyCategoryRule.applyWhen.map((ruleCondition) => { + const {value, field, condition} = ruleCondition; + + if (value === policyCategory.oldName && field === CONST.POLICY.FIELDS.CATEGORY && condition === CONST.POLICY.RULE_CONDITIONS.MATCHES) { + return {...ruleCondition, value: policyCategory.newName}; + } + + return ruleCondition; + }); + updatedApprovalRules[indexToUpdate] = policyCategoryRule; + } + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [policyCategory.oldName]: null, + [policyCategory.newName]: { + ...policyCategoryToUpdate, + name: policyCategory.newName, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + pendingFields: { + ...(policyCategoryToUpdate?.pendingFields ?? {}), + name: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + previousCategoryName: policyCategory.oldName, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + rules: { + approvalRules: updatedApprovalRules, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [policyCategory.oldName]: null, + [policyCategory.newName]: { + ...policyCategoryToUpdate, + name: policyCategory.newName, + errors: null, + pendingAction: null, + pendingFields: { + ...(policyCategoryToUpdate?.pendingFields ?? {}), + name: null, + }, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [policyCategory.newName]: null, + [policyCategory.oldName]: { + ...policyCategoryToUpdate, + name: policyCategory.oldName, + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.categories.updateFailureMessage'), + pendingAction: null, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + rules: { + approvalRules, + }, + }, + }, + ], + }; + + const parameters = { + policyID, + categories: JSON.stringify({[policyCategory.oldName]: policyCategory.newName}), + }; + + API.write(WRITE_COMMANDS.RENAME_WORKSPACE_CATEGORY, parameters, onyxData); +} + +function setPolicyCategoryPayrollCode(policyID: string, categoryName: string, payrollCode: string) { + const policyCategoryToUpdate = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]?.[categoryName] ?? {}; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + ...policyCategoryToUpdate, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + pendingFields: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'Payroll Code': CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + // eslint-disable-next-line @typescript-eslint/naming-convention + 'Payroll Code': payrollCode, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + ...policyCategoryToUpdate, + pendingAction: null, + pendingFields: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'Payroll Code': null, + }, + // eslint-disable-next-line @typescript-eslint/naming-convention + 'Payroll Code': payrollCode, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + ...policyCategoryToUpdate, + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.categories.updatePayrollCodeFailureMessage'), + pendingAction: null, + pendingFields: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'Payroll Code': null, + }, + }, + }, + }, + ], + }; + + const parameters = { + policyID, + categoryName, + payrollCode, + }; + + API.write(WRITE_COMMANDS.UPDATE_POLICY_CATEGORY_PAYROLL_CODE, parameters, onyxData); +} + +function setPolicyCategoryGLCode(policyID: string, categoryName: string, glCode: string) { + const policyCategoryToUpdate = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]?.[categoryName] ?? {}; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + ...policyCategoryToUpdate, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + pendingFields: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'GL Code': CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + // eslint-disable-next-line @typescript-eslint/naming-convention + 'GL Code': glCode, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + ...policyCategoryToUpdate, + pendingAction: null, + pendingFields: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'GL Code': null, + }, + // eslint-disable-next-line @typescript-eslint/naming-convention + 'GL Code': glCode, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + ...policyCategoryToUpdate, + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.categories.updateGLCodeFailureMessage'), + pendingAction: null, + pendingFields: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'GL Code': null, + }, + }, + }, + }, + ], + }; + + const parameters: UpdatePolicyCategoryGLCodeParams = { + policyID, + categoryName, + glCode, + }; + + API.write(WRITE_COMMANDS.UPDATE_POLICY_CATEGORY_GL_CODE, parameters, onyxData); +} + +function setWorkspaceRequiresCategory(policyID: string, requiresCategory: boolean) { + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + requiresCategory, + errors: { + requiresCategory: null, + }, + pendingFields: { + requiresCategory: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + errors: { + requiresCategory: null, + }, + pendingFields: { + requiresCategory: null, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + requiresCategory: !requiresCategory, + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.categories.updateFailureMessage'), + pendingFields: { + requiresCategory: null, + }, + }, + }, + ], + }; + + const parameters = { + policyID, + requiresCategory, + }; + + API.write(WRITE_COMMANDS.SET_WORKSPACE_REQUIRES_CATEGORY, parameters, onyxData); +} + +function clearCategoryErrors(policyID: string, categoryName: string) { + const category = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]?.[categoryName]; + + if (!category) { + return; + } + + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, { + [category.name]: { + errors: null, + }, + }); +} + +function deleteWorkspaceCategories(policyID: string, categoryNamesToDelete: string[]) { + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + const policyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`] ?? {}; + const optimisticPolicyCategoriesData = categoryNamesToDelete.reduce>>((acc, categoryName) => { + acc[categoryName] = {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}; + return acc; + }, {}); + const shouldDisableRequiresCategory = !OptionsListUtils.hasEnabledOptions( + Object.values(policyCategories).filter((category) => !categoryNamesToDelete.includes(category.name) && category.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE), + ); + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: optimisticPolicyCategoriesData, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: categoryNamesToDelete.reduce>((acc, categoryName) => { + acc[categoryName] = null; + return acc; + }, {}), + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: categoryNamesToDelete.reduce>>((acc, categoryName) => { + acc[categoryName] = { + pendingAction: null, + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.categories.deleteFailureMessage'), + }; + return acc; + }, {}), + }, + ], + }; + if (shouldDisableRequiresCategory) { + onyxData.optimisticData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + requiresCategory: false, + pendingFields: { + requiresCategory: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }); + onyxData.successData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + requiresCategory: null, + }, + }, + }); + onyxData.failureData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + requiresCategory: policy?.requiresCategory, + pendingFields: { + requiresCategory: null, + }, + }, + }); + } + + const parameters = { + policyID, + categories: JSON.stringify(categoryNamesToDelete), + }; + + API.write(WRITE_COMMANDS.DELETE_WORKSPACE_CATEGORIES, parameters, onyxData); +} + +function enablePolicyCategories(policyID: string, enabled: boolean) { + const onyxUpdatesToDisableCategories: OnyxUpdate[] = []; + if (!enabled) { + onyxUpdatesToDisableCategories.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: Object.fromEntries( + Object.entries(allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`] ?? {}).map(([categoryName]) => [ + categoryName, + { + enabled: false, + }, + ]), + ), + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + requiresCategory: false, + }, + }, + ); + } + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + areCategoriesEnabled: enabled, + pendingFields: { + areCategoriesEnabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + areCategoriesEnabled: null, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + areCategoriesEnabled: !enabled, + pendingFields: { + areCategoriesEnabled: null, + }, + }, + }, + ], + }; + + if (onyxUpdatesToDisableCategories.length > 0) { + onyxData.optimisticData?.push(...onyxUpdatesToDisableCategories); + } + + const parameters: EnablePolicyCategoriesParams = {policyID, enabled}; + + API.write(WRITE_COMMANDS.ENABLE_POLICY_CATEGORIES, parameters, onyxData); + + if (enabled && getIsNarrowLayout()) { + navigateWhenEnableFeature(policyID); + } +} + +function setPolicyDistanceRatesDefaultCategory(policyID: string, currentCustomUnit: CustomUnit, newCustomUnit: CustomUnit) { + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [newCustomUnit.customUnitID]: { + ...newCustomUnit, + pendingFields: {defaultCategory: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + }, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [newCustomUnit.customUnitID]: { + pendingFields: {defaultCategory: null}, + }, + }, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [currentCustomUnit.customUnitID]: { + ...currentCustomUnit, + errorFields: {defaultCategory: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}, + pendingFields: {defaultCategory: null}, + }, + }, + }, + }, + ]; + + const params: SetPolicyDistanceRatesDefaultCategoryParams = { + policyID, + customUnit: JSON.stringify(removePendingFieldsFromCustomUnit(newCustomUnit)), + }; + + API.write(WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_DEFAULT_CATEGORY, params, {optimisticData, successData, failureData}); +} + +function downloadCategoriesCSV(policyID: string, onDownloadFailed: () => void) { + const finalParameters = enhanceParameters(WRITE_COMMANDS.EXPORT_CATEGORIES_CSV, { + policyID, + }); + + const fileName = 'Categories.csv'; + + const formData = new FormData(); + Object.entries(finalParameters).forEach(([key, value]) => { + formData.append(key, String(value)); + }); + + fileDownload(ApiUtils.getCommandURL({command: WRITE_COMMANDS.EXPORT_CATEGORIES_CSV}), fileName, '', false, formData, CONST.NETWORK.METHOD.POST, onDownloadFailed); +} + +function setWorkspaceCategoryDescriptionHint(policyID: string, categoryName: string, commentHint: string) { + const originalCommentHint = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]?.[categoryName]?.commentHint; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + pendingFields: { + commentHint: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + commentHint, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + pendingAction: null, + pendingFields: { + commentHint: null, + }, + commentHint, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + pendingAction: null, + pendingFields: { + commentHint: null, + }, + commentHint: originalCommentHint, + }, + }, + }, + ], + }; + + const parameters: SetWorkspaceCategoryDescriptionHintParams = { + policyID, + categoryName, + commentHint, + }; + + API.write(WRITE_COMMANDS.SET_WORKSPACE_CATEGORY_DESCRIPTION_HINT, parameters, onyxData); +} + +function setPolicyCategoryMaxAmount(policyID: string, categoryName: string, maxExpenseAmount: string, expenseLimitType: PolicyCategoryExpenseLimitType) { + const policyCategoryToUpdate = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]?.[categoryName]; + const originalMaxExpenseAmount = policyCategoryToUpdate?.maxExpenseAmount; + const originalExpenseLimitType = policyCategoryToUpdate?.expenseLimitType; + const parsedMaxExpenseAmount = maxExpenseAmount === '' ? null : CurrencyUtils.convertToBackendAmount(parseFloat(maxExpenseAmount)); + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + pendingFields: { + maxExpenseAmount: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + expenseLimitType: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + maxExpenseAmount: parsedMaxExpenseAmount, + expenseLimitType, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + pendingAction: null, + pendingFields: { + maxExpenseAmount: null, + expenseLimitType: null, + }, + maxExpenseAmount: parsedMaxExpenseAmount, + expenseLimitType, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + pendingAction: null, + pendingFields: { + maxExpenseAmount: null, + expenseLimitType: null, + }, + maxExpenseAmount: originalMaxExpenseAmount, + expenseLimitType: originalExpenseLimitType, + }, + }, + }, + ], + }; + + const parameters: SetPolicyCategoryMaxAmountParams = { + policyID, + categoryName, + maxExpenseAmount: parsedMaxExpenseAmount, + expenseLimitType, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_MAX_AMOUNT, parameters, onyxData); +} + +function setPolicyCategoryApprover(policyID: string, categoryName: string, approver: string) { + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + const approvalRules = policy?.rules?.approvalRules ?? []; + let updatedApprovalRules: ApprovalRule[] = lodashCloneDeep(approvalRules); + const existingCategoryApproverRule = CategoryUtils.getCategoryApproverRule(updatedApprovalRules, categoryName); + + let newApprover = approver; + + if (!existingCategoryApproverRule) { + updatedApprovalRules.push({ + approver, + applyWhen: [ + { + condition: CONST.POLICY.RULE_CONDITIONS.MATCHES, + field: 'category', + value: categoryName, + }, + ], + }); + } else if (existingCategoryApproverRule?.approver === approver) { + updatedApprovalRules = updatedApprovalRules.filter((rule) => rule.approver !== approver); + newApprover = ''; + } else { + const indexToUpdate = updatedApprovalRules.indexOf(existingCategoryApproverRule); + updatedApprovalRules[indexToUpdate].approver = approver; + } + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + rules: { + approvalRules: updatedApprovalRules, + }, + }, + }, + ], + + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + rules: { + approvalRules, + }, + }, + }, + ], + }; + + const parameters: SetPolicyCategoryApproverParams = { + policyID, + categoryName, + approver: newApprover, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_APPROVER, parameters, onyxData); +} + +function setPolicyCategoryTax(policyID: string, categoryName: string, taxID: string) { + const policy = PolicyUtils.getPolicy(policyID); + const expenseRules = policy?.rules?.expenseRules ?? []; + const updatedExpenseRules: ExpenseRule[] = lodashCloneDeep(expenseRules); + const existingCategoryExpenseRule = updatedExpenseRules.find((rule) => rule.applyWhen.some((when) => when.value === categoryName)); + + if (!existingCategoryExpenseRule) { + updatedExpenseRules.push({ + tax: { + // eslint-disable-next-line @typescript-eslint/naming-convention + field_id_TAX: { + externalID: taxID, + }, + }, + applyWhen: [ + { + condition: CONST.POLICY.RULE_CONDITIONS.MATCHES, + field: 'category', + value: categoryName, + }, + ], + }); + } else { + const indexToUpdate = updatedExpenseRules.indexOf(existingCategoryExpenseRule); + updatedExpenseRules[indexToUpdate].tax.field_id_TAX.externalID = taxID; + } + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + rules: { + expenseRules: updatedExpenseRules, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + rules: { + expenseRules, + }, + }, + }, + ], + }; + + const parameters: SetPolicyCategoryTaxParams = { + policyID, + categoryName, + taxID, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_TAX, parameters, onyxData); +} + +export { + openPolicyCategoriesPage, + buildOptimisticPolicyRecentlyUsedCategories, + setWorkspaceCategoryEnabled, + setPolicyCategoryDescriptionRequired, + setWorkspaceCategoryDescriptionHint, + setWorkspaceRequiresCategory, + setPolicyCategoryPayrollCode, + createPolicyCategory, + renamePolicyCategory, + setPolicyCategoryGLCode, + clearCategoryErrors, + enablePolicyCategories, + setPolicyDistanceRatesDefaultCategory, + deleteWorkspaceCategories, + buildOptimisticPolicyCategories, + setPolicyCategoryReceiptsRequired, + removePolicyCategoryReceiptsRequired, + setPolicyCategoryMaxAmount, + setPolicyCategoryApprover, + setPolicyCategoryTax, + importPolicyCategories, + downloadCategoriesCSV, +}; diff --git a/src/libs/actions/Policy/DistanceRate.ts b/src/libs/actions/Policy/DistanceRate.ts index b81b180cca52..f446d2da6b52 100644 --- a/src/libs/actions/Policy/DistanceRate.ts +++ b/src/libs/actions/Policy/DistanceRate.ts @@ -128,12 +128,12 @@ function enablePolicyDistanceRates(policyID: string, enabled: boolean) { if (!enabled) { const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; - const customUnitID = Object.keys(policy?.customUnits ?? {})[0]; + const customUnitID = Object.keys(policy?.customUnits ?? {}).at(0) ?? ''; const customUnit = customUnitID ? policy?.customUnits?.[customUnitID] : undefined; const rateEntries = Object.entries(customUnit?.rates ?? {}); // find the rate to be enabled after disabling the distance rate feature - const rateEntryToBeEnabled = rateEntries[0]; + const rateEntryToBeEnabled = rateEntries.at(0); onyxData.optimisticData?.push({ onyxMethod: Onyx.METHOD.MERGE, @@ -148,7 +148,7 @@ function enablePolicyDistanceRates(policyID: string, enabled: boolean) { rateID, { ...rate, - enabled: rateID === rateEntryToBeEnabled[0], + enabled: rateID === rateEntryToBeEnabled?.at(0), }, ]; }), diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts index 146562990ca0..057729c7622c 100644 --- a/src/libs/actions/Policy/Member.ts +++ b/src/libs/actions/Policy/Member.ts @@ -395,7 +395,7 @@ function removeMembers(accountIDs: number[], policyID: string) { optimisticClosedReportActions.forEach((reportAction, index) => { failureData.push({ onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChats?.[index]?.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChats?.at(index)?.reportID}`, value: {[reportAction.reportActionID]: null}, }); }); @@ -484,7 +484,7 @@ function requestWorkspaceOwnerChange(policyID: string) { const changeOwnerErrors = Object.keys(policy?.errorFields?.changeOwner ?? {}); if (changeOwnerErrors && changeOwnerErrors.length > 0) { - const currentError = changeOwnerErrors[0]; + const currentError = changeOwnerErrors.at(0); if (currentError === CONST.POLICY.OWNERSHIP_ERRORS.AMOUNT_OWED) { ownershipChecks.shouldClearOutstandingBalance = true; } diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index b8c9014ecdcc..411c29660632 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -231,7 +231,7 @@ function getPolicy(policyID: string | undefined): OnyxEntry { function getPrimaryPolicy(activePolicyID: OnyxEntry, currentUserLogin: string | undefined): Policy | undefined { const activeAdminWorkspaces = PolicyUtils.getActiveAdminWorkspaces(allPolicies, currentUserLogin); const primaryPolicy: Policy | null | undefined = activeAdminWorkspaces.find((policy) => policy.id === activePolicyID); - return primaryPolicy ?? activeAdminWorkspaces[0]; + return primaryPolicy ?? activeAdminWorkspaces.at(0); } /** Check if the policy has invoicing company details */ @@ -1201,7 +1201,7 @@ function updateGeneralSettings(policyID: string, name: string, currencyValue?: s (request) => request.data?.policyID === policyID && request.command === WRITE_COMMANDS.CREATE_WORKSPACE && request.data?.policyName !== name, ); - const createWorkspaceRequest = persistedRequests[createWorkspaceRequestChangedIndex]; + const createWorkspaceRequest = persistedRequests.at(createWorkspaceRequestChangedIndex); if (createWorkspaceRequest) { const workspaceRequest: Request = { ...createWorkspaceRequest, @@ -1471,13 +1471,13 @@ function generateDefaultWorkspaceName(email = ''): string { if (!emailParts || emailParts.length !== 2) { return defaultWorkspaceName; } - const username = emailParts[0]; - const domain = emailParts[1]; + const username = emailParts.at(0) ?? ''; + const domain = emailParts.at(1) ?? ''; const userDetails = PersonalDetailsUtils.getPersonalDetailByEmail(sessionEmail); const displayName = userDetails?.displayName?.trim(); if (!PUBLIC_DOMAINS.some((publicDomain) => publicDomain === domain.toLowerCase())) { - defaultWorkspaceName = `${Str.UCFirst(domain.split('.')[0])}'s Workspace`; + defaultWorkspaceName = `${Str.UCFirst(domain.split('.').at(0) ?? '')}'s Workspace`; } else if (displayName) { defaultWorkspaceName = `${Str.UCFirst(displayName)}'s Workspace`; } else if (PUBLIC_DOMAINS.some((publicDomain) => publicDomain === domain.toLowerCase())) { @@ -2455,7 +2455,7 @@ function createWorkspaceFromIOUPayment(iouReport: OnyxEntry): WorkspaceF onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceChatReportID}`, value: { - [Object.keys(announceChatData)[0]]: { + [Object.keys(announceChatData).at(0) ?? '']: { pendingAction: null, }, }, @@ -2474,7 +2474,7 @@ function createWorkspaceFromIOUPayment(iouReport: OnyxEntry): WorkspaceF onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminsChatReportID}`, value: { - [Object.keys(adminsChatData)[0]]: { + [Object.keys(adminsChatData).at(0) ?? '']: { pendingAction: null, }, }, @@ -2493,7 +2493,7 @@ function createWorkspaceFromIOUPayment(iouReport: OnyxEntry): WorkspaceF onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChatReportID}`, value: { - [Object.keys(workspaceChatData)[0]]: { + [Object.keys(workspaceChatData).at(0) ?? '']: { pendingAction: null, }, }, diff --git a/src/libs/actions/Policy/Policy.ts.orig b/src/libs/actions/Policy/Policy.ts.orig new file mode 100644 index 000000000000..b8c9014ecdcc --- /dev/null +++ b/src/libs/actions/Policy/Policy.ts.orig @@ -0,0 +1,5026 @@ +import {PUBLIC_DOMAINS, Str} from 'expensify-common'; +import lodashClone from 'lodash/clone'; +import escapeRegExp from 'lodash/escapeRegExp'; +import lodashUnion from 'lodash/union'; +import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import type {ReportExportType} from '@components/ButtonWithDropdownMenu/types'; +import * as API from '@libs/API'; +import type { + AddBillingCardAndRequestWorkspaceOwnerChangeParams, + CreateWorkspaceFromIOUPaymentParams, + CreateWorkspaceParams, + DeleteWorkspaceAvatarParams, + DeleteWorkspaceParams, + DisablePolicyBillableModeParams, + EnablePolicyAutoApprovalOptionsParams, + EnablePolicyAutoReimbursementLimitParams, + EnablePolicyCompanyCardsParams, + EnablePolicyConnectionsParams, + EnablePolicyDefaultReportTitleParams, + EnablePolicyExpensifyCardsParams, + EnablePolicyInvoicingParams, + EnablePolicyReportFieldsParams, + EnablePolicyTaxesParams, + EnablePolicyWorkflowsParams, + LeavePolicyParams, + OpenDraftWorkspaceRequestParams, + OpenPolicyEditCardLimitTypePageParams, + OpenPolicyExpensifyCardsPageParams, + OpenPolicyInitialPageParams, + OpenPolicyMoreFeaturesPageParams, + OpenPolicyProfilePageParams, + OpenPolicyTaxesPageParams, + OpenPolicyWorkflowsPageParams, + OpenWorkspaceInvitePageParams, + OpenWorkspaceParams, + OpenWorkspaceReimburseViewParams, + RequestExpensifyCardLimitIncreaseParams, + SetCompanyCardExportAccountParams, + SetPolicyAutomaticApprovalLimitParams, + SetPolicyAutomaticApprovalRateParams, + SetPolicyAutoReimbursementLimitParams, + SetPolicyBillableModeParams, + SetPolicyDefaultReportTitleParams, + SetPolicyPreventMemberCreatedTitleParams, + SetPolicyPreventSelfApprovalParams, + SetPolicyRulesEnabledParams, + SetWorkspaceApprovalModeParams, + SetWorkspaceAutoReportingFrequencyParams, + SetWorkspaceAutoReportingMonthlyOffsetParams, + SetWorkspacePayerParams, + SetWorkspaceReimbursementParams, + UpdateCompanyCardNameParams, + UpdatePolicyAddressParams, + UpdateWorkspaceAvatarParams, + UpdateWorkspaceCustomUnitAndRateParams, + UpdateWorkspaceDescriptionParams, + UpdateWorkspaceGeneralSettingsParams, + UpgradeToCorporateParams, +} from '@libs/API/parameters'; +import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import * as CurrencyUtils from '@libs/CurrencyUtils'; +import DateUtils from '@libs/DateUtils'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import getIsNarrowLayout from '@libs/getIsNarrowLayout'; +import Log from '@libs/Log'; +import * as NetworkStore from '@libs/Network/NetworkStore'; +import * as NumberUtils from '@libs/NumberUtils'; +import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; +import * as PhoneNumber from '@libs/PhoneNumber'; +import * as PolicyUtils from '@libs/PolicyUtils'; +import {navigateWhenEnableFeature} from '@libs/PolicyUtils'; +import * as ReportActionsConnection from '@libs/ReportActionsConnection'; +import * as ReportConnection from '@libs/ReportConnection'; +import * as ReportUtils from '@libs/ReportUtils'; +import * as TransactionUtils from '@libs/TransactionUtils'; +import type {PolicySelector} from '@pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover'; +import * as PersistedRequests from '@userActions/PersistedRequests'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type { + InvitedEmailsToAccountIDs, + PersonalDetailsList, + Policy, + PolicyCategory, + ReimbursementAccount, + Report, + ReportAction, + Request, + TaxRatesWithDefault, + Transaction, +} from '@src/types/onyx'; +import type {Errors} from '@src/types/onyx/OnyxCommon'; +import type {Attributes, CompanyAddress, CustomUnit, NetSuiteCustomList, NetSuiteCustomSegment, Rate, TaxRate, Unit} from '@src/types/onyx/Policy'; +import type {OnyxData} from '@src/types/onyx/Request'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import {buildOptimisticPolicyCategories} from './Category'; + +type ReportCreationData = Record< + string, + { + reportID: string; + reportActionID?: string; + } +>; + +type WorkspaceMembersChats = { + onyxSuccessData: OnyxUpdate[]; + onyxOptimisticData: OnyxUpdate[]; + onyxFailureData: OnyxUpdate[]; + reportCreationData: ReportCreationData; +}; + +type OptimisticCustomUnits = { + customUnits: Record; + customUnitID: string; + customUnitRateID: string; + outputCurrency: string; +}; + +type NewCustomUnit = { + customUnitID: string; + name: string; + attributes: Attributes; + rates: Rate; +}; + +type WorkspaceFromIOUCreationData = { + policyID: string; + workspaceChatReportID: string; + reportPreviewReportActionID?: string; +}; + +const allPolicies: OnyxCollection = {}; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.POLICY, + callback: (val, key) => { + if (!key) { + return; + } + if (val === null || val === undefined) { + // If we are deleting a policy, we have to check every report linked to that policy + // and unset the draft indicator (pencil icon) alongside removing any draft comments. Clearing these values will keep the newly archived chats from being displayed in the LHN. + // More info: https://github.com/Expensify/App/issues/14260 + const policyID = key.replace(ONYXKEYS.COLLECTION.POLICY, ''); + const policyReports = ReportUtils.getAllPolicyReports(policyID); + const cleanUpMergeQueries: Record<`${typeof ONYXKEYS.COLLECTION.REPORT}${string}`, NullishDeep> = {}; + const cleanUpSetQueries: Record<`${typeof ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${string}` | `${typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${string}`, null> = {}; + policyReports.forEach((policyReport) => { + if (!policyReport) { + return; + } + const {reportID} = policyReport; + cleanUpSetQueries[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`] = null; + cleanUpSetQueries[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`] = null; + }); + Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT, cleanUpMergeQueries); + Onyx.multiSet(cleanUpSetQueries); + delete allPolicies[key]; + return; + } + + allPolicies[key] = val; + }, +}); + +let lastAccessedWorkspacePolicyID: OnyxEntry; +Onyx.connect({ + key: ONYXKEYS.LAST_ACCESSED_WORKSPACE_POLICY_ID, + callback: (value) => (lastAccessedWorkspacePolicyID = value), +}); + +let sessionEmail = ''; +let sessionAccountID = 0; +Onyx.connect({ + key: ONYXKEYS.SESSION, + callback: (val) => { + sessionEmail = val?.email ?? ''; + sessionAccountID = val?.accountID ?? -1; + }, +}); + +let allPersonalDetails: OnyxEntry; +Onyx.connect({ + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + callback: (val) => (allPersonalDetails = val), +}); + +let reimbursementAccount: OnyxEntry; +Onyx.connect({ + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + callback: (val) => (reimbursementAccount = val), +}); + +let allRecentlyUsedCurrencies: string[]; +Onyx.connect({ + key: ONYXKEYS.RECENTLY_USED_CURRENCIES, + callback: (val) => (allRecentlyUsedCurrencies = val ?? []), +}); + +/** + * Stores in Onyx the policy ID of the last workspace that was accessed by the user + */ +function updateLastAccessedWorkspace(policyID: OnyxEntry) { + Onyx.set(ONYXKEYS.LAST_ACCESSED_WORKSPACE_POLICY_ID, policyID ?? null); +} + +/** + * Checks if the currency is supported for direct reimbursement + * USD currency is the only one supported in NewDot for now + */ +function isCurrencySupportedForDirectReimbursement(currency: string) { + return currency === CONST.CURRENCY.USD; +} + +/** + * Returns the policy of the report + */ +function getPolicy(policyID: string | undefined): OnyxEntry { + if (!allPolicies || !policyID) { + return undefined; + } + return allPolicies[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; +} + +/** + * Returns a primary policy for the user + */ +// TODO: Use getInvoicePrimaryWorkspace when the invoices screen is ready - https://github.com/Expensify/App/issues/45175. +function getPrimaryPolicy(activePolicyID: OnyxEntry, currentUserLogin: string | undefined): Policy | undefined { + const activeAdminWorkspaces = PolicyUtils.getActiveAdminWorkspaces(allPolicies, currentUserLogin); + const primaryPolicy: Policy | null | undefined = activeAdminWorkspaces.find((policy) => policy.id === activePolicyID); + return primaryPolicy ?? activeAdminWorkspaces[0]; +} + +/** Check if the policy has invoicing company details */ +function hasInvoicingDetails(policy: OnyxEntry): boolean { + return !!policy?.invoice?.companyName && !!policy?.invoice?.companyWebsite; +} + +/** + * Returns a primary invoice workspace for the user + */ +function getInvoicePrimaryWorkspace(activePolicyID: OnyxEntry, currentUserLogin: string | undefined): Policy | undefined { + if (PolicyUtils.canSendInvoiceFromWorkspace(activePolicyID)) { + return allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID ?? '-1'}`]; + } + const activeAdminWorkspaces = PolicyUtils.getActiveAdminWorkspaces(allPolicies, currentUserLogin); + return activeAdminWorkspaces.find((policy) => PolicyUtils.canSendInvoiceFromWorkspace(policy.id)); +} + +/** + * Check if the user has any active free policies (aka workspaces) + */ +function hasActiveChatEnabledPolicies(policies: Array> | OnyxCollection, includeOnlyAdminPolicies = false): boolean { + const chatEnabledPolicies = Object.values(policies ?? {}).filter( + (policy) => policy?.isPolicyExpenseChatEnabled && (!includeOnlyAdminPolicies || policy.role === CONST.POLICY.ROLE.ADMIN), + ); + + if (chatEnabledPolicies.length === 0) { + return false; + } + + if (chatEnabledPolicies.some((policy) => !policy?.pendingAction)) { + return true; + } + + if (chatEnabledPolicies.some((policy) => policy?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD)) { + return true; + } + + if (chatEnabledPolicies.some((policy) => policy?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE)) { + return false; + } + + // If there are no add or delete pending actions the only option left is an update + // pendingAction, in which case we should return true. + return true; +} + +/** + * Delete the workspace + */ +function deleteWorkspace(policyID: string, policyName: string) { + if (!allPolicies) { + return; + } + + const filteredPolicies = Object.values(allPolicies).filter((policy): policy is Policy => policy?.id !== policyID); + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + avatarURL: '', + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + errors: null, + }, + }, + ...(!hasActiveChatEnabledPolicies(filteredPolicies, true) + ? [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + value: { + errors: null, + }, + }, + ] + : []), + ]; + + const reportsToArchive = Object.values(ReportConnection.getAllReports() ?? {}).filter( + (report) => report?.policyID === policyID && (ReportUtils.isChatRoom(report) || ReportUtils.isPolicyExpenseChat(report) || ReportUtils.isTaskReport(report)), + ); + const finallyData: OnyxUpdate[] = []; + const currentTime = DateUtils.getDBTime(); + reportsToArchive.forEach((report) => { + const {reportID, ownerAccountID} = report ?? {}; + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: { + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.CLOSED, + oldPolicyName: allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]?.name ?? '', + policyName: '', + // eslint-disable-next-line @typescript-eslint/naming-convention + private_isArchived: currentTime, + }, + }); + + optimisticData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`, + value: null, + }); + + // Add closed actions to all chat reports linked to this policy + // Announce & admin chats have FAKE owners, but workspace chats w/ users do have owners. + let emailClosingReport: string = CONST.POLICY.OWNER_EMAIL_FAKE; + if (!!ownerAccountID && ownerAccountID !== CONST.POLICY.OWNER_ACCOUNT_ID_FAKE) { + emailClosingReport = allPersonalDetails?.[ownerAccountID]?.login ?? ''; + } + const optimisticClosedReportAction = ReportUtils.buildOptimisticClosedReportAction(emailClosingReport, policyName, CONST.REPORT.ARCHIVE_REASON.POLICY_DELETED); + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + value: { + [optimisticClosedReportAction.reportActionID]: optimisticClosedReportAction as ReportAction, + }, + }); + + // We are temporarily adding this workaround because 'DeleteWorkspace' doesn't + // support receiving the optimistic reportActions' ids for the moment. + finallyData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + value: { + [optimisticClosedReportAction.reportActionID]: null, + }, + }); + }); + + const policy = getPolicy(policyID); + // Restore the old report stateNum and statusNum + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + value: { + errors: reimbursementAccount?.errors ?? null, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + avatarURL: policy?.avatarURL, + }, + }, + ]; + + reportsToArchive.forEach((report) => { + const {reportID, stateNum, statusNum, oldPolicyName} = report ?? {}; + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: { + stateNum, + statusNum, + oldPolicyName, + policyName: report?.policyName, + // eslint-disable-next-line @typescript-eslint/naming-convention + private_isArchived: null, + }, + }); + }); + + const params: DeleteWorkspaceParams = {policyID}; + + API.write(WRITE_COMMANDS.DELETE_WORKSPACE, params, {optimisticData, finallyData, failureData}); + + // Reset the lastAccessedWorkspacePolicyID + if (policyID === lastAccessedWorkspacePolicyID) { + updateLastAccessedWorkspace(undefined); + } +} + +function setWorkspaceAutoReportingFrequency(policyID: string, frequency: ValueOf) { + const policy = getPolicy(policyID); + + const wasPolicyOnManualReporting = PolicyUtils.getCorrectedAutoReportingFrequency(policy) === CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MANUAL; + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + // Recall that the "daily" and "manual" frequencies don't actually exist in Onyx or the DB (see PolicyUtils.getCorrectedAutoReportingFrequency) + autoReportingFrequency: frequency === CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MANUAL ? CONST.POLICY.AUTO_REPORTING_FREQUENCIES.IMMEDIATE : frequency, + pendingFields: {autoReportingFrequency: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + + // To set the frequency to "manual", we really must set it to "immediate" with harvesting disabled + ...(frequency === CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MANUAL && { + harvesting: { + enabled: false, + }, + }), + + // If the policy was on manual reporting before, and now will be auto-reported, + // then we must re-enable harvesting + ...(wasPolicyOnManualReporting && + frequency !== CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MANUAL && { + harvesting: { + enabled: true, + }, + }), + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoReportingFrequency: policy?.autoReportingFrequency ?? null, + harvesting: policy?.harvesting ?? null, + pendingFields: {autoReportingFrequency: null}, + errorFields: {autoReportingFrequency: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workflowsDelayedSubmissionPage.autoReportingFrequencyErrorMessage')}, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: {autoReportingFrequency: null}, + }, + }, + ]; + + const params: SetWorkspaceAutoReportingFrequencyParams = {policyID, frequency}; + API.write(WRITE_COMMANDS.SET_WORKSPACE_AUTO_REPORTING_FREQUENCY, params, {optimisticData, failureData, successData}); +} + +function setWorkspaceAutoReportingMonthlyOffset(policyID: string, autoReportingOffset: number | ValueOf) { + const value = JSON.stringify({autoReportingOffset}); + const policy = getPolicy(policyID); + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoReportingOffset, + pendingFields: {autoReportingOffset: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoReportingOffset: policy?.autoReportingOffset ?? null, + pendingFields: {autoReportingOffset: null}, + errorFields: {autoReportingOffset: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workflowsDelayedSubmissionPage.monthlyOffsetErrorMessage')}, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: {autoReportingOffset: null}, + }, + }, + ]; + + const params: SetWorkspaceAutoReportingMonthlyOffsetParams = {policyID, value}; + API.write(WRITE_COMMANDS.SET_WORKSPACE_AUTO_REPORTING_MONTHLY_OFFSET, params, {optimisticData, failureData, successData}); +} + +function setWorkspaceApprovalMode(policyID: string, approver: string, approvalMode: ValueOf) { + const policy = getPolicy(policyID); + + const value = { + approver, + approvalMode, + }; + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + ...value, + pendingFields: {approvalMode: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + approver: policy?.approver, + approvalMode: policy?.approvalMode, + pendingFields: {approvalMode: null}, + errorFields: {approvalMode: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workflowsApproverPage.genericErrorMessage')}, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: {approvalMode: null}, + }, + }, + ]; + + const params: SetWorkspaceApprovalModeParams = { + policyID, + value: JSON.stringify({ + ...value, + // This property should now be set to false for all Collect policies + isAutoApprovalEnabled: false, + }), + }; + API.write(WRITE_COMMANDS.SET_WORKSPACE_APPROVAL_MODE, params, {optimisticData, failureData, successData}); +} + +function setWorkspacePayer(policyID: string, reimburserEmail: string) { + const policy = getPolicy(policyID); + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + achAccount: {reimburser: reimburserEmail}, + errorFields: {reimburser: null}, + pendingFields: {reimburser: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + errorFields: {reimburser: null}, + pendingFields: {reimburser: null}, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + achAccount: {reimburser: policy?.achAccount?.reimburser ?? null}, + errorFields: {reimburser: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workflowsPayerPage.genericErrorMessage')}, + pendingFields: {reimburser: null}, + }, + }, + ]; + + const params: SetWorkspacePayerParams = {policyID, reimburserEmail}; + + API.write(WRITE_COMMANDS.SET_WORKSPACE_PAYER, params, {optimisticData, failureData, successData}); +} + +function clearPolicyErrorField(policyID: string, fieldName: string) { + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {errorFields: {[fieldName]: null}}); +} + +function clearQBOErrorField(policyID: string, fieldName: string) { + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {connections: {quickbooksOnline: {config: {errorFields: {[fieldName]: null}}}}}); +} + +function clearXeroErrorField(policyID: string, fieldName: string) { + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {connections: {xero: {config: {errorFields: {[fieldName]: null}}}}}); +} + +function clearNetSuiteErrorField(policyID: string, fieldName: string) { + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {connections: {netsuite: {options: {config: {errorFields: {[fieldName]: null}}}}}}); +} + +function clearNetSuitePendingField(policyID: string, fieldName: string) { + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {connections: {netsuite: {options: {config: {pendingFields: {[fieldName]: null}}}}}}); +} + +function removeNetSuiteCustomFieldByIndex(allRecords: NetSuiteCustomSegment[] | NetSuiteCustomList[], policyID: string, importCustomField: string, valueIndex: number) { + // We allow multiple custom list records with the same internalID. Hence it is safe to remove by index. + const filteredRecords = allRecords.filter((_, index) => index !== Number(valueIndex)); + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { + connections: { + netsuite: { + options: { + config: { + syncOptions: { + [importCustomField]: filteredRecords, + }, + }, + }, + }, + }, + }); +} + +function clearSageIntacctErrorField(policyID: string, fieldName: string) { + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {connections: {intacct: {config: {errorFields: {[fieldName]: null}}}}}); +} + +function clearNetSuiteAutoSyncErrorField(policyID: string) { + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {connections: {netsuite: {config: {errorFields: {autoSync: null}}}}}); +} + +function setWorkspaceReimbursement(policyID: string, reimbursementChoice: ValueOf, reimburserEmail: string) { + const policy = getPolicy(policyID); + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + reimbursementChoice, + isLoadingWorkspaceReimbursement: true, + achAccount: {reimburser: reimburserEmail}, + errorFields: {reimbursementChoice: null}, + pendingFields: {reimbursementChoice: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + isLoadingWorkspaceReimbursement: false, + errorFields: {reimbursementChoice: null}, + pendingFields: {reimbursementChoice: null}, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + isLoadingWorkspaceReimbursement: false, + reimbursementChoice: policy?.reimbursementChoice ?? null, + achAccount: {reimburser: policy?.achAccount?.reimburser ?? null}, + errorFields: {reimbursementChoice: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}, + pendingFields: {reimbursementChoice: null}, + }, + }, + ]; + + const params: SetWorkspaceReimbursementParams = {policyID, reimbursementChoice}; + + API.write(WRITE_COMMANDS.SET_WORKSPACE_REIMBURSEMENT, params, {optimisticData, failureData, successData}); +} + +function clearWorkspaceReimbursementErrors(policyID: string) { + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {errorFields: {reimbursementChoice: null}}); +} + +function leaveWorkspace(policyID: string) { + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + const workspaceChats = ReportUtils.getAllWorkspaceReports(policyID); + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + employeeList: { + [sessionEmail]: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + }, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: null, + }, + ]; + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingAction: policy?.pendingAction, + employeeList: { + [sessionEmail]: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.people.error.genericRemove'), + }, + }, + }, + }, + ]; + + const pendingChatMembers = ReportUtils.getPendingChatMembers([sessionAccountID], [], CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + + workspaceChats.forEach((report) => { + const parentReport = ReportUtils.getRootParentReport(report); + const reportToCheckOwner = isEmptyObject(parentReport) ? report : parentReport; + + if (ReportUtils.isPolicyExpenseChat(report) && !ReportUtils.isReportOwner(reportToCheckOwner)) { + return; + } + + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${report?.reportID}`, + value: { + statusNum: CONST.REPORT.STATUS_NUM.CLOSED, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + oldPolicyName: policy?.name ?? '', + pendingChatMembers, + }, + }); + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${report?.reportID}`, + value: { + pendingChatMembers: null, + }, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${report?.reportID}`, + value: { + pendingChatMembers: null, + }, + }); + }); + + const params: LeavePolicyParams = { + policyID, + email: sessionEmail, + }; + API.write(WRITE_COMMANDS.LEAVE_POLICY, params, {optimisticData, successData, failureData}); +} + +function addBillingCardAndRequestPolicyOwnerChange( + policyID: string, + cardData: { + cardNumber: string; + cardYear: string; + cardMonth: string; + cardCVV: string; + addressName: string; + addressZip: string; + currency: string; + }, +) { + const {cardNumber, cardYear, cardMonth, cardCVV, addressName, addressZip, currency} = cardData; + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + errorFields: null, + isLoading: true, + isChangeOwnerSuccessful: false, + isChangeOwnerFailed: false, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + isLoading: false, + isChangeOwnerSuccessful: true, + isChangeOwnerFailed: false, + owner: sessionEmail, + ownerAccountID: sessionAccountID, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + isLoading: false, + isChangeOwnerSuccessful: false, + isChangeOwnerFailed: true, + }, + }, + ]; + + const params: AddBillingCardAndRequestWorkspaceOwnerChangeParams = { + policyID, + cardNumber, + cardYear, + cardMonth, + cardCVV, + addressName, + addressZip, + currency, + }; + + API.write(WRITE_COMMANDS.ADD_BILLING_CARD_AND_REQUEST_WORKSPACE_OWNER_CHANGE, params, {optimisticData, successData, failureData}); +} + +/** + * Optimistically create a chat for each member of the workspace, creates both optimistic and success data for onyx. + * + * @returns - object with onyxSuccessData, onyxOptimisticData, and optimisticReportIDs (map login to reportID) + */ +function createPolicyExpenseChats(policyID: string, invitedEmailsToAccountIDs: InvitedEmailsToAccountIDs, hasOutstandingChildRequest = false): WorkspaceMembersChats { + const workspaceMembersChats: WorkspaceMembersChats = { + onyxSuccessData: [], + onyxOptimisticData: [], + onyxFailureData: [], + reportCreationData: {}, + }; + + Object.keys(invitedEmailsToAccountIDs).forEach((email) => { + const accountID = invitedEmailsToAccountIDs[email]; + const cleanAccountID = Number(accountID); + const login = PhoneNumber.addSMSDomainIfPhoneNumber(email); + + const oldChat = ReportUtils.getPolicyExpenseChat(cleanAccountID, policyID); + + // If the chat already exists, we don't want to create a new one - just make sure it's not archived + if (oldChat) { + workspaceMembersChats.reportCreationData[login] = { + reportID: oldChat.reportID, + }; + workspaceMembersChats.onyxOptimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${oldChat.reportID}`, + value: { + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }, + }); + return; + } + const optimisticReport = ReportUtils.buildOptimisticChatReport([sessionAccountID, cleanAccountID], undefined, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, policyID, cleanAccountID); + const optimisticCreatedAction = ReportUtils.buildOptimisticCreatedReportAction(login); + + workspaceMembersChats.reportCreationData[login] = { + reportID: optimisticReport.reportID, + reportActionID: optimisticCreatedAction.reportActionID, + }; + + workspaceMembersChats.onyxOptimisticData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticReport.reportID}`, + value: { + ...optimisticReport, + pendingFields: { + createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + isOptimisticReport: true, + hasOutstandingChildRequest, + pendingChatMembers: [ + { + accountID: accountID.toString(), + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + ], + }, + }); + workspaceMembersChats.onyxOptimisticData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticReport.reportID}`, + value: {[optimisticCreatedAction.reportActionID]: optimisticCreatedAction}, + }); + + workspaceMembersChats.onyxSuccessData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticReport.reportID}`, + value: { + pendingFields: { + createChat: null, + }, + errorFields: { + createChat: null, + }, + isOptimisticReport: false, + pendingChatMembers: null, + participants: { + [accountID]: allPersonalDetails && allPersonalDetails[accountID] ? {} : null, + }, + }, + }); + workspaceMembersChats.onyxSuccessData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticReport.reportID}`, + value: {[optimisticCreatedAction.reportActionID]: {pendingAction: null}}, + }); + + workspaceMembersChats.onyxFailureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${optimisticReport.reportID}`, + value: { + isLoadingInitialReportActions: false, + }, + }); + }); + return workspaceMembersChats; +} + +/** + * Updates a workspace avatar image + */ +function updateWorkspaceAvatar(policyID: string, file: File) { + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + avatarURL: file.uri, + originalFileName: file.name, + errorFields: { + avatarURL: null, + }, + pendingFields: { + avatarURL: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ]; + const finallyData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + avatarURL: null, + }, + }, + }, + ]; + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + avatarURL: allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]?.avatarURL, + }, + }, + ]; + + const params: UpdateWorkspaceAvatarParams = { + policyID, + file, + }; + + API.write(WRITE_COMMANDS.UPDATE_WORKSPACE_AVATAR, params, {optimisticData, finallyData, failureData}); +} + +/** + * Deletes the avatar image for the workspace + */ +function deleteWorkspaceAvatar(policyID: string) { + const policy = getPolicy(policyID); + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + avatarURL: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + errorFields: { + avatarURL: null, + }, + avatarURL: '', + originalFileName: null, + }, + }, + ]; + const finallyData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + avatarURL: null, + }, + }, + }, + ]; + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + avatarURL: policy?.avatarURL, + originalFileName: policy?.originalFileName, + errorFields: { + avatarURL: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('avatarWithImagePicker.deleteWorkspaceError'), + }, + }, + }, + ]; + + const params: DeleteWorkspaceAvatarParams = {policyID}; + + API.write(WRITE_COMMANDS.DELETE_WORKSPACE_AVATAR, params, {optimisticData, finallyData, failureData}); +} + +/** + * Clear error and pending fields for the workspace avatar + */ +function clearAvatarErrors(policyID: string) { + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { + errorFields: { + avatarURL: null, + }, + pendingFields: { + avatarURL: null, + }, + }); +} + +/** + * Optimistically update the general settings. Set the general settings as pending until the response succeeds. + * If the response fails set a general error message. Clear the error message when updating. + */ +function updateGeneralSettings(policyID: string, name: string, currencyValue?: string) { + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + if (!policy) { + return; + } + + const distanceUnit = PolicyUtils.getCustomUnit(policy); + const customUnitID = distanceUnit?.customUnitID; + const currency = currencyValue ?? policy?.outputCurrency ?? CONST.CURRENCY.USD; + + const currencyPendingAction = currency !== policy?.outputCurrency ? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE : undefined; + const namePendingAction = name !== policy?.name ? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE : undefined; + + const currentRates = distanceUnit?.rates ?? {}; + const optimisticRates: Record = {}; + const finallyRates: Record = {}; + const failureRates: Record = {}; + + if (customUnitID) { + for (const rateID of Object.keys(currentRates)) { + optimisticRates[rateID] = { + ...currentRates[rateID], + pendingFields: {currency: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + currency, + }; + finallyRates[rateID] = { + ...currentRates[rateID], + pendingFields: {currency: null}, + currency, + }; + failureRates[rateID] = { + ...currentRates[rateID], + pendingFields: {currency: null}, + errorFields: {currency: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}, + }; + } + } + + const optimisticData: OnyxUpdate[] = [ + { + // We use SET because it's faster than merge and avoids a race condition when setting the currency and navigating the user to the Bank account page in confirmCurrencyChangeAndHideModal + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + ...policy, + + pendingFields: { + ...policy.pendingFields, + ...(namePendingAction !== undefined && {name: namePendingAction}), + ...(currencyPendingAction !== undefined && {outputCurrency: currencyPendingAction}), + }, + + // Clear errorFields in case the user didn't dismiss the general settings error + errorFields: { + name: null, + outputCurrency: null, + }, + name, + outputCurrency: currency, + ...(customUnitID && { + customUnits: { + [customUnitID]: { + ...distanceUnit, + rates: optimisticRates, + }, + }, + }), + }, + }, + ]; + const finallyData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + name: null, + outputCurrency: null, + }, + ...(customUnitID && { + customUnits: { + [customUnitID]: { + ...distanceUnit, + rates: finallyRates, + }, + }, + }), + }, + }, + ]; + + const errorFields: Policy['errorFields'] = { + name: namePendingAction && ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.editor.genericFailureMessage'), + }; + + if (!errorFields.name && currencyPendingAction) { + errorFields.outputCurrency = ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.editor.genericFailureMessage'); + } + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + errorFields, + ...(customUnitID && { + customUnits: { + [customUnitID]: { + ...distanceUnit, + rates: failureRates, + }, + }, + }), + }, + }, + ]; + + const params: UpdateWorkspaceGeneralSettingsParams = { + policyID, + workspaceName: name, + currency, + }; + + const persistedRequests = PersistedRequests.getAll(); + const createWorkspaceRequestChangedIndex = persistedRequests.findIndex( + (request) => request.data?.policyID === policyID && request.command === WRITE_COMMANDS.CREATE_WORKSPACE && request.data?.policyName !== name, + ); + + const createWorkspaceRequest = persistedRequests[createWorkspaceRequestChangedIndex]; + if (createWorkspaceRequest) { + const workspaceRequest: Request = { + ...createWorkspaceRequest, + data: { + ...createWorkspaceRequest.data, + policyName: name, + }, + }; + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { + name, + }); + + PersistedRequests.update(createWorkspaceRequestChangedIndex, workspaceRequest); + return; + } + + API.write(WRITE_COMMANDS.UPDATE_WORKSPACE_GENERAL_SETTINGS, params, { + optimisticData, + finallyData, + failureData, + }); +} + +function updateWorkspaceDescription(policyID: string, description: string, currentDescription: string) { + if (description === currentDescription) { + return; + } + const parsedDescription = ReportUtils.getParsedComment(description); + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + description: parsedDescription, + pendingFields: { + description: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + errorFields: { + description: null, + }, + }, + }, + ]; + const finallyData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + description: null, + }, + }, + }, + ]; + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + errorFields: { + description: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.editor.genericFailureMessage'), + }, + }, + }, + ]; + + const params: UpdateWorkspaceDescriptionParams = { + policyID, + description: parsedDescription, + }; + + API.write(WRITE_COMMANDS.UPDATE_WORKSPACE_DESCRIPTION, params, { + optimisticData, + finallyData, + failureData, + }); +} + +function setWorkspaceErrors(policyID: string, errors: Errors) { + if (!allPolicies?.[policyID]) { + return; + } + + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {errors: null}); + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {errors}); +} + +function clearCustomUnitErrors(policyID: string, customUnitID: string, customUnitRateID: string) { + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { + customUnits: { + [customUnitID]: { + errors: null, + pendingAction: null, + rates: { + [customUnitRateID]: { + errors: null, + pendingAction: null, + }, + }, + }, + }, + }); +} + +function hideWorkspaceAlertMessage(policyID: string) { + if (!allPolicies?.[policyID]) { + return; + } + + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {alertMessage: ''}); +} + +function updateAddress(policyID: string, newAddress: CompanyAddress) { + // TODO: Change API endpoint parameters format to make it possible to follow naming-convention + const parameters: UpdatePolicyAddressParams = { + policyID, + // eslint-disable-next-line @typescript-eslint/naming-convention + 'data[addressStreet]': newAddress.addressStreet, + // eslint-disable-next-line @typescript-eslint/naming-convention + 'data[city]': newAddress.city, + // eslint-disable-next-line @typescript-eslint/naming-convention + 'data[country]': newAddress.country, + // eslint-disable-next-line @typescript-eslint/naming-convention + 'data[state]': newAddress.state, + // eslint-disable-next-line @typescript-eslint/naming-convention + 'data[zipCode]': newAddress.zipCode, + }; + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + address: newAddress, + pendingFields: { + address: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ]; + + const finallyData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + address: newAddress, + pendingFields: { + address: null, + }, + }, + }, + ]; + + API.write(WRITE_COMMANDS.UPDATE_POLICY_ADDRESS, parameters, { + optimisticData, + finallyData, + }); +} + +function updateWorkspaceCustomUnitAndRate(policyID: string, currentCustomUnit: CustomUnit, newCustomUnit: NewCustomUnit, lastModified?: string) { + if (!currentCustomUnit.customUnitID || !newCustomUnit?.customUnitID || !newCustomUnit.rates?.customUnitRateID) { + return; + } + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [newCustomUnit.customUnitID]: { + ...newCustomUnit, + rates: { + [newCustomUnit.rates.customUnitRateID]: { + ...newCustomUnit.rates, + errors: null, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [newCustomUnit.customUnitID]: { + pendingAction: null, + errors: null, + rates: { + [newCustomUnit.rates.customUnitRateID]: { + pendingAction: null, + }, + }, + }, + }, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [currentCustomUnit.customUnitID]: { + customUnitID: currentCustomUnit.customUnitID, + rates: { + [newCustomUnit.rates.customUnitRateID]: { + ...currentCustomUnit.rates, + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.reimburse.updateCustomUnitError'), + }, + }, + }, + }, + }, + }, + ]; + + const newCustomUnitParam = lodashClone(newCustomUnit); + const {pendingAction, errors, ...newRates} = newCustomUnitParam.rates ?? {}; + newCustomUnitParam.rates = newRates; + + const params: UpdateWorkspaceCustomUnitAndRateParams = { + policyID, + lastModified, + customUnit: JSON.stringify(newCustomUnitParam), + customUnitRate: JSON.stringify(newCustomUnitParam.rates), + }; + + API.write(WRITE_COMMANDS.UPDATE_WORKSPACE_CUSTOM_UNIT_AND_RATE, params, {optimisticData, successData, failureData}); +} + +/** + * Removes an error after trying to delete a workspace + */ +function clearDeleteWorkspaceError(policyID: string) { + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { + pendingAction: null, + errors: null, + }); +} + +/** + * Removes the workspace after failure to create. + */ +function removeWorkspace(policyID: string) { + Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, null); +} + +/** + * Generate a policy name based on an email and policy list. + * @param [email] the email to base the workspace name on. If not passed, will use the logged-in user's email instead + */ +function generateDefaultWorkspaceName(email = ''): string { + const emailParts = email ? email.split('@') : sessionEmail.split('@'); + let defaultWorkspaceName = ''; + if (!emailParts || emailParts.length !== 2) { + return defaultWorkspaceName; + } + const username = emailParts[0]; + const domain = emailParts[1]; + const userDetails = PersonalDetailsUtils.getPersonalDetailByEmail(sessionEmail); + const displayName = userDetails?.displayName?.trim(); + + if (!PUBLIC_DOMAINS.some((publicDomain) => publicDomain === domain.toLowerCase())) { + defaultWorkspaceName = `${Str.UCFirst(domain.split('.')[0])}'s Workspace`; + } else if (displayName) { + defaultWorkspaceName = `${Str.UCFirst(displayName)}'s Workspace`; + } else if (PUBLIC_DOMAINS.some((publicDomain) => publicDomain === domain.toLowerCase())) { + defaultWorkspaceName = `${Str.UCFirst(username)}'s Workspace`; + } else { + defaultWorkspaceName = userDetails?.phoneNumber ?? ''; + } + + if (`@${domain.toLowerCase()}` === CONST.SMS.DOMAIN) { + defaultWorkspaceName = 'My Group Workspace'; + } + + if (isEmptyObject(allPolicies)) { + return defaultWorkspaceName; + } + + // find default named workspaces and increment the last number + const numberRegEx = new RegExp(`${escapeRegExp(defaultWorkspaceName)} ?(\\d*)`, 'i'); + const parsedWorkspaceNumbers = Object.values(allPolicies ?? {}) + .filter((policy) => policy?.name && numberRegEx.test(policy.name)) + .map((policy) => Number(numberRegEx.exec(policy?.name ?? '')?.[1] ?? '1')); // parse the number at the end + const lastWorkspaceNumber = Math.max(...parsedWorkspaceNumbers); + return lastWorkspaceNumber !== -Infinity ? `${defaultWorkspaceName} ${lastWorkspaceNumber + 1}` : defaultWorkspaceName; +} + +/** + * Returns a client generated 16 character hexadecimal value for the policyID + */ +function generatePolicyID(): string { + return NumberUtils.generateHexadecimalValue(16); +} + +/** + * Returns a client generated 13 character hexadecimal value for a custom unit ID + */ +function generateCustomUnitID(): string { + return NumberUtils.generateHexadecimalValue(13); +} + +function buildOptimisticCustomUnits(reportCurrency?: string): OptimisticCustomUnits { + const currency = reportCurrency ?? allPersonalDetails?.[sessionAccountID]?.localCurrencyCode ?? CONST.CURRENCY.USD; + const customUnitID = generateCustomUnitID(); + const customUnitRateID = generateCustomUnitID(); + + const customUnits: Record = { + [customUnitID]: { + customUnitID, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + attributes: { + unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }, + rates: { + [customUnitRateID]: { + customUnitRateID, + name: CONST.CUSTOM_UNITS.DEFAULT_RATE, + rate: CONST.CUSTOM_UNITS.MILEAGE_IRS_RATE * CONST.POLICY.CUSTOM_UNIT_RATE_BASE_OFFSET, + enabled: true, + currency, + }, + }, + }, + }; + + return { + customUnits, + customUnitID, + customUnitRateID, + outputCurrency: currency, + }; +} + +/** + * Optimistically creates a Policy Draft for a new workspace + * + * @param [policyOwnerEmail] the email of the account to make the owner of the policy + * @param [policyName] custom policy name we will use for created workspace + * @param [policyID] custom policy id we will use for created workspace + * @param [makeMeAdmin] leave the calling account as an admin on the policy + */ +function createDraftInitialWorkspace(policyOwnerEmail = '', policyName = '', policyID = generatePolicyID(), makeMeAdmin = false) { + const workspaceName = policyName || generateDefaultWorkspaceName(policyOwnerEmail); + const {customUnits, outputCurrency} = buildOptimisticCustomUnits(); + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${policyID}`, + value: { + id: policyID, + type: CONST.POLICY.TYPE.TEAM, + name: workspaceName, + role: CONST.POLICY.ROLE.ADMIN, + owner: sessionEmail, + ownerAccountID: sessionAccountID, + isPolicyExpenseChatEnabled: true, + areCategoriesEnabled: true, + outputCurrency, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + customUnits, + makeMeAdmin, + autoReporting: true, + autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT, + employeeList: { + [sessionEmail]: { + role: CONST.POLICY.ROLE.ADMIN, + errors: {}, + }, + }, + approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL, + harvesting: { + enabled: true, + }, + pendingFields: { + autoReporting: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + approvalMode: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + reimbursementChoice: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + }, + }, + ]; + + Onyx.update(optimisticData); +} + +/** + * Generates onyx data for creating a new workspace + * + * @param [policyOwnerEmail] the email of the account to make the owner of the policy + * @param [makeMeAdmin] leave the calling account as an admin on the policy + * @param [policyName] custom policy name we will use for created workspace + * @param [policyID] custom policy id we will use for created workspace + * @param [expenseReportId] the reportID of the expense report that is being used to create the workspace + */ +function buildPolicyData(policyOwnerEmail = '', makeMeAdmin = false, policyName = '', policyID = generatePolicyID(), expenseReportId?: string) { + const workspaceName = policyName || generateDefaultWorkspaceName(policyOwnerEmail); + + const {customUnits, customUnitID, customUnitRateID, outputCurrency} = buildOptimisticCustomUnits(); + + const { + announceChatReportID, + announceChatData, + announceReportActionData, + announceCreatedReportActionID, + adminsChatReportID, + adminsChatData, + adminsReportActionData, + adminsCreatedReportActionID, + expenseChatReportID, + expenseChatData, + expenseReportActionData, + expenseCreatedReportActionID, + } = ReportUtils.buildOptimisticWorkspaceChats(policyID, workspaceName, expenseReportId); + + const optimisticCategoriesData = buildOptimisticPolicyCategories(policyID, CONST.POLICY.DEFAULT_CATEGORIES); + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + id: policyID, + type: CONST.POLICY.TYPE.TEAM, + name: workspaceName, + role: CONST.POLICY.ROLE.ADMIN, + owner: sessionEmail, + ownerAccountID: sessionAccountID, + isPolicyExpenseChatEnabled: true, + outputCurrency, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + autoReporting: true, + autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT, + approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL, + harvesting: { + enabled: true, + }, + customUnits, + areCategoriesEnabled: true, + areTagsEnabled: false, + areDistanceRatesEnabled: false, + areWorkflowsEnabled: false, + areReportFieldsEnabled: false, + areConnectionsEnabled: false, + employeeList: { + [sessionEmail]: { + role: CONST.POLICY.ROLE.ADMIN, + errors: {}, + }, + }, + chatReportIDAdmins: makeMeAdmin ? Number(adminsChatReportID) : undefined, + pendingFields: { + autoReporting: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + approvalMode: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + reimbursementChoice: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + name: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + outputCurrency: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + address: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + description: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${announceChatReportID}`, + value: { + pendingFields: { + addWorkspaceRoom: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + ...announceChatData, + }, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_DRAFT}${announceChatReportID}`, + value: null, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceChatReportID}`, + value: announceReportActionData, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${adminsChatReportID}`, + value: { + pendingFields: { + addWorkspaceRoom: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + ...adminsChatData, + }, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminsChatReportID}`, + value: adminsReportActionData, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${expenseChatReportID}`, + value: { + pendingFields: { + addWorkspaceRoom: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + ...expenseChatData, + }, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseChatReportID}`, + value: expenseReportActionData, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${policyID}`, + value: null, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_DRAFT}${expenseChatReportID}`, + value: null, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingAction: null, + pendingFields: { + autoReporting: null, + approvalMode: null, + reimbursementChoice: null, + name: null, + outputCurrency: null, + address: null, + description: null, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${announceChatReportID}`, + value: { + pendingFields: { + addWorkspaceRoom: null, + }, + pendingAction: null, + isOptimisticReport: false, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceChatReportID}`, + value: { + [announceCreatedReportActionID]: { + pendingAction: null, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${adminsChatReportID}`, + value: { + pendingFields: { + addWorkspaceRoom: null, + }, + pendingAction: null, + pendingChatMembers: [], + isOptimisticReport: false, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminsChatReportID}`, + value: { + [adminsCreatedReportActionID]: { + pendingAction: null, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${expenseChatReportID}`, + value: { + pendingFields: { + addWorkspaceRoom: null, + }, + pendingAction: null, + isOptimisticReport: false, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseChatReportID}`, + value: { + [expenseCreatedReportActionID]: { + pendingAction: null, + }, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: {employeeList: null}, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${announceChatReportID}`, + value: null, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceChatReportID}`, + value: null, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${adminsChatReportID}`, + value: null, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminsChatReportID}`, + value: null, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${expenseChatReportID}`, + value: null, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseChatReportID}`, + value: null, + }, + ]; + + if (optimisticCategoriesData.optimisticData) { + optimisticData.push(...optimisticCategoriesData.optimisticData); + } + + if (optimisticCategoriesData.failureData) { + failureData.push(...optimisticCategoriesData.failureData); + } + + if (optimisticCategoriesData.successData) { + successData.push(...optimisticCategoriesData.successData); + } + + const params: CreateWorkspaceParams = { + policyID, + announceChatReportID, + adminsChatReportID, + expenseChatReportID, + ownerEmail: policyOwnerEmail, + makeMeAdmin, + policyName: workspaceName, + type: CONST.POLICY.TYPE.TEAM, + announceCreatedReportActionID, + adminsCreatedReportActionID, + expenseCreatedReportActionID, + customUnitID, + customUnitRateID, + }; + + return {successData, optimisticData, failureData, params}; +} + +/** + * Optimistically creates a new workspace and default workspace chats + * + * @param [policyOwnerEmail] the email of the account to make the owner of the policy + * @param [makeMeAdmin] leave the calling account as an admin on the policy + * @param [policyName] custom policy name we will use for created workspace + * @param [policyID] custom policy id we will use for created workspace + */ +function createWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policyName = '', policyID = generatePolicyID()): CreateWorkspaceParams { + const {optimisticData, failureData, successData, params} = buildPolicyData(policyOwnerEmail, makeMeAdmin, policyName, policyID); + API.write(WRITE_COMMANDS.CREATE_WORKSPACE, params, {optimisticData, successData, failureData}); + + return params; +} + +/** + * Creates a draft workspace for various money request flows + * + * @param [policyOwnerEmail] the email of the account to make the owner of the policy + * @param [makeMeAdmin] leave the calling account as an admin on the policy + * @param [policyName] custom policy name we will use for created workspace + * @param [policyID] custom policy id we will use for created workspace + */ +function createDraftWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policyName = '', policyID = generatePolicyID()): CreateWorkspaceParams { + const workspaceName = policyName || generateDefaultWorkspaceName(policyOwnerEmail); + + const {customUnits, customUnitID, customUnitRateID, outputCurrency} = buildOptimisticCustomUnits(); + + const {expenseChatData, announceChatReportID, announceCreatedReportActionID, adminsChatReportID, adminsCreatedReportActionID, expenseChatReportID, expenseCreatedReportActionID} = + ReportUtils.buildOptimisticWorkspaceChats(policyID, workspaceName); + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${policyID}`, + value: { + id: policyID, + type: CONST.POLICY.TYPE.TEAM, + name: workspaceName, + role: CONST.POLICY.ROLE.ADMIN, + owner: sessionEmail, + ownerAccountID: sessionAccountID, + isPolicyExpenseChatEnabled: true, + outputCurrency, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + autoReporting: true, + autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT, + approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL, + harvesting: { + enabled: true, + }, + customUnits, + areCategoriesEnabled: true, + areTagsEnabled: false, + areDistanceRatesEnabled: false, + areWorkflowsEnabled: false, + areReportFieldsEnabled: false, + areConnectionsEnabled: false, + employeeList: { + [sessionEmail]: { + role: CONST.POLICY.ROLE.ADMIN, + errors: {}, + }, + }, + chatReportIDAdmins: makeMeAdmin ? Number(adminsChatReportID) : undefined, + pendingFields: { + autoReporting: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + approvalMode: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + reimbursementChoice: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_DRAFT}${expenseChatReportID}`, + value: expenseChatData, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES_DRAFT}${policyID}`, + value: CONST.POLICY.DEFAULT_CATEGORIES.reduce>((acc, category) => { + acc[category] = { + name: category, + enabled: true, + errors: null, + }; + return acc; + }, {}), + }, + ]; + + const params: CreateWorkspaceParams = { + policyID, + announceChatReportID, + adminsChatReportID, + expenseChatReportID, + ownerEmail: policyOwnerEmail, + makeMeAdmin, + policyName: workspaceName, + type: CONST.POLICY.TYPE.TEAM, + announceCreatedReportActionID, + adminsCreatedReportActionID, + expenseCreatedReportActionID, + customUnitID, + customUnitRateID, + }; + + Onyx.update(optimisticData); + + return params; +} + +function openWorkspaceReimburseView(policyID: string) { + if (!policyID) { + Log.warn('openWorkspaceReimburseView invalid params', {policyID}); + return; + } + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + value: { + isLoading: false, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + value: { + isLoading: false, + }, + }, + ]; + + const params: OpenWorkspaceReimburseViewParams = {policyID}; + + API.read(READ_COMMANDS.OPEN_WORKSPACE_REIMBURSE_VIEW, params, {successData, failureData}); +} + +function openPolicyWorkflowsPage(policyID: string) { + if (!policyID) { + Log.warn('openPolicyWorkflowsPage invalid params', {policyID}); + return; + } + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + isLoading: true, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + isLoading: false, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + isLoading: false, + }, + }, + ], + }; + + const params: OpenPolicyWorkflowsPageParams = {policyID}; + + API.read(READ_COMMANDS.OPEN_POLICY_WORKFLOWS_PAGE, params, onyxData); +} + +function setPolicyIDForReimburseView(policyID: string) { + Onyx.merge(ONYXKEYS.WORKSPACE_RATE_AND_UNIT, {policyID, rate: null, unit: null}); +} + +function clearOnyxDataForReimburseView() { + Onyx.merge(ONYXKEYS.WORKSPACE_RATE_AND_UNIT, null); +} + +function setRateForReimburseView(rate: string) { + Onyx.merge(ONYXKEYS.WORKSPACE_RATE_AND_UNIT, {rate}); +} + +function setUnitForReimburseView(unit: Unit) { + Onyx.merge(ONYXKEYS.WORKSPACE_RATE_AND_UNIT, {unit}); +} + +/** + * Returns the accountIDs of the members of the policy whose data is passed in the parameters + */ +function openWorkspace(policyID: string, clientMemberAccountIDs: number[]) { + if (!policyID || !clientMemberAccountIDs) { + Log.warn('openWorkspace invalid params', {policyID, clientMemberAccountIDs}); + return; + } + + const params: OpenWorkspaceParams = { + policyID, + clientMemberAccountIDs: JSON.stringify(clientMemberAccountIDs), + }; + + API.read(READ_COMMANDS.OPEN_WORKSPACE, params); +} + +function openPolicyTaxesPage(policyID: string) { + if (!policyID) { + Log.warn('openPolicyTaxesPage invalid params', {policyID}); + return; + } + + const params: OpenPolicyTaxesPageParams = { + policyID, + }; + + API.read(READ_COMMANDS.OPEN_POLICY_TAXES_PAGE, params); +} + +function openPolicyCompanyCardsPage(policyID: string, workspaceAccountID: number) { + const authToken = NetworkStore.getAuthToken(); + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`, + value: { + isLoading: true, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`, + value: { + isLoading: false, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`, + value: { + isLoading: false, + }, + }, + ]; + + const params: OpenPolicyExpensifyCardsPageParams = { + policyID, + authToken, + }; + + API.read(READ_COMMANDS.OPEN_POLICY_COMPANY_CARDS_PAGE, params, {optimisticData, successData, failureData}); +} + +function openPolicyExpensifyCardsPage(policyID: string, workspaceAccountID: number) { + const authToken = NetworkStore.getAuthToken(); + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${workspaceAccountID}`, + value: { + isLoading: true, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${workspaceAccountID}`, + value: { + isLoading: false, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${workspaceAccountID}`, + value: { + isLoading: false, + }, + }, + ]; + + const params: OpenPolicyExpensifyCardsPageParams = { + policyID, + authToken, + }; + + API.read(READ_COMMANDS.OPEN_POLICY_EXPENSIFY_CARDS_PAGE, params, {optimisticData, successData, failureData}); +} + +function openPolicyEditCardLimitTypePage(policyID: string, cardID: number) { + const authToken = NetworkStore.getAuthToken(); + + const params: OpenPolicyEditCardLimitTypePageParams = { + policyID, + authToken, + cardID, + }; + + API.read(READ_COMMANDS.OPEN_POLICY_EDIT_CARD_LIMIT_TYPE_PAGE, params); +} + +function openWorkspaceInvitePage(policyID: string, clientMemberEmails: string[]) { + if (!policyID || !clientMemberEmails) { + Log.warn('openWorkspaceInvitePage invalid params', {policyID, clientMemberEmails}); + return; + } + + const params: OpenWorkspaceInvitePageParams = { + policyID, + clientMemberEmails: JSON.stringify(clientMemberEmails), + }; + + API.read(READ_COMMANDS.OPEN_WORKSPACE_INVITE_PAGE, params); +} + +function openDraftWorkspaceRequest(policyID: string) { + if (policyID === '-1' || policyID === CONST.POLICY.ID_FAKE) { + Log.warn('openDraftWorkspaceRequest invalid params', {policyID}); + return; + } + + const params: OpenDraftWorkspaceRequestParams = {policyID}; + + API.read(READ_COMMANDS.OPEN_DRAFT_WORKSPACE_REQUEST, params); +} + +function requestExpensifyCardLimitIncrease(settlementBankAccountID?: number) { + if (!settlementBankAccountID) { + return; + } + + const authToken = NetworkStore.getAuthToken(); + + const params: RequestExpensifyCardLimitIncreaseParams = { + authToken, + settlementBankAccountID, + }; + + API.write(WRITE_COMMANDS.REQUEST_EXPENSIFY_CARD_LIMIT_INCREASE, params); +} + +function setWorkspaceInviteMessageDraft(policyID: string, message: string | null) { + Onyx.set(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MESSAGE_DRAFT}${policyID}`, message); +} + +function clearErrors(policyID: string) { + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {errors: null}); + hideWorkspaceAlertMessage(policyID); +} + +/** + * Dismiss the informative messages about which policy members were added with primary logins when invited with their secondary login. + */ +function dismissAddedWithPrimaryLoginMessages(policyID: string) { + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {primaryLoginsInvited: null}); +} + +function buildOptimisticRecentlyUsedCurrencies(currency?: string) { + if (!currency) { + return []; + } + + return lodashUnion([currency], allRecentlyUsedCurrencies).slice(0, CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW); +} + +/** + * This flow is used for bottom up flow converting IOU report to an expense report. When user takes this action, + * we create a Collect type workspace when the person taking the action becomes an owner and an admin, while we + * add a new member to the workspace as an employee and convert the IOU report passed as a param into an expense report. + * + * @returns policyID of the workspace we have created + */ +function createWorkspaceFromIOUPayment(iouReport: OnyxEntry): WorkspaceFromIOUCreationData | undefined { + // This flow only works for IOU reports + if (!ReportUtils.isIOUReportUsingReport(iouReport)) { + return; + } + + // Generate new variables for the policy + const policyID = generatePolicyID(); + const workspaceName = generateDefaultWorkspaceName(sessionEmail); + const employeeAccountID = iouReport.ownerAccountID; + const employeeEmail = iouReport.ownerEmail ?? ''; + const {customUnits, customUnitID, customUnitRateID} = buildOptimisticCustomUnits(iouReport.currency); + const oldPersonalPolicyID = iouReport.policyID; + const iouReportID = iouReport.reportID; + + const { + announceChatReportID, + announceChatData, + announceReportActionData, + announceCreatedReportActionID, + adminsChatReportID, + adminsChatData, + adminsReportActionData, + adminsCreatedReportActionID, + expenseChatReportID: workspaceChatReportID, + expenseChatData: workspaceChatData, + expenseReportActionData: workspaceChatReportActionData, + expenseCreatedReportActionID: workspaceChatCreatedReportActionID, + } = ReportUtils.buildOptimisticWorkspaceChats(policyID, workspaceName); + + if (!employeeAccountID) { + return; + } + + // Create the workspace chat for the employee whose IOU is being paid + const employeeWorkspaceChat = createPolicyExpenseChats(policyID, {[employeeEmail]: employeeAccountID}, true); + const newWorkspace = { + id: policyID, + + // We are creating a collect policy in this case + type: CONST.POLICY.TYPE.TEAM, + name: workspaceName, + role: CONST.POLICY.ROLE.ADMIN, + owner: sessionEmail, + ownerAccountID: sessionAccountID, + isPolicyExpenseChatEnabled: true, + + // Setting the currency to USD as we can only add the VBBA for this policy currency right now + outputCurrency: CONST.CURRENCY.USD, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + autoReporting: true, + autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT, + approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL, + harvesting: { + enabled: true, + }, + customUnits, + areCategoriesEnabled: true, + areTagsEnabled: false, + areDistanceRatesEnabled: false, + areWorkflowsEnabled: false, + areReportFieldsEnabled: false, + areConnectionsEnabled: false, + employeeList: { + [sessionEmail]: { + role: CONST.POLICY.ROLE.ADMIN, + errors: {}, + }, + [employeeEmail]: { + role: CONST.POLICY.ROLE.USER, + errors: {}, + }, + }, + pendingFields: { + autoReporting: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + approvalMode: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + reimbursementChoice: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + }; + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: newWorkspace, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${announceChatReportID}`, + value: { + pendingFields: { + addWorkspaceRoom: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + ...announceChatData, + }, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceChatReportID}`, + value: announceReportActionData, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${adminsChatReportID}`, + value: { + pendingFields: { + addWorkspaceRoom: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + ...adminsChatData, + }, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminsChatReportID}`, + value: adminsReportActionData, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${workspaceChatReportID}`, + value: { + pendingFields: { + addWorkspaceRoom: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + ...workspaceChatData, + }, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChatReportID}`, + value: workspaceChatReportActionData, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${policyID}`, + value: { + pendingFields: { + addWorkspaceRoom: null, + }, + pendingAction: null, + }, + }, + ]; + optimisticData.push(...employeeWorkspaceChat.onyxOptimisticData); + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingAction: null, + pendingFields: { + autoReporting: null, + approvalMode: null, + reimbursementChoice: null, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${announceChatReportID}`, + value: { + pendingFields: { + addWorkspaceRoom: null, + }, + pendingAction: null, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceChatReportID}`, + value: { + [Object.keys(announceChatData)[0]]: { + pendingAction: null, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${adminsChatReportID}`, + value: { + pendingFields: { + addWorkspaceRoom: null, + }, + pendingAction: null, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminsChatReportID}`, + value: { + [Object.keys(adminsChatData)[0]]: { + pendingAction: null, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${workspaceChatReportID}`, + value: { + pendingFields: { + addWorkspaceRoom: null, + }, + pendingAction: null, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChatReportID}`, + value: { + [Object.keys(workspaceChatData)[0]]: { + pendingAction: null, + }, + }, + }, + ]; + successData.push(...employeeWorkspaceChat.onyxSuccessData); + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${announceChatReportID}`, + value: { + pendingFields: { + addWorkspaceRoom: null, + }, + pendingAction: null, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceChatReportID}`, + value: { + pendingAction: null, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${adminsChatReportID}`, + value: { + pendingFields: { + addWorkspaceRoom: null, + }, + pendingAction: null, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminsChatReportID}`, + value: { + pendingAction: null, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${workspaceChatReportID}`, + value: { + pendingFields: { + addWorkspaceRoom: null, + }, + pendingAction: null, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChatReportID}`, + value: { + pendingAction: null, + }, + }, + ]; + + // Compose the memberData object which is used to add the employee to the workspace and + // optimistically create the workspace chat for them. + const memberData = { + accountID: Number(employeeAccountID), + email: employeeEmail, + workspaceChatReportID: employeeWorkspaceChat.reportCreationData[employeeEmail].reportID, + workspaceChatCreatedReportActionID: employeeWorkspaceChat.reportCreationData[employeeEmail].reportActionID, + }; + + const oldChatReportID = iouReport.chatReportID; + + // Next we need to convert the IOU report to Expense report. + // We need to change: + // - report type + // - change the sign of the report total + // - update its policyID and policyName + // - update the chatReportID to point to the new workspace chat + const expenseReport = { + ...iouReport, + chatReportID: memberData.workspaceChatReportID, + policyID, + policyName: workspaceName, + type: CONST.REPORT.TYPE.EXPENSE, + total: -(iouReport?.total ?? 0), + }; + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, + value: expenseReport, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, + value: iouReport, + }); + + // The expense report transactions need to have the amount reversed to negative values + const reportTransactions = TransactionUtils.getAllReportTransactions(iouReportID); + + // For performance reasons, we are going to compose a merge collection data for transactions + const transactionsOptimisticData: Record = {}; + const transactionFailureData: Record = {}; + reportTransactions.forEach((transaction) => { + transactionsOptimisticData[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`] = { + ...transaction, + amount: -transaction.amount, + modifiedAmount: transaction.modifiedAmount ? -transaction.modifiedAmount : 0, + }; + + transactionFailureData[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`] = transaction; + }); + + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE_COLLECTION, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}`, + value: transactionsOptimisticData, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE_COLLECTION, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}`, + value: transactionFailureData, + }); + + // We need to move the report preview action from the DM to the workspace chat. + const parentReport = ReportActionsConnection.getAllReportActions()?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.parentReportID}`]; + const parentReportActionID = iouReport.parentReportActionID; + const reportPreview = iouReport?.parentReportID && parentReportActionID ? parentReport?.[parentReportActionID] : undefined; + + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oldChatReportID}`, + value: {[reportPreview?.reportActionID ?? '-1']: null}, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oldChatReportID}`, + value: {[reportPreview?.reportActionID ?? '-1']: reportPreview}, + }); + + // To optimistically remove the GBR from the DM we need to update the hasOutstandingChildRequest param to false + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${oldChatReportID}`, + value: { + hasOutstandingChildRequest: false, + }, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${oldChatReportID}`, + value: { + hasOutstandingChildRequest: true, + }, + }); + + if (reportPreview?.reportActionID) { + // Update the created timestamp of the report preview action to be after the workspace chat created timestamp. + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${memberData.workspaceChatReportID}`, + value: { + [reportPreview.reportActionID]: { + ...reportPreview, + message: [ + { + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + text: ReportUtils.getReportPreviewMessage(expenseReport, null, false, false, newWorkspace), + }, + ], + created: DateUtils.getDBTime(), + }, + }, + }); + } + + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${memberData.workspaceChatReportID}`, + value: {[reportPreview?.reportActionID ?? '-1']: null}, + }); + + // Create the MOVED report action and add it to the DM chat which indicates to the user where the report has been moved + const movedReportAction = ReportUtils.buildOptimisticMovedReportAction(oldPersonalPolicyID ?? '-1', policyID, memberData.workspaceChatReportID, iouReportID, workspaceName); + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oldChatReportID}`, + value: {[movedReportAction.reportActionID]: movedReportAction}, + }); + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oldChatReportID}`, + value: { + [movedReportAction.reportActionID]: { + ...movedReportAction, + pendingAction: null, + }, + }, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oldChatReportID}`, + value: {[movedReportAction.reportActionID]: null}, + }); + + // We know that this new workspace has no BankAccount yet, so we can set + // the reimbursement account to be immediately in the setup state for a new bank account: + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.REIMBURSEMENT_ACCOUNT}`, + value: { + isLoading: false, + achData: { + currentStep: CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT, + policyID, + subStep: '', + }, + }, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.REIMBURSEMENT_ACCOUNT}`, + value: CONST.REIMBURSEMENT_ACCOUNT.DEFAULT_DATA, + }); + + const params: CreateWorkspaceFromIOUPaymentParams = { + policyID, + announceChatReportID, + adminsChatReportID, + expenseChatReportID: workspaceChatReportID, + ownerEmail: '', + makeMeAdmin: false, + policyName: workspaceName, + type: CONST.POLICY.TYPE.TEAM, + announceCreatedReportActionID, + adminsCreatedReportActionID, + expenseCreatedReportActionID: workspaceChatCreatedReportActionID, + customUnitID, + customUnitRateID, + iouReportID, + memberData: JSON.stringify(memberData), + reportActionID: movedReportAction.reportActionID, + }; + + API.write(WRITE_COMMANDS.CREATE_WORKSPACE_FROM_IOU_PAYMENT, params, {optimisticData, successData, failureData}); + + return {policyID, workspaceChatReportID: memberData.workspaceChatReportID, reportPreviewReportActionID: reportPreview?.reportActionID}; +} + +function enablePolicyConnections(policyID: string, enabled: boolean) { + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + areConnectionsEnabled: enabled, + pendingFields: { + areConnectionsEnabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + areConnectionsEnabled: null, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + areConnectionsEnabled: !enabled, + pendingFields: { + areConnectionsEnabled: null, + }, + }, + }, + ], + }; + + const parameters: EnablePolicyConnectionsParams = {policyID, enabled}; + + API.write(WRITE_COMMANDS.ENABLE_POLICY_CONNECTIONS, parameters, onyxData); + + if (enabled && getIsNarrowLayout()) { + navigateWhenEnableFeature(policyID); + } +} + +/** Save the preferred export method for a policy */ +function savePreferredExportMethod(policyID: string, exportMethod: ReportExportType) { + Onyx.merge(`${ONYXKEYS.LAST_EXPORT_METHOD}`, {[policyID]: exportMethod}); +} + +function enableExpensifyCard(policyID: string, enabled: boolean) { + const authToken = NetworkStore.getAuthToken(); + if (!authToken) { + return; + } + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + areExpensifyCardsEnabled: enabled, + pendingFields: { + areExpensifyCardsEnabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + areExpensifyCardsEnabled: null, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + areExpensifyCardsEnabled: !enabled, + pendingFields: { + areExpensifyCardsEnabled: null, + }, + }, + }, + ], + }; + + const parameters: EnablePolicyExpensifyCardsParams = {authToken, policyID, enabled}; + + API.write(WRITE_COMMANDS.ENABLE_POLICY_EXPENSIFY_CARDS, parameters, onyxData); + + if (enabled && getIsNarrowLayout()) { + navigateWhenEnableFeature(policyID); + } +} + +function enableCompanyCards(policyID: string, enabled: boolean) { + const authToken = NetworkStore.getAuthToken(); + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + areCompanyCardsEnabled: enabled, + pendingFields: { + areCompanyCardsEnabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + areCompanyCardsEnabled: null, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + areCompanyCardsEnabled: !enabled, + pendingFields: { + areCompanyCardsEnabled: null, + }, + }, + }, + ], + }; + + const parameters: EnablePolicyCompanyCardsParams = {authToken, policyID, enabled}; + + API.write(WRITE_COMMANDS.ENABLE_POLICY_COMPANY_CARDS, parameters, onyxData); + + if (enabled && getIsNarrowLayout()) { + navigateWhenEnableFeature(policyID); + } +} + +function enablePolicyReportFields(policyID: string, enabled: boolean, disableRedirect = false) { + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + areReportFieldsEnabled: enabled, + pendingFields: { + areReportFieldsEnabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + areReportFieldsEnabled: null, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + areReportFieldsEnabled: !enabled, + pendingFields: { + areReportFieldsEnabled: null, + }, + }, + }, + ], + }; + + const parameters: EnablePolicyReportFieldsParams = {policyID, enabled}; + + API.write(WRITE_COMMANDS.ENABLE_POLICY_REPORT_FIELDS, parameters, onyxData); + + if (enabled && getIsNarrowLayout() && !disableRedirect) { + navigateWhenEnableFeature(policyID); + } +} + +function enablePolicyTaxes(policyID: string, enabled: boolean) { + const defaultTaxRates: TaxRatesWithDefault = CONST.DEFAULT_TAX; + const taxRatesData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + ...defaultTaxRates, + taxes: { + ...Object.keys(defaultTaxRates.taxes).reduce((acc, taxKey) => { + acc[taxKey] = { + ...defaultTaxRates.taxes[taxKey], + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }; + return acc; + }, {} as Record), + }, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + taxes: { + ...Object.keys(defaultTaxRates.taxes).reduce((acc, taxKey) => { + acc[taxKey] = {pendingAction: null}; + return acc; + }, {} as Record), + }, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: undefined, + }, + }, + ], + }; + const policy = getPolicy(policyID); + const shouldAddDefaultTaxRatesData = (!policy?.taxRates || isEmptyObject(policy.taxRates)) && enabled; + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + tax: { + trackingEnabled: enabled, + }, + pendingFields: { + tax: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ]; + optimisticData.push(...(shouldAddDefaultTaxRatesData ? taxRatesData.optimisticData ?? [] : [])); + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + tax: null, + }, + }, + }, + ]; + successData.push(...(shouldAddDefaultTaxRatesData ? taxRatesData.successData ?? [] : [])); + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + tax: { + trackingEnabled: !enabled, + }, + pendingFields: { + tax: null, + }, + }, + }, + ]; + failureData.push(...(shouldAddDefaultTaxRatesData ? taxRatesData.failureData ?? [] : [])); + + const onyxData: OnyxData = { + optimisticData, + successData, + failureData, + }; + + const parameters: EnablePolicyTaxesParams = {policyID, enabled}; + if (shouldAddDefaultTaxRatesData) { + parameters.taxFields = JSON.stringify(defaultTaxRates); + } + API.write(WRITE_COMMANDS.ENABLE_POLICY_TAXES, parameters, onyxData); + + if (enabled && getIsNarrowLayout()) { + navigateWhenEnableFeature(policyID); + } +} + +function enablePolicyWorkflows(policyID: string, enabled: boolean) { + const policy = getPolicy(policyID); + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + areWorkflowsEnabled: enabled, + ...(!enabled + ? { + approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL, + autoReporting: false, + harvesting: { + enabled: false, + }, + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO, + } + : {}), + pendingFields: { + areWorkflowsEnabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + ...(!enabled + ? { + approvalMode: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + autoReporting: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + harvesting: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + reimbursementChoice: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + } + : {}), + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + areWorkflowsEnabled: null, + ...(!enabled + ? { + approvalMode: null, + autoReporting: null, + harvesting: null, + reimbursementChoice: null, + } + : {}), + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + areWorkflowsEnabled: !enabled, + ...(!enabled + ? { + approvalMode: policy?.approvalMode, + autoReporting: policy?.autoReporting, + harvesting: policy?.harvesting, + reimbursementChoice: policy?.reimbursementChoice, + } + : {}), + pendingFields: { + areWorkflowsEnabled: null, + ...(!enabled + ? { + approvalMode: null, + autoReporting: null, + harvesting: null, + reimbursementChoice: null, + } + : {}), + }, + }, + }, + ], + }; + + const parameters: EnablePolicyWorkflowsParams = {policyID, enabled}; + + API.write(WRITE_COMMANDS.ENABLE_POLICY_WORKFLOWS, parameters, onyxData); + + if (enabled && getIsNarrowLayout()) { + navigateWhenEnableFeature(policyID); + } +} + +const DISABLED_MAX_EXPENSE_VALUES: Pick = { + maxExpenseAmountNoReceipt: CONST.DISABLED_MAX_EXPENSE_VALUE, + maxExpenseAmount: CONST.DISABLED_MAX_EXPENSE_VALUE, + maxExpenseAge: CONST.DISABLED_MAX_EXPENSE_VALUE, +}; + +function enablePolicyRules(policyID: string, enabled: boolean, disableRedirect = false) { + const policy = getPolicy(policyID); + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + areRulesEnabled: enabled, + ...(!enabled ? DISABLED_MAX_EXPENSE_VALUES : {}), + pendingFields: { + areRulesEnabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + areRulesEnabled: null, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + areRulesEnabled: !enabled, + ...(!enabled + ? { + maxExpenseAmountNoReceipt: policy?.maxExpenseAmountNoReceipt, + maxExpenseAmount: policy?.maxExpenseAmount, + maxExpenseAge: policy?.maxExpenseAge, + } + : {}), + pendingFields: { + areRulesEnabled: null, + }, + }, + }, + ], + }; + + const parameters: SetPolicyRulesEnabledParams = {policyID, enabled}; + API.write(WRITE_COMMANDS.SET_POLICY_RULES_ENABLED, parameters, onyxData); + + if (enabled && getIsNarrowLayout() && !disableRedirect) { + navigateWhenEnableFeature(policyID); + } +} + +function enableDistanceRequestTax(policyID: string, customUnitName: string, customUnitID: string, attributes: Attributes) { + const policy = getPolicy(policyID); + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [customUnitID]: { + attributes, + pendingFields: { + taxEnabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [customUnitID]: { + pendingFields: { + taxEnabled: null, + }, + }, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [customUnitID]: { + attributes: policy?.customUnits ? policy?.customUnits[customUnitID].attributes : null, + errorFields: { + taxEnabled: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + }, + }, + ], + }; + + const params = { + policyID, + customUnit: JSON.stringify({ + customUnitName, + customUnitID, + attributes, + }), + }; + API.write(WRITE_COMMANDS.ENABLE_DISTANCE_REQUEST_TAX, params, onyxData); +} + +function enablePolicyInvoicing(policyID: string, enabled: boolean) { + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + areInvoicesEnabled: enabled, + pendingFields: { + areInvoicesEnabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + areInvoicesEnabled: null, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + areInvoicesEnabled: !enabled, + pendingFields: { + areInvoicesEnabled: null, + }, + }, + }, + ], + }; + + const parameters: EnablePolicyInvoicingParams = {policyID, enabled}; + + API.write(WRITE_COMMANDS.ENABLE_POLICY_INVOICING, parameters, onyxData); + + // TODO: Uncomment the following line when the invoices screen is ready - https://github.com/Expensify/App/issues/45175. + // if (enabled && getIsNarrowLayout()) { + // navigateWhenEnableFeature(policyID); + // } +} + +function openPolicyMoreFeaturesPage(policyID: string) { + const params: OpenPolicyMoreFeaturesPageParams = {policyID}; + + API.read(READ_COMMANDS.OPEN_POLICY_MORE_FEATURES_PAGE, params); +} + +function openPolicyProfilePage(policyID: string) { + const params: OpenPolicyProfilePageParams = {policyID}; + + API.read(READ_COMMANDS.OPEN_POLICY_PROFILE_PAGE, params); +} + +function openPolicyInitialPage(policyID: string) { + const params: OpenPolicyInitialPageParams = {policyID}; + + API.read(READ_COMMANDS.OPEN_POLICY_INITIAL_PAGE, params); +} + +function setPolicyCustomTaxName(policyID: string, customTaxName: string) { + const policy = getPolicy(policyID); + const originalCustomTaxName = policy?.taxRates?.name; + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + name: customTaxName, + pendingFields: {name: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + errorFields: null, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + pendingFields: {name: null}, + errorFields: null, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + name: originalCustomTaxName, + pendingFields: {name: null}, + errorFields: {name: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}, + }, + }, + }, + ], + }; + + const parameters = { + policyID, + customTaxName, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_CUSTOM_TAX_NAME, parameters, onyxData); +} + +function setWorkspaceCurrencyDefault(policyID: string, taxCode: string) { + const policy = getPolicy(policyID); + const originalDefaultExternalID = policy?.taxRates?.defaultExternalID; + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + defaultExternalID: taxCode, + pendingFields: {defaultExternalID: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + errorFields: null, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + pendingFields: {defaultExternalID: null}, + errorFields: null, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + defaultExternalID: originalDefaultExternalID, + pendingFields: {defaultExternalID: null}, + errorFields: {defaultExternalID: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}, + }, + }, + }, + ], + }; + + const parameters = { + policyID, + taxCode, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_TAXES_CURRENCY_DEFAULT, parameters, onyxData); +} + +function setForeignCurrencyDefault(policyID: string, taxCode: string) { + const policy = getPolicy(policyID); + const originalDefaultForeignCurrencyID = policy?.taxRates?.foreignTaxDefault; + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + foreignTaxDefault: taxCode, + pendingFields: {foreignTaxDefault: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + errorFields: null, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + pendingFields: {foreignTaxDefault: null}, + errorFields: null, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + foreignTaxDefault: originalDefaultForeignCurrencyID, + pendingFields: {foreignTaxDefault: null}, + errorFields: {foreignTaxDefault: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}, + }, + }, + }, + ], + }; + + const parameters = { + policyID, + taxCode, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_TAXES_FOREIGN_CURRENCY_DEFAULT, parameters, onyxData); +} + +function upgradeToCorporate(policyID: string, featureName: string) { + const policy = getPolicy(policyID); + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `policy_${policyID}`, + value: { + isPendingUpgrade: true, + type: CONST.POLICY.TYPE.CORPORATE, + maxExpenseAge: CONST.POLICY.DEFAULT_MAX_EXPENSE_AGE, + maxExpenseAmount: CONST.POLICY.DEFAULT_MAX_EXPENSE_AMOUNT, + maxExpenseAmountNoReceipt: CONST.POLICY.DEFAULT_MAX_AMOUNT_NO_RECEIPT, + glCodes: true, + ...(PolicyUtils.isInstantSubmitEnabled(policy) && { + autoReporting: true, + autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.IMMEDIATE, + }), + harvesting: { + enabled: false, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `policy_${policyID}`, + value: { + isPendingUpgrade: false, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `policy_${policyID}`, + value: { + isPendingUpgrade: false, + type: policy?.type, + maxExpenseAge: policy?.maxExpenseAge ?? null, + maxExpenseAmount: policy?.maxExpenseAmount ?? null, + maxExpenseAmountNoReceipt: policy?.maxExpenseAmountNoReceipt ?? null, + glCodes: policy?.glCodes ?? null, + autoReporting: policy?.autoReporting ?? null, + autoReportingFrequency: policy?.autoReportingFrequency ?? null, + harvesting: policy?.harvesting ?? null, + }, + }, + ]; + + const parameters: UpgradeToCorporateParams = {policyID, featureName}; + + API.write(WRITE_COMMANDS.UPGRADE_TO_CORPORATE, parameters, {optimisticData, successData, failureData}); +} + +function setWorkspaceDefaultSpendCategory(policyID: string, groupID: string, category: string) { + const policy = getPolicy(policyID); + if (!policy) { + return; + } + + const {mccGroup} = policy; + + const optimisticData: OnyxUpdate[] = mccGroup + ? [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `policy_${policyID}`, + value: { + mccGroup: { + ...mccGroup, + [groupID]: { + category, + groupID, + }, + }, + }, + }, + ] + : []; + + const failureData: OnyxUpdate[] = mccGroup + ? [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `policy_${policyID}`, + value: { + mccGroup, + }, + }, + ] + : []; + + API.write(WRITE_COMMANDS.SET_WORKSPACE_DEFAULT_SPEND_CATEGORY, {policyID, groupID, category}, {optimisticData, successData: [], failureData}); +} +/** + * Call the API to set the receipt required amount for the given policy + * @param policyID - id of the policy to set the receipt required amount + * @param maxExpenseAmountNoReceipt - new value of the receipt required amount + */ +function setPolicyMaxExpenseAmountNoReceipt(policyID: string, maxExpenseAmountNoReceipt: string) { + const policy = getPolicy(policyID); + const parsedMaxExpenseAmountNoReceipt = maxExpenseAmountNoReceipt === '' ? CONST.DISABLED_MAX_EXPENSE_VALUE : CurrencyUtils.convertToBackendAmount(parseFloat(maxExpenseAmountNoReceipt)); + const originalMaxExpenseAmountNoReceipt = policy?.maxExpenseAmountNoReceipt; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + maxExpenseAmountNoReceipt: parsedMaxExpenseAmountNoReceipt, + pendingFields: { + maxExpenseAmountNoReceipt: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: {maxExpenseAmountNoReceipt: null}, + errorFields: null, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + maxExpenseAmountNoReceipt: originalMaxExpenseAmountNoReceipt, + pendingFields: {maxExpenseAmountNoReceipt: null}, + errorFields: {maxExpenseAmountNoReceipt: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}, + }, + }, + ], + }; + + const parameters = { + policyID, + maxExpenseAmountNoReceipt: parsedMaxExpenseAmountNoReceipt, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_EXPENSE_MAX_AMOUNT_NO_RECEIPT, parameters, onyxData); +} + +/** + * Call the API to set the max expense amount for the given policy + * @param policyID - id of the policy to set the max expense amount + * @param maxExpenseAmount - new value of the max expense amount + */ +function setPolicyMaxExpenseAmount(policyID: string, maxExpenseAmount: string) { + const policy = getPolicy(policyID); + const parsedMaxExpenseAmount = maxExpenseAmount === '' ? CONST.DISABLED_MAX_EXPENSE_VALUE : CurrencyUtils.convertToBackendAmount(parseFloat(maxExpenseAmount)); + const originalMaxExpenseAmount = policy?.maxExpenseAmount; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + maxExpenseAmount: parsedMaxExpenseAmount, + pendingFields: { + maxExpenseAmount: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: {maxExpenseAmount: null}, + errorFields: null, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + maxExpenseAmount: originalMaxExpenseAmount, + pendingFields: {maxExpenseAmount: null}, + errorFields: {maxExpenseAmount: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}, + }, + }, + ], + }; + + const parameters = { + policyID, + maxExpenseAmount: parsedMaxExpenseAmount, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_EXPENSE_MAX_AMOUNT, parameters, onyxData); +} + +/** + * Call the API to set the max expense age for the given policy + * @param policyID - id of the policy to set the max expense age + * @param maxExpenseAge - the max expense age value given in days + */ +function setPolicyMaxExpenseAge(policyID: string, maxExpenseAge: string) { + const policy = getPolicy(policyID); + const parsedMaxExpenseAge = maxExpenseAge === '' ? CONST.DISABLED_MAX_EXPENSE_VALUE : parseInt(maxExpenseAge, 10); + const originalMaxExpenseAge = policy?.maxExpenseAge; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + maxExpenseAge: parsedMaxExpenseAge, + pendingFields: { + maxExpenseAge: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + maxExpenseAge: null, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + maxExpenseAge: originalMaxExpenseAge, + pendingFields: {maxExpenseAge: null}, + errorFields: {maxExpenseAge: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}, + }, + }, + ], + }; + + const parameters = { + policyID, + maxExpenseAge: parsedMaxExpenseAge, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_EXPENSE_MAX_AGE, parameters, onyxData); +} + +/** + * Call the API to enable or disable the billable mode for the given policy + * @param policyID - id of the policy to enable or disable the bilable mode + * @param defaultBillable - whether the billable mode is enabled in the given policy + */ +function setPolicyBillableMode(policyID: string, defaultBillable: boolean) { + const policy = getPolicy(policyID); + + const originalDefaultBillable = policy?.defaultBillable; + const originalDefaultBillableDisabled = policy?.disabledFields?.defaultBillable; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + defaultBillable, + disabledFields: { + defaultBillable: false, + }, + pendingFields: { + defaultBillable: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + disabledFields: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + defaultBillable: null, + disabledFields: null, + }, + errorFields: null, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + disabledFields: {defaultBillable: originalDefaultBillableDisabled}, + defaultBillable: originalDefaultBillable, + pendingFields: {defaultBillable: null, disabledFields: null}, + errorFields: {defaultBillable: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}, + }, + }, + ], + }; + + const parameters: SetPolicyBillableModeParams = { + policyID, + defaultBillable, + disabledFields: JSON.stringify({ + defaultBillable: false, + }), + }; + + API.write(WRITE_COMMANDS.SET_POLICY_BILLABLE_MODE, parameters, onyxData); +} + +/** + * Call the API to disable the billable mode for the given policy + * @param policyID - id of the policy to enable or disable the bilable mode + */ +function disableWorkspaceBillableExpenses(policyID: string) { + const policy = getPolicy(policyID); + const originalDefaultBillableDisabled = policy?.disabledFields?.defaultBillable; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + disabledFields: { + defaultBillable: true, + }, + pendingFields: { + disabledFields: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + disabledFields: null, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: {disabledFields: null}, + disabledFields: {defaultBillable: originalDefaultBillableDisabled}, + }, + }, + ], + }; + + const parameters: DisablePolicyBillableModeParams = { + policyID, + }; + + API.write(WRITE_COMMANDS.DISABLE_POLICY_BILLABLE_MODE, parameters, onyxData); +} + +function setWorkspaceEReceiptsEnabled(policyID: string, eReceipts: boolean) { + const policy = getPolicy(policyID); + + const originalEReceipts = policy?.eReceipts; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + eReceipts, + pendingFields: { + eReceipts: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + eReceipts: null, + }, + errorFields: null, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + eReceipts: originalEReceipts, + pendingFields: {defaultBillable: null}, + errorFields: {defaultBillable: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}, + }, + }, + ], + }; + + const parameters = { + policyID, + eReceipts, + }; + + API.write(WRITE_COMMANDS.SET_WORKSPACE_ERECEIPTS_ENABLED, parameters, onyxData); +} + +function getAdminPoliciesConnectedToSageIntacct(): Policy[] { + return Object.values(allPolicies ?? {}).filter((policy): policy is Policy => !!policy && policy.role === CONST.POLICY.ROLE.ADMIN && !!policy?.connections?.intacct); +} + +function getAdminPoliciesConnectedToNetSuite(): Policy[] { + return Object.values(allPolicies ?? {}).filter((policy): policy is Policy => !!policy && policy.role === CONST.POLICY.ROLE.ADMIN && !!policy?.connections?.netsuite); +} + +/** + * Call the API to enable custom report title for the reports in the given policy + * @param policyID - id of the policy to apply the limit to + * @param enabled - whether custom report title for the reports is enabled in the given policy + */ +function enablePolicyDefaultReportTitle(policyID: string, enabled: boolean) { + const policy = getPolicy(policyID); + + if (enabled === policy?.shouldShowCustomReportTitleOption) { + return; + } + + const previousReportTitleField = policy?.fieldList?.[CONST.POLICY.FIELDS.FIELD_LIST_TITLE] ?? {}; + const titleFieldValues = enabled ? {} : {fieldList: {[CONST.POLICY.FIELDS.FIELD_LIST_TITLE]: {...previousReportTitleField, defaultValue: CONST.POLICY.DEFAULT_REPORT_NAME_PATTERN}}}; + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + shouldShowCustomReportTitleOption: enabled, + ...titleFieldValues, + pendingFields: { + shouldShowCustomReportTitleOption: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + shouldShowCustomReportTitleOption: null, + }, + errorFields: null, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + shouldShowCustomReportTitleOption: !!policy?.shouldShowCustomReportTitleOption, + fieldList: { + [CONST.POLICY.FIELDS.FIELD_LIST_TITLE]: previousReportTitleField, + }, + pendingFields: { + shouldShowCustomReportTitleOption: null, + }, + errorFields: { + shouldShowCustomReportTitleOption: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + ]; + + const parameters: EnablePolicyDefaultReportTitleParams = { + enable: enabled, + policyID, + }; + + API.write(WRITE_COMMANDS.ENABLE_POLICY_DEFAULT_REPORT_TITLE, parameters, { + optimisticData, + successData, + failureData, + }); +} + +/** + * Call the API to set default report title pattern for the given policy + * @param policyID - id of the policy to apply the naming pattern to + * @param customName - name pattern to be used for the reports + */ +function setPolicyDefaultReportTitle(policyID: string, customName: string) { + const policy = getPolicy(policyID); + + if (customName === policy?.fieldList?.[CONST.POLICY.FIELDS.FIELD_LIST_TITLE]?.defaultValue) { + return; + } + + const previousReportTitleField = policy?.fieldList?.[CONST.POLICY.FIELDS.FIELD_LIST_TITLE] ?? {}; + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + fieldList: { + [CONST.POLICY.FIELDS.FIELD_LIST_TITLE]: { + defaultValue: customName, + pendingFields: {defaultValue: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + }, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + fieldList: { + [CONST.POLICY.FIELDS.FIELD_LIST_TITLE]: {pendingFields: {defaultValue: null}}, + }, + errorFields: null, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + fieldList: { + [CONST.POLICY.FIELDS.FIELD_LIST_TITLE]: {...previousReportTitleField, pendingFields: {defaultValue: null}}, + }, + errorFields: { + fieldList: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + ]; + + const parameters: SetPolicyDefaultReportTitleParams = { + value: customName, + policyID, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_DEFAULT_REPORT_TITLE, parameters, { + optimisticData, + successData, + failureData, + }); +} + +/** + * Call the API to enable or disable enforcing the naming pattern for member created reports on a policy + * @param policyID - id of the policy to apply the naming pattern to + * @param enforced - flag whether to enforce policy name + */ +function setPolicyPreventMemberCreatedTitle(policyID: string, enforced: boolean) { + const policy = getPolicy(policyID); + + if (!enforced === policy?.fieldList?.[CONST.POLICY.FIELDS.FIELD_LIST_TITLE].deletable) { + return; + } + + const previousReportTitleField = policy?.fieldList?.[CONST.POLICY.FIELDS.FIELD_LIST_TITLE] ?? {}; + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + fieldList: { + [CONST.POLICY.FIELDS.FIELD_LIST_TITLE]: {...previousReportTitleField, deletable: !enforced, pendingFields: {deletable: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}}, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + fieldList: { + [CONST.POLICY.FIELDS.FIELD_LIST_TITLE]: {pendingFields: {deletable: null}}, + }, + errorFields: null, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + fieldList: { + [CONST.POLICY.FIELDS.FIELD_LIST_TITLE]: {...previousReportTitleField, pendingFields: {deletable: null}}, + }, + errorFields: { + fieldList: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + ]; + + const parameters: SetPolicyPreventMemberCreatedTitleParams = { + enforced, + policyID, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_PREVENT_MEMBER_CREATED_TITLE, parameters, { + optimisticData, + successData, + failureData, + }); +} + +/** + * Call the API to enable or disable self approvals for the reports + * @param policyID - id of the policy to apply the naming pattern to + * @param preventSelfApproval - flag whether to prevent workspace members from approving their own expense reports + */ +function setPolicyPreventSelfApproval(policyID: string, preventSelfApproval: boolean) { + const policy = getPolicy(policyID); + + if (preventSelfApproval === policy?.preventSelfApproval) { + return; + } + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + preventSelfApproval, + pendingFields: { + preventSelfApproval: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + preventSelfApproval: null, + }, + errorFields: null, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + preventSelfApproval: policy?.preventSelfApproval ?? false, + pendingFields: { + preventSelfApproval: null, + }, + errorFields: { + preventSelfApproval: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + ]; + + const parameters: SetPolicyPreventSelfApprovalParams = { + preventSelfApproval, + policyID, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_PREVENT_SELF_APPROVAL, parameters, { + optimisticData, + successData, + failureData, + }); +} + +/** + * Call the API to apply automatic approval limit for the given policy + * @param policyID - id of the policy to apply the limit to + * @param limit - max amount for auto-approval of the reports in the given policy + */ +function setPolicyAutomaticApprovalLimit(policyID: string, limit: string) { + const policy = getPolicy(policyID); + + const fallbackLimit = limit === '' ? '0' : limit; + const parsedLimit = CurrencyUtils.convertToBackendAmount(parseFloat(fallbackLimit)); + + if (parsedLimit === policy?.autoApproval?.limit ?? CONST.POLICY.AUTO_APPROVE_REPORTS_UNDER_DEFAULT_CENTS) { + return; + } + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoApproval: { + limit: parsedLimit, + pendingFields: {limit: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoApproval: { + pendingFields: { + limit: null, + }, + }, + errorFields: null, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoApproval: { + limit: policy?.autoApproval?.limit ?? CONST.POLICY.AUTO_APPROVE_REPORTS_UNDER_DEFAULT_CENTS, + pendingFields: { + limit: null, + }, + }, + errorFields: { + autoApproval: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + ]; + + const parameters: SetPolicyAutomaticApprovalLimitParams = { + limit: parsedLimit, + policyID, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_AUTOMATIC_APPROVAL_LIMIT, parameters, { + optimisticData, + successData, + failureData, + }); +} + +/** + * Call the API to set the audit rate for the given policy + * @param policyID - id of the policy to apply the limit to + * @param auditRate - percentage of the reports to be qualified for a random audit + */ +function setPolicyAutomaticApprovalRate(policyID: string, auditRate: string) { + const policy = getPolicy(policyID); + const fallbackAuditRate = auditRate === '' ? '0' : auditRate; + const parsedAuditRate = parseInt(fallbackAuditRate, 10); + + if (parsedAuditRate === policy?.autoApproval?.auditRate ?? CONST.POLICY.RANDOM_AUDIT_DEFAULT_PERCENTAGE) { + return; + } + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoApproval: { + auditRate: parsedAuditRate, + pendingFields: { + auditRate: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoApproval: { + pendingFields: { + auditRate: null, + }, + }, + errorFields: null, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoApproval: { + auditRate: policy?.autoApproval?.auditRate ?? CONST.POLICY.RANDOM_AUDIT_DEFAULT_PERCENTAGE, + pendingFields: { + auditRate: null, + }, + }, + errorFields: { + autoApproval: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + ]; + + const parameters: SetPolicyAutomaticApprovalRateParams = { + auditRate: parsedAuditRate, + policyID, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_AUTOMATIC_APPROVAL_RATE, parameters, { + optimisticData, + successData, + failureData, + }); +} + +/** + * Call the API to enable auto-approval for the reports in the given policy + * @param policyID - id of the policy to apply the limit to + * @param enabled - whether auto-approve for the reports is enabled in the given policy + */ +function enableAutoApprovalOptions(policyID: string, enabled: boolean) { + const policy = getPolicy(policyID); + + if (enabled === policy?.shouldShowAutoApprovalOptions) { + return; + } + + const autoApprovalCleanupValues = !enabled + ? { + pendingFields: { + limit: null, + auditRate: null, + }, + } + : {}; + const autoApprovalValues = !enabled ? {auditRate: CONST.POLICY.RANDOM_AUDIT_DEFAULT_PERCENTAGE, limit: CONST.POLICY.AUTO_APPROVE_REPORTS_UNDER_DEFAULT_CENTS} : {}; + const autoApprovalFailureValues = !enabled ? {autoApproval: {limit: policy?.autoApproval?.limit, auditRate: policy?.autoApproval?.auditRate, ...autoApprovalCleanupValues}} : {}; + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoApproval: { + ...autoApprovalValues, + pendingFields: { + limit: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + auditRate: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + shouldShowAutoApprovalOptions: enabled, + pendingFields: { + shouldShowAutoApprovalOptions: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoApproval: {...autoApprovalCleanupValues}, + pendingFields: { + shouldShowAutoApprovalOptions: null, + }, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + ...autoApprovalFailureValues, + shouldShowAutoApprovalOptions: policy?.shouldShowAutoApprovalOptions, + pendingFields: { + shouldShowAutoApprovalOptions: null, + }, + }, + }, + ]; + + const parameters: EnablePolicyAutoApprovalOptionsParams = { + enabled, + policyID, + }; + + API.write(WRITE_COMMANDS.ENABLE_POLICY_AUTO_APPROVAL_OPTIONS, parameters, { + optimisticData, + successData, + failureData, + }); +} + +/** + * Call the API to set the limit for auto-payments in the given policy + * @param policyID - id of the policy to apply the limit to + * @param limit - max amount for auto-payment for the reports in the given policy + */ +function setPolicyAutoReimbursementLimit(policyID: string, limit: string) { + const policy = getPolicy(policyID); + const fallbackLimit = limit === '' ? '0' : limit; + const parsedLimit = CurrencyUtils.convertToBackendAmount(parseFloat(fallbackLimit)); + + if (parsedLimit === policy?.autoReimbursement?.limit ?? CONST.POLICY.AUTO_REIMBURSEMENT_DEFAULT_LIMIT_CENTS) { + return; + } + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoReimbursement: { + limit: parsedLimit, + pendingFields: { + limit: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoReimbursement: { + limit: parsedLimit, + pendingFields: { + limit: null, + }, + }, + errorFields: null, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoReimbursement: {limit: policy?.autoReimbursement?.limit ?? policy?.autoReimbursementLimit, pendingFields: {limit: null}}, + errorFields: { + autoReimbursement: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + ]; + + const parameters: SetPolicyAutoReimbursementLimitParams = { + autoReimbursement: {limit: parsedLimit}, + policyID, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_AUTO_REIMBURSEMENT_LIMIT, parameters, { + optimisticData, + successData, + failureData, + }); +} + +/** + * Call the API to enable auto-payment for the reports in the given policy + * @param policyID - id of the policy to apply the limit to + * @param enabled - whether auto-payment for the reports is enabled in the given policy + */ +function enablePolicyAutoReimbursementLimit(policyID: string, enabled: boolean) { + const policy = getPolicy(policyID); + + if (enabled === policy?.shouldShowAutoReimbursementLimitOption) { + return; + } + + const autoReimbursementCleanupValues = !enabled + ? { + pendingFields: { + limit: null, + }, + } + : {}; + const autoReimbursementFailureValues = !enabled ? {autoReimbursement: {limit: policy?.autoReimbursement?.limit, ...autoReimbursementCleanupValues}} : {}; + const autoReimbursementValues = !enabled ? {limit: CONST.POLICY.AUTO_REIMBURSEMENT_DEFAULT_LIMIT_CENTS} : {}; + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoReimbursement: { + ...autoReimbursementValues, + pendingFields: { + limit: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + shouldShowAutoReimbursementLimitOption: enabled, + pendingFields: { + shouldShowAutoReimbursementLimitOption: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoReimbursement: {...autoReimbursementCleanupValues}, + pendingFields: { + shouldShowAutoReimbursementLimitOption: null, + }, + errorFields: null, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + ...autoReimbursementFailureValues, + shouldShowAutoReimbursementLimitOption: policy?.shouldShowAutoReimbursementLimitOption, + pendingFields: { + shouldShowAutoReimbursementLimitOption: null, + }, + }, + }, + ]; + + const parameters: EnablePolicyAutoReimbursementLimitParams = { + enabled, + policyID, + }; + + API.write(WRITE_COMMANDS.ENABLE_POLICY_AUTO_REIMBURSEMENT_LIMIT, parameters, { + optimisticData, + successData, + failureData, + }); +} + +function setWorkspaceCompanyCardFeedName(policyID: string, workspaceAccountID: number, bankName: string, userDefinedName: string) { + const authToken = NetworkStore.getAuthToken(); + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`, + value: { + companyCardNicknames: { + [bankName]: userDefinedName, + }, + }, + }, + ], + }; + + const parameters = { + authToken, + policyID, + bankName, + userDefinedName, + }; + + API.write(WRITE_COMMANDS.SET_COMPANY_CARD_FEED_NAME, parameters, onyxData); +} + +function setWorkspaceCompanyCardTransactionLiability(workspaceAccountID: number, bankName: string, liabilityType: string) { + const authToken = NetworkStore.getAuthToken(); + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`, + value: { + companyCards: { + [bankName]: {liabilityType}, + }, + }, + }, + ], + }; + + const parameters = { + authToken, + bankName, + liabilityType, + }; + + API.write(WRITE_COMMANDS.SET_COMPANY_CARD_TRANSACTION_LIABILITY, parameters, onyxData); +} + +function deleteWorkspaceCompanyCardFeed(policyID: string, workspaceAccountID: number, bankName: string) { + const authToken = NetworkStore.getAuthToken(); + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`, + value: { + companyCards: { + [bankName]: null, + }, + companyCardNicknames: { + [bankName]: null, + }, + }, + }, + ], + }; + + const parameters = { + authToken, + policyID, + bankName, + }; + + API.write(WRITE_COMMANDS.DELETE_COMPANY_CARD_FEED, parameters, onyxData); +} + +function unassignWorkspaceCompanyCard(workspaceAccountID: number, cardID: string, bankName: string) { + const authToken = NetworkStore.getAuthToken(); + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${bankName}`, + value: { + [cardID]: null, + }, + }, + ], + }; + + const parameters = { + authToken, + cardID, + }; + + API.write(WRITE_COMMANDS.UNASSIGN_COMPANY_CARD, parameters, onyxData); +} + +function updateWorkspaceCompanyCard(workspaceAccountID: number, cardID: string, bankName: string) { + const authToken = NetworkStore.getAuthToken(); + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${bankName}`, + value: { + [cardID]: { + isLoadingLastUpdated: true, + pendingFields: { + lastUpdated: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + errorFields: { + lastUpdated: null, + }, + }, + }, + }, + ]; + + const finallyData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${bankName}`, + value: { + [cardID]: { + isLoadingLastUpdated: false, + pendingFields: { + lastUpdated: null, + }, + }, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${bankName}`, + value: { + [cardID]: { + isLoadingLastUpdated: false, + pendingFields: { + lastUpdated: null, + }, + errorFields: { + lastUpdated: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + }, + ]; + + const parameters = { + authToken, + cardID, + }; + + API.write(WRITE_COMMANDS.UPDATE_COMPANY_CARD, parameters, {optimisticData, finallyData, failureData}); +} + +function updateCompanyCardName(workspaceAccountID: number, cardID: string, newCardTitle: string, bankName: string) { + const authToken = NetworkStore.getAuthToken(); + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${bankName}`, + value: { + [cardID]: { + nameValuePairs: { + cardTitle: newCardTitle, + pendingFields: { + cardTitle: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + errorFields: { + cardTitle: null, + }, + }, + }, + }, + }, + ]; + + const finallyData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${bankName}`, + value: { + [cardID]: { + nameValuePairs: { + pendingFields: { + cardTitle: null, + }, + }, + }, + }, + }, + ]; + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${bankName}`, + value: { + [cardID]: { + nameValuePairs: { + pendingFields: { + cardTitle: null, + }, + errorFields: { + cardTitle: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + }, + }, + ]; + + const parameters: UpdateCompanyCardNameParams = { + authToken, + cardID: Number(cardID), + cardName: newCardTitle, + }; + + API.write(WRITE_COMMANDS.UPDATE_COMPANY_CARD_NAME, parameters, {optimisticData, finallyData, failureData}); +} + +function setCompanyCardExportAccount(workspaceAccountID: number, cardID: string, accountKey: string, newAccount: string, bankName: string) { + const authToken = NetworkStore.getAuthToken(); + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${bankName}`, + value: { + [cardID]: { + nameValuePairs: { + pendingFields: { + exportAccountDetails: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + errorFields: { + exportAccountDetails: null, + }, + exportAccountDetails: { + [accountKey]: newAccount, + }, + }, + }, + }, + }, + ]; + + const finallyData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${bankName}`, + value: { + [cardID]: { + nameValuePairs: { + pendingFields: { + exportAccountDetails: null, + }, + }, + }, + }, + }, + ]; + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${bankName}`, + value: { + [cardID]: { + nameValuePairs: { + pendingFields: { + exportAccountDetails: null, + }, + errorFields: { + exportAccountDetails: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + }, + }, + ]; + + const parameters: SetCompanyCardExportAccountParams = { + authToken, + cardID: Number(cardID), + exportAccountDetails: {[accountKey]: newAccount}, + }; + + API.write(WRITE_COMMANDS.SET_CARD_EXPORT_ACCOUNT, parameters, {optimisticData, finallyData, failureData}); +} + +function clearCompanyCardErrorField(workspaceAccountID: number, cardID: string, bankName: string, fieldName: string, isRootLevel?: boolean) { + if (isRootLevel) { + Onyx.merge(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${bankName}`, { + [cardID]: { + errorFields: {[fieldName]: null}, + }, + }); + return; + } + Onyx.merge(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${bankName}`, { + [cardID]: { + nameValuePairs: { + errorFields: {[fieldName]: null}, + }, + }, + }); +} + +function clearAllPolicies() { + if (!allPolicies) { + return; + } + Object.keys(allPolicies).forEach((key) => delete allPolicies[key]); +} + +export { + leaveWorkspace, + addBillingCardAndRequestPolicyOwnerChange, + hasActiveChatEnabledPolicies, + setWorkspaceErrors, + clearCustomUnitErrors, + hideWorkspaceAlertMessage, + deleteWorkspace, + updateAddress, + updateWorkspaceCustomUnitAndRate, + updateLastAccessedWorkspace, + clearDeleteWorkspaceError, + openWorkspaceReimburseView, + setPolicyIDForReimburseView, + clearOnyxDataForReimburseView, + setWorkspaceDefaultSpendCategory, + setRateForReimburseView, + setUnitForReimburseView, + generateDefaultWorkspaceName, + updateGeneralSettings, + deleteWorkspaceAvatar, + updateWorkspaceAvatar, + clearAvatarErrors, + generatePolicyID, + createWorkspace, + openPolicyTaxesPage, + openWorkspaceInvitePage, + openWorkspace, + removeWorkspace, + createWorkspaceFromIOUPayment, + clearErrors, + dismissAddedWithPrimaryLoginMessages, + openDraftWorkspaceRequest, + createDraftInitialWorkspace, + buildOptimisticRecentlyUsedCurrencies, + setWorkspaceInviteMessageDraft, + setWorkspaceApprovalMode, + setWorkspaceAutoReportingFrequency, + setWorkspaceAutoReportingMonthlyOffset, + updateWorkspaceDescription, + setWorkspacePayer, + setWorkspaceReimbursement, + openPolicyWorkflowsPage, + enableCompanyCards, + enablePolicyConnections, + enablePolicyReportFields, + enablePolicyTaxes, + enablePolicyWorkflows, + enableDistanceRequestTax, + enablePolicyInvoicing, + openPolicyMoreFeaturesPage, + openPolicyProfilePage, + openPolicyInitialPage, + generateCustomUnitID, + clearQBOErrorField, + clearXeroErrorField, + clearSageIntacctErrorField, + clearNetSuiteErrorField, + clearNetSuitePendingField, + clearNetSuiteAutoSyncErrorField, + removeNetSuiteCustomFieldByIndex, + clearWorkspaceReimbursementErrors, + setWorkspaceCurrencyDefault, + setForeignCurrencyDefault, + setPolicyCustomTaxName, + clearPolicyErrorField, + isCurrencySupportedForDirectReimbursement, + getPrimaryPolicy, + getInvoicePrimaryWorkspace, + createDraftWorkspace, + savePreferredExportMethod, + buildPolicyData, + enableExpensifyCard, + createPolicyExpenseChats, + upgradeToCorporate, + openPolicyExpensifyCardsPage, + openPolicyEditCardLimitTypePage, + requestExpensifyCardLimitIncrease, + getAdminPoliciesConnectedToNetSuite, + getAdminPoliciesConnectedToSageIntacct, + hasInvoicingDetails, + clearAllPolicies, + enablePolicyRules, + setPolicyDefaultReportTitle, + setPolicyPreventMemberCreatedTitle, + setPolicyPreventSelfApproval, + setPolicyAutomaticApprovalLimit, + setPolicyAutomaticApprovalRate, + setPolicyAutoReimbursementLimit, + enablePolicyDefaultReportTitle, + enablePolicyAutoReimbursementLimit, + enableAutoApprovalOptions, + setPolicyMaxExpenseAmountNoReceipt, + setPolicyMaxExpenseAmount, + setPolicyMaxExpenseAge, + setPolicyBillableMode, + disableWorkspaceBillableExpenses, + setWorkspaceEReceiptsEnabled, + setWorkspaceCompanyCardFeedName, + deleteWorkspaceCompanyCardFeed, + setWorkspaceCompanyCardTransactionLiability, + openPolicyCompanyCardsPage, + unassignWorkspaceCompanyCard, + updateWorkspaceCompanyCard, + updateCompanyCardName, + setCompanyCardExportAccount, + clearCompanyCardErrorField, +}; + +export type {NewCustomUnit}; diff --git a/src/libs/actions/Policy/ReportField.ts b/src/libs/actions/Policy/ReportField.ts index 9a56c719fd73..2ee1707c21d1 100644 --- a/src/libs/actions/Policy/ReportField.ts +++ b/src/libs/actions/Policy/ReportField.ts @@ -383,7 +383,7 @@ function updateReportFieldListValueEnabled(policyID: string, reportFieldID: stri valueIndexes.forEach((valueIndex) => { updatedReportField.disabledOptions[valueIndex] = !enabled; - const shouldResetDefaultValue = !enabled && reportField.defaultValue === reportField.values[valueIndex]; + const shouldResetDefaultValue = !enabled && reportField.defaultValue === reportField.values.at(valueIndex); if (shouldResetDefaultValue) { updatedReportField.defaultValue = ''; @@ -460,7 +460,7 @@ function removeReportFieldListValue(policyID: string, reportFieldID: string, val valueIndexes .sort((a, b) => b - a) .forEach((valueIndex) => { - const shouldResetDefaultValue = reportField.defaultValue === reportField.values[valueIndex]; + const shouldResetDefaultValue = reportField.defaultValue === reportField.values.at(valueIndex); if (shouldResetDefaultValue) { updatedReportField.defaultValue = ''; diff --git a/src/libs/actions/Policy/Tag.ts b/src/libs/actions/Policy/Tag.ts index 88430b941838..a0d5aae745a3 100644 --- a/src/libs/actions/Policy/Tag.ts +++ b/src/libs/actions/Policy/Tag.ts @@ -25,6 +25,7 @@ import * as PolicyUtils from '@libs/PolicyUtils'; import {navigateWhenEnableFeature} from '@libs/PolicyUtils'; import * as ReportUtils from '@libs/ReportUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; +import type {PolicyTagList} from '@pages/workspace/tags/types'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, PolicyTag, PolicyTagLists, PolicyTags, RecentlyUsedTags, Report} from '@src/types/onyx'; @@ -121,7 +122,7 @@ function buildOptimisticPolicyRecentlyUsedTags(policyID?: string, transactionTag return; } - const tagListKey = policyTagKeys[index]; + const tagListKey = policyTagKeys.at(index) ?? ''; newOptimisticPolicyRecentlyUsedTags[tagListKey] = [...new Set([tag, ...(policyRecentlyUsedTags[tagListKey] ?? [])])]; }); @@ -157,7 +158,7 @@ function updateImportSpreadsheetData(tagsLength: number): OnyxData { } function createPolicyTag(policyID: string, tagName: string) { - const policyTag = PolicyUtils.getTagLists(allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {})?.[0] ?? {}; + const policyTag = PolicyUtils.getTagLists(allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {})?.at(0) ?? ({} as PolicyTagList); const newTagName = PolicyUtils.escapeTagName(tagName); const onyxData: OnyxData = { @@ -234,7 +235,11 @@ function importPolicyTags(policyID: string, tags: PolicyTag[]) { } function setWorkspaceTagEnabled(policyID: string, tagsToUpdate: Record, tagListIndex: number) { - const policyTag = PolicyUtils.getTagLists(allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {})?.[tagListIndex] ?? {}; + const policyTag = PolicyUtils.getTagLists(allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {})?.at(tagListIndex); + + if (!policyTag) { + return; + } const optimisticPolicyTagsData = { ...Object.keys(tagsToUpdate).reduce((acc, key) => { @@ -242,8 +247,9 @@ function setWorkspaceTagEnabled(policyID: string, tagsToUpdate: Record = {}; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.POLICY, + callback: (val, key) => { + if (!key) { + return; + } + if (val === null || val === undefined) { + // If we are deleting a policy, we have to check every report linked to that policy + // and unset the draft indicator (pencil icon) alongside removing any draft comments. Clearing these values will keep the newly archived chats from being displayed in the LHN. + // More info: https://github.com/Expensify/App/issues/14260 + const policyID = key.replace(ONYXKEYS.COLLECTION.POLICY, ''); + const policyReports = ReportUtils.getAllPolicyReports(policyID); + const cleanUpMergeQueries: Record<`${typeof ONYXKEYS.COLLECTION.REPORT}${string}`, NullishDeep> = {}; + const cleanUpSetQueries: Record<`${typeof ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${string}` | `${typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${string}`, null> = {}; + policyReports.forEach((policyReport) => { + if (!policyReport) { + return; + } + const {reportID} = policyReport; + cleanUpSetQueries[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`] = null; + cleanUpSetQueries[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`] = null; + }); + Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT, cleanUpMergeQueries); + Onyx.multiSet(cleanUpSetQueries); + delete allPolicies[key]; + return; + } + + allPolicies[key] = val; + }, +}); + +let allPolicyTags: OnyxCollection = {}; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.POLICY_TAGS, + waitForCollectionCallback: true, + callback: (value) => { + if (!value) { + allPolicyTags = {}; + return; + } + + allPolicyTags = value; + }, +}); + +let allRecentlyUsedTags: OnyxCollection = {}; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS, + waitForCollectionCallback: true, + callback: (val) => (allRecentlyUsedTags = val), +}); + +function openPolicyTagsPage(policyID: string) { + if (!policyID) { + Log.warn('openPolicyTasgPage invalid params', {policyID}); + return; + } + + const params: OpenPolicyTagsPageParams = { + policyID, + }; + + API.read(READ_COMMANDS.OPEN_POLICY_TAGS_PAGE, params); +} + +function buildOptimisticPolicyRecentlyUsedTags(policyID?: string, transactionTags?: string): RecentlyUsedTags { + if (!policyID || !transactionTags) { + return {}; + } + + const policyTags = allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {}; + const policyTagKeys = PolicyUtils.getSortedTagKeys(policyTags); + const policyRecentlyUsedTags = allRecentlyUsedTags?.[`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${policyID}`] ?? {}; + const newOptimisticPolicyRecentlyUsedTags: RecentlyUsedTags = {}; + + TransactionUtils.getTagArrayFromName(transactionTags).forEach((tag, index) => { + if (!tag) { + return; + } + + const tagListKey = policyTagKeys[index]; + newOptimisticPolicyRecentlyUsedTags[tagListKey] = [...new Set([tag, ...(policyRecentlyUsedTags[tagListKey] ?? [])])]; + }); + + return newOptimisticPolicyRecentlyUsedTags; +} + +function updateImportSpreadsheetData(tagsLength: number): OnyxData { + const onyxData: OnyxData = { + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.IMPORTED_SPREADSHEET, + value: { + shouldFinalModalBeOpened: true, + importFinalModal: {title: translateLocal('spreadsheet.importSuccessfullTitle'), prompt: translateLocal('spreadsheet.importTagsSuccessfullDescription', tagsLength)}, + }, + }, + ], + + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.IMPORTED_SPREADSHEET, + value: { + shouldFinalModalBeOpened: true, + importFinalModal: {title: translateLocal('spreadsheet.importFailedTitle'), prompt: translateLocal('spreadsheet.importFailedDescription')}, + }, + }, + ], + }; + + return onyxData; +} + +function createPolicyTag(policyID: string, tagName: string) { + const policyTag = PolicyUtils.getTagLists(allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {})?.[0] ?? {}; + const newTagName = PolicyUtils.escapeTagName(tagName); + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: { + [policyTag.name]: { + tags: { + [newTagName]: { + name: newTagName, + enabled: true, + errors: null, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + }, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: { + [policyTag.name]: { + tags: { + [newTagName]: { + errors: null, + pendingAction: null, + }, + }, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: { + [policyTag.name]: { + tags: { + [newTagName]: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.tags.genericFailureMessage'), + pendingAction: null, + }, + }, + }, + }, + }, + ], + }; + + const parameters = { + policyID, + tags: JSON.stringify([{name: newTagName}]), + }; + + API.write(WRITE_COMMANDS.CREATE_POLICY_TAG, parameters, onyxData); +} + +function importPolicyTags(policyID: string, tags: PolicyTag[]) { + const onyxData = updateImportSpreadsheetData(tags.length); + + const parameters = { + policyID, + // eslint-disable-next-line @typescript-eslint/naming-convention + tags: JSON.stringify(tags.map((tag) => ({name: tag.name, enabled: tag.enabled, 'GL Code': tag['GL Code']}))), + }; + + API.write(WRITE_COMMANDS.IMPORT_TAGS_SREADSHEET, parameters, onyxData); +} + +function setWorkspaceTagEnabled(policyID: string, tagsToUpdate: Record, tagListIndex: number) { + const policyTag = PolicyUtils.getTagLists(allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {})?.[tagListIndex] ?? {}; + + const optimisticPolicyTagsData = { + ...Object.keys(tagsToUpdate).reduce((acc, key) => { + acc[key] = { + ...policyTag.tags[key], + ...tagsToUpdate[key], + errors: null, + pendingFields: { + ...policyTag.tags[key].pendingFields, + enabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }; + + return acc; + }, {}), + }; + const shouldDisableRequiredTag = !OptionsListUtils.hasEnabledOptions({...policyTag.tags, ...optimisticPolicyTagsData}); + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: { + [policyTag.name]: { + ...(shouldDisableRequiredTag ? {required: false, pendingFields: {required: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}} : {}), + tags: optimisticPolicyTagsData, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: { + [policyTag.name]: { + ...(shouldDisableRequiredTag ? {pendingFields: {required: null}} : {}), + tags: { + ...Object.keys(tagsToUpdate).reduce((acc, key) => { + acc[key] = { + ...policyTag.tags[key], + ...tagsToUpdate[key], + errors: null, + pendingFields: { + ...policyTag.tags[key].pendingFields, + enabled: null, + }, + pendingAction: null, + }; + + return acc; + }, {}), + }, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: { + [policyTag.name]: { + ...(shouldDisableRequiredTag ? {pendingFields: {required: null}, required: policyTag.required} : {}), + tags: { + ...Object.keys(tagsToUpdate).reduce((acc, key) => { + acc[key] = { + ...policyTag.tags[key], + ...tagsToUpdate[key], + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.tags.genericFailureMessage'), + pendingFields: { + ...policyTag.tags[key].pendingFields, + enabled: null, + }, + pendingAction: null, + }; + + return acc; + }, {}), + }, + }, + }, + }, + ], + }; + + const parameters: SetPolicyTagsEnabled = { + policyID, + tags: JSON.stringify(Object.keys(tagsToUpdate).map((key) => tagsToUpdate[key])), + tagListIndex, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_TAGS_ENABLED, parameters, onyxData); +} + +function deletePolicyTags(policyID: string, tagsToDelete: string[]) { + const policyTag = PolicyUtils.getTagLists(allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {})?.[0] ?? {}; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: { + [policyTag.name]: { + tags: { + ...tagsToDelete.reduce>>>((acc, tagName) => { + acc[tagName] = {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}; + return acc; + }, {}), + }, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: { + [policyTag.name]: { + tags: { + ...tagsToDelete.reduce>>>((acc, tagName) => { + acc[tagName] = null; + return acc; + }, {}), + }, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: { + [policyTag.name]: { + tags: { + ...tagsToDelete.reduce>>>((acc, tagName) => { + acc[tagName] = {pendingAction: null, errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.tags.deleteFailureMessage')}; + return acc; + }, {}), + }, + }, + }, + }, + ], + }; + + const parameters = { + policyID, + tags: JSON.stringify(tagsToDelete), + }; + + API.write(WRITE_COMMANDS.DELETE_POLICY_TAGS, parameters, onyxData); +} + +function clearPolicyTagErrors(policyID: string, tagName: string, tagListIndex: number) { + const tagListName = PolicyUtils.getTagListName(allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`], tagListIndex); + const tag = allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`]?.[tagListName].tags?.[tagName]; + if (!tag) { + return; + } + + if (tag.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) { + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, { + [tagListName]: { + tags: { + [tagName]: null, + }, + }, + }); + return; + } + + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, { + [tagListName]: { + tags: { + [tagName]: { + errors: null, + pendingAction: null, + }, + }, + }, + }); +} + +function clearPolicyTagListErrorField(policyID: string, tagListIndex: number, errorField: string) { + const policyTag = PolicyUtils.getTagLists(allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {})?.[tagListIndex] ?? {}; + + if (!policyTag.name) { + return; + } + + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, { + [policyTag.name]: { + errorFields: { + [errorField]: null, + }, + }, + }); +} + +function clearPolicyTagListErrors(policyID: string, tagListIndex: number) { + const policyTag = PolicyUtils.getTagLists(allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {})?.[tagListIndex] ?? {}; + + if (!policyTag.name) { + return; + } + + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, { + [policyTag.name]: { + errors: null, + }, + }); +} + +function renamePolicyTag(policyID: string, policyTag: {oldName: string; newName: string}, tagListIndex: number) { + const policy = PolicyUtils.getPolicy(policyID); + const tagList = PolicyUtils.getTagLists(allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {})?.[tagListIndex] ?? {}; + const tag = tagList.tags?.[policyTag.oldName]; + const oldTagName = policyTag.oldName; + const newTagName = PolicyUtils.escapeTagName(policyTag.newName); + + const policyTagRule = PolicyUtils.getTagApproverRule(policyID, oldTagName); + const approvalRules = policy?.rules?.approvalRules ?? []; + const updatedApprovalRules: ApprovalRule[] = lodashCloneDeep(approvalRules); + + // Its related by name, so the corresponding rule has to be updated to handle offline scenario + if (policyTagRule) { + const indexToUpdate = updatedApprovalRules.findIndex((rule) => rule.id === policyTagRule.id); + policyTagRule.applyWhen = policyTagRule.applyWhen.map((ruleCondition) => { + const {value, field, condition} = ruleCondition; + + if (value === policyTag.oldName && field === CONST.POLICY.FIELDS.TAG && condition === CONST.POLICY.RULE_CONDITIONS.MATCHES) { + return {...ruleCondition, value: policyTag.newName}; + } + + return ruleCondition; + }); + updatedApprovalRules[indexToUpdate] = policyTagRule; + } + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: { + [tagList.name]: { + tags: { + [oldTagName]: null, + [newTagName]: { + ...tag, + name: newTagName, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + pendingFields: { + ...tag.pendingFields, + name: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + previousTagName: oldTagName, + errors: null, + }, + }, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + rules: { + approvalRules: updatedApprovalRules, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: { + [tagList.name]: { + tags: { + [newTagName]: { + pendingAction: null, + pendingFields: { + ...tag.pendingFields, + name: null, + }, + }, + }, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: { + [tagList.name]: { + tags: { + [newTagName]: null, + [oldTagName]: { + ...tag, + pendingAction: null, + pendingFields: { + ...tag.pendingFields, + name: null, + }, + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.tags.genericFailureMessage'), + }, + }, + }, + }, + }, + ], + }; + + const parameters: RenamePolicyTagsParams = { + policyID, + oldName: oldTagName, + newName: newTagName, + tagListIndex, + }; + + API.write(WRITE_COMMANDS.RENAME_POLICY_TAG, parameters, onyxData); +} + +function enablePolicyTags(policyID: string, enabled: boolean) { + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + areTagsEnabled: enabled, + pendingFields: { + areTagsEnabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + areTagsEnabled: null, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + areTagsEnabled: !enabled, + pendingFields: { + areTagsEnabled: null, + }, + }, + }, + ], + }; + + const policyTagList = allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`]; + if (!policyTagList) { + const defaultTagList: PolicyTagLists = { + Tag: { + name: 'Tag', + orderWeight: 0, + required: false, + tags: {}, + }, + }; + onyxData.optimisticData?.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: defaultTagList, + }); + onyxData.failureData?.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: null, + }); + } else if (!enabled) { + const policyTag = PolicyUtils.getTagLists(policyTagList)[0]; + onyxData.optimisticData?.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: { + [policyTag.name]: { + tags: Object.fromEntries( + Object.keys(policyTag.tags).map((tagName) => [ + tagName, + { + enabled: false, + }, + ]), + ), + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + requiresTag: false, + }, + }, + ); + } + + const parameters: EnablePolicyTagsParams = {policyID, enabled}; + + API.write(WRITE_COMMANDS.ENABLE_POLICY_TAGS, parameters, onyxData); + + if (enabled && getIsNarrowLayout()) { + navigateWhenEnableFeature(policyID); + } +} + +function renamePolicyTaglist(policyID: string, policyTagListName: {oldName: string; newName: string}, policyTags: OnyxEntry, tagListIndex: number) { + const newName = policyTagListName.newName; + const oldName = policyTagListName.oldName; + const oldPolicyTags = policyTags?.[oldName] ?? {}; + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: { + [newName]: {...oldPolicyTags, name: newName, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, errors: null}, + [oldName]: null, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: { + [newName]: {pendingAction: null}, + [oldName]: null, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: { + [newName]: null, + [oldName]: { + ...oldPolicyTags, + pendingAction: null, + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.tags.genericFailureMessage'), + }, + }, + }, + ], + }; + const parameters: RenamePolicyTaglistParams = { + policyID, + oldName, + newName, + tagListIndex, + }; + + API.write(WRITE_COMMANDS.RENAME_POLICY_TAG_LIST, parameters, onyxData); +} + +function setPolicyRequiresTag(policyID: string, requiresTag: boolean) { + const policyTags = allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {}; + const isMultiLevelTags = PolicyUtils.isMultiLevelTags(policyTags); + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + requiresTag, + errors: {requiresTag: null}, + pendingFields: { + requiresTag: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + errors: { + requiresTag: null, + }, + pendingFields: { + requiresTag: null, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + requiresTag: !requiresTag, + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.tags.genericFailureMessage'), + pendingFields: { + requiresTag: null, + }, + }, + }, + ], + }; + + if (isMultiLevelTags) { + const getUpdatedTagsData = (required: boolean): OnyxUpdate => ({ + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + onyxMethod: Onyx.METHOD.MERGE, + value: { + ...Object.keys(policyTags).reduce((acc, key) => { + acc[key] = { + ...acc[key], + required, + }; + return acc; + }, {}), + }, + }); + + onyxData.optimisticData?.push(getUpdatedTagsData(requiresTag)); + onyxData.failureData?.push(getUpdatedTagsData(!requiresTag)); + onyxData.successData?.push(getUpdatedTagsData(requiresTag)); + } + + const parameters = { + policyID, + requiresTag, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_REQUIRES_TAG, parameters, onyxData); +} + +function setPolicyTagsRequired(policyID: string, requiresTag: boolean, tagListIndex: number) { + const policyTag = PolicyUtils.getTagLists(allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {})?.[tagListIndex] ?? {}; + + if (!policyTag.name) { + return; + } + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: { + [policyTag.name]: { + required: requiresTag, + pendingFields: {required: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + errorFields: {required: null}, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: { + [policyTag.name]: { + pendingFields: {required: null}, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: { + [policyTag.name]: { + required: policyTag.required, + pendingFields: {required: null}, + errorFields: { + required: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.tags.genericFailureMessage'), + }, + }, + }, + }, + ], + }; + + const parameters: SetPolicyTagsRequired = { + policyID, + tagListIndex, + requireTagList: requiresTag, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_TAGS_REQUIRED, parameters, onyxData); +} + +function setPolicyTagGLCode(policyID: string, tagName: string, tagListIndex: number, glCode: string) { + const tagListName = PolicyUtils.getTagListName(allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`], tagListIndex); + const policyTagToUpdate = allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`]?.[tagListName]?.tags?.[tagName] ?? {}; + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: { + [tagListName]: { + tags: { + [tagName]: { + ...policyTagToUpdate, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + pendingFields: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'GL Code': CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + // eslint-disable-next-line @typescript-eslint/naming-convention + 'GL Code': glCode, + }, + }, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: { + [tagListName]: { + tags: { + [tagName]: { + errors: null, + pendingAction: null, + pendingFields: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'GL Code': null, + }, + }, + }, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: { + [tagListName]: { + tags: { + [tagName]: { + ...policyTagToUpdate, + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.tags.updateGLCodeFailureMessage'), + }, + }, + }, + }, + }, + ], + }; + + const parameters: UpdatePolicyTagGLCodeParams = { + policyID, + tagName, + tagListName, + tagListIndex, + glCode, + }; + + API.write(WRITE_COMMANDS.UPDATE_POLICY_TAG_GL_CODE, parameters, onyxData); +} + +function setPolicyTagApprover(policyID: string, tag: string, approver: string) { + const policy = PolicyUtils.getPolicy(policyID); + const prevApprovalRules = policy?.rules?.approvalRules ?? []; + const approverRuleToUpdate = PolicyUtils.getTagApproverRule(policyID, tag); + const filteredApprovalRules = approverRuleToUpdate ? prevApprovalRules.filter((rule) => rule.id !== approverRuleToUpdate.id) : prevApprovalRules; + const toBeUnselected = approverRuleToUpdate?.approver === approver; + + const updatedApproverRule = approverRuleToUpdate + ? {...approverRuleToUpdate, approver} + : { + applyWhen: [ + { + condition: CONST.POLICY.RULE_CONDITIONS.MATCHES, + field: CONST.POLICY.FIELDS.TAG, + value: tag, + }, + ], + approver, + id: '-1', + }; + + const updatedApprovalRules = toBeUnselected ? filteredApprovalRules : [...filteredApprovalRules, updatedApproverRule]; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + rules: { + approvalRules: updatedApprovalRules, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + rules: { + approvalRules: updatedApprovalRules, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + rules: { + approvalRules: prevApprovalRules, + }, + }, + }, + ], + }; + + const parameters: SetPolicyTagApproverParams = { + policyID, + tagName: tag, + approver: toBeUnselected ? null : approver, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_TAG_APPROVER, parameters, onyxData); +} + +function downloadTagsCSV(policyID: string) { + const finalParameters = enhanceParameters(WRITE_COMMANDS.EXPORT_TAGS_CSV, { + policyID, + }); + const fileName = 'Tags.csv'; + + const formData = new FormData(); + Object.entries(finalParameters).forEach(([key, value]) => { + formData.append(key, String(value)); + }); + + fileDownload(ApiUtils.getCommandURL({command: WRITE_COMMANDS.EXPORT_TAGS_CSV}), fileName, '', false, formData, CONST.NETWORK.METHOD.POST); +} + +export { + buildOptimisticPolicyRecentlyUsedTags, + setPolicyRequiresTag, + setPolicyTagsRequired, + createPolicyTag, + clearPolicyTagErrors, + clearPolicyTagListErrors, + clearPolicyTagListErrorField, + deletePolicyTags, + enablePolicyTags, + openPolicyTagsPage, + renamePolicyTag, + renamePolicyTaglist, + setWorkspaceTagEnabled, + setPolicyTagGLCode, + setPolicyTagApprover, + importPolicyTags, + downloadTagsCSV, +}; + +export type {NewCustomUnit}; diff --git a/src/libs/actions/Policy/Tag.ts.rej b/src/libs/actions/Policy/Tag.ts.rej new file mode 100644 index 000000000000..987f37592e28 --- /dev/null +++ b/src/libs/actions/Policy/Tag.ts.rej @@ -0,0 +1,14 @@ +@@ -455,7 +475,12 @@ + } + + function renamePolicyTag(policyID: string, policyTag: {oldName: string; newName: string}, tagListIndex: number) { +- const tagList = PolicyUtils.getTagLists(allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {})?.[tagListIndex] ?? {}; ++ const tagList = PolicyUtils.getTagLists(allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {})?.at(tagListIndex); ++ ++ if (!tagList) { ++ return; ++ } ++ + const tag = tagList.tags?.[policyTag.oldName]; + const oldTagName = policyTag.oldName; + const newTagName = PolicyUtils.escapeTagName(policyTag.newName); diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 89eec7835e0b..3de8171a5482 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -329,7 +329,7 @@ function subscribeToReportTypingEvents(reportID: string) { // login OR by 'Concierge'. If the pusher message comes from NewDot, it is keyed by accountID // since personal details are keyed by accountID. const normalizedTypingStatus = getNormalizedStatus(typingStatus); - const accountIDOrLogin = Object.keys(normalizedTypingStatus)[0]; + const accountIDOrLogin = Object.keys(normalizedTypingStatus).at(0); if (!accountIDOrLogin) { return; @@ -372,7 +372,7 @@ function subscribeToReportLeavingEvents(reportID: string) { // login OR by 'Concierge'. If the pusher message comes from NewDot, it is keyed by accountID // since personal details are keyed by accountID. const normalizedLeavingStatus = getNormalizedStatus(leavingStatus); - const accountIDOrLogin = Object.keys(normalizedLeavingStatus)[0]; + const accountIDOrLogin = Object.keys(normalizedLeavingStatus).at(0); if (!accountIDOrLogin) { return; @@ -862,16 +862,19 @@ function openReport( if (isCreatingNewReport) { // Change the method to set for new reports because it doesn't exist yet, is faster, // and we need the data to be available when we navigate to the chat page - optimisticData[0].onyxMethod = Onyx.METHOD.SET; - optimisticData[0].value = { - ...optimisticReport, - reportName: CONST.REPORT.DEFAULT_REPORT_NAME, - ...newReportObject, - pendingFields: { - createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - }, - isOptimisticReport: true, - }; + const optimisticDataItem = optimisticData.at(0); + if (optimisticDataItem) { + optimisticDataItem.onyxMethod = Onyx.METHOD.SET; + optimisticDataItem.value = { + ...optimisticReport, + reportName: CONST.REPORT.DEFAULT_REPORT_NAME, + ...newReportObject, + pendingFields: { + createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + isOptimisticReport: true, + }; + } let emailCreatingAction: string = CONST.REPORT.OWNER_EMAIL_FAKE; if (newReportObject.ownerAccountID && newReportObject.ownerAccountID !== CONST.REPORT.OWNER_ACCOUNT_ID_FAKE) { @@ -895,7 +898,7 @@ function openReport( const redundantParticipants: Record = {}; const participantAccountIDs = PersonalDetailsUtils.getAccountIDsByLogins(participantLoginList); participantLoginList.forEach((login, index) => { - const accountID = participantAccountIDs[index]; + const accountID = participantAccountIDs.at(index) ?? -1; const isOptimisticAccount = !allPersonalDetails?.[accountID]; if (!isOptimisticAccount) { @@ -2072,7 +2075,7 @@ function updateDescription(reportID: string, previousValue: string, newValue: st pendingFields: {description: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, lastActorAccountID: currentUserAccountID, lastVisibleActionCreated: optimisticDescriptionUpdatedReportAction.created, - lastMessageText: (optimisticDescriptionUpdatedReportAction?.message as Message[])?.[0]?.text, + lastMessageText: (optimisticDescriptionUpdatedReportAction?.message as Message[])?.at(0)?.text, }, }, { diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts index 41ccfee1786b..745b4b3b01d1 100644 --- a/src/libs/actions/Task.ts +++ b/src/libs/actions/Task.ts @@ -896,7 +896,7 @@ function getShareDestination(reportID: string, reports: OnyxCollection a.localeCompare(b))[0]; + const firstTaxID = Object.keys(policyTaxRates ?? {}) + .sort((a, b) => a.localeCompare(b)) + .at(0); if (!policyTaxRates) { console.debug('Policy or tax rates not found'); diff --git a/src/libs/actions/TeachersUnite.ts b/src/libs/actions/TeachersUnite.ts index ba67e579e95a..1e550f22400d 100644 --- a/src/libs/actions/TeachersUnite.ts +++ b/src/libs/actions/TeachersUnite.ts @@ -143,7 +143,7 @@ function addSchoolPrincipal(firstName: string, partnerUserID: string, lastName: onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseChatReportID}`, value: { - [Object.keys(expenseChatData)[0]]: { + [Object.keys(expenseChatData).at(0) ?? '']: { pendingAction: null, }, }, diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index e19251b62ce8..3c1e02751727 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -143,7 +143,7 @@ function removeWaypoint(transaction: OnyxEntry, currentIndex: strin return Promise.resolve(); } - const isRemovedWaypointEmpty = removed.length > 0 && !TransactionUtils.waypointHasValidAddress(removed[0] ?? {}); + const isRemovedWaypointEmpty = removed.length > 0 && !TransactionUtils.waypointHasValidAddress(removed.at(0) ?? {}); // When there are only two waypoints we are adding empty waypoint back if (totalWaypoints === 2 && (index === 0 || index === totalWaypoints - 1)) { @@ -311,7 +311,7 @@ function dismissDuplicateTransactionViolation(transactionIDs: string[], dissmiss onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${action?.childReportID ?? '-1'}`, value: { - [optimisticDissmidedViolationReportActions[index].reportActionID]: optimisticDissmidedViolationReportActions[index] as ReportAction, + [optimisticDissmidedViolationReportActions.at(index)?.reportActionID ?? '']: optimisticDissmidedViolationReportActions.at(index) as ReportAction, }, })); const optimisticDataTransactionViolations: OnyxUpdate[] = currentTransactionViolations.map((transactionViolations) => ({ @@ -359,7 +359,7 @@ function dismissDuplicateTransactionViolation(transactionIDs: string[], dissmiss onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${action?.childReportID ?? '-1'}`, value: { - [optimisticDissmidedViolationReportActions[index].reportActionID]: null, + [optimisticDissmidedViolationReportActions.at(index)?.reportActionID ?? '']: null, }, })); @@ -371,7 +371,7 @@ function dismissDuplicateTransactionViolation(transactionIDs: string[], dissmiss onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${action?.childReportID ?? '-1'}`, value: { - [optimisticDissmidedViolationReportActions[index].reportActionID]: { + [optimisticDissmidedViolationReportActions.at(index)?.reportActionID ?? '']: { pendingAction: null, }, }, diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index 7ef7d6b94c9e..f2ba1dceb442 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -33,7 +33,6 @@ import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import playSound, {SOUNDS} from '@libs/Sound'; import playSoundExcludingMobile from '@libs/Sound/playSoundExcludingMobile'; import Visibility from '@libs/Visibility'; -import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -775,7 +774,7 @@ function playSoundForMessageType(pushJSON: OnyxServerUpdate[]) { const reportActionsOnly = pushJSON.filter((update) => update.key?.includes('reportActions_')); // "reportActions_5134363522480668" -> "5134363522480668" const reportID = reportActionsOnly - .map((value) => value.key.split('_')[1]) + .map((value) => value.key.split('_').at(1)) .find((reportKey) => reportKey === Navigation.getTopmostReportId() && Visibility.isVisible() && Visibility.hasFocus()); if (!reportID) { @@ -1328,13 +1327,6 @@ function requestRefund() { API.write(WRITE_COMMANDS.REQUEST_REFUND, null); } -function subscribeToActiveGuides() { - const pusherChannelName = `${CONST.PUSHER.PRESENCE_ACTIVE_GUIDES}${CONFIG.PUSHER.SUFFIX}`; - Pusher.subscribe(pusherChannelName).catch(() => { - Log.hmmm('[User] Failed to initially subscribe to Pusher channel', {pusherChannelName}); - }); -} - function setIsDebugModeEnabled(isDebugModeEnabled: boolean) { Onyx.merge(ONYXKEYS.USER, {isDebugModeEnabled}); } @@ -1377,7 +1369,6 @@ export { requestValidateCodeAction, addPendingContactMethod, clearValidateCodeActionError, - subscribeToActiveGuides, dismissGBRTooltip, setIsDebugModeEnabled, }; diff --git a/src/libs/actions/Welcome/OnboardingFlow.ts b/src/libs/actions/Welcome/OnboardingFlow.ts index 4e780090299d..4c275dfdd062 100644 --- a/src/libs/actions/Welcome/OnboardingFlow.ts +++ b/src/libs/actions/Welcome/OnboardingFlow.ts @@ -46,7 +46,7 @@ function adaptOnboardingRouteState() { const rootState = navigationRef.getRootState(); const adaptedState = rootState; const lastRouteIndex = (adaptedState?.routes?.length ?? 0) - 1; - const onBoardingModalNavigatorState = adaptedState?.routes[lastRouteIndex]?.state; + const onBoardingModalNavigatorState = adaptedState?.routes.at(lastRouteIndex)?.state; if (!onBoardingModalNavigatorState || onBoardingModalNavigatorState?.routes?.length > 1) { return; } @@ -80,7 +80,11 @@ function adaptOnboardingRouteState() { } as Readonly>; } - adaptedState.routes[lastRouteIndex].state = adaptedOnboardingModalNavigatorState; + const route = adaptedState.routes.at(lastRouteIndex); + + if (route) { + route.state = adaptedOnboardingModalNavigatorState; + } navigationRef.resetRoot(adaptedState); } diff --git a/src/libs/actions/Workflow.ts b/src/libs/actions/Workflow.ts index 5e4b0408f155..9731b62838f5 100644 --- a/src/libs/actions/Workflow.ts +++ b/src/libs/actions/Workflow.ts @@ -122,7 +122,7 @@ function updateApprovalWorkflow(policyID: string, approvalWorkflow: ApprovalWork } const previousDefaultApprover = policy.approver ?? policy.owner; - const newDefaultApprover = approvalWorkflow.isDefault ? approvalWorkflow.approvers[0].email : undefined; + const newDefaultApprover = approvalWorkflow.isDefault ? approvalWorkflow.approvers.at(0)?.email : undefined; const previousEmployeeList = Object.fromEntries(Object.entries(policy.employeeList ?? {}).map(([key, value]) => [key, {...value, pendingAction: null}])); const updatedEmployees = convertApprovalWorkflowToPolicyEmployees({ previousEmployeeList, diff --git a/src/libs/fileDownload/FileUtils.ts b/src/libs/fileDownload/FileUtils.ts index 05f29390ca14..35919c29d81b 100644 --- a/src/libs/fileDownload/FileUtils.ts +++ b/src/libs/fileDownload/FileUtils.ts @@ -225,10 +225,10 @@ const readFileAsync: ReadFileAsync = (path, fileName, onSuccess, onFailure = () */ function base64ToFile(base64: string, filename: string): File { // Decode the base64 string - const byteString = atob(base64.split(',')[1]); + const byteString = atob(base64.split(',').at(1) ?? ''); // Get the mime type from the base64 string - const mimeString = base64.split(',')[0].split(':')[1].split(';')[0]; + const mimeString = base64.split(',').at(0)?.split(':').at(1)?.split(';').at(0); // Convert byte string to Uint8Array const arrayBuffer = new ArrayBuffer(byteString.length); diff --git a/src/libs/fileDownload/index.desktop.ts b/src/libs/fileDownload/index.desktop.ts index 6a601a4af249..accd4905e53c 100644 --- a/src/libs/fileDownload/index.desktop.ts +++ b/src/libs/fileDownload/index.desktop.ts @@ -25,7 +25,7 @@ const fileDownload: FileDownload = (url, fileName, successMessage, shouldOpenExt }, CONST.DOWNLOADS_TIMEOUT); const handleDownloadStatus = (...args: unknown[]) => { - const arg = Array.isArray(args) ? args[0] : null; + const arg = Array.isArray(args) ? args.at(0) : null; const eventUrl = arg && typeof arg === 'object' && 'url' in arg ? arg.url : null; if (eventUrl === url) { diff --git a/src/libs/mapChildrenFlat.ts b/src/libs/mapChildrenFlat.ts index 73009a3340d4..34527f4775c1 100644 --- a/src/libs/mapChildrenFlat.ts +++ b/src/libs/mapChildrenFlat.ts @@ -22,7 +22,7 @@ const mapChildrenFlat = (element: C, fn: (child: C, index: number) => T) = const mappedChildren = React.Children.map(element, fn); if (Array.isArray(mappedChildren) && mappedChildren.length === 1) { - return mappedChildren[0]; + return mappedChildren.at(0); } return mappedChildren; diff --git a/src/libs/memoize/cache/ArrayCache.ts b/src/libs/memoize/cache/ArrayCache.ts index 058efefdb1aa..209250e47675 100644 --- a/src/libs/memoize/cache/ArrayCache.ts +++ b/src/libs/memoize/cache/ArrayCache.ts @@ -16,7 +16,8 @@ function ArrayCache(config: CacheConfig): Cache { */ function getKeyIndex(key: K): number { for (let i = cache.length - 1; i >= 0; i--) { - if (keyComparator(cache[i][0], key)) { + const cacheItem = cache.at(i)?.at(0); + if (cacheItem && keyComparator(cacheItem as K, key)) { return i; } } diff --git a/src/pages/Debug/DebugDetails.tsx b/src/pages/Debug/DebugDetails.tsx index 28bcb4e0df8b..3d7290d03b79 100644 --- a/src/pages/Debug/DebugDetails.tsx +++ b/src/pages/Debug/DebugDetails.tsx @@ -74,7 +74,7 @@ function DebugDetails({data, onSave, onDelete, validate}: DebugDetailsProps) { ) // eslint-disable-next-line @typescript-eslint/no-unsafe-argument .map(([key, value]) => [key, DebugUtils.onyxDataToString(value)]) - .sort((a, b) => a[0].localeCompare(b[0])), + .sort((a, b) => a.at(0).localeCompare(b.at(0))), [data], ); const dateTimeFields = useMemo(() => Object.entries(data ?? {}).filter(([key]) => DETAILS_DATETIME_FIELDS.includes(key as DetailsDatetimeFieldsKeys)) as Array<[string, string]>, [data]); diff --git a/src/pages/Debug/DebugDetailsConstantPickerPage.tsx b/src/pages/Debug/DebugDetailsConstantPickerPage.tsx index 7dff4f2fe2ff..a98ef9963542 100644 --- a/src/pages/Debug/DebugDetailsConstantPickerPage.tsx +++ b/src/pages/Debug/DebugDetailsConstantPickerPage.tsx @@ -64,7 +64,7 @@ function DebugDetailsConstantPickerPage({ Navigation.navigate(appendParam(backTo, fieldName, value)); } }; - const selectedOptionKey = useMemo(() => sections.filter((option) => option.searchText === fieldValue)[0]?.keyForList, [sections, fieldValue]); + const selectedOptionKey = useMemo(() => sections.filter((option) => option.searchText === fieldValue).at(0)?.keyForList, [sections, fieldValue]); return ( diff --git a/src/pages/EditReportFieldDropdown.tsx b/src/pages/EditReportFieldDropdown.tsx index ba6828eca782..a46a6db6f8a8 100644 --- a/src/pages/EditReportFieldDropdown.tsx +++ b/src/pages/EditReportFieldDropdown.tsx @@ -102,7 +102,7 @@ function EditReportFieldDropdownPage({onSubmit, fieldKey, fieldValue, fieldOptio return [policyReportFieldOptions, header]; }, [recentlyUsedOptions, debouncedSearchValue, fieldValue, fieldOptions]); - const selectedOptionKey = useMemo(() => (sections?.[0]?.data ?? []).filter((option) => option.searchText === fieldValue)?.[0]?.keyForList, [sections, fieldValue]); + const selectedOptionKey = useMemo(() => (sections?.[0]?.data ?? []).filter((option) => option.searchText === fieldValue)?.at(0)?.keyForList, [sections, fieldValue]); return ( !policyField.disabledOptions[index])} + fieldOptions={policyField.values.filter((_value: string, index: number) => !policyField.disabledOptions.at(index))} onSubmit={handleReportFieldChange} /> )} diff --git a/src/pages/EnablePayments/IdologyQuestions.tsx b/src/pages/EnablePayments/IdologyQuestions.tsx index b9b0ac4eca34..602754bef556 100644 --- a/src/pages/EnablePayments/IdologyQuestions.tsx +++ b/src/pages/EnablePayments/IdologyQuestions.tsx @@ -44,7 +44,7 @@ function IdologyQuestions({questions, idNumber}: IdologyQuestionsProps) { const [shouldHideSkipAnswer, setShouldHideSkipAnswer] = useState(false); const [userAnswers, setUserAnswers] = useState([]); - const currentQuestion = questions[currentQuestionIndex] || {}; + const currentQuestion = questions.at(currentQuestionIndex) ?? ({} as WalletAdditionalQuestionDetails); const possibleAnswers: Choice[] = currentQuestion.answer .map((answer) => { if (shouldHideSkipAnswer && answer === SKIP_QUESTION_TEXT) { @@ -70,7 +70,7 @@ function IdologyQuestions({questions, idNumber}: IdologyQuestionsProps) { * Show next question or send all answers for Idology verifications when we've answered enough */ const submitAnswers = () => { - if (!userAnswers[currentQuestionIndex]) { + if (!userAnswers.at(currentQuestionIndex)) { return; } // Get the number of questions that were skipped by the user. @@ -83,7 +83,7 @@ function IdologyQuestions({questions, idNumber}: IdologyQuestionsProps) { // Auto skip any remaining questions if (tempAnswers.length < questions.length) { for (let i = tempAnswers.length; i < questions.length; i++) { - tempAnswers[i] = {question: questions[i].type, answer: SKIP_QUESTION_TEXT}; + tempAnswers[i] = {question: questions.at(i)?.type ?? '', answer: SKIP_QUESTION_TEXT}; } } @@ -123,7 +123,7 @@ function IdologyQuestions({questions, idNumber}: IdologyQuestionsProps) { { diff --git a/src/pages/NewChatPage.tsx b/src/pages/NewChatPage.tsx index 15263441220e..1478b5a1dab9 100755 --- a/src/pages/NewChatPage.tsx +++ b/src/pages/NewChatPage.tsx @@ -215,7 +215,7 @@ function NewChatPage({isGroupChat}: NewChatPageProps) { if (option?.login) { login = option.login; } else if (selectedOptions.length === 1) { - login = selectedOptions[0].login ?? ''; + login = selectedOptions.at(0)?.login ?? ''; } if (!login) { Log.warn('Tried to create chat with empty login'); diff --git a/src/pages/ReportDetailsPage.tsx b/src/pages/ReportDetailsPage.tsx index d0049dfe5865..6dd1dcfa7116 100644 --- a/src/pages/ReportDetailsPage.tsx +++ b/src/pages/ReportDetailsPage.tsx @@ -510,8 +510,8 @@ function ReportDetailsPage({policies, report}: ReportDetailsPageProps) { if (isGroupChat && !isThread) { return ( (title ? Expensicons.Filters : popoverMenuItems[activeItemIndex]?.icon ?? Expensicons.Receipt), [activeItemIndex, popoverMenuItems, title]); - const menuTitle = useMemo(() => title ?? popoverMenuItems[activeItemIndex]?.text, [activeItemIndex, popoverMenuItems, title]); + const menuIconAndTitle = useMemo(() => { + if (title) { + return { + icon: Expensicons.Filters, + title, + }; + } + + const item = popoverMenuItems.at(activeItemIndex); + return { + icon: item?.icon ?? Expensicons.Receipt, + title: item?.text, + }; + }, [activeItemIndex, popoverMenuItems, title]); + + const menuIcon = menuIconAndTitle.icon; + const menuTitle = menuIconAndTitle.title; + const titleViewStyles = useMemo(() => (title ? {...styles.flex1, ...styles.justifyContentCenter} : {}), [title, styles]); const savedSearchItems = savedSearchesMenuItems.map((item) => ({ @@ -139,7 +155,7 @@ function SearchTypeMenuNarrow({typeMenuItems, activeItemIndex, queryJSON, title, {shouldShowSubscript ? ( ) : ( diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index b8b551a345ca..c256e9e51232 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -687,7 +687,7 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro // After creating the task report then navigating to task detail we don't have any report actions and the last read time is empty so We need to update the initial last read time when opening the task report detail. Report.readNewestAction(report?.reportID ?? ''); }, [report]); - const mostRecentReportAction = reportActions[0]; + const mostRecentReportAction = reportActions.at(0); const shouldShowMostRecentReportAction = !!mostRecentReportAction && !ReportActionsUtils.isActionOfType(mostRecentReportAction, CONST.REPORT.ACTIONS.TYPE.CREATED) && diff --git a/src/pages/home/ReportScreen.tsx.orig b/src/pages/home/ReportScreen.tsx.orig new file mode 100644 index 000000000000..b8b551a345ca --- /dev/null +++ b/src/pages/home/ReportScreen.tsx.orig @@ -0,0 +1,821 @@ +import {PortalHost} from '@gorhom/portal'; +import {useIsFocused} from '@react-navigation/native'; +import type {StackScreenProps} from '@react-navigation/stack'; +import lodashIsEqual from 'lodash/isEqual'; +import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import type {FlatList, ViewStyle} from 'react-native'; +import {InteractionManager, View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; +import Banner from '@components/Banner'; +import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import DragAndDropProvider from '@components/DragAndDrop/Provider'; +import MoneyReportHeader from '@components/MoneyReportHeader'; +import MoneyRequestHeader from '@components/MoneyRequestHeader'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; +import ScreenWrapper from '@components/ScreenWrapper'; +import TaskHeaderActionButton from '@components/TaskHeaderActionButton'; +import type {CurrentReportIDContextValue} from '@components/withCurrentReportID'; +import withCurrentReportID from '@components/withCurrentReportID'; +import useActiveWorkspace from '@hooks/useActiveWorkspace'; +import useAppFocusEvent from '@hooks/useAppFocusEvent'; +import useDeepCompareRef from '@hooks/useDeepCompareRef'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; +import usePermissions from '@hooks/usePermissions'; +import usePrevious from '@hooks/usePrevious'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useViewportOffsetTop from '@hooks/useViewportOffsetTop'; +import Timing from '@libs/actions/Timing'; +import Log from '@libs/Log'; +import Navigation from '@libs/Navigation/Navigation'; +import clearReportNotifications from '@libs/Notification/clearReportNotifications'; +import Performance from '@libs/Performance'; +import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; +import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +import * as ReportUtils from '@libs/ReportUtils'; +import shouldFetchReport from '@libs/shouldFetchReport'; +import * as ValidationUtils from '@libs/ValidationUtils'; +import type {AuthScreensParamList} from '@navigation/types'; +import * as ComposerActions from '@userActions/Composer'; +import * as Report from '@userActions/Report'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type * as OnyxTypes from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; +import HeaderView from './HeaderView'; +import ReportActionsListItemRenderer from './report/ReportActionsListItemRenderer'; +import ReportActionsView from './report/ReportActionsView'; +import ReportFooter from './report/ReportFooter'; +import type {ActionListContextType, ReactionListRef, ScrollPosition} from './ReportScreenContext'; +import {ActionListContext, ReactionListContext} from './ReportScreenContext'; + +type ReportScreenNavigationProps = StackScreenProps; + +type ReportScreenProps = CurrentReportIDContextValue & ReportScreenNavigationProps; + +const defaultReportMetadata = { + isLoadingInitialReportActions: true, + isLoadingOlderReportActions: false, + hasLoadingOlderReportActionsError: false, + isLoadingNewerReportActions: false, + hasLoadingNewerReportActionsError: false, +}; + +/** Get the currently viewed report ID as number */ +function getReportID(route: ReportScreenNavigationProps['route']): string { + // The report ID is used in an onyx key. If it's an empty string, onyx will return + // a collection instead of an individual report. + return String(route.params?.reportID || 0); +} + +/** + * Check is the report is deleted. + * We currently use useMemo to memorize every properties of the report + * so we can't check using isEmpty. + * + * @param report + */ +function isEmpty(report: OnyxEntry): boolean { + if (isEmptyObject(report)) { + return true; + } + return !Object.values(report).some((value) => value !== undefined && value !== ''); +} + +function getParentReportAction(parentReportActions: OnyxEntry, parentReportActionID: string | undefined): OnyxEntry { + if (!parentReportActions || !parentReportActionID) { + return; + } + return parentReportActions[parentReportActionID ?? '0']; +} + +function ReportScreen({route, currentReportID = '', navigation}: ReportScreenProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const reportIDFromRoute = getReportID(route); + const reportActionIDFromRoute = route?.params?.reportActionID ?? ''; + const isFocused = useIsFocused(); + const prevIsFocused = usePrevious(isFocused); + const firstRenderRef = useRef(true); + const flatListRef = useRef(null); + const {canUseDefaultRooms} = usePermissions(); + const reactionListRef = useRef(null); + const {isOffline} = useNetwork(); + const {shouldUseNarrowLayout, isInNarrowPaneModal} = useResponsiveLayout(); + const {activeWorkspaceID} = useActiveWorkspace(); + + const [modal] = useOnyx(ONYXKEYS.MODAL); + const [isComposerFullSize] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportIDFromRoute}`, {initialValue: false}); + const [accountManagerReportID] = useOnyx(ONYXKEYS.ACCOUNT_MANAGER_REPORT_ID, {initialValue: ''}); + const [userLeavingStatus] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_USER_IS_LEAVING_ROOM}${reportIDFromRoute}`, {initialValue: false}); + const [reportOnyx, reportResult] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportIDFromRoute}`, {allowStaleData: true}); + const [reportMetadata = defaultReportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportIDFromRoute}`, {initialValue: defaultReportMetadata}); + const [isSidebarLoaded] = useOnyx(ONYXKEYS.IS_SIDEBAR_LOADED, {initialValue: false}); + const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {allowStaleData: true, initialValue: {}}); + const [betas] = useOnyx(ONYXKEYS.BETAS); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const [parentReportAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportOnyx?.parentReportID || -1}`, { + canEvict: false, + selector: (parentReportActions) => getParentReportAction(parentReportActions, reportOnyx?.parentReportActionID ?? ''), + }); + const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); + const [workspaceTooltip] = useOnyx(ONYXKEYS.NVP_WORKSPACE_TOOLTIP); + const wasLoadingApp = usePrevious(isLoadingApp); + const finishedLoadingApp = wasLoadingApp && !isLoadingApp; + const isDeletedParentAction = ReportActionsUtils.isDeletedParentAction(parentReportAction); + const prevIsDeletedParentAction = usePrevious(isDeletedParentAction); + + const isLoadingReportOnyx = isLoadingOnyxValue(reportResult); + const permissions = useDeepCompareRef(reportOnyx?.permissions); + + useEffect(() => { + // Don't update if there is a reportID in the params already + if (route.params.reportID) { + const reportActionID = route?.params?.reportActionID; + const isValidReportActionID = ValidationUtils.isNumeric(reportActionID); + if (reportActionID && !isValidReportActionID) { + navigation.setParams({reportActionID: ''}); + } + return; + } + + const lastAccessedReportID = ReportUtils.findLastAccessedReport(!canUseDefaultRooms, !!route.params.openOnAdminRoom, activeWorkspaceID)?.reportID; + + // It's possible that reports aren't fully loaded yet + // in that case the reportID is undefined + if (!lastAccessedReportID) { + return; + } + + Log.info(`[ReportScreen] no reportID found in params, setting it to lastAccessedReportID: ${lastAccessedReportID}`); + navigation.setParams({reportID: lastAccessedReportID}); + }, [activeWorkspaceID, canUseDefaultRooms, navigation, route, finishedLoadingApp]); + + /** + * Create a lightweight Report so as to keep the re-rendering as light as possible by + * passing in only the required props. + * + * Also, this plays nicely in contrast with Onyx, + * which creates a new object every time collection changes. Because of this we can't + * put this into onyx selector as it will be the same. + */ + const report = useMemo( + (): OnyxEntry => + reportOnyx && { + lastReadTime: reportOnyx.lastReadTime, + reportID: reportOnyx.reportID ?? '', + policyID: reportOnyx.policyID, + lastVisibleActionCreated: reportOnyx.lastVisibleActionCreated, + statusNum: reportOnyx.statusNum, + stateNum: reportOnyx.stateNum, + writeCapability: reportOnyx.writeCapability, + type: reportOnyx.type, + errorFields: reportOnyx.errorFields, + isPolicyExpenseChat: reportOnyx.isPolicyExpenseChat, + parentReportID: reportOnyx.parentReportID, + parentReportActionID: reportOnyx.parentReportActionID, + chatType: reportOnyx.chatType, + pendingFields: reportOnyx.pendingFields, + isDeletedParentAction: reportOnyx.isDeletedParentAction, + reportName: reportOnyx.reportName, + description: reportOnyx.description, + managerID: reportOnyx.managerID, + total: reportOnyx.total, + nonReimbursableTotal: reportOnyx.nonReimbursableTotal, + fieldList: reportOnyx.fieldList, + ownerAccountID: reportOnyx.ownerAccountID, + currency: reportOnyx.currency, + unheldTotal: reportOnyx.unheldTotal, + participants: reportOnyx.participants, + isWaitingOnBankAccount: reportOnyx.isWaitingOnBankAccount, + iouReportID: reportOnyx.iouReportID, + isOwnPolicyExpenseChat: reportOnyx.isOwnPolicyExpenseChat, + isPinned: reportOnyx.isPinned, + chatReportID: reportOnyx.chatReportID, + visibility: reportOnyx.visibility, + oldPolicyName: reportOnyx.oldPolicyName, + policyName: reportOnyx.policyName, + // eslint-disable-next-line @typescript-eslint/naming-convention + private_isArchived: reportOnyx.private_isArchived, + isOptimisticReport: reportOnyx.isOptimisticReport, + lastMentionedTime: reportOnyx.lastMentionedTime, + avatarUrl: reportOnyx.avatarUrl, + avatarFileName: reportOnyx.avatarFileName, + permissions, + invoiceReceiver: reportOnyx.invoiceReceiver, + policyAvatar: reportOnyx.policyAvatar, + }, + [reportOnyx, permissions], + ); + const reportID = report?.reportID; + + const prevReport = usePrevious(report); + const prevUserLeavingStatus = usePrevious(userLeavingStatus); + const [isLinkingToMessage, setIsLinkingToMessage] = useState(!!reportActionIDFromRoute); + + const [currentUserAccountID = -1] = useOnyx(ONYXKEYS.SESSION, {selector: (value) => value?.accountID}); + const {reportActions, linkedAction, sortedAllReportActions} = usePaginatedReportActions(reportID, reportActionIDFromRoute); + + const [isBannerVisible, setIsBannerVisible] = useState(true); + const [scrollPosition, setScrollPosition] = useState({}); + + const wasReportAccessibleRef = useRef(false); + if (firstRenderRef.current) { + Timing.start(CONST.TIMING.CHAT_RENDER); + Performance.markStart(CONST.TIMING.CHAT_RENDER); + } + const [isComposerFocus, setIsComposerFocus] = useState(false); + const shouldAdjustScrollView = useMemo(() => isComposerFocus && !modal?.willAlertModalBecomeVisible, [isComposerFocus, modal]); + const viewportOffsetTop = useViewportOffsetTop(shouldAdjustScrollView); + + const {reportPendingAction, reportErrors} = ReportUtils.getReportOfflinePendingActionAndErrors(report); + const screenWrapperStyle: ViewStyle[] = [styles.appContent, styles.flex1, {marginTop: viewportOffsetTop}]; + const isEmptyChat = useMemo(() => ReportUtils.isEmptyReport(report), [report]); + const isOptimisticDelete = report?.statusNum === CONST.REPORT.STATUS_NUM.CLOSED; + const indexOfLinkedMessage = useMemo( + (): number => reportActions.findIndex((obj) => String(obj.reportActionID) === String(reportActionIDFromRoute)), + [reportActions, reportActionIDFromRoute], + ); + + const isPendingActionExist = !!reportActions.at(0)?.pendingAction; + const doesCreatedActionExists = useCallback(() => !!sortedAllReportActions?.findLast((action) => ReportActionsUtils.isCreatedAction(action)), [sortedAllReportActions]); + const isLinkedMessageAvailable = useMemo(() => indexOfLinkedMessage > -1, [indexOfLinkedMessage]); + + // The linked report actions should have at least 15 messages (counting as 1 page) above them to fill the screen. + // If the count is too high (equal to or exceeds the web pagination size / 50) and there are no cached messages in the report, + // OpenReport will be called each time the user scrolls up the report a bit, clicks on report preview, and then goes back." + const isLinkedMessagePageReady = isLinkedMessageAvailable && (reportActions.length - indexOfLinkedMessage >= CONST.REPORT.MIN_INITIAL_REPORT_ACTION_COUNT || doesCreatedActionExists()); + + // If there's a non-404 error for the report we should show it instead of blocking the screen + const hasHelpfulErrors = Object.keys(report?.errorFields ?? {}).some((key) => key !== 'notFound'); + const shouldHideReport = !hasHelpfulErrors && !ReportUtils.canAccessReport(report, policies, betas); + + const transactionThreadReportID = ReportActionsUtils.getOneTransactionThreadReportID(reportID ?? '', reportActions ?? [], isOffline); + const [transactionThreadReportActions = {}] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`); + const combinedReportActions = ReportActionsUtils.getCombinedReportActions(reportActions, transactionThreadReportID ?? null, Object.values(transactionThreadReportActions)); + const lastReportAction = [...combinedReportActions, parentReportAction].find((action) => ReportUtils.canEditReportAction(action) && !ReportActionsUtils.isMoneyRequestAction(action)); + const isSingleTransactionView = ReportUtils.isMoneyRequest(report) || ReportUtils.isTrackExpenseReport(report); + const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID ?? '-1'}`]; + const isTopMostReportId = currentReportID === reportIDFromRoute; + const didSubscribeToReportLeavingEvents = useRef(false); + + useEffect(() => { + if (!report?.reportID || shouldHideReport) { + wasReportAccessibleRef.current = false; + return; + } + wasReportAccessibleRef.current = true; + }, [shouldHideReport, report]); + + const onBackButtonPress = useCallback(() => { + if (isInNarrowPaneModal) { + Navigation.dismissModal(); + return; + } + Navigation.goBack(undefined, false, true); + }, [isInNarrowPaneModal]); + + let headerView = report && ( + + ); + + if (isSingleTransactionView && report) { + headerView = ( + + ); + } + + useEffect(() => { + if (!transactionThreadReportID || !route?.params?.reportActionID || !ReportUtils.isOneTransactionThread(linkedAction?.childReportID ?? '-1', reportID ?? '', linkedAction)) { + return; + } + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(route?.params?.reportID)); + }, [transactionThreadReportID, route?.params?.reportActionID, route?.params?.reportID, linkedAction, reportID]); + + if (report && (ReportUtils.isMoneyRequestReport(report) || ReportUtils.isInvoiceReport(report))) { + headerView = ( + + ); + } + + /** + * When false the ReportActionsView will completely unmount and we will show a loader until it returns true. + */ + const isCurrentReportLoadedFromOnyx = useMemo((): boolean => { + // This is necessary so that when we are retrieving the next report data from Onyx the ReportActionsView will remount completely + const isTransitioning = report && report?.reportID !== reportIDFromRoute; + return reportIDFromRoute !== '' && !!report?.reportID && !isTransitioning; + }, [report, reportIDFromRoute]); + + const isInitialPageReady = isOffline + ? reportActions.length > 0 + : reportActions.length >= CONST.REPORT.MIN_INITIAL_REPORT_ACTION_COUNT || isPendingActionExist || (doesCreatedActionExists() && reportActions.length > 0); + + const isLinkedActionDeleted = useMemo(() => !!linkedAction && !ReportActionsUtils.shouldReportActionBeVisible(linkedAction, linkedAction.reportActionID), [linkedAction]); + const prevIsLinkedActionDeleted = usePrevious(linkedAction ? isLinkedActionDeleted : undefined); + const isLinkedActionInaccessibleWhisper = useMemo( + () => !!linkedAction && ReportActionsUtils.isWhisperAction(linkedAction) && !(linkedAction?.whisperedToAccountIDs ?? []).includes(currentUserAccountID), + [currentUserAccountID, linkedAction], + ); + + /** + * Using logical OR operator because with nullish coalescing operator, when `isLoadingApp` is false, the right hand side of the operator + * is not evaluated. This causes issues where we have `isLoading` set to false and later set to true and then set to false again. + * Ideally, `isLoading` should be set initially to true and then set to false. We can achieve this by using logical OR operator. + */ + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const isLoading = isLoadingApp || !reportIDFromRoute || (!isSidebarLoaded && !isInNarrowPaneModal) || PersonalDetailsUtils.isPersonalDetailsEmpty(); + + const shouldShowSkeleton = + (isLinkingToMessage && !isLinkedMessagePageReady) || + (!isLinkingToMessage && !isInitialPageReady) || + isEmptyObject(reportOnyx) || + isLoadingReportOnyx || + !isCurrentReportLoadedFromOnyx || + isLoading; + + // eslint-disable-next-line rulesdir/no-negated-variables + const shouldShowNotFoundLinkedAction = + (!isLinkedActionInaccessibleWhisper && isLinkedActionDeleted && !!prevIsLinkedActionDeleted) || + (shouldShowSkeleton && + !reportMetadata.isLoadingInitialReportActions && + !!reportActionIDFromRoute && + !!sortedAllReportActions && + sortedAllReportActions?.length > 0 && + reportActions.length === 0 && + !isLinkingToMessage); + + const currentReportIDFormRoute = route.params?.reportID; + + // eslint-disable-next-line rulesdir/no-negated-variables + const shouldShowNotFoundPage = useMemo((): boolean => { + if (shouldShowNotFoundLinkedAction) { + return true; + } + + // Wait until we're sure the app is done loading (needs to be a strict equality check since it's undefined initially) + if (isLoadingApp !== false) { + return false; + } + + // If we just finished loading the app, we still need to try fetching the report. Wait until that's done before + // showing the Not Found page + if (finishedLoadingApp) { + return false; + } + + if (!wasReportAccessibleRef.current && !firstRenderRef.current && !reportID && !isOptimisticDelete && !reportMetadata?.isLoadingInitialReportActions && !userLeavingStatus) { + return true; + } + + if (shouldHideReport) { + return true; + } + return !!currentReportIDFormRoute && !ReportUtils.isValidReportIDFromPath(currentReportIDFormRoute); + }, [ + shouldShowNotFoundLinkedAction, + isLoadingApp, + finishedLoadingApp, + reportID, + isOptimisticDelete, + reportMetadata?.isLoadingInitialReportActions, + userLeavingStatus, + shouldHideReport, + currentReportIDFormRoute, + ]); + + const fetchReport = useCallback(() => { + Report.openReport(reportIDFromRoute, reportActionIDFromRoute); + }, [reportIDFromRoute, reportActionIDFromRoute]); + + useEffect(() => { + if (!reportID || !isFocused) { + return; + } + Report.updateLastVisitTime(reportID); + }, [reportID, isFocused]); + + const fetchReportIfNeeded = useCallback(() => { + // Report ID will be empty when the reports collection is empty. + // This could happen when we are loading the collection for the first time after logging in. + if (!ReportUtils.isValidReportIDFromPath(reportIDFromRoute)) { + return; + } + + /** + * Since OpenReport is a write, the response from OpenReport will get dropped while the app is + * still loading. This usually happens when signing in and deeplinking to a report. Instead, + * we'll fetch the report after the app finishes loading. + * + * This needs to be a strict equality check since isLoadingApp is initially undefined until the + * value is loaded from Onyx + */ + if (isLoadingApp !== false) { + return; + } + + if (report && !shouldFetchReport(report) && (isInitialPageReady || isLinkedMessagePageReady)) { + return; + } + + fetchReport(); + }, [report, fetchReport, reportIDFromRoute, isLoadingApp, isInitialPageReady, isLinkedMessagePageReady]); + + const dismissBanner = useCallback(() => { + setIsBannerVisible(false); + }, []); + + const chatWithAccountManager = useCallback(() => { + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(accountManagerReportID ?? '')); + }, [accountManagerReportID]); + + // Clear notifications for the current report when it's opened and re-focused + const clearNotifications = useCallback(() => { + // Check if this is the top-most ReportScreen since the Navigator preserves multiple at a time + if (!isTopMostReportId) { + return; + } + + clearReportNotifications(reportID ?? ''); + }, [reportID, isTopMostReportId]); + + useEffect(clearNotifications, [clearNotifications]); + useAppFocusEvent(clearNotifications); + + useEffect(() => { + Timing.end(CONST.TIMING.CHAT_RENDER); + Performance.markEnd(CONST.TIMING.CHAT_RENDER); + + const interactionTask = InteractionManager.runAfterInteractions(() => { + ComposerActions.setShouldShowComposeInput(true); + }); + return () => { + interactionTask.cancel(); + if (!didSubscribeToReportLeavingEvents.current) { + return; + } + + Report.unsubscribeFromLeavingRoomReportChannel(reportID ?? ''); + }; + + // I'm disabling the warning, as it expects to use exhaustive deps, even though we want this useEffect to run only on the first render. + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + // Call OpenReport only if we are not linking to a message or the report is not available yet + if (isLoadingReportOnyx || reportActionIDFromRoute) { + return; + } + fetchReportIfNeeded(); + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps + }, [isLoadingReportOnyx]); + + useEffect(() => { + if (isLoadingReportOnyx || !reportActionIDFromRoute || isLinkedMessagePageReady) { + return; + } + + // This function is triggered when a user clicks on a link to navigate to a report. + // For each link click, we retrieve the report data again, even though it may already be cached. + // There should be only one openReport execution per page start or navigating + fetchReportIfNeeded(); + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps + }, [route, isLinkedMessagePageReady, isLoadingReportOnyx, reportActionIDFromRoute]); + + // If a user has chosen to leave a thread, and then returns to it (e.g. with the back button), we need to call `openReport` again in order to allow the user to rejoin and to receive real-time updates + useEffect(() => { + if ( + !shouldUseNarrowLayout || + !isFocused || + prevIsFocused || + !ReportUtils.isChatThread(report) || + ReportUtils.getReportNotificationPreference(report) !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN || + isSingleTransactionView + ) { + return; + } + Report.openReport(reportID ?? ''); + + // We don't want to run this useEffect every time `report` is changed + // Excluding shouldUseNarrowLayout from the dependency list to prevent re-triggering on screen resize events. + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps + }, [prevIsFocused, report?.participants, isFocused, isSingleTransactionView, reportID]); + + useEffect(() => { + // We don't want this effect to run on the first render. + if (firstRenderRef.current) { + firstRenderRef.current = false; + return; + } + + const onyxReportID = report?.reportID; + const prevOnyxReportID = prevReport?.reportID; + const wasReportRemoved = !!prevOnyxReportID && prevOnyxReportID === reportIDFromRoute && !onyxReportID; + const isRemovalExpectedForReportType = + isEmpty(report) && + (ReportUtils.isMoneyRequest(prevReport) || ReportUtils.isMoneyRequestReport(prevReport) || ReportUtils.isPolicyExpenseChat(prevReport) || ReportUtils.isGroupChat(prevReport)); + const didReportClose = wasReportRemoved && prevReport.statusNum === CONST.REPORT.STATUS_NUM.OPEN && report?.statusNum === CONST.REPORT.STATUS_NUM.CLOSED; + const isTopLevelPolicyRoomWithNoStatus = !report?.statusNum && !prevReport?.parentReportID && prevReport?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_ROOM; + const isClosedTopLevelPolicyRoom = wasReportRemoved && prevReport.statusNum === CONST.REPORT.STATUS_NUM.OPEN && isTopLevelPolicyRoomWithNoStatus; + // Navigate to the Concierge chat if the room was removed from another device (e.g. user leaving a room or removed from a room) + if ( + // non-optimistic case + (!prevUserLeavingStatus && !!userLeavingStatus) || + didReportClose || + isRemovalExpectedForReportType || + isClosedTopLevelPolicyRoom || + (prevIsDeletedParentAction && !isDeletedParentAction) + ) { + // Early return if the report we're passing isn't in a focused state. We only want to navigate to Concierge if the user leaves the room from another device or gets removed from the room while the report is in a focused state. + // Prevent auto navigation for report in RHP + if (!isFocused || isInNarrowPaneModal) { + return; + } + Navigation.dismissModal(); + if (Navigation.getTopmostReportId() === prevOnyxReportID) { + Navigation.setShouldPopAllStateOnUP(true); + Navigation.goBack(undefined, false, true); + } + if (prevReport?.parentReportID) { + // Prevent navigation to the IOU/Expense Report if it is pending deletion. + if (ReportUtils.isMoneyRequestReportPendingDeletion(prevReport.parentReportID)) { + return; + } + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(prevReport.parentReportID)); + return; + } + + Report.navigateToConciergeChat(); + return; + } + + // If you already have a report open and are deeplinking to a new report on native, + // the ReportScreen never actually unmounts and the reportID in the route also doesn't change. + // Therefore, we need to compare if the existing reportID is the same as the one in the route + // before deciding that we shouldn't call OpenReport. + if (onyxReportID === prevReport?.reportID && (!onyxReportID || onyxReportID === reportIDFromRoute)) { + return; + } + + fetchReportIfNeeded(); + ComposerActions.setShouldShowComposeInput(true); + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps + }, [ + route, + report, + // errors, + fetchReportIfNeeded, + prevReport?.reportID, + prevUserLeavingStatus, + userLeavingStatus, + prevReport?.statusNum, + prevReport?.parentReportID, + prevReport?.chatType, + prevReport, + reportIDFromRoute, + isFocused, + isDeletedParentAction, + prevIsDeletedParentAction, + ]); + + useEffect(() => { + if (!ReportUtils.isValidReportIDFromPath(reportIDFromRoute)) { + return; + } + // Ensures the optimistic report is created successfully + if (reportIDFromRoute !== report?.reportID) { + return; + } + // Ensures subscription event succeeds when the report/workspace room is created optimistically. + // Check if the optimistic `OpenReport` or `AddWorkspaceRoom` has succeeded by confirming + // any `pendingFields.createChat` or `pendingFields.addWorkspaceRoom` fields are set to null. + // Existing reports created will have empty fields for `pendingFields`. + const didCreateReportSuccessfully = !report?.pendingFields || (!report?.pendingFields.addWorkspaceRoom && !report?.pendingFields.createChat); + let interactionTask: ReturnType | null = null; + if (!didSubscribeToReportLeavingEvents.current && didCreateReportSuccessfully) { + interactionTask = InteractionManager.runAfterInteractions(() => { + Report.subscribeToReportLeavingEvents(reportIDFromRoute); + didSubscribeToReportLeavingEvents.current = true; + }); + } + return () => { + if (!interactionTask) { + return; + } + interactionTask.cancel(); + }; + }, [report, didSubscribeToReportLeavingEvents, reportIDFromRoute]); + + const actionListValue = useMemo((): ActionListContextType => ({flatListRef, scrollPosition, setScrollPosition}), [flatListRef, scrollPosition, setScrollPosition]); + + // This helps in tracking from the moment 'route' triggers useMemo until isLoadingInitialReportActions becomes true. It prevents blinking when loading reportActions from cache. + useEffect(() => { + InteractionManager.runAfterInteractions(() => { + setIsLinkingToMessage(false); + }); + }, [reportMetadata?.isLoadingInitialReportActions]); + + // If we deeplinked to the report after signing in, we need to fetch the report after the app is done loading + useEffect(() => { + if (!finishedLoadingApp) { + return; + } + + fetchReportIfNeeded(); + + // This should only run once when the app is done loading + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps + }, [finishedLoadingApp]); + + const navigateToEndOfReport = useCallback(() => { + Navigation.setParams({reportActionID: ''}); + fetchReport(); + }, [fetchReport]); + + useEffect(() => { + // If the linked action is previously available but now deleted, + // remove the reportActionID from the params to not link to the deleted action. + const isLinkedActionBecomesDeleted = prevIsLinkedActionDeleted !== undefined && !prevIsLinkedActionDeleted && isLinkedActionDeleted; + if (!isLinkedActionBecomesDeleted) { + return; + } + Navigation.setParams({reportActionID: ''}); + }, [prevIsLinkedActionDeleted, isLinkedActionDeleted]); + + // If user redirects to an inaccessible whisper via a deeplink, on a report they have access to, + // then we set reportActionID as empty string, so we display them the report and not the "Not found page". + useEffect(() => { + if (!isLinkedActionInaccessibleWhisper) { + return; + } + Navigation.isNavigationReady().then(() => { + Navigation.setParams({reportActionID: ''}); + }); + }, [isLinkedActionInaccessibleWhisper]); + + useEffect(() => { + if (!!report?.lastReadTime || !ReportUtils.isTaskReport(report)) { + return; + } + // After creating the task report then navigating to task detail we don't have any report actions and the last read time is empty so We need to update the initial last read time when opening the task report detail. + Report.readNewestAction(report?.reportID ?? ''); + }, [report]); + const mostRecentReportAction = reportActions[0]; + const shouldShowMostRecentReportAction = + !!mostRecentReportAction && + !ReportActionsUtils.isActionOfType(mostRecentReportAction, CONST.REPORT.ACTIONS.TYPE.CREATED) && + !ReportActionsUtils.isDeletedAction(mostRecentReportAction); + + const lastRoute = usePrevious(route); + const lastReportActionIDFromRoute = usePrevious(reportActionIDFromRoute); + // Define here because reportActions are recalculated before mount, allowing data to display faster than useEffect can trigger. + // If we have cached reportActions, they will be shown immediately. + // We aim to display a loader first, then fetch relevant reportActions, and finally show them. + if ((lastRoute !== route || lastReportActionIDFromRoute !== reportActionIDFromRoute) && isLinkingToMessage !== !!reportActionIDFromRoute) { + setIsLinkingToMessage(!!reportActionIDFromRoute); + return null; + } + + return ( + + + + + + {headerView} + {report && ReportUtils.isTaskReport(report) && shouldUseNarrowLayout && ReportUtils.isOpenTaskReport(report, parentReportAction) && ( + + + + + + + + )} + + {!!accountManagerReportID && ReportUtils.isConciergeChatReport(report) && isBannerVisible && ( + + )} + + + {!shouldShowSkeleton && report && ( + + )} + + {/* Note: The ReportActionsSkeletonView should be allowed to mount even if the initial report actions are not loaded. + If we prevent rendering the report while they are loading then + we'll unnecessarily unmount the ReportActionsView which will clear the new marker lines initial state. */} + {shouldShowSkeleton && ( + <> + + {shouldShowMostRecentReportAction && report && ( + + )} + + )} + + {isCurrentReportLoadedFromOnyx ? ( + setIsComposerFocus(true)} + onComposerBlur={() => setIsComposerFocus(false)} + report={report} + reportMetadata={reportMetadata} + policy={policy} + pendingAction={reportPendingAction} + isComposerFullSize={!!isComposerFullSize} + isEmptyChat={isEmptyChat} + lastReportAction={lastReportAction} + workspaceTooltip={workspaceTooltip} + /> + ) : null} + + + + + + + + ); +} + +ReportScreen.displayName = 'ReportScreen'; +export default withCurrentReportID(memo(ReportScreen, (prevProps, nextProps) => prevProps.currentReportID === nextProps.currentReportID && lodashIsEqual(prevProps.route, nextProps.route))); diff --git a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.tsx b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.tsx index 3051bfb1689e..c29eb44942b2 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.tsx +++ b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.tsx @@ -83,8 +83,8 @@ function SuggestionEmoji( const insertSelectedEmoji = useCallback( (highlightedEmojiIndexInner: number) => { const commentBeforeColon = value.slice(0, suggestionValues.colonIndex); - const emojiObject = suggestionValues.suggestedEmojis[highlightedEmojiIndexInner]; - const emojiCode = emojiObject.types?.[preferredSkinTone] ? emojiObject.types[preferredSkinTone] : emojiObject.code; + const emojiObject = suggestionValues.suggestedEmojis.at(highlightedEmojiIndexInner); + const emojiCode = emojiObject?.types?.[preferredSkinTone] ? emojiObject?.types.at(preferredSkinTone) : emojiObject?.code; const commentAfterColonWithEmojiNameRemoved = value.slice(selection.end); updateComment(`${commentBeforeColon}${emojiCode} ${SuggestionsUtils.trimLeadingSpace(commentAfterColonWithEmojiNameRemoved)}`, true); @@ -95,8 +95,8 @@ function SuggestionEmoji( resetKeyboardInput?.(); setSelection({ - start: suggestionValues.colonIndex + emojiCode.length + CONST.SPACE_LENGTH, - end: suggestionValues.colonIndex + emojiCode.length + CONST.SPACE_LENGTH, + start: suggestionValues.colonIndex + (emojiCode?.length ?? 0) + CONST.SPACE_LENGTH, + end: suggestionValues.colonIndex + (emojiCode?.length ?? 0) + CONST.SPACE_LENGTH, }); setSuggestionValues((prevState) => ({...prevState, suggestedEmojis: []})); }, diff --git a/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx b/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx index 8d37a1915cbd..54840d24a2cc 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx +++ b/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx @@ -171,7 +171,7 @@ function SuggestionMention( } // Otherwise, the emails must be of the same private domain, so we should remove the domain part - return displayText.split('@')[0]; + return displayText.split('@').at(0); }, [currentUserPersonalDetails.login], ); @@ -193,7 +193,10 @@ function SuggestionMention( const insertSelectedMention = useCallback( (highlightedMentionIndexInner: number) => { const commentBeforeAtSign = value.slice(0, suggestionValues.atSignIndex); - const mentionObject = suggestionValues.suggestedMentions[highlightedMentionIndexInner]; + const mentionObject = suggestionValues.suggestedMentions.at(highlightedMentionIndexInner); + if (!mentionObject) { + return; + } const mentionCode = getMentionCode(mentionObject, suggestionValues.prefixType); const commentAfterMention = value.slice(suggestionValues.atSignIndex + suggestionValues.mentionPrefix.length + 1); @@ -357,7 +360,7 @@ function SuggestionMention( const leftString = newValue.substring(afterLastBreakLineIndex, selectionEnd); const words = leftString.split(CONST.REGEX.SPACE_OR_EMOJI); const lastWord: string = words.at(-1) ?? ''; - const secondToLastWord = words[words.length - 3]; + const secondToLastWord = words.at(words.length - 3); let atSignIndex: number | undefined; let suggestionWord = ''; diff --git a/src/pages/home/report/ReportActionItemSingle.tsx b/src/pages/home/report/ReportActionItemSingle.tsx index 0e58296d39f2..c65eceed469e 100644 --- a/src/pages/home/report/ReportActionItemSingle.tsx +++ b/src/pages/home/report/ReportActionItemSingle.tsx @@ -28,6 +28,7 @@ import ROUTES from '@src/ROUTES'; import type {Report, ReportAction} from '@src/types/onyx'; import type {Icon} from '@src/types/onyx/OnyxCommon'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; +import getDefaultIcon from '@src/utils/getDefaultIcon'; import ReportActionItemDate from './ReportActionItemDate'; import ReportActionItemFragment from './ReportActionItemFragment'; @@ -146,7 +147,7 @@ function ReportActionItemSingle({ const avatarIconIndex = report.isOwnPolicyExpenseChat || ReportUtils.isPolicyExpenseChat(report) ? 0 : 1; const reportIcons = ReportUtils.getIcons(report, {}); - secondaryAvatar = reportIcons[avatarIconIndex]; + secondaryAvatar = reportIcons.at(avatarIconIndex) ?? getDefaultIcon; } else { secondaryAvatar = {name: '', source: '', type: 'avatar'}; } @@ -206,7 +207,7 @@ function ReportActionItemSingle({ if (shouldShowSubscriptAvatar) { return ( diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index 6828e10e7e3b..340624247dee 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -221,7 +221,7 @@ function ReportActionsList({ */ const unreadMarkerReportActionID = useMemo(() => { const shouldDisplayNewMarker = (reportAction: OnyxTypes.ReportAction, index: number): boolean => { - const nextMessage = sortedVisibleReportActions[index + 1]; + const nextMessage = sortedVisibleReportActions.at(index + 1); const isCurrentMessageUnread = isMessageUnread(reportAction, unreadMarkerTime); const isNextMessageRead = !nextMessage || !isMessageUnread(nextMessage, unreadMarkerTime); let shouldDisplay = isCurrentMessageUnread && isNextMessageRead && !ReportActionsUtils.shouldHideNewMarker(reportAction); @@ -238,7 +238,13 @@ function ReportActionsList({ // Scan through each visible report action until we find the appropriate action to show the unread marker for (let index = 0; index < sortedVisibleReportActions.length; index++) { - const reportAction = sortedVisibleReportActions[index]; + const reportAction = sortedVisibleReportActions.at(index); + + if (!reportAction) { + // eslint-disable-next-line no-continue + continue; + } + if (shouldDisplayNewMarker(reportAction, index)) { return reportAction.reportActionID; } @@ -277,7 +283,7 @@ function ReportActionsList({ return; } - const mostRecentReportActionCreated = sortedVisibleReportActions[0]?.created ?? ''; + const mostRecentReportActionCreated = sortedVisibleReportActions.at(0)?.created ?? ''; if (mostRecentReportActionCreated === unreadMarkerTime) { return; } @@ -288,14 +294,14 @@ function ReportActionsList({ // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [sortedVisibleReportActions]); - const lastActionIndex = sortedVisibleReportActions[0]?.reportActionID; + const lastActionIndex = sortedVisibleReportActions.at(0)?.reportActionID; const reportActionSize = useRef(sortedVisibleReportActions.length); - const hasNewestReportAction = sortedVisibleReportActions[0]?.created === report.lastVisibleActionCreated; + const hasNewestReportAction = sortedVisibleReportActions.at(0)?.created === report.lastVisibleActionCreated; const hasNewestReportActionRef = useRef(hasNewestReportAction); hasNewestReportActionRef.current = hasNewestReportAction; const previousLastIndex = useRef(lastActionIndex); - const isLastPendingActionIsDelete = sortedReportActions?.[0]?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + const isLastPendingActionIsDelete = sortedReportActions?.at(0)?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; const [isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible] = useState(false); @@ -455,7 +461,7 @@ function ReportActionsList({ const firstVisibleReportActionID = useMemo(() => ReportActionsUtils.getFirstVisibleReportActionID(sortedReportActions, isOffline), [sortedReportActions, isOffline]); const shouldUseThreadDividerLine = useMemo(() => { - const topReport = sortedVisibleReportActions.length > 0 ? sortedVisibleReportActions[sortedVisibleReportActions.length - 1] : null; + const topReport = sortedVisibleReportActions.length > 0 ? sortedVisibleReportActions.at(sortedVisibleReportActions.length - 1) : null; if (topReport && topReport.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED) { return false; @@ -479,7 +485,7 @@ function ReportActionsList({ if (!isVisible || !isFocused) { if (!lastMessageTime.current) { - lastMessageTime.current = sortedVisibleReportActions[0]?.created ?? ''; + lastMessageTime.current = sortedVisibleReportActions.at(0)?.created ?? ''; } return; } diff --git a/src/pages/home/report/ReportActionsView.tsx b/src/pages/home/report/ReportActionsView.tsx index 1460942931fc..452e5bf539b9 100755 --- a/src/pages/home/report/ReportActionsView.tsx +++ b/src/pages/home/report/ReportActionsView.tsx @@ -159,9 +159,9 @@ function ReportActionsView({ } const actions = [...allReportActions]; - const lastAction = allReportActions[allReportActions.length - 1]; + const lastAction = allReportActions.at(allReportActions.length - 1); - if (!ReportActionsUtils.isCreatedAction(lastAction)) { + if (lastAction && !ReportActionsUtils.isCreatedAction(lastAction)) { const optimisticCreatedAction = ReportUtils.buildOptimisticCreatedReportAction(String(report?.ownerAccountID), DateUtils.subtractMillisecondsFromDateTime(lastAction.created, 1)); optimisticCreatedAction.pendingAction = null; actions.push(optimisticCreatedAction); @@ -192,7 +192,7 @@ function ReportActionsView({ false, false, false, - DateUtils.subtractMillisecondsFromDateTime(actions[actions.length - 1].created, 1), + DateUtils.subtractMillisecondsFromDateTime(actions.at(actions.length - 1)?.created ?? '', 1), ) as OnyxTypes.ReportAction; moneyRequestActions.push(optimisticIOUAction); actions.splice(actions.length - 1, 0, optimisticIOUAction); @@ -283,10 +283,10 @@ function ReportActionsView({ ); const hasMoreCached = reportActions.length < combinedReportActions.length; - const newestReportAction = useMemo(() => reportActions?.[0], [reportActions]); + const newestReportAction = useMemo(() => reportActions?.at(0), [reportActions]); const mostRecentIOUReportActionID = useMemo(() => ReportActionsUtils.getMostRecentIOURequestActionID(reportActions), [reportActions]); const hasCachedActionOnFirstRender = useInitialValue(() => reportActions.length > 0); - const hasNewestReportAction = reportActions[0]?.created === report.lastVisibleActionCreated || reportActions[0]?.created === transactionThreadReport?.lastVisibleActionCreated; + const hasNewestReportAction = reportActions.at(0)?.created === report.lastVisibleActionCreated || reportActions.at(0)?.created === transactionThreadReport?.lastVisibleActionCreated; const oldestReportAction = useMemo(() => reportActions?.at(-1), [reportActions]); const hasCreatedAction = oldestReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED; @@ -321,7 +321,9 @@ function ReportActionsView({ // This function is a placeholder as the actual pagination is handled by visibleReportActions if (!hasMoreCached && !hasNewestReportAction) { isFirstLinkedActionRender.current = false; - fetchNewerAction(newestReportAction); + if (newestReportAction) { + fetchNewerAction(newestReportAction); + } } if (isFirstLinkedActionRender.current) { isFirstLinkedActionRender.current = false; @@ -393,7 +395,7 @@ function ReportActionsView({ // If there was an error only try again once on initial mount. We should also still load // more in case we have cached messages. (!hasMoreCached && didLoadNewerChats.current && hasLoadingNewerReportActionsError) || - newestReportAction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) + newestReportAction?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) ) { return; } @@ -401,7 +403,7 @@ function ReportActionsView({ didLoadNewerChats.current = true; if ((reportActionID && indexOfLinkedAction > -1) || !reportActionID) { - handleReportActionPagination({firstReportActionID: newestReportAction?.reportActionID}); + handleReportActionPagination({firstReportActionID: newestReportAction?.reportActionID ?? '-1'}); } }, [ diff --git a/src/pages/home/report/ReportTypingIndicator.tsx b/src/pages/home/report/ReportTypingIndicator.tsx index 3ff8f2b0eb8e..7a1c90ded34a 100755 --- a/src/pages/home/report/ReportTypingIndicator.tsx +++ b/src/pages/home/report/ReportTypingIndicator.tsx @@ -26,7 +26,7 @@ function ReportTypingIndicator({userTypingStatuses}: ReportTypingIndicatorProps) const styles = useThemeStyles(); const usersTyping = useMemo(() => Object.keys(userTypingStatuses ?? {}).filter((loginOrAccountID) => userTypingStatuses?.[loginOrAccountID]), [userTypingStatuses]); - const firstUserTyping = usersTyping[0]; + const firstUserTyping = usersTyping.at(0); const isUserTypingADisplayName = Number.isNaN(Number(firstUserTyping)); diff --git a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx index 4444f519dbcd..ce69d2a67171 100644 --- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx +++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx @@ -214,7 +214,7 @@ function FloatingActionButtonAndPopover( return ''; } if (quickAction?.action === CONST.QUICK_ACTIONS.SEND_MONEY && quickActionAvatars.length > 0) { - const name: string = ReportUtils.getDisplayNameForParticipant(+(quickActionAvatars[0]?.id ?? -1), true) ?? ''; + const name: string = ReportUtils.getDisplayNameForParticipant(+(quickActionAvatars.at(0)?.id ?? -1), true) ?? ''; return translate('quickAction.paySomeone', name); } const titleKey = getQuickActionTitle(quickAction?.action ?? ('' as QuickActionName)); @@ -228,7 +228,7 @@ function FloatingActionButtonAndPopover( if (quickActionAvatars.length === 0) { return false; } - const displayName = personalDetails?.[quickActionAvatars[0]?.id ?? -1]?.firstName ?? ''; + const displayName = personalDetails?.[quickActionAvatars.at(0)?.id ?? -1]?.firstName ?? ''; return quickAction?.action === CONST.QUICK_ACTIONS.SEND_MONEY && displayName.length === 0; }, [personalDetails, quickActionReport, quickAction?.action, quickActionAvatars]); diff --git a/src/pages/iou/SplitBillDetailsPage.tsx b/src/pages/iou/SplitBillDetailsPage.tsx index efefc8ccc9ea..96ae7bb69b60 100644 --- a/src/pages/iou/SplitBillDetailsPage.tsx +++ b/src/pages/iou/SplitBillDetailsPage.tsx @@ -72,7 +72,7 @@ function SplitBillDetailsPage({personalDetails, report, route, reportActions, tr let participants: Array; if (ReportUtils.isPolicyExpenseChat(report)) { participants = [ - OptionsListUtils.getParticipantsOption({accountID: participantAccountIDs[0], selected: true, reportID: ''}, personalDetails), + OptionsListUtils.getParticipantsOption({accountID: participantAccountIDs.at(0), selected: true, reportID: ''}, personalDetails), OptionsListUtils.getPolicyExpenseReportOption({...report, selected: true, reportID}), ]; } else { diff --git a/src/pages/iou/request/step/IOURequestStepAmount.tsx b/src/pages/iou/request/step/IOURequestStepAmount.tsx index a03c40e8c1f3..fb7a5144ec39 100644 --- a/src/pages/iou/request/step/IOURequestStepAmount.tsx +++ b/src/pages/iou/request/step/IOURequestStepAmount.tsx @@ -214,11 +214,11 @@ function IOURequestStepAmount({ if (iouType === CONST.IOU.TYPE.PAY || iouType === CONST.IOU.TYPE.SEND) { if (paymentMethod && paymentMethod === CONST.IOU.PAYMENT_TYPE.EXPENSIFY) { - IOU.sendMoneyWithWallet(report, backendAmount, currency, '', currentUserPersonalDetails.accountID, participants[0]); + IOU.sendMoneyWithWallet(report, backendAmount, currency, '', currentUserPersonalDetails.accountID, participants.at(0) ?? {}); return; } - IOU.sendMoneyElsewhere(report, backendAmount, currency, '', currentUserPersonalDetails.accountID, participants[0]); + IOU.sendMoneyElsewhere(report, backendAmount, currency, '', currentUserPersonalDetails.accountID, participants.at(0) ?? {}); return; } if (iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.REQUEST) { @@ -230,7 +230,7 @@ function IOURequestStepAmount({ CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, currentUserPersonalDetails.login, currentUserPersonalDetails.accountID, - participants[0], + participants.at(0) ?? {}, '', {}, ); @@ -245,7 +245,7 @@ function IOURequestStepAmount({ CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, currentUserPersonalDetails.login, currentUserPersonalDetails.accountID, - participants[0], + participants.at(0) ?? {}, '', ); return; diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 6c1457abef62..15dbecace7e7 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -245,6 +245,11 @@ function IOURequestStepConfirmation({ return; } + const participant = selectedParticipants.at(0); + if (!participant) { + return; + } + IOU.requestMoney( report, transaction.amount, @@ -253,7 +258,7 @@ function IOURequestStepConfirmation({ transaction.merchant, currentUserPersonalDetails.login, currentUserPersonalDetails.accountID, - selectedParticipants[0], + participant, trimmedComment, receiptObj, transaction.category, @@ -279,6 +284,10 @@ function IOURequestStepConfirmation({ if (!report || !transaction) { return; } + const participant = selectedParticipants.at(0); + if (!participant) { + return; + } IOU.trackExpense( report, transaction.amount, @@ -287,7 +296,7 @@ function IOURequestStepConfirmation({ transaction.merchant, currentUserPersonalDetails.login, currentUserPersonalDetails.accountID, - selectedParticipants[0], + participant, trimmedComment, receiptObj, transaction.category, @@ -552,7 +561,7 @@ function IOURequestStepConfirmation({ (paymentMethod: PaymentMethodType | undefined) => { const currency = transaction?.currency; const trimmedComment = transaction?.comment?.comment?.trim() ?? ''; - const participant = participants?.[0]; + const participant = participants?.at(0); if (!participant || !transaction?.amount || !currency) { return; diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx index 14597df8e313..da50d39dec06 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx @@ -275,7 +275,8 @@ function IOURequestStepDistance({ } IOU.setMoneyRequestPendingFields(transactionID, {waypoints: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}); IOU.setMoneyRequestMerchant(transactionID, translate('iou.fieldPending'), false); - if (iouType === CONST.IOU.TYPE.TRACK) { + const participant = participants.at(0); + if (iouType === CONST.IOU.TYPE.TRACK && participant) { IOU.trackExpense( report, 0, @@ -284,7 +285,7 @@ function IOURequestStepDistance({ translate('iou.fieldPending'), currentUserPersonalDetails.login, currentUserPersonalDetails.accountID, - participants[0], + participant, '', {}, '', diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx.orig b/src/pages/iou/request/step/IOURequestStepDistance.tsx.orig new file mode 100644 index 000000000000..14597df8e313 --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx.orig @@ -0,0 +1,550 @@ +import isEmpty from 'lodash/isEmpty'; +import isEqual from 'lodash/isEqual'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {View} from 'react-native'; +// eslint-disable-next-line no-restricted-imports +import type {ScrollView as RNScrollView} from 'react-native'; +import type {RenderItemParams} from 'react-native-draggable-flatlist/lib/typescript/types'; +import type {OnyxEntry} from 'react-native-onyx'; +import {useOnyx, withOnyx} from 'react-native-onyx'; +import Button from '@components/Button'; +import DistanceRequestFooter from '@components/DistanceRequest/DistanceRequestFooter'; +import DistanceRequestRenderItem from '@components/DistanceRequest/DistanceRequestRenderItem'; +import DotIndicatorMessage from '@components/DotIndicatorMessage'; +import DraggableList from '@components/DraggableList'; +import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; +import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; +import useFetchRoute from '@hooks/useFetchRoute'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import usePrevious from '@hooks/usePrevious'; +import useThemeStyles from '@hooks/useThemeStyles'; +import DistanceRequestUtils from '@libs/DistanceRequestUtils'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import * as IOUUtils from '@libs/IOUUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import * as OptionsListUtils from '@libs/OptionsListUtils'; +import * as ReportUtils from '@libs/ReportUtils'; +import * as TransactionUtils from '@libs/TransactionUtils'; +import * as IOU from '@userActions/IOU'; +import * as MapboxToken from '@userActions/MapboxToken'; +import * as TransactionAction from '@userActions/Transaction'; +import * as TransactionEdit from '@userActions/TransactionEdit'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type * as OnyxTypes from '@src/types/onyx'; +import type {Errors} from '@src/types/onyx/OnyxCommon'; +import type {Waypoint, WaypointCollection} from '@src/types/onyx/Transaction'; +import StepScreenWrapper from './StepScreenWrapper'; +import withFullTransactionOrNotFound from './withFullTransactionOrNotFound'; +import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; +import withWritableReportOrNotFound from './withWritableReportOrNotFound'; + +type IOURequestStepDistanceOnyxProps = { + /** backup version of the original transaction */ + transactionBackup: OnyxEntry; + + /** The policy which the user has access to and which the report is tied to */ + policy: OnyxEntry; + + /** Personal details of all users */ + personalDetails: OnyxEntry; + + /** Whether the confirmation step should be skipped */ + skipConfirmation: OnyxEntry; +}; + +type IOURequestStepDistanceProps = IOURequestStepDistanceOnyxProps & + WithCurrentUserPersonalDetailsProps & + WithWritableReportOrNotFoundProps & { + /** The transaction object being modified in Onyx */ + transaction: OnyxEntry; + }; + +function IOURequestStepDistance({ + report, + policy, + route: { + params: {action, iouType, reportID, transactionID, backTo}, + }, + transaction, + transactionBackup, + personalDetails, + currentUserPersonalDetails, + skipConfirmation, +}: IOURequestStepDistanceProps) { + const styles = useThemeStyles(); + const {isOffline} = useNetwork(); + const {translate} = useLocalize(); + const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID ?? -1}`); + + const [optimisticWaypoints, setOptimisticWaypoints] = useState(null); + const waypoints = useMemo( + () => + optimisticWaypoints ?? + transaction?.comment?.waypoints ?? { + waypoint0: {keyForList: 'start_waypoint'}, + waypoint1: {keyForList: 'stop_waypoint'}, + }, + [optimisticWaypoints, transaction], + ); + const {shouldFetchRoute, validatedWaypoints} = useFetchRoute(transaction, waypoints, action); + const waypointsList = Object.keys(waypoints); + const previousWaypoints = usePrevious(waypoints); + const numberOfWaypoints = Object.keys(waypoints).length; + const numberOfPreviousWaypoints = Object.keys(previousWaypoints).length; + const scrollViewRef = useRef(null); + const isLoadingRoute = transaction?.comment?.isLoading ?? false; + const isLoading = transaction?.isLoading ?? false; + const hasRouteError = !!transaction?.errorFields?.route; + const [shouldShowAtLeastTwoDifferentWaypointsError, setShouldShowAtLeastTwoDifferentWaypointsError] = useState(false); + const isWaypointEmpty = (waypoint?: Waypoint) => { + if (!waypoint) { + return true; + } + const {keyForList, ...waypointWithoutKey} = waypoint; + return isEmpty(waypointWithoutKey); + }; + const nonEmptyWaypointsCount = useMemo(() => Object.keys(waypoints).filter((key) => !isWaypointEmpty(waypoints[key])).length, [waypoints]); + + const duplicateWaypointsError = useMemo( + () => nonEmptyWaypointsCount >= 2 && Object.keys(validatedWaypoints).length !== nonEmptyWaypointsCount, + [nonEmptyWaypointsCount, validatedWaypoints], + ); + const atLeastTwoDifferentWaypointsError = useMemo(() => Object.keys(validatedWaypoints).length < 2, [validatedWaypoints]); + const isEditing = action === CONST.IOU.ACTION.EDIT; + const transactionWasSaved = useRef(false); + const isCreatingNewRequest = !(backTo || isEditing); + const [recentWaypoints, {status: recentWaypointsStatus}] = useOnyx(ONYXKEYS.NVP_RECENT_WAYPOINTS); + const iouRequestType = TransactionUtils.getRequestType(transaction); + const customUnitRateID = TransactionUtils.getRateID(transaction); + + // For quick button actions, we'll skip the confirmation page unless the report is archived or this is a workspace + // request and the workspace requires a category or a tag + const shouldSkipConfirmation: boolean = useMemo(() => { + if (!skipConfirmation || !report?.reportID) { + return false; + } + + return ( + !ReportUtils.isArchivedRoom(report, reportNameValuePairs) && !(ReportUtils.isPolicyExpenseChat(report) && ((policy?.requiresCategory ?? false) || (policy?.requiresTag ?? false))) + ); + }, [report, skipConfirmation, policy, reportNameValuePairs]); + let buttonText = !isCreatingNewRequest ? translate('common.save') : translate('common.next'); + if (shouldSkipConfirmation) { + if (iouType === CONST.IOU.TYPE.SPLIT) { + buttonText = translate('iou.split'); + } else if (iouType === CONST.IOU.TYPE.TRACK) { + buttonText = translate('iou.trackExpense'); + } else { + buttonText = translate('iou.submitExpense'); + } + } + + useEffect(() => { + if (iouRequestType !== CONST.IOU.REQUEST_TYPE.DISTANCE || isOffline || recentWaypointsStatus === 'loading' || recentWaypoints !== undefined) { + return; + } + + // Only load the recent waypoints if they have been read from Onyx as undefined + // If the account doesn't have recent waypoints they will be returned as an empty array + TransactionAction.openDraftDistanceExpense(); + }, [iouRequestType, recentWaypointsStatus, recentWaypoints, isOffline]); + + useEffect(() => { + MapboxToken.init(); + return MapboxToken.stop; + }, []); + + useEffect(() => { + if (numberOfWaypoints <= numberOfPreviousWaypoints) { + return; + } + scrollViewRef.current?.scrollToEnd({animated: true}); + }, [numberOfPreviousWaypoints, numberOfWaypoints]); + + useEffect(() => { + if (nonEmptyWaypointsCount >= 2 && (duplicateWaypointsError || atLeastTwoDifferentWaypointsError || hasRouteError || isLoadingRoute || isLoading)) { + return; + } + setShouldShowAtLeastTwoDifferentWaypointsError(false); + }, [atLeastTwoDifferentWaypointsError, duplicateWaypointsError, hasRouteError, isLoading, isLoadingRoute, nonEmptyWaypointsCount, transaction]); + + // This effect runs when the component is mounted and unmounted. It's purpose is to be able to properly + // discard changes if the user cancels out of making any changes. This is accomplished by backing up the + // original transaction, letting the user modify the current transaction, and then if the user ever + // cancels out of the modal without saving changes, the original transaction is restored from the backup. + useEffect(() => { + if (isCreatingNewRequest) { + return () => {}; + } + + // On mount, create the backup transaction. + TransactionEdit.createBackupTransaction(transaction); + + return () => { + // If the user cancels out of the modal without without saving changes, then the original transaction + // needs to be restored from the backup so that all changes are removed. + if (transactionWasSaved.current) { + TransactionEdit.removeBackupTransaction(transaction?.transactionID ?? '-1'); + return; + } + TransactionEdit.restoreOriginalTransactionFromBackup(transaction?.transactionID ?? '-1', IOUUtils.shouldUseTransactionDraft(action)); + }; + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps + }, []); + + const navigateBack = useCallback(() => { + Navigation.goBack(backTo); + }, [backTo]); + + /** + * Takes the user to the page for editing a specific waypoint + * @param index of the waypoint to edit + */ + const navigateToWaypointEditPage = useCallback( + (index: number) => { + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_WAYPOINT.getRoute(action, CONST.IOU.TYPE.SUBMIT, transactionID, report?.reportID ?? reportID, index.toString(), Navigation.getActiveRoute()), + ); + }, + [action, transactionID, report?.reportID, reportID], + ); + + const navigateToParticipantPage = useCallback(() => { + switch (iouType) { + case CONST.IOU.TYPE.REQUEST: + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(CONST.IOU.TYPE.SUBMIT, transactionID, reportID)); + break; + case CONST.IOU.TYPE.SEND: + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(CONST.IOU.TYPE.PAY, transactionID, reportID)); + break; + default: + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(iouType, transactionID, reportID)); + } + }, [iouType, reportID, transactionID]); + + const navigateToConfirmationPage = useCallback(() => { + switch (iouType) { + case CONST.IOU.TYPE.REQUEST: + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(CONST.IOU.ACTION.CREATE, CONST.IOU.TYPE.SUBMIT, transactionID, reportID)); + break; + case CONST.IOU.TYPE.SEND: + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(CONST.IOU.ACTION.CREATE, CONST.IOU.TYPE.PAY, transactionID, reportID)); + break; + default: + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID)); + } + }, [iouType, reportID, transactionID]); + + const navigateToNextStep = useCallback(() => { + if (backTo) { + Navigation.goBack(backTo); + return; + } + + // If a reportID exists in the report object, it's because the user started this flow from using the + button in the composer + // inside a report. In this case, the participants can be automatically assigned from the report and the user can skip the participants step and go straight + // to the confirm step. + if (report?.reportID && !ReportUtils.isArchivedRoom(report, reportNameValuePairs)) { + const selectedParticipants = IOU.setMoneyRequestParticipantsFromReport(transactionID, report); + const participants = selectedParticipants.map((participant) => { + const participantAccountID = participant?.accountID ?? -1; + return participantAccountID ? OptionsListUtils.getParticipantsOption(participant, personalDetails) : OptionsListUtils.getReportOption(participant); + }); + if (shouldSkipConfirmation) { + if (iouType === CONST.IOU.TYPE.SPLIT) { + IOU.splitBill({ + participants, + currentUserLogin: currentUserPersonalDetails.login ?? '', + currentUserAccountID: currentUserPersonalDetails.accountID, + amount: 0, + comment: '', + currency: transaction?.currency ?? 'USD', + merchant: translate('iou.fieldPending'), + created: transaction?.created ?? '', + category: '', + tag: '', + billable: false, + iouRequestType, + existingSplitChatReportID: report?.reportID, + }); + return; + } + IOU.setMoneyRequestPendingFields(transactionID, {waypoints: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}); + IOU.setMoneyRequestMerchant(transactionID, translate('iou.fieldPending'), false); + if (iouType === CONST.IOU.TYPE.TRACK) { + IOU.trackExpense( + report, + 0, + transaction?.currency ?? 'USD', + transaction?.created ?? '', + translate('iou.fieldPending'), + currentUserPersonalDetails.login, + currentUserPersonalDetails.accountID, + participants[0], + '', + {}, + '', + '', + '', + 0, + false, + policy, + undefined, + undefined, + undefined, + TransactionUtils.getValidWaypoints(waypoints, true), + undefined, + undefined, + undefined, + undefined, + customUnitRateID, + ); + return; + } + + IOU.createDistanceRequest( + report, + participants, + '', + transaction?.created ?? '', + '', + '', + '', + 0, + 0, + transaction?.currency ?? 'USD', + translate('iou.fieldPending'), + !!policy?.defaultBillable, + TransactionUtils.getValidWaypoints(waypoints, true), + undefined, + undefined, + undefined, + DistanceRequestUtils.getCustomUnitRateID(report.reportID), + currentUserPersonalDetails.login ?? '', + currentUserPersonalDetails.accountID, + transaction?.splitShares, + iouType, + ); + return; + } + IOU.setMoneyRequestParticipantsFromReport(transactionID, report); + navigateToConfirmationPage(); + return; + } + + // If there was no reportID, then that means the user started this flow from the global menu + // and an optimistic reportID was generated. In that case, the next step is to select the participants for this expense. + navigateToParticipantPage(); + }, [ + report, + iouType, + transactionID, + backTo, + waypoints, + currentUserPersonalDetails, + personalDetails, + shouldSkipConfirmation, + transaction, + translate, + navigateToParticipantPage, + navigateToConfirmationPage, + policy, + iouRequestType, + reportNameValuePairs, + customUnitRateID, + ]); + + const getError = () => { + // Get route error if available else show the invalid number of waypoints error. + if (hasRouteError) { + return ErrorUtils.getLatestErrorField(transaction, 'route'); + } + if (duplicateWaypointsError) { + return {duplicateWaypointsError: translate('iou.error.duplicateWaypointsErrorMessage')} as Errors; + } + if (atLeastTwoDifferentWaypointsError) { + return {atLeastTwoDifferentWaypointsError: translate('iou.error.atLeastTwoDifferentWaypoints')} as Errors; + } + return {}; + }; + + type DataParams = { + data: string[]; + }; + + const updateWaypoints = useCallback( + ({data}: DataParams) => { + if (isEqual(waypointsList, data)) { + return; + } + + const newWaypoints: WaypointCollection = {}; + let emptyWaypointIndex = -1; + data.forEach((waypoint, index) => { + newWaypoints[`waypoint${index}`] = waypoints[waypoint] ?? {}; + // Find waypoint that BECOMES empty after dragging + if (isWaypointEmpty(newWaypoints[`waypoint${index}`]) && !isWaypointEmpty(waypoints[`waypoint${index}`])) { + emptyWaypointIndex = index; + } + }); + + setOptimisticWaypoints(newWaypoints); + Promise.all([ + TransactionAction.removeWaypoint(transaction, emptyWaypointIndex.toString(), IOUUtils.shouldUseTransactionDraft(action)), + TransactionAction.updateWaypoints(transactionID, newWaypoints, IOUUtils.shouldUseTransactionDraft(action)), + ]).then(() => { + setOptimisticWaypoints(null); + }); + }, + [transactionID, transaction, waypoints, waypointsList, action], + ); + + const submitWaypoints = useCallback(() => { + // If there is any error or loading state, don't let user go to next page. + if (duplicateWaypointsError || atLeastTwoDifferentWaypointsError || hasRouteError || isLoadingRoute || (!isEditing && isLoading)) { + setShouldShowAtLeastTwoDifferentWaypointsError(true); + return; + } + if (!isCreatingNewRequest) { + transactionWasSaved.current = true; + } + if (isEditing) { + // If nothing was changed, simply go to transaction thread + // We compare only addresses because numbers are rounded while backup + const oldWaypoints = transactionBackup?.comment?.waypoints ?? {}; + const oldAddresses = Object.fromEntries(Object.entries(oldWaypoints).map(([key, waypoint]) => [key, 'address' in waypoint ? waypoint.address : {}])); + const addresses = Object.fromEntries(Object.entries(waypoints).map(([key, waypoint]) => [key, 'address' in waypoint ? waypoint.address : {}])); + const hasRouteChanged = !isEqual(transactionBackup?.routes, transaction?.routes); + if (isEqual(oldAddresses, addresses)) { + navigateBack(); + return; + } + IOU.updateMoneyRequestDistance({ + transactionID: transaction?.transactionID ?? '-1', + transactionThreadReportID: report?.reportID ?? '-1', + waypoints, + ...(hasRouteChanged ? {routes: transaction?.routes} : {}), + policy, + }); + navigateBack(); + return; + } + + navigateToNextStep(); + }, [ + navigateBack, + duplicateWaypointsError, + atLeastTwoDifferentWaypointsError, + hasRouteError, + isLoadingRoute, + isLoading, + isCreatingNewRequest, + isEditing, + navigateToNextStep, + transactionBackup, + waypoints, + transaction?.transactionID, + transaction?.routes, + report?.reportID, + policy, + ]); + + const renderItem = useCallback( + ({item, drag, isActive, getIndex}: RenderItemParams) => ( + + ), + [isLoadingRoute, navigateToWaypointEditPage, waypoints], + ); + + return ( + + <> + + (waypoints[item]?.keyForList ?? waypoints[item]?.address ?? '') + item} + shouldUsePortal + onDragEnd={updateWaypoints} + ref={scrollViewRef} + renderItem={renderItem} + ListFooterComponent={ + + } + /> + + + {/* Show error message if there is route error or there are less than 2 routes and user has tried submitting, */} + {((shouldShowAtLeastTwoDifferentWaypointsError && atLeastTwoDifferentWaypointsError) || duplicateWaypointsError || hasRouteError) && ( + + )} +