diff --git a/.claude/agents/deploy-blocker-investigator.md b/.claude/agents/deploy-blocker-investigator.md index 3b2a038a427b..02a06c5aec1d 100644 --- a/.claude/agents/deploy-blocker-investigator.md +++ b/.claude/agents/deploy-blocker-investigator.md @@ -129,6 +129,10 @@ removeDeployBlockerLabel.sh "$ISSUE_URL" DeployBlocker # For Frontend bugs Call scripts by name only (e.g., `removeDeployBlockerLabel.sh`), not with full paths. +### Step 8: Assign contributors + +When a causing PR is identified with medium or high confidence, assign the PR author and all approving reviewers to the deploy blocker issue using `gh issue edit "$ISSUE_URL" --add-assignee`. Extract approving reviewers from `gh pr view --json reviews`. + --- ## Decision Tree @@ -182,6 +186,7 @@ Post ONE comment using this exact format: Brief explanation of why this recommendation (1-2 sentences). +**Assigned**: @author (PR author), @reviewer (approving reviewer) — or omit if no causing PR identified **Labels**: [Describe any label changes made]
diff --git a/.claude/commands/investigate-deploy-blocker.md b/.claude/commands/investigate-deploy-blocker.md index ce96841f410f..814f219f184f 100644 --- a/.claude/commands/investigate-deploy-blocker.md +++ b/.claude/commands/investigate-deploy-blocker.md @@ -1,5 +1,5 @@ --- -allowed-tools: Bash(gh issue view:*),Bash(gh issue comment:*),Bash(gh issue list:*),Bash(gh pr view:*),Bash(gh pr list:*),Bash(gh pr diff:*),Bash(gh api:*),Bash(git log:*),Bash(git show:*),Bash(git blame:*),Bash(removeDeployBlockerLabel.sh:*),Glob,Grep,Read +allowed-tools: Bash(gh issue view:*),Bash(gh issue comment:*),Bash(gh issue edit:*),Bash(gh issue list:*),Bash(gh pr view:*),Bash(gh pr list:*),Bash(gh pr diff:*),Bash(gh api:*),Bash(git log:*),Bash(git show:*),Bash(git blame:*),Bash(removeDeployBlockerLabel.sh:*),Glob,Grep,Read description: Investigate a deploy blocker issue to find the causing PR and recommend resolution --- diff --git a/.claude/scripts/createInlineComment.sh b/.claude/scripts/createInlineComment.sh index 8e69d12fe122..5ebfe4cd9da3 100755 --- a/.claude/scripts/createInlineComment.sh +++ b/.claude/scripts/createInlineComment.sh @@ -3,7 +3,7 @@ # Secure proxy script to create an inline comment on a GitHub PR. set -eu -readonly ALLOWED_RULES_FILE="${GITHUB_WORKSPACE}/.claude/allowed-rules.txt" +readonly ALLOWED_RULES_FILE="${ALLOWED_RULES_FILE:-${GITHUB_WORKSPACE}/.claude/allowed-rules.txt}" # Print error and exit. die() { @@ -47,11 +47,12 @@ readonly LINE_ARG="${3:-}" validate_rule "$BODY_ARG" echo "Comment approved: $COMMENT_STATUS_REASON" -readonly FOOTER=$'\n\n---\n\nPlease rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.' -readonly COMMENT_BODY="${BODY_ARG}${FOOTER}" - COMMIT_ID=$(gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq '.head.sha') readonly COMMIT_ID +readonly SHORT_SHA="${COMMIT_ID:0:7}" + +readonly FOOTER=$'\n\n---\n\n'"Reviewed at: [${SHORT_SHA}](https://github.com/${GITHUB_REPOSITORY}/commit/${COMMIT_ID}) | Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency." +readonly COMMENT_BODY="${BODY_ARG}${FOOTER}" PAYLOAD=$(jq -n \ --arg body "$COMMENT_BODY" \ diff --git a/.github/actions/composite/setupNode/action.yml b/.github/actions/composite/setupNode/action.yml index 311bcd6990d1..3bd28aeed64f 100644 --- a/.github/actions/composite/setupNode/action.yml +++ b/.github/actions/composite/setupNode/action.yml @@ -49,7 +49,7 @@ runs: - name: Install root project node packages if: steps.cache-node-modules.outputs.cache-hit != 'true' || (inputs.IS_HYBRID_BUILD == 'true' && steps.cache-old-dot-node-modules.outputs.cache-hit != 'true') - uses: nick-fields/retry@3f757583fb1b1f940bc8ef4bf4734c8dc02a5847 + uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4 with: timeout_minutes: 30 max_attempts: 3 diff --git a/.github/workflows/buildAndroid.yml b/.github/workflows/buildAndroid.yml index 001ee25ee4b3..afaddae8de08 100644 --- a/.github/workflows/buildAndroid.yml +++ b/.github/workflows/buildAndroid.yml @@ -178,6 +178,8 @@ jobs: sign: true re-sign: true ad-hoc: ${{ inputs.variant == 'Adhoc' }} + # We don't need Rock to setup java, because we already ran the setup-java action above + setup-java: false keystore-file: './upload-key.keystore' keystore-store-file: 'upload-key.keystore' keystore-store-password: ${{ steps.load-credentials.outputs.ANDROID_UPLOAD_KEYSTORE_PASSWORD }} diff --git a/.github/workflows/buildIOS.yml b/.github/workflows/buildIOS.yml index 0c2077322b1a..16b945cc61ec 100644 --- a/.github/workflows/buildIOS.yml +++ b/.github/workflows/buildIOS.yml @@ -126,7 +126,7 @@ jobs: run: echo "IS_PODFILE_SAME_AS_MANIFEST=${{ hashFiles('Mobile-Expensify/iOS/Podfile.lock') == hashFiles('Mobile-Expensify/iOS/Pods/Manifest.lock') }}" >> "$GITHUB_OUTPUT" - name: Install cocoapods - uses: nick-fields/retry@3f757583fb1b1f940bc8ef4bf4734c8dc02a5847 + uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4 if: steps.pods-cache.outputs.cache-hit != 'true' || steps.compare-podfile-and-manifest.outputs.IS_PODFILE_SAME_AS_MANIFEST != 'true' || steps.setup-node.outputs.cache-hit != 'true' with: timeout_minutes: 10 diff --git a/.github/workflows/cherryPick.yml b/.github/workflows/cherryPick.yml index ab42c398621e..4f77fc84a7a2 100644 --- a/.github/workflows/cherryPick.yml +++ b/.github/workflows/cherryPick.yml @@ -34,9 +34,34 @@ concurrency: cancel-in-progress: false jobs: + validate: + runs-on: blacksmith-2vcpu-ubuntu-2404 + steps: + - name: Validate pull request URL + if: ${{ inputs.PULL_REQUEST_URL != '' }} + run: | + REPO_FULL_NAME=$(echo '${{ inputs.PULL_REQUEST_URL }}' | sed -E 's|https?://github.com/([^/]+/[^/]+)/pull/.*|\1|') + if [[ "$REPO_FULL_NAME" != ${{ github.repository }} ]] && [[ ! "$REPO_FULL_NAME" =~ Expensify/Mobile-Expensify* ]]; then + echo "::error::❌ Cherry picks are only supported for the Expensify/App and Expensify/Mobile-Expensify repositories. Found: $REPO_FULL_NAME" + exit 1 + fi + + - name: Verify PR is merged + if: ${{ inputs.PULL_REQUEST_URL != '' }} + env: + GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} + run: | + if gh pr view "${{ inputs.PULL_REQUEST_URL }}" --json mergedAt | jq --exit-status '.mergedAt'; then + echo "PR is merged: ${{ inputs.PULL_REQUEST_URL }}" + else + echo "::error::❌ PR is not merged: ${{ inputs.PULL_REQUEST_URL }}" + exit 1 + fi + createNewVersion: uses: ./.github/workflows/createNewVersion.yml secrets: inherit + needs: [validate] with: # In order to submit a new build for production review, it must have a higher PATCH version than the previously-submitted build. # The typical case is that with each staging deploy, we bump the BUILD version, and with each prod deploy we bump the PATCH version. @@ -59,14 +84,6 @@ jobs: echo "REPO_FULL_NAME=$(echo '${{ inputs.PULL_REQUEST_URL }}' | sed -E 's|https?://github.com/([^/]+/[^/]+)/pull/.*|\1|')" >> "$GITHUB_OUTPUT" echo "PR_NUMBER=$(echo '${{ inputs.PULL_REQUEST_URL }}' | sed -E 's|.*/pull/([0-9]+).*|\1|')" >> "$GITHUB_OUTPUT" - - name: Verify pull request URL - if: ${{ inputs.PULL_REQUEST_URL != '' }} - run: | - if [[ "${{ steps.getPRInfo.outputs.REPO_FULL_NAME }}" != ${{ github.repository }} ]] && [[ ! "${{ steps.getPRInfo.outputs.REPO_FULL_NAME }}" =~ Expensify/Mobile-Expensify* ]]; then - echo "::error::❌ Cherry picks are only supported for the Expensify/App and Expensify/Mobile-Expensify repositories. Found: ${{ steps.getPRInfo.outputs.REPO_FULL_NAME }}" - exit 1 - fi - - name: Set conflict branch name if: ${{ inputs.PULL_REQUEST_URL != '' }} id: getBranchName @@ -262,7 +279,7 @@ jobs: channel: '#deployer', attachments: [{ color: 'good', - text: `🍒 Cherry-pick to *${{ inputs.TARGET }}* successful\nPR: ${{ inputs.PULL_REQUEST_URL || '(version bump only)' }}\nDeploy workflow: ${{ steps.findDeployRun.outputs.DEPLOY_RUN_MESSAGE }}` + text: `🍒 Cherry-pick to *${{ inputs.TARGET }}* successfully started\nPR: ${{ inputs.PULL_REQUEST_URL || '(version bump only)' }}\nDeploy workflow: ${{ steps.findDeployRun.outputs.DEPLOY_RUN_MESSAGE }}` }] } env: @@ -427,3 +444,20 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} + + - name: "Announce CP cancellation in #deployer" + uses: 8398a7/action-slack@1750b5085f3ec60384090fb7c52965ef822e869e + if: ${{ cancelled() }} + with: + status: custom + custom_payload: | + { + channel: '#deployer', + attachments: [{ + color: 'warning', + text: `🍒 Cherry-pick to *${{ inputs.TARGET }}* was cancelled\nPR: ${{ inputs.PULL_REQUEST_URL || '(version bump only)' }}\nWorkflow run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}` + }] + } + env: + GITHUB_TOKEN: ${{ github.token }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index ca547e41d5de..1e307b941aa9 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -57,7 +57,7 @@ jobs: run: | "$GITHUB_WORKSPACE/.github/scripts/extractAllowedRules.sh" \ "$GITHUB_WORKSPACE/.claude/skills/coding-standards/rules" \ - "$GITHUB_WORKSPACE/.claude/allowed-rules.txt" + "$RUNNER_TEMP/allowed-rules.txt" - name: Load code review JSON schema id: schema @@ -82,6 +82,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} STRUCTURED_OUTPUT: ${{ steps.code-review.outputs.structured_output }} + ALLOWED_RULES_FILE: ${{ runner.temp }}/allowed-rules.txt run: | if [ -z "$STRUCTURED_OUTPUT" ]; then echo "::error::Claude Code Action returned empty structured output" diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ebcd9cbb1919..f165e79ad8e4 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -66,7 +66,7 @@ jobs: # JS is better at handling JSON: https://stackoverflow.com/questions/72953526/github-actions-how-to-pass-tojson-result-to-shell-commands - name: Check if this deploy was triggered by a cherry-pick id: isCherryPick - uses: actions/github-script@e7aeb8c663f696059ebb5f9ab1425ed2ef511bdb + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 with: script: | const commitMessages = context.payload.commits.map((commit) => commit.message); diff --git a/.github/workflows/deployBlockerInvestigation.yml b/.github/workflows/deployBlockerInvestigation.yml index 9f099414d1b1..bd07fd4458a8 100644 --- a/.github/workflows/deployBlockerInvestigation.yml +++ b/.github/workflows/deployBlockerInvestigation.yml @@ -77,4 +77,4 @@ jobs: github_token: ${{ secrets.OS_BOTIFY_TOKEN }} prompt: "/investigate-deploy-blocker ISSUE_URL: ${{ env.ISSUE_URL }}" claude_args: | - --allowedTools "Task,Glob,Grep,Read,Bash(gh issue view:*),Bash(gh issue comment:*),Bash(gh issue list:*),Bash(gh pr view:*),Bash(gh pr list:*),Bash(gh pr diff:*),Bash(gh api:*),Bash(git log:*),Bash(git show:*),Bash(git blame:*),Bash(removeDeployBlockerLabel.sh:*)" + --allowedTools "Task,Glob,Grep,Read,Bash(gh issue view:*),Bash(gh issue comment:*),Bash(gh issue edit:*),Bash(gh issue list:*),Bash(gh pr view:*),Bash(gh pr list:*),Bash(gh pr diff:*),Bash(gh api:*),Bash(git log:*),Bash(git show:*),Bash(git blame:*),Bash(removeDeployBlockerLabel.sh:*)" diff --git a/Gemfile b/Gemfile index 4a74f5363ad2..2c89f021d804 100644 --- a/Gemfile +++ b/Gemfile @@ -4,7 +4,7 @@ source "https://rubygems.org" ruby ">= 3.3.4" gem "cocoapods", "= 1.16.2" -gem 'activesupport', '>= 6.1.7.5', '!= 7.1.0' +gem 'activesupport', '>= 7.2.3.1' gem 'xcodeproj', '~> 1.27' gem "fastlane", "~> 2", ">= 2.229.0" gem "xcpretty", "~> 0" diff --git a/Gemfile.lock b/Gemfile.lock index 769319b16842..9e3c96f1c6e0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,7 +3,7 @@ GEM specs: CFPropertyList (3.0.8) abbrev (0.1.2) - activesupport (7.2.3) + activesupport (7.2.3.1) base64 benchmark (>= 0.3) bigdecimal @@ -12,7 +12,7 @@ GEM drb i18n (>= 1.6, < 2) logger (>= 1.4.2) - minitest (>= 5.1) + minitest (>= 5.1, < 6) securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) addressable (2.8.8) @@ -45,8 +45,8 @@ GEM aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) base64 (0.3.0) - benchmark (0.3.0) - bigdecimal (4.0.1) + benchmark (0.5.0) + bigdecimal (4.1.1) claide (1.1.0) cocoapods (1.16.2) addressable (~> 2.8) @@ -89,8 +89,8 @@ GEM colored2 (3.1.2) commander (4.6.0) highline (~> 2.0.0) - concurrent-ruby (1.3.5) - connection_pool (2.5.4) + concurrent-ruby (1.3.6) + connection_pool (3.0.2) csv (3.3.5) declarative (0.0.20) digest-crc (0.7.0) @@ -238,7 +238,7 @@ GEM domain_name (~> 0.5) httpclient (2.9.0) mutex_m - i18n (1.14.7) + i18n (1.14.8) concurrent-ruby (~> 1.0) jmespath (1.6.2) json (2.18.0) @@ -251,7 +251,7 @@ GEM mime-types-data (3.2025.0924) mini_magick (4.13.2) mini_mime (1.1.5) - minitest (5.26.1) + minitest (5.27.0) molinillo (0.8.0) multi_json (1.18.0) multipart-post (2.4.1) @@ -322,7 +322,7 @@ PLATFORMS x86_64-linux DEPENDENCIES - activesupport (>= 6.1.7.5, != 7.1.0) + activesupport (>= 7.2.3.1) benchmark bigdecimal cocoapods (= 1.16.2) diff --git a/Mobile-Expensify b/Mobile-Expensify index 466923a5c186..d10f5855820f 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 466923a5c1865ace1d75de76a3ab698bcd2b5f68 +Subproject commit d10f5855820ff5fe53fd1a4564434d2eb8152efe diff --git a/android/app/build.gradle b/android/app/build.gradle index 0c83e92da388..056857951728 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -111,8 +111,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009035200 - versionName "9.3.52-0" + versionCode 1009035903 + versionName "9.3.59-3" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/android/app/src/main/java/com/expensify/chat/MainActivity.kt b/android/app/src/main/java/com/expensify/chat/MainActivity.kt index bbc44e50a434..c1fc3c15b1e4 100644 --- a/android/app/src/main/java/com/expensify/chat/MainActivity.kt +++ b/android/app/src/main/java/com/expensify/chat/MainActivity.kt @@ -46,9 +46,6 @@ class MainActivity : ReactActivity() { .apply() BootSplash.init(this) super.onCreate(null) - if (resources.getBoolean(R.bool.portrait_only)) { - requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT - } // Sets translucent status bar. This code is based on what the react-native StatusBar // module does, but we need to do it here to avoid the splash screen jumping on app start. diff --git a/android/app/src/main/res/values-large/orientation.xml b/android/app/src/main/res/values-large/orientation.xml deleted file mode 100644 index 9f60d109a2fc..000000000000 --- a/android/app/src/main/res/values-large/orientation.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - true - diff --git a/android/app/src/main/res/values-sw600dp/orientation.xml b/android/app/src/main/res/values-sw600dp/orientation.xml deleted file mode 100644 index 9f60d109a2fc..000000000000 --- a/android/app/src/main/res/values-sw600dp/orientation.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - true - diff --git a/android/app/src/main/res/values/orientation.xml b/android/app/src/main/res/values/orientation.xml deleted file mode 100644 index 9f60d109a2fc..000000000000 --- a/android/app/src/main/res/values/orientation.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - true - diff --git a/assets/images/integrationicons/gusto-icon-square.svg b/assets/images/integrationicons/gusto-icon-square.svg new file mode 100644 index 000000000000..437dcac3f054 --- /dev/null +++ b/assets/images/integrationicons/gusto-icon-square.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/simple-illustrations/simple-illustration__foodtruck-tacos.svg b/assets/images/simple-illustrations/simple-illustration__foodtruck-tacos.svg new file mode 100644 index 000000000000..43501efd70c5 --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__foodtruck-tacos.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/simple-illustrations/simple-illustration__target.svg b/assets/images/simple-illustrations/simple-illustration__target.svg index 00ca28aa5c91..5ded5e7eae62 100644 --- a/assets/images/simple-illustrations/simple-illustration__target.svg +++ b/assets/images/simple-illustrations/simple-illustration__target.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/config/webpack/webpack.common.ts b/config/webpack/webpack.common.ts index 3f9d2cfd01e1..ed3fa5460180 100644 --- a/config/webpack/webpack.common.ts +++ b/config/webpack/webpack.common.ts @@ -162,8 +162,8 @@ const getCommonConfiguration = ({file = '.env', platform = 'web'}: Environment): {from: 'assets/fonts/web', to: 'fonts'}, {from: 'assets/sounds', to: 'sounds'}, {from: 'assets/pdfs', to: 'pdfs'}, - {from: 'node_modules/react-pdf/dist/esm/Page/AnnotationLayer.css', to: 'css/AnnotationLayer.css'}, - {from: 'node_modules/react-pdf/dist/esm/Page/TextLayer.css', to: 'css/TextLayer.css'}, + {from: 'node_modules/react-pdf/dist/Page/AnnotationLayer.css', to: 'css/AnnotationLayer.css'}, + {from: 'node_modules/react-pdf/dist/Page/TextLayer.css', to: 'css/TextLayer.css'}, {from: '.well-known/apple-app-site-association', to: '.well-known/apple-app-site-association', toType: 'file'}, {from: '.well-known/assetlinks.json', to: '.well-known/assetlinks.json'}, @@ -260,7 +260,7 @@ const getCommonConfiguration = ({file = '.env', platform = 'web'}: Environment): // We are importing this worker as a string by using asset/source otherwise it will default to loading via an HTTPS request later. // This causes issues if we have gone offline before the pdfjs web worker is set up as we won't be able to load it from the server. { - test: new RegExp('node_modules/pdfjs-dist/build/pdf.worker.min.mjs'), + test: new RegExp('node_modules/pdfjs-dist/legacy/build/pdf.worker.min.mjs'), type: 'asset/source', }, @@ -330,6 +330,8 @@ const getCommonConfiguration = ({file = '.env', platform = 'web'}: Environment): 'victory-native': path.resolve(dirname, '../../node_modules/victory-native/src/index.ts'), // Required for @shopify/react-native-skia web support 'react-native/Libraries/Image/AssetRegistry': false, + // Use legacy build of pdfjs-dist to support older browsers + 'pdfjs-dist$': path.resolve(dirname, '../../node_modules/pdfjs-dist/legacy/build/pdf.mjs'), // Module alias for web // https://webpack.js.org/configuration/resolve/#resolvealias '@assets': path.resolve(dirname, '../../assets'), diff --git a/contributingGuides/BUGZERO_CHECKLIST.md b/contributingGuides/BUGZERO_CHECKLIST.md index 488d68c1cc31..de3ff5195c6f 100644 --- a/contributingGuides/BUGZERO_CHECKLIST.md +++ b/contributingGuides/BUGZERO_CHECKLIST.md @@ -1,8 +1,9 @@ # BugZero Checklist: -- [ ] **[Contributor]** The offending PR has been commented on, pointing out the bug it caused and why, so the author and reviewers can learn from the mistake. +- [ ] **[Contributor]** The offending PR and associated issue have been commented on, pointing out the bug it caused and why, so the author and reviewers can learn from the mistake. - Link to comment: + Link to the comment on the PR: + Link to the comment on the Issue: - [ ] **[Contributor]** If the regression was CRITICAL (e.g. interrupts a core flow) A discussion in [#expensify-open-source](https://app.slack.com/client/E047TPA624F/C01GTK53T8Q) has been started about whether any other steps should be taken (e.g. updating the PR review checklist) in order to catch this type of bug sooner. diff --git a/contributingGuides/NAVIGATION.md b/contributingGuides/NAVIGATION.md index 2b631fe86699..3473e569991e 100644 --- a/contributingGuides/NAVIGATION.md +++ b/contributingGuides/NAVIGATION.md @@ -702,7 +702,7 @@ A dynamic route is a URL suffix (e.g. `verify-account`) that can be appended to Do not use dynamic routes when: - Your use case falls under the [current limitations](#current-limitations-work-in-progress): - - You need path parameters in dynamic suffixes (e.g. `a/:reportID`). + - You need optional path parameters in dynamic suffixes (e.g. `a/:reportID?`). - The screen has a single, fixed entry and a fixed back destination. In this case, use a normal static route instead. ### Dynamic routes configuration @@ -751,9 +751,9 @@ KEYBOARD_SHORTCUTS: { ### Current limitations (work in progress) -- **Path parameters:** Suffixes must not include path params (e.g. `a/:reportID`). Query parameters are supported - see [Dynamic routes with query parameters](#dynamic-routes-with-query-parameters). +- **Optional path parameters:** Suffixes must not include optional path params (e.g. `a/:reportID?`). Required path parameters (e.g. `flag/:reportActionID`) and query parameters are supported - see [Dynamic routes with query parameters](#dynamic-routes-with-query-parameters). -If you try to use dynamic routes for this case now, you will either fail to navigate to the page at all or end up on a non-existent page, and the navigation will be broken. +If you try to use dynamic routes with optional path parameters now, you will either fail to navigate to the page at all or end up on a non-existent page, and the navigation will be broken. ### Multi-segment dynamic routes @@ -892,9 +892,7 @@ and [`src/pages/settings/Profile/PersonalDetails/DynamicCountrySelectionPage.tsx ### How to add a new dynamic route 1. Add to `DYNAMIC_ROUTES` in [`src/ROUTES.ts`](../src/ROUTES.ts): define `path` and -`entryScreens` (screen names that may open this route). -If the suffix needs query parameters, also define `getRoute` -and `queryParams` - see +`entryScreens` (screen names that may open this route). If the suffix needs path parameters, define `path` with `:param` placeholders and a `getRoute` function. If the suffix needs query parameters, also define `getRoute` and `queryParams` - see [Dynamic routes with query parameters](#dynamic-routes-with-query-parameters). 2. Add a screen constant in [`src/SCREENS.ts`](../src/SCREENS.ts). The name must start with the `DYNAMIC_` prefix diff --git a/cspell.json b/cspell.json index ed2cbd7589da..ac25b59fc7cd 100644 --- a/cspell.json +++ b/cspell.json @@ -552,7 +552,9 @@ "phonenumber", "Picklist", "picklists", + "PINATM", "PINGPONG", + "PINATM", "pkill", "Pluginfile", "pluralrules", @@ -653,6 +655,7 @@ "Salagatan", "samltool", "Saqbd", + "sbaiahmed", "SBFJ", "Scaleway", "Scaleway's", @@ -871,6 +874,7 @@ "zxldvw", "مثال", "Airwallex", + "deprioritizes", "AMRO", "Bancorporation", "Banque", @@ -902,7 +906,9 @@ "Synovus", "Wallester", "Wintrust", - "Zürcher" + "Zürcher", + "CARDFROZEN", + "CARDUNFROZEN" ], "ignorePaths": [ "src/languages/de.ts", diff --git a/docs/_config.yml b/docs/_config.yml index 36eea67ad2de..fbf0e75d8708 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -15,7 +15,7 @@ defaults: layout: "default" image: /assets/images/og-preview-image.png -exclude: [README.md, TEMPLATE.md, vendor] +exclude: [README.md, TEMPLATE.md, vendor, Gemfile, Gemfile.lock] plugins: - jekyll-seo-tag diff --git a/docs/articles/Unlisted/Concierge-in-Expensify.md b/docs/articles/Unlisted/Concierge-in-Expensify.md index 04409b815bc7..92d98622a217 100644 --- a/docs/articles/Unlisted/Concierge-in-Expensify.md +++ b/docs/articles/Unlisted/Concierge-in-Expensify.md @@ -34,7 +34,7 @@ You can reach Concierge by email at concierge@expensify.com or directly in the a - **Expensify Classic (web):** Click the chat icon in the navigation tabs on the left. - **Expensify Classic (mobile):** Tap the chat icon in the upper right corner. -- **New Expensify:** Press the big green **+** button, select **New Chat**, and type *Concierge* into the composer box. +- **New Expensify:** Press the **+** button, select **New Chat**, and type *Concierge* into the composer box. # Common Concierge use cases diff --git a/docs/articles/new-expensify/chat/Create-a-New-Chat.md b/docs/articles/new-expensify/chat/Create-a-New-Chat.md index 919ca7eae4c7..9fa2c2aed152 100644 --- a/docs/articles/new-expensify/chat/Create-a-New-Chat.md +++ b/docs/articles/new-expensify/chat/Create-a-New-Chat.md @@ -25,7 +25,7 @@ Expensify Chat is an instant messaging system that helps you converse with peopl ## Start a Group Chat -1. Click the green **+** button in the navigation tabs and select **Start Chat**. +1. Click the **+** button in the navigation tabs and select **Start Chat**. 2. Enter the name, email, or phone number of each participant and select **Add to group**. - Group participants are listed with a green checkmark. 3. Click or tap **Next**. @@ -38,7 +38,7 @@ Expensify Chat is an instant messaging system that helps you converse with peopl ## Start a Chat Room -1. Click the green **+** button in the navigation tabs and select **Start Chat**. +1. Click the **+** button in the navigation tabs and select **Start Chat**. 2. Click or tap the **#Room** tab at the top. 3. Enter a unique name for the room. 4. (Optional) Add a room description. diff --git a/docs/articles/new-expensify/concierge-ai/What-Concierge-Can-Do.md b/docs/articles/new-expensify/concierge-ai/What-Concierge-Can-Do.md new file mode 100644 index 000000000000..5e149aa158e4 --- /dev/null +++ b/docs/articles/new-expensify/concierge-ai/What-Concierge-Can-Do.md @@ -0,0 +1,167 @@ +--- +title: What Concierge can help you do in Expensify +description: A complete reference of what you can ask Concierge to do in Expensify, with real examples of questions and actions. +keywords: [what can Concierge do, Concierge examples, Expensify AI capabilities, Concierge commands, expense assistant examples] +internalScope: Audience is all Expensify members. Covers a comprehensive capability reference for Concierge. Does not include step-by-step instructions for completing specific workflows. +--- + +# What Concierge can help you do in Expensify + +Concierge is your built-in assistant in Expensify. You can ask it questions, give it instructions, or describe what you want to do and it will respond or take action. + +Use this page as a reference for the types of things you can ask Concierge to do. + +If you're not sure whether Concierge can do something, try asking—it will either take action, explain, or connect you with a human. + +--- + +## Who can use Concierge + +All Expensify members can use Concierge. + +There are no special permissions required. Concierge is available across web, mobile, email, and chat. + +[Learn where to find Concierge](/articles/new-expensify/concierge-ai/Concierge-Basics#where-to-find-concierge). + +--- + +## How to ask Concierge for help in Expensify + +You can talk to Concierge like you would a coworker. You don’t need special commands—just describe what you want. Be as specific as possible for the best results. + +You can: +- Ask questions +- Give instructions +- Combine multiple requests in one message + +--- + +## What questions you can ask Concierge about Expensify + +Ask Concierge anything you’d normally search for or contact support about. + +Examples: +- “How do I change my Workspace settings?” +- “Why was this expense categorized as Travel?” +- “Why was my report submitted automatically?” +- “How do reimbursements work in my Workspace?” +- “Why isn’t my bank account syncing?” + +Concierge will answer or connect you with a human if needed. + +[Learn about Concierge Support Agent](/articles/new-expensify/concierge-ai/Support-Agent). + +--- + +## What issues Concierge can troubleshoot in Expensify + +Describe the problem, and Concierge will help you figure it out. + +Examples: +- “My card transaction isn’t showing up” +- “This report won’t submit—what’s wrong?” +- “Why was this expense marked non-reimbursable?” +- “My integration disconnected—how do I fix it?” + +--- + +## What actions Concierge can take on expenses + +You can create or update expenses just by describing what you need. + +Examples: +- “Create a $12 lunch expense for today” +- “Add a $60 dinner to my New York trip report” +- “Change this expense to Client Meals” +- “Make this expense non-reimbursable” +- “Update the amount to $45” +- “Add Alice as an attendee” + +You can also upload or forward receipts, and Concierge will create the expense for you. + +[Learn about Concierge Expense Assistant](/articles/new-expensify/concierge-ai/Expense-Assistant). + +--- + +## What actions Concierge can take on reports + +Concierge can help you manage reports without navigating through menus. + +Examples: +- “Submit my April expense report” +- “Approve Alice’s report” +- “Add this expense to my Chicago trip report” +- “Is this report reimbursable?” +- “Export this report” + +--- + +## What Concierge can explain about activity in Expensify + +You can always ask “why” to understand what happened. + +Examples: +- “Why did this get categorized as Office Supplies?” +- “Why was this report submitted automatically?” +- “Why did this expense get added to this report?” + +Concierge uses your Workspace rules and past behavior to explain decisions. + +[Learn about Concierge Intelligence](/articles/new-expensify/concierge-ai/Concierge-Intelligence). + +--- + +## How Concierge can help you improve your Expensify workflow + +You can describe a goal, and Concierge will help you figure out how to do it. + +Examples: +- “I want to require receipts for all expenses over $25—how do I do that?” +- “How should I set up approvals for my team?” +- “What’s the best way to organize reports for travel?” + +--- + +## What happens after you message Concierge + +After you send a message: +- Concierge responds in the same chat +- Actions are completed in real time when possible +- You’ll get confirmation of any changes +- If needed, your request is escalated to a human without restarting the conversation + +--- + +# FAQ + +## Do I need to use specific commands with Concierge? + +No. Just type naturally, like you’re messaging a coworker. + +## Can I ask multiple things in one message? + +Yes. Concierge can handle multi-step or combined requests. + +## What if Concierge can’t help? + +It will automatically connect you with a human support specialist, and you can also request one at any time. + +## What kinds of requests work best with Concierge? + +Concierge works best when your request is clear and specific. Including details like amounts, report names, dates, or people helps Concierge take action more accurately. + +## Can Concierge take actions without me opening a specific expense or report? + +Yes. You can describe what you want, and Concierge will find the relevant expense or report using context like names, amounts, and recent activity. If it’s unclear, Concierge will ask for more details. + +## Can I undo or correct something Concierge did? + +Yes. Just tell Concierge what to change, and it will update or fix the action. You can also ask it to explain what it did before making changes. + +## Does Concierge understand context from previous messages? + +Yes. Concierge remembers the context of your conversation, so you can ask follow-up questions or give additional instructions without repeating everything. + +## Are there things Concierge can’t do yet? + +Some advanced or bulk actions may not be supported yet. If something isn’t available, Concierge will let you know or guide you to the best alternative. diff --git a/docs/articles/new-expensify/connect-credit-cards/Assign-Company-Cards.md b/docs/articles/new-expensify/connect-credit-cards/Assign-Company-Cards.md new file mode 100644 index 000000000000..09539068fdc8 --- /dev/null +++ b/docs/articles/new-expensify/connect-credit-cards/Assign-Company-Cards.md @@ -0,0 +1,51 @@ +--- +title: Assign Company Cards +description: Learn how to assign company cards to members in your Workspace using the Company cards page. +keywords: [New Expensify, assign company cards, Expensify card assignment, workspace company cards, assign card to employee] +internalScope: Audience is Workspace Admins. Covers assigning company cards to members in New Expensify, does not cover initial connection or troubleshooting connection issues. +--- + +# Assign Company Cards + +Assigning company cards links company cards to Workspace members so their transactions are automatically imported into their account for expense tracking. + +--- + +## Who can assign company cards + +Workspace Admins can assign company cards after a company card feed is connected. + +If you haven’t set up a feed yet, learn how to set one up: + + - [Learn how to set up a direct company card feed connection](/articles/new-expensify/connect-credit-cards/Set-up-a-Direct-Company-Card-Feed-Connection). + - [Learn how to set up a commercial card feed connection](/articles/new-expensify/connect-credit-cards/Set-up-a-Commercial-Card-Feed-Connection). + +## How to assign company cards + +1. Click the navigation tabs (on the left on web, on the bottom on mobile) and select **Workspaces > [Workspace Name]**. +2. Select **Company cards**. +3. Select the card connection name. +4. Click **Assign card** on the card you want to assign. +5. Enter the assignee's name, email address, or phone number. +6. Review the details and click **Assign card** to finalize the assignment. + +--- + +## What happens after you assign company cards + + - Posted transactions from the assigned card import automatically into the member's account as expenses. + - The imported company card expenses are added to reports and submitted according to Workspace rules. + +# FAQ + +## Can I assign a company card to someone who is not a member of the Workspace? + +You can enter any name, email address, or phone number when assigning a company card. However, only Workspace members can have company cards assigned to them. If the assignee is not yet a Workspace member, you can invite them while assigning the card. + +## Can I assign multiple company cards to one member? + +Yes. A member can have multiple company cards assigned. Each card’s transactions will be automatically imported into their account. + +## Can members unassign their own company cards? + +No. Only Workspace Admins can assign or reassign company cards. diff --git a/docs/articles/new-expensify/connect-credit-cards/Assign-and-Manage-Cards.md b/docs/articles/new-expensify/connect-credit-cards/Assign-and-Manage-Cards.md deleted file mode 100644 index 6c2194f4931f..000000000000 --- a/docs/articles/new-expensify/connect-credit-cards/Assign-and-Manage-Cards.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -title: Assign and Manage Corporate Cards -description: Learn how to assign corporate cards to your employees and manage assigned corporate cards in Expensify. -keywords: [New Expensify, corporate cards, bulk assignment, card feed, workspace cards, card management, card assignment, admin tools] ---- - -
- -Admins can assign and manage all corporate cards — both assigned and unassigned — from a single streamlined view in New Expensify. Updating in bulk makes it easier and faster to assign cards, especially when managing dozens or hundreds at once. - - -# Assign Company Cards - -1. Go to your workspaces. -2. Select **[Workspace Name] > Company cards**. -3. If you have multiple card feeds in your workspace, click the feed name at the top left to select the correct one. -4. Click **Assign card** next to the card you want to assign. You can filter by card number or name above the card list to make it easier to find a specific card. -5. Select which employee you want to assign the card to. - - **Note:** If you're assigning a card to a new employee, or one without an Expensify account, you can invite them during card assignment. Please note that this may result in an increase in your bill depending on the number of available seats you have. -6. Review the details to ensure it looks correct. - - **Note:** You can choose to either import cards using a custom transaction start date or from the beginning, which imports transactions as far back as available. Most company card feeds can import 30-90 days of historical transactions, depending on the bank. -7. Click **Confirm** to finalize the assignment. - -# Manage Assigned Cards - -After assigning cards to users, you can manage those assigned cards from the same page. To find a card: -- Filter cards to assigned cards, unassigned cards, or all cards. -- Search by employee name/email, last 4 digits of the card, or custom card name. -- Sort columns for faster organization and review. - -**Available actions:** -- Rename the card -- Assign a specific export account -- Update transactions (to manually import newer transactions) -- Unassign the card - -**Note:** Unassigning a card will delete unsubmitted expenses from draft reports for that cardholder. - - -# FAQ - -## What happens if a card fails to assign? - -You’ll see an error message directly in the card row. This allows you to quickly resolve the issue without leaving the page. - -
diff --git a/docs/articles/new-expensify/connect-credit-cards/Check-Supported-Banks.md b/docs/articles/new-expensify/connect-credit-cards/Check-Supported-Banks.md index 9cff0958519d..eefcc5de3ab5 100644 --- a/docs/articles/new-expensify/connect-credit-cards/Check-Supported-Banks.md +++ b/docs/articles/new-expensify/connect-credit-cards/Check-Supported-Banks.md @@ -1,11 +1,11 @@ --- -title: Check supported banks and card issuers in Expensify +title: Check Supported Banks and Card Issuers in Expensify description: See which banks and card issuers Expensify supports for connecting company credit cards. keywords: [check supported banks, supported card issuers, Plaid, connect credit card, bank list, direct feed banks] internalScope: Audience is any Expensify user checking bank compatibility. Covers checking whether a bank or card issuer is supported, does not cover how to connect a card feed or troubleshoot connection errors. --- -# Check which banks and card issuers are supported in Expensify +# Check Supported Banks and Card Issuers in Expensify Expensify supports most major banks and card issuers worldwide through direct and commercial feeds. New banks are added regularly, so even if your bank isn't listed below, it may still be supported. diff --git a/docs/articles/new-expensify/connect-credit-cards/Commercial-feeds.md b/docs/articles/new-expensify/connect-credit-cards/Commercial-feeds.md deleted file mode 100644 index a9afddab0c53..000000000000 --- a/docs/articles/new-expensify/connect-credit-cards/Commercial-feeds.md +++ /dev/null @@ -1,109 +0,0 @@ ---- -title: Commercial Card Feeds -description: Learn how to set up and manage commercial card feeds (Visa, Mastercard, Amex) in Expensify. -keywords: [New Expensify, commercial feed, Mastercard feed, Visa feed, Amex feed, company cards, corporate cards, CDF, VCF, GL1025, control account] ---- - -Commercial feeds are the most reliable way to import company card expenses. These feeds are not affected by login credential changes or banking UI updates, making them ideal for growing teams and finance admins. - ---- -# Commercial Card Feeds - -Commercial card feeds are file-based connections provided directly by your bank. Unlike direct feeds, which require you to log in with your bank credentials, commercial feeds are managed by your bank and typically used for large-scale corporate card programs. Contact your bank directly to check if your cards are eligible for a commercial feed. - -## Who can use Commercial Card Feeds - -- **Collect plan** members can add one commercial card feed. -- **Control plan** members can add an unlimited number of company card feeds. -- Workspace creation is required before connecting cards. - ---- - -# Set up a Mastercard Commercial Feed - -To use a Mastercard commercial feed, your bank must send your transaction data from **Mastercard’s SmartData portal**. - -1. Ask your bank to deliver your **CDF (Common Data File)** to Expensify via SmartData. - - File type must be: **CDF v3 Release 11.01** - - Specify the earliest transaction date needed. -2. Your bank will send you a **Distribution ID**. -3. In Expensify, go to **Workspaces > [Workspace Name] > Company Cards > Add Cards**. -4. Choose **Commercial Feed**, then select **Mastercard Commercial Cards**. -5. Enter the **Distribution ID** and submit. - -Expensify will notify you when the feed is live. - ---- - - -# Set up a Visa Commercial Feed - -Visa commercial feeds are configured through your bank using Visa’s **Subscription Management portal**. - -1. Ask your bank’s relationship manager to enable your **VCF (Visa Commercial Format)** feed and send it to **Expensify**. - - Mention: “You can check a box in Visa’s Subscription Management portal to enable feed delivery—no test file needed.” -2. Request the feed details: - - **Processor ID** - - **Bank (Financial Institution) ID** - - **Company ID** -3. In Expensify, go to **Workspaces > [Workspace Name] > Company Cards > Add Cards**. -4. Choose **Commercial Feed**, then select **Visa Commercial Cards**. -5. Enter the required IDs and submit. - -Expensify will notify you when the feed is connected. - ---- - -# Set up an American Express Corporate Card Feed - -To set up an Amex Corporate Feed, you’ll need to complete and email their required forms. - -1. Download the forms: - [Amex Feed Setup Forms (Google Drive)](https://drive.google.com/file/d/1zqDA_MCk06jk_fWjzx2y0r4gOyAMqKJe/view?usp=sharing) -2. Complete the form as follows: - - **Page 1:** Fill in legal company details, requestor name/email, feed start date, and **Control Account Number** (not a credit card number). - - **Page 2:** Leave blank. - - **Page 3:** Re-enter client name and Control Account. - - **Page 4:** Add country, authorization name, title, and signature (DD/MM/YY format). -3. Email the completed forms to: **electronictransmissionsteam@aexp.com** -4. Wait for the **Production Letter** containing your feed’s file name. -5. 3. In Expensify, go to **Workspaces > [Workspace Name] > Company Cards > Add Cards**. -4. Choose **Commercial Feed**, then select **American Express Corporate Cards**. -7. You’ll be notified once the feed is live. - ---- - -## Manage commercial card settings - -You can update individual cards under **Company Cards > [Card Name]**. - -### Available actions: -- **Rename the card** for easier tracking. -- **Assign export account** for accounting integration. -- **Update transactions** to pull the latest expenses. -- **Unassign the card** to remove it from a user and delete unsubmitted drafts. - -![Manage the card on the card details page]({{site.url}}/assets/images/commfeed/commfeed-07-updated.png){:width="100%"} - ---- - -# FAQ - -## What’s the difference between a commercial feed and a direct feed? - -- **Direct feeds** connect using your bank login credentials. They’re quick to set up but may require periodic re-authentication. -- **Commercial feeds** are bank-managed file connections. They require setup through your bank but offer a **more stable and uninterrupted** connection. - -## My feed is connected, but I don’t see my card. What now? - -Cards will only appear if: -- They are active -- At least one transaction has posted - -If a card still isn’t showing, contact your account manager or email [concierge@expensify.com](mailto:concierge@expensify.com). - -## Is there an extra cost for commercial feeds? - -No. Commercial feeds are included in the Collect and Control plans: -- **Collect** includes one commercial feed. -- **Control** supports unlimited feeds. diff --git a/docs/articles/new-expensify/connect-credit-cards/Company-Card-Settings.md b/docs/articles/new-expensify/connect-credit-cards/Configure-Company-Card-Settings.md similarity index 63% rename from docs/articles/new-expensify/connect-credit-cards/Company-Card-Settings.md rename to docs/articles/new-expensify/connect-credit-cards/Configure-Company-Card-Settings.md index 916ef6202b2f..13b285bf7966 100644 --- a/docs/articles/new-expensify/connect-credit-cards/Company-Card-Settings.md +++ b/docs/articles/new-expensify/connect-credit-cards/Configure-Company-Card-Settings.md @@ -1,42 +1,57 @@ --- -title: Company Card Settings +title: Configure Company Card Settings description: Learn how to manage third-party company card feeds in Expensify, including commercial and direct feeds, accounting exports, and automated eReceipts. keywords: [New Expensify, company cards, card feed settings, third-party cards, commercial feeds, direct feeds, accounting exports, ereceipts, expense automation, card management] +internalScope: Applies to Workspace Admins. Covers how to manage third-party feed settings on the workspace level. Does not cover company card troubleshooting, setup or card assignment. --- +# Configure Company Card Settings + Workspace Admins can manage company card settings, export logic, and eReceipt functionality at the workspace level. This guide outlines how to configure your company card connection, route expenses to accounting systems, and automate receipt handling. +To set up a direct company credit card feed on a Workspace, see [Direct Feeds](/articles/new-expensify/connect-credit-cards/Direct-feeds)). + +To set up a commercial card file feed on a Workspace, see [Commercial Feeds](https://help.expensify.com/articles/new-expensify/connect-credit-cards/Commercial-feeds). + --- -# Manage Company Card Settings for Commercial and Direct Feeds +## How to configure settings for company card feeds -To adjust your card feed configuration: +1. In the navigation tabs (on the left on web, on the bottom on mobile) navigate to **Workspaces > [Workspace Name]**. +2. Select **Company Cards** and chose the relevant company card feed. +3. Click **Settings**. -1. Go to **Workspaces > [Workspace Name] > Company Cards**. -2. Click **Settings** in the top-right corner. +You can manage the following options: + +- **Card feed name** + Update the name of the card feed to help identify it. -**Available actions include:** +- **Allow deleting transactions** + Enable this setting to allow cardholders to delete card transactions. + + **Note:** This setting only applies to transactions imported after the setting is enabled. -- **Rename the card feed connection** -- **Control whether cardholders can delete transactions:** Applies only to new transactions going forward -- **Remove the card connection:** Unassigns all cards and deletes unsubmitted expenses in draft reports +- **Remove card feed** + Remove the card feed from the workspace and unassign all cards. If the feed is not connected to another workspace, it is permanently deleted. + + **Note:** Removing the feed or unassigning a card deletes all imported expenses in the **Unreported** and **Draft** states. ![Tap settings to open the card feed settings page]({{site.url}}/assets/images/Direct Feed HelpDot Images/directfeeds_12.png){:width="100%"} --- -# Export Transactions to Your Accounting System +## How to export transactions to your accounting system If you’ve connected accounting software (like **QuickBooks**, **NetSuite**, or **Xero**), you can export company card expenses either to a **central account** or **individual accounts**. -## Export to a Central Account +## How to export to a central account To apply this setting to all card expenses in the workspace: 1. Go to **Workspaces > [Workspace Name] > Accounting > Connections > Export**. 2. Under **Export company card expenses as**, choose **Central account**. -## Export to Individual Accounts +## How to export to individual accounts To override the central export account per card: @@ -48,7 +63,7 @@ To override the central export account per card: --- -# Use eReceipts with Third-Party Card Feeds +## How to use eReceipts with third-party card Feeds **eReceipts** are digital receipts that replace paper ones for many USD transactions of $75 or less on commercial and direct card feeds. diff --git a/docs/articles/new-expensify/connect-credit-cards/Connect-Personal-Cards.md b/docs/articles/new-expensify/connect-credit-cards/Connect-Personal-Cards.md new file mode 100644 index 000000000000..bba95525d299 --- /dev/null +++ b/docs/articles/new-expensify/connect-credit-cards/Connect-Personal-Cards.md @@ -0,0 +1,66 @@ +--- +title: Connect a personal card +description: Learn how to connect a new personal credit card to your Expensify account to automatically import transactions. +keywords: [New Expensify, personal cards, connect card, Wallet, Plaid, bank connection, card import, credit card, New Expensify, oAuth, add card] +internalScope: Audience is members who want to connect a new personal credit card. Covers the card connection flow including country selection, bank selection, oAuth and Plaid connections, company card warnings, and free card limits. Does not cover managing existing cards or company card programs. +--- + +# Connect a personal card + +Connect your personal credit card to Expensify to automatically import transactions, eliminating the need to manually create expenses or scan receipts. Expensify supports over 10,000 banks across the United States, Canada, the United Kingdom, and the European Union. + +--- + +## Who can connect a personal card + +Anyone with an Expensify account can connect up to two personal cards for free. To connect more than two personal cards, you'll need a paid workspace (Collect plan or above). + +--- + +## How to connect a personal card + +1. Click the navigation tabs (on the left on web, on the bottom on mobile), then go to **Account > Wallet**. +2. Tap **Add personal card**. +3. Select your country from the country selector. +4. If you selected **United States**, choose your bank from the bank selector. + - If your bank is listed, you'll be prompted to connect via your bank's secure login (oAuth). + - If your bank is not listed, select **Other** to connect via Plaid. +5. Follow the prompts to log into your bank and authorize the connection. + +Once your card is connected, it will appear under **Assigned cards** in the **Wallet**, and transactions will begin importing automatically. + +--- + +## What to do if you see a company card warning + +If you're a member of a Workspace or domain that has a company card feed, Expensify will ask you to confirm that the card you're adding is a personal card. + +- **If you're an employee:** You'll see options to ask your admin if you should be assigned a company card, or continue adding a personal card. +- **If you're an admin:** You'll see options to confirm this is a company card (which redirects you to the company card connection flow) or proceed with adding it as a personal card. + +--- + +## What happens when you reach the free card limit + +If you're not on a paid workspace and try to add more than two personal cards, you'll see an upgrade page prompting you to create a Collect workspace. + +If you proceed with the upgrade: + +1. A Collect workspace is created for your account. +2. You'll see a success page with options to add company cards or additional personal cards. + +--- + +# FAQ + +## How can I check if my bank or card issuer is supported? + +Expensify supports most major banks and card issuers worldwide. [Learn how to check which banks are supported in Expensify](/articles/new-expensify/connect-credit-cards/Check-Supported-Banks) + +## How many personal cards can I connect for free? + +You can connect up to two personal cards for free. To add more, you'll need a paid workspace on the Collect plan or above. + +## How do I manage a personal card after connecting it? + +Once connected, your card appears in **Account > Wallet** under **Assigned cards**. [Learn how to manage your personal cards](/articles/new-expensify/connect-credit-cards/Personal-Cards). diff --git a/docs/articles/new-expensify/connect-credit-cards/Direct-feeds.md b/docs/articles/new-expensify/connect-credit-cards/Direct-feeds.md deleted file mode 100644 index bc95dc03ce43..000000000000 --- a/docs/articles/new-expensify/connect-credit-cards/Direct-feeds.md +++ /dev/null @@ -1,141 +0,0 @@ ---- -title: Direct Company Card Feeds -description: Learn how to connect company credit cards using direct feeds or Plaid to import transactions and assign cards in New Expensify. -keywords: [New Expensify, direct feeds, commercial cards, Plaid integration, company cards, credit card import, assign cards, connect bank, manage card feeds, corporate cards, Amex Small Business] ---- - - -Direct feeds in Expensify provide a fast, reliable way to import company card transactions. You can also use Plaid to connect your company card program, giving you even more options to manage your corporate card account directly in Expensify. You can connect eligible commercial cards using a direct feed. - -You can add one **direct** or [**commercial feed**](https://help.expensify.com/articles/new-expensify/connect-credit-cards/Commercial-feeds) on the **Collect plan**. Upgrade to the **Control plan** to add unlimited company cards. - ---- - -# Prerequisites - -**A workspace on the Collect or Control plan:** Before setting up a direct feed, go to **Workspaces > New Workspace** to create a workspace (if you haven't already). - ---- - -# Set Up a Direct Feed - -1. . Go to **Workspaces > [Workspace Name] > Company cards** to view your company’s card setup page. -2. Click **Add cards** to start connecting a new feed. -3. **Select your country** from the list. - - Expensify will prefill this based on your workspace’s currency, but you can manually change it if needed. - - You won’t be able to continue without selecting a country -- You'll see an error. - -4. **Choose your feed type** when prompted: - - **Direct feed** – This is the default option in most supported countries and works for the majority of users. - - **Commercial feed** – Best for large-scale card programs where your bank facilitates the connection directly. -5. **Pick your bank:** - - If you’re in the U.S., select your bank from the list or choose **Other** to see additional banks supported by Plaid. - - For international members in supported countries, choosing **Direct feed** will automatically launch the Plaid connection process. - -![Click add cards to add a card feed]({{site.url}}/assets/images/Direct Feed HelpDot Images/directfeeds_01.png){:width="100%"} - ---- - -# Assign Company Cards - -Once connected, you'll want to assign the company cards to their respective cardholders: - -1. Go to **Workspaces > [Workspace Name] > Company cards**. -2. If you have multiple feeds, click the feed name at the top left to select the correct one. -3. Click **Assign card** - - You may be prompted to reauthenticate your account before assigning cards. -5. Select a **workspace member** from the list. -6. Choose a **card** (only cards with recent transactions will appear). -7. Set a **start date**: - - **From the beginning** (typically 30–90 days) - - **Custom date** to define your own range -8. Review your selections and click **Assign card**. - -![Click the feed name in the top left to open the feed selector where you can select a feed from the list]({{site.url}}/assets/images/Direct Feed HelpDot Images/directfeeds_06.png){:width="100%"} - -![Click company cards in the workspace editor to open the feed]({{site.url}}/assets/images/Direct Feed HelpDot Images/directfeeds_05.png){:width="100%"} - ---- - -# Manage Assigned Cards - -To manage a card, click any assigned card to open the **Card details** page. - -![Tap the assigned card to open the card details page where you can manage the card]({{site.url}}/assets/images/Direct Feed HelpDot Images/directfeeds_11.png){:width="100%"} - -**Available actions:** -- Rename the card -- Assign a specific export account -- Update transactions -- Unassign the card - -**Note:** Unassigning a card will delete unsubmitted expenses from draft reports for that cardholder. - ---- - -# FAQ - -## What if I’m outside the U.S.? - -Plaid feeds are available in many countries outside the U.S. If your country isn’t supported, you’ll see the option to connect a **Commercial feed**. - -**Direct feeds are supported for the following countries:** -- United States -- Canada -- United Kingdom -- Austria -- Belgium -- Denmark -- Estonia -- Finland -- France -- Germany -- Ireland -- Italy -- Latvia -- Lithuania -- Netherlands -- Norway -- Poland -- Portugal -- Spain -- Sweden - -## How do I reconnect a broken feed? - -1. Go to **Workspaces > [Workspace Name] > Company cards** -2. Click the red error banner -3. Log in to your bank using your bank account credentials to restore the connection - -## Can I connect multiple feeds from the same bank? - -Not quite. Each workspace can only have **one direct feed per bank**. -If your company uses cards from multiple banks, that’s no problem — just connect each bank as its own separate feed. - -## Is there an extra cost? - -Nope — there's no extra charge for using Plaid or direct feeds. - -- With the **Collect** plan, you can add one direct or Plaid feed at no cost. -- To connect more than one, you'll need to upgrade to the **Control** plan. - -## Can I import debit card transactions? - -Yes! Debit card transactions can be imported by linking a checking/current account in any Plaid-supported country. - -This is especially helpful in the EU, where open banking rules often block access to credit card data via aggregators. Debit cards, however, are fully supported under these regulations. - -## Which banks support direct feeds? - -Below are examples of banks that support direct feeds. This is not a complete list: - -- American Express -- Bank of America -- Brex -- Capital One -- Chase -- Citibank -- Stripe -- Wells Fargo -- Plaid enables connections with hundreds of additional banks across the U.S., Canada, and Europe. Check the [Plaid coverage page for US/Canada](https://plaid.com/docs/institutions/) or [Plaid coverage page for UK/Europe](https://plaid.com/docs/institutions/europe/) to see if your bank is supported. - diff --git a/docs/articles/new-expensify/connect-credit-cards/Fix-a-Broken-Company-Card-Feed-Connection.md b/docs/articles/new-expensify/connect-credit-cards/Fix-a-Broken-Company-Card-Feed-Connection.md new file mode 100644 index 000000000000..7cc235bbdf70 --- /dev/null +++ b/docs/articles/new-expensify/connect-credit-cards/Fix-a-Broken-Company-Card-Feed-Connection.md @@ -0,0 +1,96 @@ +--- +title: Fix a Broken Company Card Feed Connection +description: Learn how to fix a broken company card connection in Expensify so transactions resume importing correctly. +keywords: [fix company card connection, troubleshoot card feed, Expensify card errors, reconnect bank feed] +internalScope: Audience is Workspace Admins. Covers fixing broken company card connections and resolving connection errors. Does not cover CSV imports, missing transactions, or feed monitoring. +--- + +# Fix a Broken Company Card Feed Connection + +When a company card connection breaks, transactions stop importing. Workspace Admins can reconnect the card feed so expenses resume importing. + +--- +## How to know if your company card connection is broken + +Workspace Admins see indicators in Expensify when a company card feed is broken: + +- A notification appears in the **Time Sensitive** section on **Home**. +- Concierge sends a message to the #admins room +- A red dot appears on the card feed with the message “Card feed connection is broken” + +--- + +## How to fix a broken company card connection + +If your company card feed stops working, a Workspace Admin can reconnect it from the Workspace. + +1. In the navigation tabs (on the left on web, on the bottom on mobile), go to **Workspaces > [Workspace name]**. +2. Select **Company cards**. +3. Locate the card feed with a red dot indicator and the message “Card feed connection is broken” +4. Select the error message under the card feed name and follow the prompts to restore the connection. + +--- + +## How to troubleshoot company card connection errors + +If you see an error while reconnecting, use the guidance below: + +**Error: Too many attempts** + - **What it means:** Your login was attempted too many times in a short period. + - **What to do:** Wait 24 hours before trying again. + +**Error: Invalid credentials / Login failed** + - **What it means**: Your bank login failed authentication. + - **What to do:** Log into your bank directly to confirm the credentials, and check for bank-side maintenance or password resets that may be affecting login. + +**Error: Account setup required** + - **What it means:** Your bank requires an additional step before allowing connections. + - **What to do:** Complete any required actions in your bank’s portal, then try again + +**Error: Direct Connect not enabled** + - **What it means:** Your bank requires Direct Connect to be activated. + - **What to do:** Enable Direct Connect in your bank portal (typically under security or download settings) + +**Error: Account not found / Card number changed** + - **What it means:** The card number changed or the account is no longer active. + - **What to do:** Import the new card using the updated number + +**Error: General connection issue** + - **What it means:** Temporary downtime or maintenance from your bank or connection provider + - **What to do:** Wait and try again later + +--- + +## What happens after you fix a company card connection + +When a company card connection is fixed, the card feed reconnects to your bank and the red dot error message disappears. + + - New transactions begin importing again. + - Any missing transactions during downtime will import automatically. + +--- + +## How to connect a company card using an alternative method + +If the company card feed connection continues to fail, you can connect your card using an alternative method. + +Expensify supports connecting company cards through Plaid as a fallback option. [Learn how to set up a direct connection using Plaid](/articles/new-expensify/connect-credit-cards/Set-up-a-Direct-Company-Card-Feed-Connection). + +--- + +# FAQ + +## Why was I notified that my company card feed is broken? + +Workspace Admins are alerted on the Home page and in the #admins room when a card feed stops working so it can be fixed quickly. + +## What do I do if company card expenses are still missing after fixing the feed? + +After fixing the feed, you can update an assigned company card manually from the Company cards list: +1. Click on the company card you want to update +2. Select **Update card** + +## What should I do if the connection still fails after retrying? + +Double-check your bank credentials by logging into your bank, and complete any required steps in your bank portal. If the issue continues, contact Concierge with the cardholder email and last four digits of the card. + diff --git a/docs/articles/new-expensify/connect-credit-cards/Manage-Personal-Cards.md b/docs/articles/new-expensify/connect-credit-cards/Manage-Personal-Cards.md new file mode 100644 index 000000000000..6ee204c53804 --- /dev/null +++ b/docs/articles/new-expensify/connect-credit-cards/Manage-Personal-Cards.md @@ -0,0 +1,135 @@ +--- +title: Manage Personal Cards +description: Learn how to view, manage, and fix broken connections for personal credit cards in New Expensify. +keywords: [New Expensify, personal cards, Wallet, reimbursable, credit card import, Expensify Classic, New Expensify, assigned cards, card details, broken connection, fix card] +internalScope: Audience is members with personal credit cards already connected to their account. Covers viewing and managing existing personal cards in the Wallet, including reimbursable settings and fixing broken card connections. Does not cover connecting new personal cards or company card programs. +--- + +# Manage Personal Cards + +Expensify lets you manage your personal credit cards in one place using **Wallet**. From there, you can review card details, track spending, and use transactions for expense reporting. + +If you don’t have a card connected yet, [learn how to connect a personal card](/articles/new-expensify/connect-credit-cards/Connect-Personal-Cards). + +--- + +## Who can manage personal cards + +Anyone with a personal credit card connected to their Expensify account can manage it in **Wallet**. + +--- + +## How to view personal cards in Wallet + +1. Click the navigation tabs (on the left on web, on the bottom on mobile), then select **Account > Wallet**. +2. Under **Assigned cards**: + - Personal cards will appear in the **Personal** section. + - You'll see the card name, bank icon, and last 4 digits. + +If you have both company and personal cards, you'll see them separated into **Company cards** and **Personal cards** sections. + +![Tap a personal card to open card details]({{site.url}}/assets/images/ExpensifyHelp-FixPersonalCards.png){:width="100%"} + +--- + +## What you can manage on a personal card in Wallet + +You can manage the following settings for a personal card: + +- Rename the card with a custom nickname +- Turn on **Mark transactions as reimbursable** for imported transactions +- View the last successful transaction import +- Update the transaction start date for imports +- View imported transactions from the card +- Manually refresh the card to import recent transactions +- Fix connection issues if the card stops syncing +- Remove the card from your account + +--- + +## How to update personal card settings + +1. Click the navigation tabs (on the left on web, on the bottom on mobile), then select **Account > Wallet**. +2. Under **Assigned cards**, select a personal card. +3. On the **Card details** page, you can: + - Rename the card + - Update the card to pull in new transactions (if not CSV-imported) + - Unassign the card if it's no longer needed + - Toggle **Mark transactions as reimbursable** to mark future transactions as reimbursable. + +**Note:** Removing a personal card permanently deletes any unreported expenses or expenses on draft reports from that card. + +![Update personal card settings from the card details page]({{site.url}}/assets/images/personal-card-02.png){:width="100%"} + +--- + +## Why a personal card connection can break + +A personal card connection can break when your bank can no longer authenticate your account. Common causes include: + +- Your bank login credentials changed (e.g., you updated your password). +- Your bank requires additional verification (e.g., multi-factor authentication expired). +- Your bank revoked access to the third-party connection. +- A temporary outage at your bank disrupted the connection. + +When a connection breaks, Expensify can no longer import new transactions from that card. + +--- + +## How to know when a personal card connection is broken + +When a personal card connection breaks, you'll see: + +- A notification in the **Time Sensitive** section on **Home**. +- A red dot indicator on **Wallet** in the navigation tabs. +- A red dot indicator on the card in **Wallet** leading to the error. + + + + +## How to fix a broken personal card connection + +1. In the navigation tabs (on the left on web, on the bottom on mobile) navigate to **Account > Wallet**. +2. Under **Assigned cards**, select the card with the broken connection. +3. Choose **Fix card**. +4. Log into your bank when prompted to re-authenticate the connection. + +If logging into your bank doesn't resolve the issue, tap **Update card** to attempt a manual refresh. + +--- + +## How the broken connection violation works + +When you have a broken personal card connection, Expensify adds a **broken connection** violation to receipt-scanned cash expenses across all of your workspaces. This violation holds those expenses back in case they need to be merged with card transactions once the connection is fixed. + +To resolve the violation, fix the broken card connection by following the steps above. The violation is automatically removed from affected expenses once the connection is restored. + +You can tap the hyperlinked text in the violation to go directly to the **Card details** page in **Wallet** where you can fix the connection. + +--- + +# FAQ + + +## Can I change the reimbursable setting for past transactions? + +No. Changes to the reimbursable setting only apply to transactions imported after the change. + +## Why don't I see my personal card in the Wallet? + +Personal cards appear in the Wallet only if they were previously connected to your account in Expensify Classic. If your card doesn't show up, it means it wasn't connected before and isn't available to manage yet. + +## Why do I see a broken connection violation on my expenses? + +When a personal card connection breaks, Expensify adds a broken connection violation to receipt-scanned cash expenses. This holds them back in case they need to merge with card transactions once the connection is restored. Fix the card connection to remove the violation automatically. + +## When is the broken connection error removed? + +The broken connection error is automatically removed when any of the following occurs: + +- Logging into your bank successfully re-authenticates the connection. +- Tapping Update card successfully refreshes the card. +- Expensify's daily automatic card refresh succeeds. diff --git a/docs/articles/new-expensify/connect-credit-cards/Personal-Cards.md b/docs/articles/new-expensify/connect-credit-cards/Personal-Cards.md deleted file mode 100644 index 5f5f27f794b1..000000000000 --- a/docs/articles/new-expensify/connect-credit-cards/Personal-Cards.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -title: Manage personal cards in New Expensify -description: Learn how to view and manage personal credit cards in New Expensify. -keywords: [personal cards, Wallet, reimbursable, credit card import, Expensify Classic, New Expensify, assigned cards, card details] -internalScope: Audience is members with personal credit cards already connected to their account. Covers viewing and managing existing personal cards in the Wallet, including reimbursable settings. Does not cover adding new personal cards or company card programs. ---- - -# Manage personal cards in New Expensify - -Expensify lets you view and manage your personal credit cards in one place, making it easier to track spending, submit reimbursable expenses, and stay organized for tax time. - -If you previously connected a personal card in Expensify Classic, you can now manage that card directly in Expensify on both web and mobile. - ---- - -## Who can manage personal cards - -You can manage personal cards if you have a personal credit card that was already connected to your account. You can manage these cards on both web and mobile, alongside any company cards on your account. - ---- - -## Where to find personal cards in the Wallet - -1. Navigate to **Account > Wallet** on web or mobile. -2. Under **Assigned cards**: - - Personal cards imported from Expensify Classic will appear here. - - You'll see the card name, bank icon, and last 4 digits. -3. Tap the personal card you want to manage. - -If you have both company and personal cards, you'll see them separated into **Company cards** and **Personal cards** sections. - -![Tap a personal card to open card details]({{site.url}}/assets/images/personal-card-01.png){:width="100%"} - ---- - -## What you can do with personal cards - -You can: -- View all personal cards imported from Expensify Classic -- Automatically import transactions from your linked personal cards -- Generate IRS-compliant eReceipts for eligible USD purchases -- Update personal card settings -- Filter expenses by card in the **Expenses** tab on the **Reports** page - ---- - -## How to update personal card settings - -1. Navigate to **Account > Wallet** on web or mobile. -2. Under **Assigned cards**, select a personal card. -3. On the **Card details** page, you can: - - Rename the card - - Update the card to pull in new transactions (if not CSV-imported) - - Unassign the card if it's no longer needed - - Toggle **Mark transactions as reimbursable** - - The reimbursable setting applies only to **new** transactions and is turned on by default. - -**Note:** Unassigning a personal card permanently deletes any unreported expenses or expenses on draft reports from that card. - -![Update personal card settings from the card details page]({{site.url}}/assets/images/personal-card-02.png){:width="100%"} - ---- - -# FAQ - -## Can I add another personal card? - -Not yet. Only cards connected to your account in Expensify Classic are available to manage in New Expensify. Support for adding new personal cards will be available in a future update. - -## Can I change the reimbursable setting for past transactions? - -No. Changes to the reimbursable setting only apply to transactions imported after the change. - -## Why don’t I see my personal card in the Wallet? - -Personal cards appear in the Wallet only if they were previously connected to your account in Expensify Classic. If your card doesn’t show up, it means it wasn’t connected before and isn’t available to manage yet. - diff --git a/docs/articles/new-expensify/connect-credit-cards/Set-up-a-Commercial-Card-Feed-Connection.md b/docs/articles/new-expensify/connect-credit-cards/Set-up-a-Commercial-Card-Feed-Connection.md new file mode 100644 index 000000000000..e4ebf2c2ce49 --- /dev/null +++ b/docs/articles/new-expensify/connect-credit-cards/Set-up-a-Commercial-Card-Feed-Connection.md @@ -0,0 +1,112 @@ +--- +title: Set up a Commercial Card Feed Connection +description: Learn how to set up a Visa, Mastercard or Amex commercial card feed in Expensify +keywords: [New Expensify, commercial feed, Mastercard feed, Visa feed, Amex feed, company cards, corporate cards, CDF, VCF, GL1025, control account] +internalScope: Audience is Workspace Admins. Covers setting up a commercial card feed for Visa, Mastercard, or American Express. Does not cover assigning cards, managing cards, or troubleshooting feeds. +--- + +# Set up a Commercial Card Feed Connection + +Commercial card feeds are file-based connections managed by your bank that import company card transactions into Expensify. + +Before you begin, contact your bank to confirm that your card program is eligible for a commercial card feed. + +If you're not eligible for a commercial card feed, you can [set up a direct company card feed connection instead](/articles/new-expensify/connect-credit-cards/Set-up-a-Direct-Company-Card-Feed-Connection). + +--- + +## Who can set up a commercial card feed connection + +Any Workspace Admin can set up a commercial card feed connection. + + - On the **Collect** plan, you can add one company card feed. + - On the **Control** plan, you can add unlimited company card feeds. + +[Learn about the different plan types available in Expensify.](/articles/new-expensify/billing-and-subscriptions/Plan-types-and-pricing) + +--- + +## How to set up a new commercial card feed connection + +1. Click the navigation tabs (on the left on web, on the bottom on mobile) and select **Workspaces > [Workspace name]** +2. Choose **Company cards** to view your company’s card setup page. + - If you don't see **Company cards**, enable the feature under **More features > Company cards** +3. Click **Add cards** to set up your first connection. + - If you don't see **Add cards**, click on your existing company card feed connection then **Add cards** +4. Select the country your bank is located in and select **Next**. +5. Choose your **Commercial feed** and select **Next**. +6. Choose your bank from the list and follow the instructions. + +## How to enable a Mastercard commercial card feed (CDF) + +To use a Mastercard commercial feed, your bank must send your transaction data from **Mastercard’s SmartData portal**. + +1. Ask your bank to deliver your **CDF (Common Data File)** to Expensify via SmartData. + - File type must be: **CDF v3 Release 11.01** + - Specify the earliest transaction date needed. +2. Your bank will send you a **Distribution ID**. +3. In Expensify, go to **Workspaces > [Workspace Name] > Company Cards > Add Cards**. +4. Choose **Commercial Feed**, then select **Mastercard Commercial Cards**. +5. Enter the **Distribution ID** and submit. + +Expensify will notify you when the feed is connected. + +--- + +## How to enable a Visa commercial card feed (VCF) + +Visa commercial feeds are configured through your bank using Visa’s **Subscription Management portal**. + +1. Ask your bank’s relationship manager to enable your **VCF (Visa Commercial Format)** feed and send it to **Expensify**. + - Mention: “You can check a box in Visa’s Subscription Management portal to enable feed delivery—no test file needed.” +2. Request the feed details: + - **Processor ID** + - **Bank (Financial Institution) ID** + - **Company ID** +3. In Expensify, go to **Workspaces > [Workspace Name] > Company Cards > Add Cards**. +4. Choose **Commercial Feed**, then select **Visa Commercial Cards**. +5. Enter the required IDs and submit. + +Expensify will notify you when the feed is connected. + +--- + +## How to enable an American Express commercial card feed (GL1025) + +To set up an Amex Corporate Feed, you’ll need to complete and email their required forms. + +1. Download the forms: + [Amex Feed Setup Forms (Google Drive)](https://drive.google.com/file/d/1zqDA_MCk06jk_fWjzx2y0r4gOyAMqKJe/view?usp=sharing) +2. Complete the form as follows: + - **Page 1:** Fill in legal company details, requestor name/email, feed start date, and **Control Account Number** (not a credit card number). + - **Page 2:** Leave blank. + - **Page 3:** Re-enter client name and Control Account. + - **Page 4:** Add country, authorization name, title, and signature (DD/MM/YY format). +3. Email the completed forms to: **electronictransmissionsteam@aexp.com** +4. Wait for the **Production Letter** containing your feed’s file name. +5. In Expensify, go to **Workspaces > [Workspace Name] > Company Cards > Add Cards**. +6. Choose **Commercial Feed**, then select **American Express Corporate Cards**. + +Expensify will notify you when the feed is connected. + +--- + +## What happens after a commercial card feed is connected + + - Cards with recent expenses will appear as a list and can be assigned to Workspace members. + - After a card is assigned, posted transactions import into the assigned member's account automatically as expenses. + +[Learn how to assign company cards](/articles/new-expensify/connect-credit-cards/Assign-Company-Cards). + +--- + +# FAQ + +## What can I do if my bank doesn't offer a commercial card feed? + +If you're not eligible for a commercial card feed, connect company cards using a direct feed or through Plaid using your online banking credentials. [Learn how to set up a direct company card feed connection](/articles/new-expensify/connect-credit-cards/Set-up-a-Direct-Company-Card-Feed-Connection). + +## Can I connect the same commercial card feed connection across different workspaces? + +Yes, commercial card feeds can be shared across workspaces. [Learn how to add an existing company card feed to a workspace](/articles/new-expensify/connect-credit-cards/Share-a-Company-Card-Connection-Across-Workspaces). + diff --git a/docs/articles/new-expensify/connect-credit-cards/Set-up-a-Direct-Company-Card-Feed-Connection.md b/docs/articles/new-expensify/connect-credit-cards/Set-up-a-Direct-Company-Card-Feed-Connection.md new file mode 100644 index 000000000000..e178dc196a16 --- /dev/null +++ b/docs/articles/new-expensify/connect-credit-cards/Set-up-a-Direct-Company-Card-Feed-Connection.md @@ -0,0 +1,91 @@ +--- +title: Set up a Direct Company Card Feed Connection +description: Learn how to connect company credit cards using a direct bank connection or Plaid to import transactions and assign company card. +keywords: [New Expensify, set up direct company card feed, connect company cards Expensify, direct feed Expensify, Plaid company cards] +internalScope: Audience is Workspace Admins. Covers connecting a company card feed using direct feed or Plaid. Does not cover assigning cards, managing cards, or troubleshooting feeds. +--- + +# Set up a Direct Company Card Feed Connection + +Setting up a direct company card feed connection allows you to automatically import card transactions into Expensify. + +You can connect company cards using a direct connection with your bank, or through Plaid, depending on your bank and country. + +--- + +## Who can set up a direct company card feed connection + +Any Workspace Admin can set up a direct company card feed connection. + + - On the **Collect** plan, you can add one company card feed. + - On the **Control** plan, you can add unlimited company card feeds. + +[Learn about the different plan types available in Expensify.](/articles/new-expensify/billing-and-subscriptions/Plan-types-and-pricing) + +--- + +## How to set up a direct company card feed connection + +1. Click the navigation tabs (on the left on web, on the bottom on mobile) and select **Workspaces > [Workspace name]**. +2. Choose **Company cards** to view your company’s card setup page. + - If you don't see **Company cards**, enable the feature under **More features > Company cards**. +3. Click **Add cards** to set up your first connection. + - If you don't see **Add cards**, click on your existing company card feed connection then **Add cards**. +4. Select the country your bank is located in and select **Next**. +5. Choose **Direct feed** and select **Next**. +6. Choose your bank from the list, or select **Other** to see additional banks supported by Plaid. + + +![Click add cards to add a card feed]({{site.url}}/assets/images/Direct Feed HelpDot Images/directfeeds_01.png){:width="100%"} + +--- + +## What happens after you set up a direct company card feed connection + + - Cards with recent expenses will appear as a list and can be assigned to Workspace members. + - After a card is assigned, posted transactions import into the assigned member's account automatically as expenses. + +[Learn how to assign company cards](/articles/new-expensify/connect-credit-cards/Assign-Company-Cards). + +--- + +## What bank requirements affect company card connections + +Some banks have specific requirements for successful connections: + + - **Chase:** Password must be 8–32 characters + - **Wells Fargo:** Password must be under 14 characters + - **SVB:** Enable Direct Connect and use a Direct Connect PIN instead of your online banking password + +--- + +# FAQ + +## How do I fix a broken company card feed connection? + +If your company card feed is broken, you can fix it from the **Time Sensitive** section on **Home**. Click **Fix** to restore the connection. [Learn how to fix a broken company card feed connection](/articles/new-expensify/connect-credit-cards/Fix-a-broken-Company-Card-Feed-Connection). + +## Can I connect the same company card account across different workspaces? + +Yes, direct company card feeds can be shared across workspaces. [Learn how to add an existing company card feed connection to a workspace](/articles/new-expensify/connect-credit-cards/Share-a-Company-Card-Connection-Across-Workspaces). + +## Can I import debit card transactions? + +Yes, debit card transactions can be imported by following the same steps for setting up a direct company card feed connection. + +This is especially helpful in the EU, where open banking rules often block access to credit card data via aggregators. Debit cards are fully supported under these regulations. + +## How can I check if my bank is supported? + +Below are examples of banks that support direct company card feeds. This is not a complete list: + +- American Express +- Bank of America +- Brex +- Capital One +- Chase +- Citibank +- Stripe +- Wells Fargo +- Plaid enables connections with hundreds of additional banks across the U.S., Canada, and Europe. Check the [Plaid coverage page for US/Canada](https://plaid.com/docs/institutions/) or [Plaid coverage page for UK/Europe](https://plaid.com/docs/institutions/europe/) to see if your bank is supported. + diff --git a/docs/articles/new-expensify/connect-credit-cards/Share-a-Company-Card-Connection-Across-Workspaces.md b/docs/articles/new-expensify/connect-credit-cards/Share-a-Company-Card-Connection-Across-Workspaces.md new file mode 100644 index 000000000000..536893989306 --- /dev/null +++ b/docs/articles/new-expensify/connect-credit-cards/Share-a-Company-Card-Connection-Across-Workspaces.md @@ -0,0 +1,72 @@ +--- +title: Share a Company Card Feed Across Workspaces +description: Learn how to add an existing company card feed to another Workspace instead of connecting a new one. +keywords: [New Expensify, share company card feed Expensify, connect existing company cards, company cards Workspaces, direct feed Expensify, commercial card feed] +internalScope: Audience is Workspace Admins. Covers sharing an existing company card feed across Workspaces. Does not cover setting up new feeds, assigning cards, or troubleshooting feeds. +--- + +# Share a Company Card Feed Across Workspaces + +Sharing a company card feed lets you reuse an existing card connection in another Workspace instead of creating a new one. This keeps card management centralized and prevents duplicate connections. + +You can share both direct company card feeds and commercial card feeds across multiple Workspaces. + +If you haven’t set up a feed yet, learn how to connect to your bank: + + - [Learn how to set up a direct company card feed connection](/articles/new-expensify/connect-credit-cards/Set-up-a-Direct-Company-Card-Feed-Connection). + - [Learn how to set up a commercial card feed connection](/articles/new-expensify/connect-credit-cards/Set-up-a-Commercial-Card-Feed-Connection). + +--- + +## Who can share a company card feed across Workspaces + +Workspace Admins with an email on a private domain can share a company card feed. + + - On the **Collect** plan, you can add one company card feed per Workspace. + - On the **Control** plan, you can add unlimited company card feeds. + +[Learn about the different plan types available in Expensify.](/articles/new-expensify/billing-and-subscriptions/Plan-types-and-pricing) + +--- + +## How to share a company card feed across Workspaces + +1. Click the navigation tabs (on the left on web, on the bottom on mobile) and select **Workspaces > [Workspace Name]** +2. Choose **Company cards** to view your company’s card setup page. + - If you don't see **Company cards**, enable the feature under **More features > Company cards** +3. Select your existing card connection. +4. In **From other workspaces**, choose a company card feed to add to the Workspace. + +--- + + + +--- + +## What happens after you share a company card feed across Workspaces + + - Workspace members with assigned cards on the company card feed appear in the card list + - Unassigned cards can be assigned to Workspace Members + - After a card is assigned, posted transactions import into the assigned member's account automatically as expenses. + +[Learn how to assign company cards](/articles/new-expensify/connect-credit-cards/Assign-Company-Cards). + +# FAQ + +## What happens when you remove a shared company card feed? + +Removing a company card feed from a Workspace disconnects that Workspace and removes its members from the feed. The feed itself is only deleted if it is no longer connected to any Workspaces. + +## Why don’t I see the “From other workspaces” section? + +This can happen if: + + - There are no existing company card feeds available to share + - Your email is not on a private domain + +## Does sharing a company card feed duplicate transactions + +No. Sharing a feed does not duplicate transactions. Transactions are tied to the assigned card and will only appear for the member the card is assigned to. diff --git a/docs/articles/new-expensify/connections/Uber-for-Business.md b/docs/articles/new-expensify/connections/Uber-for-Business.md index af2aaad1abe3..0f88573df141 100644 --- a/docs/articles/new-expensify/connections/Uber-for-Business.md +++ b/docs/articles/new-expensify/connections/Uber-for-Business.md @@ -72,4 +72,7 @@ Yes! Go to **Manage invites**, find their name or email, and click **Resend**. - **Linked badge** – The employee has accepted the invite. - **Suspended badge** – The employee’s Uber account is linked, but has been temporarily suspended by Uber (i.e rider disputes, etc). +## Does Expensify support expense memos? +Yes. If an expense memo is added to the trip in Uber, it will be added to the **Description** field on the expense created in Expensify. + diff --git a/docs/articles/new-expensify/expensify-card/Cardholder-Settings-and-Features.md b/docs/articles/new-expensify/expensify-card/Cardholder-Settings-and-Features.md index 424dc6074642..d82167d0dd4b 100644 --- a/docs/articles/new-expensify/expensify-card/Cardholder-Settings-and-Features.md +++ b/docs/articles/new-expensify/expensify-card/Cardholder-Settings-and-Features.md @@ -1,7 +1,7 @@ --- title: Using Your Expensify Card in New Expensify description: Learn how to activate, use, and manage your physical and virtual Expensify Card, including Smart Limits, notifications, and troubleshooting. -keywords: [New Expensify, Expensify Card, Smart Limit, activate card, virtual card, card notifications, card declined, eReceipts] +keywords: [New Expensify, Expensify Card, Smart Limit, activate card, virtual card, card notifications, card declined, eReceipts, freeze card, unfreeze card] --- Once you receive your Expensify Visa® Commercial Card, you can activate it and start using it immediately. When you use your Expensify Card, expenses are automatically created, and SmartScanned receipts are matched to the corresponding transactions. For most purchases, excluding lodging, Expensify also generates IRS-compliant eReceipts. If your organization doesn't require itemized receipts, you can rely on eReceipts to meet documentation requirements. @@ -96,6 +96,30 @@ For more details on adding a card to Apple or Android Pay, see this [guide](http --- +# How to freeze or unfreeze your Expensify Card + +Need to temporarily pause spending? You can freeze your Expensify Card at any time - without canceling or reissuing it. + +Freezing your card: +- Immediately blocks new purchases on the card +- Keeps your card number, CVV, and expiration date intact +- Can be reversed at any time + +## To freeze or unfreeze your card + +1. From the navigation tabs (on the left on web, and at the bottom on mobile), go to **Account > Wallet > Expensify Card**. +2. Click your Expensify Card to view its details. +3. Click **Freeze card** or **Unfreeze card**, depending on the card's current state. +4. Click **Confirm** in the dialog that appears. + +Once frozen: +- You'll see a **Frozen** badge and a scarf graphic over your card +- All new transactions will be declined until you unfreeze it + +**Note:** If your workspace admin froze the card, you'll need to contact them directly via Expensify Chat to request that they unfreeze it. + +--- + # FAQ ## What if I haven’t received my Expensify Card? diff --git a/docs/articles/new-expensify/expensify-card/Set-Up-and-Manage-the-Expensify-Card.md b/docs/articles/new-expensify/expensify-card/Set-Up-and-Manage-the-Expensify-Card.md index e739c310d35d..cfa03397d57c 100644 --- a/docs/articles/new-expensify/expensify-card/Set-Up-and-Manage-the-Expensify-Card.md +++ b/docs/articles/new-expensify/expensify-card/Set-Up-and-Manage-the-Expensify-Card.md @@ -2,6 +2,7 @@ title: Set Up and Manage Expensify Cards description: Learn how Workspace Admins can enable, issue, and manage Expensify Cards for employees, including spending limits, bank connections, and virtual card setup. keywords: [New Expensify, Expensify Card setup, manage virtual card, card limits, Expensify Visa, card settings, Workspace Admin] +internalScope: Applies to Workspace Admins. Covers Expensify Card setup and management. Does not cover troubleshooting or third-party feeds. --- Workspace Admins can enable and issue Expensify Visa® Commercial Cards to manage company spending with real-time controls and flexibility across employees and subscriptions. @@ -38,6 +39,8 @@ Link a U.S. business bank account to pay the card balance: 2. Click **Issue new card** 3. Choose an existing account or [add a new bank account](https://help.expensify.com/articles/new-expensify/expenses-and-payments/Connect-a-Business-Bank-Account) as the settlement account. +If you already have an Expensify Card feed on another workspace, you can select that existing feed instead of connecting a new bank account. This links the same card feed to the current workspace, so you can manage cards across multiple workspaces with a single settlement account. + ![Click the issue card button]({{site.url}}/assets/images/ExpensifyHelp-ExpensifyCard_02.png){:width="100%"} --- @@ -94,6 +97,31 @@ If a card reaches its expiration date, it automatically deactivates and declines --- +# How to freeze or unfreeze an Expensify Card + +As a Workspace Admin, you can freeze or unfreeze any card in your workspace without needing to cancel or reissue it. + +Freezing a card is helpful if: +- You suspect misuse or a policy violation +- You want to temporarily pause employee spending +- You're enforcing a department-wide budget freeze + +To freeze or unfreeze a card: + +1. Go to **Settings > Workspaces > [Workspace Name] > Expensify Card**. +2. Click a card from the list to open its details pane. +3. Click **Freeze card** or **Unfreeze card**. +4. Confirm the action in the popup modal. + +Once frozen: +- The card immediately stops working for purchases +- You'll see a note below the card indicating who froze the card and when +- The employee sees the same message and cannot unfreeze the card themselves + +All freeze/unfreeze actions are automatically posted to the workspace chat. + +--- + # FAQ ## What kind of bank account is required? @@ -102,7 +130,7 @@ You’ll need a U.S. business bank account registered to a U.S.-incorporated bus ## Can I use Expensify Cards across multiple workspaces? -Yes, but each workspace must have its **own settlement account**. For example, using the card in three workspaces requires three separate bank accounts. +Yes. You can link a single Expensify Card feed to multiple workspaces using the same settlement account. When issuing cards on a new workspace, select an existing feed that is already connected to another workspace. Each workspace shows only its own members in the card list, but all workspaces share the same card feed and settlement account. ## Can an employee have multiple cards? diff --git a/docs/articles/new-expensify/getting-started/Navigating-New-Expensify.md b/docs/articles/new-expensify/getting-started/Navigating-New-Expensify.md index 120ecd2125ba..ec9162dade4c 100644 --- a/docs/articles/new-expensify/getting-started/Navigating-New-Expensify.md +++ b/docs/articles/new-expensify/getting-started/Navigating-New-Expensify.md @@ -98,7 +98,7 @@ From Account, you can manage: - **Security** to manage security options and Copilot delegated access You can also access: - - **Help** to browse Expensify's help site + - **Help** to access help and support resources, including a link to the help site - **What's new** to view product updates - **About** to see app details - **Troubleshoot** to run diagnostics or switch to Expensify Classic diff --git a/docs/articles/new-expensify/reports-and-expenses/Adding-Attendees.md b/docs/articles/new-expensify/reports-and-expenses/Adding-Attendees.md index c0f2f0c22039..fc3896217f1a 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Adding-Attendees.md +++ b/docs/articles/new-expensify/reports-and-expenses/Adding-Attendees.md @@ -2,35 +2,60 @@ title: Adding Attendees description: Learn how to add attendees to your expenses and resolve category limit errors by splitting the cost across participants. keywords: [New Expensify, add attendees, expense attendees, split expense, category limit, attendee allocation] +internalScope: Audience is all members. Covers adding attendees to an expense. Does not cover category limit policies member management. --- -You can add attendees to an expense to indicate who participated in a meal, meeting, or event. This is especially helpful when your company has per-person category limits, as Expensify can split the cost evenly across all attendees to help avoid violations. +# Adding Attendees + +Adding attendees to an expense shows who participated in a meal, meeting, or event. Expensify automatically splits the total cost across all attendees, making it easier to track per-person spending and stay within company limits. --- -# Add Attendees to an Expense +## Who can add attendees to an expense -1. From the navigation tabs (on the left on web, and at the bottom on mobile), head to **Reports > Expenses**. -2. Open the expense and click the **Attendees** caret to expand attendee options. -3. Select participants from your **Recents**, **Contacts**, or use the **Search bar** to find someone by name or email. -4. To add someone new, manually enter their name or email. They’ll be saved to your Contacts for future use. -5. Click on each attendee to add them. A checkmark (✅) will show who’s been selected. -6. Click **Save**. +All members can add attendees to their own expenses. --- -# Resolve Category Limit Errors by Adding Attendees +## How to add attendees to an expense -If an expense exceeds a category limit set by your admin, you can resolve the issue by adding attendees. +1. From the navigation tabs (on the left on web, and at the bottom on mobile), go to **Reports > Expenses**. +2. Open the expense and click **Attendees**. +3. Select attendees from **Recents**, **Contacts**, or enter a name, email, or phone number. +4. Select each attendee to add a checkmark next to their name. +5. Click **Save**. -1. Click the **Attendees** caret to open the attendee selector. -2. Add the attendees involved in the expense. -3. Expensify will automatically split the total cost across all attendees. -4. Once added, a dot will appear between the **Attendees** field and the **per-person amount**, making it easy for approvers to verify the distribution. +The selected attendees are added to the expense. --- -# Audit Trail for Attendees +## What happens after you add attendees to an expense + + - The total expense is automatically split evenly across all attendees + - The per-attendee amount updates instantly on the expense + - If your Workspace has per-attendee category limits, adding attendees can reduce the per-attendee amount and help resolve limit violations + - Approvers can review attendee details during the approval process + - All attendee changes are recorded in the expense history for auditing + +--- + +# How to view attendees on a report + +When any expense in a report has attendees, the report automatically shows two additional columns: + +- **Attendees:** displays the attendees added to each expense. +- **Per attendee:** shows the total amount divided equally among all attendees. + +These columns appear in both the report and search results. + +--- + +# FAQ + +## Is there an audit trail for attendee changes? + +Yes. Expensify automatically records all attendee updates in the expense history. These system records cannot be edited. -🕵️ **Note:** Expensify automatically records attendee changes on the expense report. These system messages cannot be edited, ensuring a clear and secure history of all attendee updates. +## Can I edit attendees on an expense before it's submitted? +Yes. Open the expense, click **Attendees**, update the list, and click **Save**. diff --git a/docs/articles/new-expensify/reports-and-expenses/Create-and-Submit-Reports.md b/docs/articles/new-expensify/reports-and-expenses/Create-and-Submit-Reports.md index ae4dca70e5f5..3a900cf59587 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Create-and-Submit-Reports.md +++ b/docs/articles/new-expensify/reports-and-expenses/Create-and-Submit-Reports.md @@ -25,9 +25,11 @@ Follow these steps to build an expense report manually. 1. Open an expense report that includes at least one expense. 2. Select **Submit**. -After submitting, the report status updates and a **“submitted”** entry appears in the activity at the bottom of the report. For details on what each report status means, see [Understanding Report Statuses](https://help.expensify.com/articles/new-expensify/reports-and-expenses/Understanding-Report-Statuses-and-Actions). +After submitting, the report status updates and a **”submitted”** entry appears in the activity at the bottom of the report. For details on what each report status means, see [Understanding Report Statuses](https://help.expensify.com/articles/new-expensify/reports-and-expenses/Understanding-Report-Statuses-and-Actions). -**Note:** If your workspace uses automatic submission, you’ll see **“Waiting for your expenses to automatically submit”** at the top of the report instead of a Submit button. +**Note:** If your workspace uses automatic submission, you’ll see **”Waiting for your expenses to automatically submit”** at the top of the report instead of a Submit button. + +**Note:** If you selected “Track and budget expenses” during onboarding and your workspace does not use an approval workflow, you’ll see **Mark as done** instead of **Submit**, and the activity entry will say **”marked as done”** instead of **”submitted”**. --- @@ -177,7 +179,7 @@ Unsubmitted reports will remain in your Open reports section. You can go back an ## Why don’t I see the “Submit” button? -The Submit button only appears once your report includes at least one valid expense. If the report has already been submitted, the button will no longer be visible. +The Submit button only appears once your report includes at least one valid expense. If the report has already been submitted, the button will no longer be visible. If you selected “Track and budget expenses” during onboarding and your workspace has no approval workflow, the button will say **Mark as done** instead of **Submit**. ## Can I remove an expense after submitting? diff --git a/docs/articles/new-expensify/reports-and-expenses/Distance-Expenses.md b/docs/articles/new-expensify/reports-and-expenses/Distance-Expenses.md index 5dbddde2b551..8a69b68895b8 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Distance-Expenses.md +++ b/docs/articles/new-expensify/reports-and-expenses/Distance-Expenses.md @@ -1,13 +1,13 @@ --- title: Distance Expenses -description: Learn how to create a Distance expense using GPS tracking, map-based routes, or manual entry, and how the reimbursement rate is determined in New Expensify. -keywords: [New Expensify, distance expense, mileage reimbursement, create expense, distance rate, workspace rate, map route, reimbursement rate, manual mileage, manual distance, global create, track distance, GPS, GPS tracking, start GPS, track route, track mileage, mileage tracking, calculate mileage reimbursement, mileage rate] -internalScope: Audience is all members. Covers creating Distance expenses using GPS tracking, map-based routes, and manual entry, plus how reimbursement rates are applied. Does not cover configuring Workspace distance rates in detail or broader report submission workflows. +description: Learn how to create a Distance expense using GPS tracking, map-based routes, manual entry, or odometer readings, and how the reimbursement rate is determined in New Expensify. +keywords: [New Expensify, distance expense, mileage reimbursement, create expense, distance rate, workspace rate, map route, reimbursement rate, manual mileage, manual distance, global create, track distance, GPS, GPS tracking, start GPS, track route, track mileage, mileage tracking, calculate mileage reimbursement, mileage rate, odometer, odometer reading, odometer image, odometer mileage, odometer distance, start reading, end reading] +internalScope: Audience is all members. Covers creating Distance expenses using GPS tracking, map-based routes, manual entry, and odometer readings, plus how reimbursement rates are applied. Does not cover configuring Workspace distance rates in detail or broader report submission workflows. --- # Distance Expenses -Expensify offers three ways to create a Distance expense: **GPS tracking** on mobile, **map-based routes** using start and end locations, or **manual entry** by typing in the distance. This guide explains each method and how the reimbursement rate is determined. +Expensify offers four ways to create a Distance expense: **GPS tracking** on mobile, **map-based routes** using start and end locations, **manual entry** by typing in the distance, or **odometer readings** using start and end values from your vehicle. This guide explains each method and how the reimbursement rate is determined. --- @@ -62,7 +62,28 @@ To create an expense by inputting a distance manually: - (Optional) Add a description, category, tag or receipt. 6. Select **Create expense**. -Once a Distance expense is created, it can be submitted on a report. To learn how to add expenses to a report, see [Create and Submit Reports](https://help.expensify.com/articles/new-expensify/reports-and-expenses/Create-and-Submit-Reports). +--- + +## How to create a Distance expense using Odometer readings (Web and Mobile) + +To create an expense using your vehicle's odometer readings: + +1. Select the **➕ Create** button and select **Track distance**. +2. Select **Odometer** from the top row. +3. Enter the **Start reading** from your odometer before the trip. +4. Enter the **End reading** from your odometer after the trip. + - The total distance is calculated automatically as you type. +5. (Optional) Select the image icon next to each reading to attach a photo of your odometer. +6. Select **Next**. +7. On the confirmation screen, review and confirm: + - Distance + - Amount + - Rate + - Date + - (Optional) Add a description, category, or tag. +8. Select **Create expense**. + +Once a Distance expense is created, it can be submitted on a report. To learn how to add expenses to a report, see [Create and Submit Reports](/articles/new-expensify/reports-and-expenses/Create-and-Submit-Reports). --- @@ -75,7 +96,7 @@ If you are creating expenses on a Workspace: - Workspace Admins set and manage the reimbursement rates for the workspace's distance unit (miles or kilometers). - When creating a Distance expense, the available reimbursement rates will show for selection. -[Learn how to manage distance rates as a Workspace Admin](https://help.expensify.com/articles/new-expensify/reports-and-expenses/Managing-Distance-Rates) +[Learn how to manage distance rates as a Workspace Admin](/articles/new-expensify/reports-and-expenses/Managing-Distance-Rates) ### Distance expenses created for personal tracking @@ -108,7 +129,7 @@ The expense amount is automatically calculated by multiplying the distance by th ## Can I edit a Distance expense after I’ve created it? -Yes! You can edit the expense before it is approved. To learn how to edit an expense, see [Managing Expenses in a Report](https://help.expensify.com/articles/new-expensify/reports-and-expenses/Managing-Expenses-in-a-Report). +Yes! You can edit the expense before it is approved. To learn how to edit an expense, see [Managing Expenses in a Report](/articles/new-expensify/reports-and-expenses/Managing-Expenses-in-a-Report). ## Can I update the Distance expense unit or rate? @@ -132,3 +153,15 @@ The GPS receipt shows a map of your actual route driven, along with the total di No. GPS tracking requires the iOS or Android mobile app because it uses your device's location services. On web or desktop, you'll see a prompt to download the mobile app when selecting GPS. You can still use map-based or manual distance entry on any platform. +## Can my odometer images be SmartScanned? + +SmartScan does not read odometer readings. You will need to manually enter the reading and upload supporting images. + +## Can I attach a photo of my odometer readings to the expense? + +Yes. Manual readings are required, but you have the option to attach images of the readings to your expense. + +## Can I add two odometer images to a distance expense? + +Yes. When you add two odometer images to a distance expense, they will be merged into a single image for easy viewing. + diff --git a/docs/articles/new-expensify/reports-and-expenses/Expense-and-Report-Actions.md b/docs/articles/new-expensify/reports-and-expenses/Expense-and-Report-Actions.md index f0577f974e08..f76bd63be9bf 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Expense-and-Report-Actions.md +++ b/docs/articles/new-expensify/reports-and-expenses/Expense-and-Report-Actions.md @@ -32,6 +32,8 @@ Report actions guide the submitter, approver, and payer regarding the report's s | **Export** | When the report is approved and not yet exported | Admin, Exporter | Sends report data to an integrated accounting system | | **Review** | When a report has violations (duplicate expenses, a missing receipt, etc.) | All roles | Prompts to resolve issues before submitting, approving, or paying | +**Note:** If you selected "Track and budget expenses" during onboarding and your workspace does not use an approval workflow, **Submit** and **Approve** will both display as **Mark as done**. + --- ## Secondary (“More” menu) Actions diff --git a/docs/articles/new-expensify/reports-and-expenses/How-to-Find-and-Resolve-Flagged-Duplicate-Expenses.md b/docs/articles/new-expensify/reports-and-expenses/How-to-Find-and-Resolve-Flagged-Duplicate-Expenses.md index 7ff96232d8c6..f338fca60aac 100644 --- a/docs/articles/new-expensify/reports-and-expenses/How-to-Find-and-Resolve-Flagged-Duplicate-Expenses.md +++ b/docs/articles/new-expensify/reports-and-expenses/How-to-Find-and-Resolve-Flagged-Duplicate-Expenses.md @@ -35,10 +35,7 @@ Within the report, duplicate expenses will show the label **Potential duplicate* --- - +![Inbox > Chat with red dot]({{site.url}}/assets/images/ExpensifyHelp-ResolveDuplicates_03.png){:width="100%"} --- @@ -56,10 +53,9 @@ Design request:https://github.com/Expensify/Expensify/issues/614048 --- - +![expense with Review Duplicates at the top]({{site.url}}/assets/images/ExpensifyHelp-ResolveDuplicates_04.png){:width="100%"} + +--- ## What happens after you resolve a flagged duplicate @@ -73,9 +69,11 @@ The hold is removed from the expense. Depending on your choice: # FAQ ## Can I edit a duplicate expense after resolving it? + Yes, you can edit a duplicate expense as long as it is in the Unreported, Draft or Outstanding state. ## Will two SmartScanned receipts from the same day with the same amount be flagged? + Yes, unless: - The expenses were split from a single expense. - They were imported from a credit card. diff --git a/docs/articles/new-expensify/reports-and-expenses/How-to-Merge-Expenses.md b/docs/articles/new-expensify/reports-and-expenses/How-to-Merge-Expenses.md index f4a445529ae2..0cb089c12408 100644 --- a/docs/articles/new-expensify/reports-and-expenses/How-to-Merge-Expenses.md +++ b/docs/articles/new-expensify/reports-and-expenses/How-to-Merge-Expenses.md @@ -77,10 +77,9 @@ Use this method when both expenses are inside the same report. --- - +![expenses selected within a report > Selected button > Merge highlighted]({{site.url}}/assets/images/ExpensifyHelp-ResolveDuplicates_01){:width="100%"} + +--- ## How to merge duplicate expenses from an individual expense @@ -94,10 +93,7 @@ Design request:https://github.com/Expensify/Expensify/issues/614048 --- - +![one individual expense > More > Merge > one eligible expense selected]({{site.url}}/assets/images/ExpensifyHelp-ResolveDuplicates_02){:width="100%"} --- diff --git a/docs/articles/new-expensify/reports-and-expenses/Managing-Expenses-in-a-Report.md b/docs/articles/new-expensify/reports-and-expenses/Managing-Expenses-in-a-Report.md index a548ae3a9c51..3ac705c3355b 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Managing-Expenses-in-a-Report.md +++ b/docs/articles/new-expensify/reports-and-expenses/Managing-Expenses-in-a-Report.md @@ -90,6 +90,8 @@ The table displays: - Date - Merchant - Category +- Attendees (if any expense has attendees) +- Per attendee (if any expense has attendees) - Amount - Workspace violations (if applicable) diff --git a/docs/articles/new-expensify/reports-and-expenses/Per-Diem-Expenses.md b/docs/articles/new-expensify/reports-and-expenses/Per-Diem-Expenses.md index 724b8cf9e4f8..84ac15bf2580 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Per-Diem-Expenses.md +++ b/docs/articles/new-expensify/reports-and-expenses/Per-Diem-Expenses.md @@ -12,7 +12,7 @@ Per diem expenses let you claim daily allowances for meals, lodging, and inciden **To create a per diem expense:** -1. In the navigation tabs, click the green **+** button and click **Create expense**. +1. In the navigation tabs, click the **+** button and click **Create expense**. 2. If your workspace supports Per Diem, you'll have the option to choose **Per Diem**. 3. Choose the **workspace** you want to create under (if you're in more than one). 4. Choose the **destination** (country or region where the Per Diem applies). diff --git a/docs/articles/new-expensify/reports-and-expenses/Understanding-Report-Statuses-and-Actions.md b/docs/articles/new-expensify/reports-and-expenses/Understanding-Report-Statuses-and-Actions.md index f543f3d853ae..bae7dcafc8cc 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Understanding-Report-Statuses-and-Actions.md +++ b/docs/articles/new-expensify/reports-and-expenses/Understanding-Report-Statuses-and-Actions.md @@ -54,6 +54,8 @@ Depending on the report status and your role, the primary action button may say: - **Export**: Send the approved report to your accounting system. - **View**: No further action is required; you have view-only access. +**Note:** If you selected "Track and budget expenses" during onboarding and your workspace does not use an approval workflow, **Submit** and **Approve** will both display as **Mark as done**. + --- # Special Actions diff --git a/docs/articles/new-expensify/reports-and-expenses/Using-search-operators.md b/docs/articles/new-expensify/reports-and-expenses/Using-search-operators.md index f4fd15164daa..4d86aeec3a91 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Using-search-operators.md +++ b/docs/articles/new-expensify/reports-and-expenses/Using-search-operators.md @@ -1,11 +1,10 @@ --- -title: "How to use search operators in Expensify" -description: "Learn how to use advanced search filters, comparisons, and groupings to find exactly what you need across expenses, chats, reports, and more." -keywords: "search operators, filters, search rules, expense search, report search, chat filters, advanced search, group-by, search syntax" +title: How to use search operators in Expensify +description: Learn how to use advanced search filters, comparisons, and groupings to find exactly what you need across expenses, chats, reports, and more. +keywords: [New Expensify, search operators, filters, search rules, expense search, report search, chat filters, advanced search, group-by, view, chart, search syntax] +internalScope: Audience is all Expensify users. Covers search operator syntax for filtering, grouping, and chart views. Does not cover saved search management or Search page UI navigation. --- -
- The search operator framework lets you quickly filter, sort, and group items like expenses, chats, reports, and tasks using powerful text-based rules. This guide walks you through the supported syntax, available filters, and usage tips. --- @@ -58,7 +57,7 @@ type:expense merchant:Starbucks category:Meals amount>20 has:receipt - `tag:` – tag or multiple tags - `amount:` / `purchase-amount:` – supports `=`, `>`, `<`, `>=`, `<=` - `status:` – unreported, draft, outstanding, approved, paid, done -- `date:` – expense date, supports relative dates like `date:this-month`, `date:last-month`, `date:year-to-date`, `date:this-week` +- `date:` – expense date, supports relative dates like `date:this-month`, `date:last-month`, `date:year-to-date`, `date:this-week`. Also supports comparisons (`date>=2024-01-01 date<=2024-01-31`) for date ranges - `has:` – attachment, receipt, category, tag - `expense-type:` – cash, card, distance, per-diem - `reimbursable:` and `billable:` – yes or no @@ -69,7 +68,7 @@ type:expense merchant:Starbucks category:Meals amount>20 has:receipt - `report-id:` – unique report reference - `status:` – draft, outstanding, approved, paid, done -- `submitted:` / `approved:` / `paid:` / `exported:` – supports absolute or relative dates +- `submitted:` / `approved:` / `paid:` / `exported:` – supports absolute or relative dates, and comparisons for date ranges (e.g., `submitted>=2024-01-01 submitted<=2024-01-31`) - `title:` – report title - `total:` – total amount with relative comparisons - `withdrawn:` – ACH withdrawal date @@ -101,12 +100,12 @@ type:task assignee:"Charlie Brown" status:outstanding --- -# Available filters for grouping and currency conversion +## How to use grouping, chart views, and currency conversion filters -Use `group-by:` to analyze data by dimension, and `group-currency:` to normalize totals. +Use `group-by:` to analyze data by dimension, `view:` to choose how grouped results are displayed, and `group-currency:` to normalize totals. ``` -group-by:merchant group-currency:USD +group-by:category view:bar group-currency:USD ``` Supported groupings include: @@ -123,6 +122,19 @@ Supported groupings include: - `group-by:quarter` - Group by fiscal quarter - `group-by:year` - Group by calendar year +## How to choose a chart view for grouped results + +When using `group-by:`, you can add `view:` to control the visualization type. If you save a grouped search with a specific `view:`, that choice is preserved in the saved search label. + +Supported views: + +- `view:table` - Display grouped results as a table (default) +- `view:bar` - Display grouped results as a bar chart +- `view:pie` - Display grouped results as a pie chart +- `view:line` - Display grouped results as a line chart + +> **Note:** The `view:` operator only applies when `group-by:` is also used. Without `group-by:`, the `view:` value is ignored. + --- # FAQ @@ -139,4 +151,3 @@ If the search operator isn’t recognized, the system will ignore it and return Only use quotes for values that include spaces or exact phrases, like `description:"client lunch"` or `in:"#general"`. -
diff --git a/docs/articles/new-expensify/settings/Update-Email-Address.md b/docs/articles/new-expensify/settings/Update-Email-Address.md index c7f2f5c14c65..59326066a8cd 100644 --- a/docs/articles/new-expensify/settings/Update-Email-Address.md +++ b/docs/articles/new-expensify/settings/Update-Email-Address.md @@ -1,42 +1,69 @@ --- title: Update Contact Methods -description: Add or update contact methods for your Expensify account, including changing your default email or phone number. +description: Add or update contact methods in Expensify and understand how login and magic codes work with multiple emails or phone numbers. keywords: [New Expensify, change email, add email, change phone number, add phone number, contact method, update default email, account access, secondary login, login methods, additional email, multiple logins] +internalScope: Audience is all members. Covers adding contact methods, login behavior, verification, default contact method rules. Does not cover workspace-level settings, domain controls, account recovery edge cases. --- -Update or add new contact methods to keep your Expensify account secure and accessible. You can add personal or secondary contact methods, change your default contact method, and remove outdated contact methods. +# Update contact methods + +Keep your Expensify account secure and accessible by adding or updating contact methods. You can add multiple emails or phone numbers, choose a default contact method, and remove outdated contact methods. + +If your company uses SAML SSO, you can only log in with your company email address. + +If you're having trouble logging into your account, see the [login troubleshooting guide](/articles/new-expensify/settings/Login-Troubleshooting). --- -# Why Add Another Contact Method? +## Why add another contact method to your Expensify account Adding an additional contact method helps you: -- **Switch your default email** to a new one (e.g., a personal address). +- **Log in using multiple options** — any added email or phone number can be used to access your account. - **Maintain access** if your primary email is tied to an employer domain. -- **Use multiple logins**—emails and phone numbers can be used interchangeably to access your account. +- **Switch your default contact method** to a new email or phone number. + +--- -💡 **Note:** You must verify any new contact method before making it your default or removing the existing one. +## How to add or change a contact method ---- +To add or update a contact method: -# Add or Change a Contact Method +1. In the navigation tabs (on the left on web, and at the bottom on mobile), select **Account > Profile**. +2. In the **Public** section, select **Contact methods**. +3. Select **New Contact Method**. +4. Enter the verification code sent to your current default contact method. +5. Enter the new email address or phone number you want to add. +6. Click **Add**. +7. Enter the verification code sent to the new contact method, then select **Verify**. -1. In the navigation tabs (on the left on web, and at the bottom on mobile), click **Account > Profile**. -2. Under **Contact Method**, click **New Contact Method**. -3. Enter the email address or phone number you want to add. -4. Click **Add**. -5. Check your email or texts for a **verification code**, enter it in Expensify, and click **Verify**. +After verifying, select any contact method to: -After verifying, click any contact method to: -- Set it as your **default** -- **Remove** it -- **Re-send verification** +- Set it as your default +- Remove it +- Re-send verification --- -# Important Notes +## How login works with multiple contact methods + +- You can log in using any verified email or phone number on your account. +- When you enter an email or phone number on the login screen, Expensify sends the magic code to that same email or phone number. + +# FAQ + +## Can I change where my magic code is sent? + +Magic codes are always sent to the email or phone number you enter when logging in. To use a different destination, enter a different verified contact method. + +## Can I log in with a phone number instead of an email? + +Yes. Any verified phone number or email added to your account can be used to log in. + +## How can I confirm my current default contact method? + +You can view your current default contact method by navigating to **Account > Profile** and clicking **Contact methods**. Your default contact method will show a note that says "_We'll use this method to contact you_". -- You can’t remove your default contact method until another verified contact method is set as default. -- You can add multiple contact methods for flexibility when logging in or recovering your account. +## Why don't I see **Set as default** under a contact method? +Only verified contact methods can be set as default. If you don't see **Set as default** under your contact method, please verify it by entering the magic code sent to the current default contact method. diff --git a/docs/articles/new-expensify/wallet-and-payments/Connect-a-Business-Bank-Account.md b/docs/articles/new-expensify/wallet-and-payments/Connect-a-Business-Bank-Account.md index 4bc142f64d17..88f06b43fcb4 100644 --- a/docs/articles/new-expensify/wallet-and-payments/Connect-a-Business-Bank-Account.md +++ b/docs/articles/new-expensify/wallet-and-payments/Connect-a-Business-Bank-Account.md @@ -1,107 +1,133 @@ --- -title: Connect a Business Bank Account -description: How to connect a business bank account to New Expensify in supported currencies (USD, CAD, GBP, EUR, AUD). -keywords: [New Expensify, business bank account, verified business bank account, send reimbursement, micro transactions, test deposits, verification amounts, bank verification, share bank account, share business bank account] +title: Connect a US Business Bank Account +description: Learn how to connect and verify a US business bank account in Expensify for reimbursements, Expensify Card payments, and invoice payouts. +keywords: [New Expensify, business bank account, connect bank account, verified business bank account, reimbursement, Expensify Card, Plaid, Wallet, workspace payments, link bank account, ACH] +internalScope: Audience is Workspace Admins. Covers connecting a new US business bank account from a workspace or Wallet, and linking an existing one to a workspace for reimbursements. Does not cover validating test transactions, sharing, unsharing, or unlocking a business bank account. --- -
+# Connect a US Business Bank Account -You can connect a business bank account in **New Expensify** using the following supported currencies: **USD, CAD, GBP, EUR, and AUD**. +You can connect a US business bank account from **Wallet**, or from a Workspace if you are a Workspace Admin. Once connected, you can use the bank account for reimbursements, Expensify Card payments, and invoice payouts. -Follow the steps below to connect your business bank account and enable payment features. +This article covers connecting US bank accounts only. If your business bank account is based outside the United States, [learn how to connect an international business bank account](/articles/new-expensify/wallet-and-payments/Enable-Global-Reimbursement). --- -# How to connect and verify a business bank account +## Who can connect a US business bank account -## Step 1: Enable Make or Track Payments +- Any member can connect a US business bank account in **Wallet**. +- Workspace Admins can connect a business bank account to a Workspace. +- The member who sets up the account and all beneficial owners must have a physical US address, a US photo ID, and a US SSN. -1. Click **Workspaces** in the navigation tabs. -2. Navigate to **[Workspace Name] > More Features**. -3. Click **Enable Workflows**. -4. A **Workflows** setting will appear in the left-hand menu. -5. Click **Workflows** and enable **Make or Track Payments**. +To connect a business bank account, the Workspace must have Workflows enabled. [Learn how to enable Workflows](/articles/new-expensify/workspaces/Workspace-Workflows#enable-workflows). -![Enable workflows from the more features page]({{site.url}}/assets/images/Help-ConnectBusinessBankAccount-1.png){:width="100%"} +--- -![Workflows screen with Connect Bank Account button]({{site.url}}/assets/images/Help-ConnectBusinessBankAccount-2.png){:width="100%"} +## How to add a new US business bank account in Wallet + +1. In the navigation tabs (on the left on web, on the bottom on mobile), click **Account > Wallet**. +2. Click **Add bank account**. +3. Select **Make payments**. +4. Choose the country where your bank is located. +5. Select **Log into your bank** (preferred) or **Connect manually**. +6. Complete the identity verification, company details, and Beneficial Owner steps described below. + + --- -## Step 2: Connect your business bank account +## How to connect a new US business bank account in a Workspace -1. Click **Connect Bank Account**. -2. Select either **Connect online with Plaid (preferred)** or **Connect manually**. -3. Enter your bank details. +1. In the navigation tabs (on the left on web, on the bottom on mobile)**,** click **Workspace > [Workspace name]**. +2. Select **Workflows**. +3. Enable **Payments**. +4. In the **Payments** section, select **Add bank account**. +5. Select **Log into your bank** (preferred) or **Connect manually**. +6. Enter your bank details. +7. Complete the identity verification, company details, and Beneficial Owner steps described below. -**Note:** If your business bank account already exists in Expensify, an admin with access can **share it with you** — no need to complete the setup process again. [Learn how to share a business bank account](https://help.expensify.com/articles/new-expensify/wallet-and-payments/Share-a-Business-Bank-Account). +If your business bank account already exists in Expensify, an admin with access can share it with you instead. Learn how to [share a business bank account](/articles/new-expensify/wallet-and-payments/Share-a-Business-Bank-Account). ---- + -## Step 3: Verify your identity with a U.S.-issued photo ID +![Workflows screen with Connect Bank Account button]({{site.url}}/assets/images/Help-ConnectBusinessBankAccount-2.png){:width="100%"} + +--- -After entering your personal details, you’ll be prompted to complete identity verification: -1. Upload a photo of the **front and back** of your ID (**cannot be a photo of an existing image**). -2. Use your device to take a **selfie** and record a **short video** of yourself. +## How to link an existing business bank account to a workspace -**Your ID must be:** -- Issued in the **US** -- Current (**expiration date must be in the future**) +If you already have a verified business bank account, you can share it to another workspace without needing to add it again. [Learn how to share a business bank account](/articles/new-expensify/wallet-and-payments/Share-a-Business-Bank-Account). --- -## Step 4: Add company details for bank account verification +## How to verify your identity with a US-issued photo ID + +After entering your personal details, complete identity verification: + +1. Upload a photo of the **front and back** of your ID. The photo cannot be a photo of an existing image. +2. Take a **selfie** and record a **short video** of yourself. -Add your company details, including: -- **Company Address:** Must be a **physical** US location (**PO Boxes or mail drop addresses will be flagged for review and may delay verification**). -- **Tax Identification Number (TIN):** Assigned by the **IRS**. -- **Company Website:** Required to access most **Expensify payment features**. -- **Industry Classification Code:** Find the list of codes [here](https://www.census.gov/naics/?input=software&year=2022). +Your ID must be issued in the US with an expiration date in the future. --- -## Step 5: Add Beneficial Owner details (if applicable) +## How to add company details for bank account verification -1. Check the appropriate box under **Beneficial Owner**: - - A **Beneficial Owner** is an **individual** who owns **25% or more** of the business. - - If no individual owns **25% or more**, leave both boxes **unchecked**. -2. Accept the agreement terms and verify that all details are **true and accurate**. +Provide the following company information: + +1. **Company address** — Must be a physical US address. PO Boxes or mail drop addresses will be flagged for review and may delay verification. +2. **Tax Identification Number (TIN)** — Assigned by the IRS. +3. **Company website** — Required to access most Expensify payment features. +4. **Industry Classification Code** — Find the list of codes at [census.gov/naics](https://www.census.gov/naics/?input=software&year=2022). --- -## Step 6: Wait for documentation review +## How to add Beneficial Owner details -In some cases, Concierge may request additional documents (e.g., business address verification or a bank letter). If this happens, Concierge will follow up directly. +1. Check the appropriate box under **Beneficial Owner**. A Beneficial Owner is an individual who owns 25% or more of the business. If no individual owns 25% or more, leave both boxes unchecked. +2. Accept the agreement terms and verify that all details are true and accurate. --- -## Step 7: Watch for the test transaction message +## What to expect after submitting your bank account details -Once your account is nearly ready, you’ll see the message: **“Your bank account is almost set up!”** +After you submit your details: -This means Expensify has sent **three test transactions** (usually arriving within 1–2 business days). You’ll need these to complete setup. +1. **Documentation review** — In some cases, Concierge may request additional documents such as business address verification or a bank letter. Concierge will follow up directly if needed. +2. **Test transactions** — Once your account is nearly ready, you'll see the message **"Your bank account is almost set up!"** This means Expensify has sent three test transactions, which usually arrive within 1–2 business days. +3. **Validation** — Enter the three exact amounts to complete setup. Learn how to [validate a business bank account](/articles/new-expensify/wallet-and-payments/Validate-a-Business-Bank-Account). --- -## Step 8: Validate the test transactions +## How business bank accounts appear in Wallet + +Once connected, your business bank account appears in **Account > Wallet**. If you have both personal and business bank accounts, Wallet separates them into **Personal bank accounts** and **Business bank accounts** sections. -1. Return to **Settings > Workspaces > [Workspace Name] > Workflows > Payments**. -2. Click **Enter test transactions**. -3. Input the **three exact amounts** and click **Validate**. +Partially set-up accounts display an **Action required** badge. Click the account row to resume setup from where you left off. -Once validated, your **verified business bank account** is fully connected and ready to use. + --- # FAQ -## What are the general requirements for adding a business bank account? -To add a **business bank account** for **ACH reimbursements (US) or Expensify Cards**: -- Enter a **physical address** for yourself, any **Beneficial Owner** (if applicable), and the **business**. -- **US photo ID, address, and SSN** are required for all individuals associated with the account. +## What are the general requirements for adding a US business bank account? + +To add a business bank account for ACH reimbursements or the Expensify Card, enter a physical address for yourself, any Beneficial Owner (if applicable), and the business. A US photo ID, US address, and US SSN are required for all individuals associated with the account. -## Are there certain industries or businesses for which Expensify cannot process automatic, in-app payments? -Yes, Expensify cannot process direct payments for businesses in the following industries: +## Which industries does Expensify not support for direct payments? + +Expensify cannot process direct payments for businesses in the following industries: - Security Brokers & Dealers - Dating & Escort Services @@ -119,28 +145,33 @@ Yes, Expensify cannot process direct payments for businesses in the following in - NFT (non-fungible token) services ## What is a Beneficial Owner? -A **Beneficial Owner** is an **individual** who owns **25% or more** of the business. If no individual meets this threshold, you do **not** need to list a **Beneficial Owner**. + +A Beneficial Owner is an individual who owns 25% or more of the business. If no individual meets this threshold, you do not need to list a Beneficial Owner. ## What if my business is owned by another company? -Please only check the Beneficial Owner box if an individual owns 25% or more of the business. -## Why can’t I input my address or upload my ID? -All individuals associated with the account must have a **US address, US photo ID, and a US SSN**. If you do not meet these requirements, an **admin who qualifies** should add the bank account. +Only check the Beneficial Owner box if an individual owns 25% or more of the business. + +## Why can't I input my address or upload my ID? + +All individuals associated with the account must have a US address, US photo ID, and a US SSN. If you do not meet these requirements, an admin who qualifies should add the bank account instead. ## Why am I asked for documents when adding my bank account? -Expensify follows **federal regulations (BSA / AML laws)** and anti-fraud measures. If automatic verification fails, we may request manual verification (e.g., address verification, bank ownership letter, etc.). Contact **Concierge** for assistance. -## I don’t see all three microtransactions I need to validate my bank account. What should I do? -Wait **two business days**. If still missing, contact your **bank** and ask them to whitelist our **ACH IDs:** +Expensify follows federal regulations (BSA/AML laws) and anti-fraud measures. If automatic verification fails, Expensify may request manual verification such as address verification or a bank ownership letter. Contact Concierge for assistance. + +## What should I do if I don't see all three test transactions? + +Wait two business days. If the transactions still haven't arrived, contact your bank and ask them to whitelist the following ACH IDs: + - **1270239450** - **4270239450** - **2270239450** -Expensify’s **ACH Originator Name**: **Expensify**. +ACH Originator Name: **Expensify** -Once completed, contact **Concierge** to re-trigger the microtransactions. +Once completed, contact Concierge to re-trigger the test transactions. -## Is my data safe? -Yes! Expensify uses **bank-level security measures** and is **tested daily** by **McAfee**. Learn more about our security policies [here](https://help.expensify.com/articles/new-expensify/settings/Encryption-and-Data-Security). +## How does Expensify protect my data? -
+Expensify uses bank-level security measures. Learn more about [Expensify's security policies](/articles/new-expensify/settings/Encryption-and-Data-Security). diff --git a/docs/articles/new-expensify/wallet-and-payments/Share-a-Business-Bank-Account.md b/docs/articles/new-expensify/wallet-and-payments/Share-a-Business-Bank-Account.md index 804c196690f8..f39790376d5d 100644 --- a/docs/articles/new-expensify/wallet-and-payments/Share-a-Business-Bank-Account.md +++ b/docs/articles/new-expensify/wallet-and-payments/Share-a-Business-Bank-Account.md @@ -1,74 +1,61 @@ --- title: Share a Business Bank Account -description: Workspace Admins can now share access to a business bank account with other admins — no need to set it up from scratch. -keywords: [share business bank account, reimbursements, Wallet, Workspace Admin, Settings > Wallet, pending validation, Expensify business bank] +description: Share a verified business bank account with other Workspace Admins so they can use it without repeating setup. +keywords: [New Expensify, share business bank account, shared bank, Workspace Admin, verified bank account, bank account access, ACH sharing, shared access, Wallet] +internalScope: Audience is Workspace Admins with a verified business bank account. Covers sharing a verified business bank account with other admins. Does not cover initial bank account setup, validation, unsharing, or unlocking. --- -
+# Share a Business Bank Account -# Sharing a business bank account in Expensify +If you've already added and verified a business bank account, you can share it with other Workspace Admins. This allows them to use the account without repeating the full setup process. -If you’ve already added a business bank account, you can share access to that account with other Workspace Admins. This eliminates the need to repeat the full setup process. - -## Who can share a business bank account in Expensify +--- -Only **Workspace Admins** can share an existing business bank account. You can only share accounts that have been verified. +## Who can share a business bank account -## Where to find the business bank account sharing option +- Only **Workspace Admins** can share a business bank account. +- You can only share **verified** business bank accounts. +- You can only share with admins of Workspaces where you are also an admin. -Go to the navigation tabs and select **Settings > Wallet**. +--- ## How to share a business bank account -To share an existing account with other admins: - -1. Open **Settings > Wallet**. -2. Click the **three-dot menu (⋮)** next to the bank account you want to share. +1. In the navigation tabs (on the left on web, on the bottom on mobile) go to **Account > Wallet**. +2. In the **Bank accounts** section, find the verified business bank account and select the three dots **(⋮)** next to the account. 3. Select **Share**. -4. On the Share screen: - - The list shows only Workspace Admins from workspaces where you also have admin access. -5. Select the admins you want to share with, then click **Share**. -6. You’ll see a confirmation screen once the share is successful. +4. Select the admins you want to share the account with. +5. Click **Share**. +6. You'll see a confirmation once the process is complete. -**Note:** You’ll only see Admins of workspaces where you’re also an admin in the list. +**Note:** You can only share with Workspace Admins from Workspaces where you also have admin access. ![Open the wallet page]({{site.url}}/assets/images/Help-ShareBankAccount-1.png){:width="100%"} -![Open the three dot menu next to the bank account and tap share]({{site.url}}/assets/images/Help-ShareBankAccount-2.png){:width="100%"} +![Open the three dots next to the bank account and tap share]({{site.url}}/assets/images/Help-ShareBankAccount-2.png){:width="100%"} ![Select the admins you want to share with]({{site.url}}/assets/images/Help-ShareBankAccount-3.png){:width="100%"} ![Close the confirmation screen once the share is successful]({{site.url}}/assets/images/Help-ShareBankAccount-4.png){:width="100%"} -## What happens after sharing a business bank account - -- The shared-to admins will receive a Concierge message letting them know the account was shared. -- They’ll now see the account in their **Wallet**. - -**For US accounts:** -- The shared account appears with an **Action required** badge. -- The shared-to admin will need to **validate** the account by entering test transactions. -- After validation, they can begin using it. - -**For non-US accounts:** -- The shared account is ready to use immediately — no test transactions required. - -## How business bank account sharing works when changing the payer +--- -When changing the **Authorized Payer** in a Workspace: +## What happens after you share a business bank account -- If you have access but the new payer doesn't: - - You'll be prompted to share the bank account from the Authorized Payer screen. - - The account shares immediately when you confirm. - - A confirmation screen appears after successful sharing. +The receiving admin must validate their copy (required for US accounts) before using the account. -- If your own copy of the bank account is pending validation, and you try to assign a new payer who doesn’t have access: - - You’ll see an error prompting you to **validate your account first** by entering the test transactions before changing payers. +- The selected admins receive a Concierge message notifying them the account was shared. +- The account appears in their **Wallet**. -- If **neither** you nor the new selected payer has access: - - You’ll be shown an error telling you to ask the current payer to share the account. +**For US accounts:** +- The account shows an **Action required** badge +- The admin must validate the account with the test transactions before using it. +- After validation, the account is ready to use + +**For non-US accounts:** +- The shared account is ready to use immediately — no validation is required. -This process ensures you can only set an Authorized Payer who already has bank account access or receives access during the payer change. +[Learn how to validate a business bank account](/articles/new-expensify/wallet-and-payments/Validate-a-Business-Bank-Account). --- @@ -76,33 +63,22 @@ This process ensures you can only set an Authorized Payer who already has bank a ## Why share a business bank account? -Sharing saves time. You don’t have to redo the full verification process, and it makes it easier to: -- Change the workspace payer -- Set up new Workspaces -- Enable reimbursements without delay +Sharing saves time by avoiding repeated setup. It also helps when changing the workspace payer or enabling reimbursements quickly. ## Can I share the same bank account with multiple admins? -Yes. You can share a bank account with multiple admins at once. +Yes. You can select multiple Workspace Admins when sharing. -## Why don’t I see the Share option? +## Why don't I see the Share option? -You’ll only see **Share** if the bank account is verified and there is at least one eligible Workspace Admin you haven’t shared with yet. +The **Share** option only appears if: +- The account is verified +- There is at least one eligible Workspace Admin you haven’t shared it with ## What if the other admin already has a copy of the account? -If an admin already has access to the bank account, they won’t appear in the share list. +They won’t appear in the list of admins you can share with. ## Can a shared admin share the bank account with others? -Yes — once an admin has validated the account (if required), they can share the bank account with other admins. - -## Can I share a bank account during the payer change process? - -Yes. If the new payer doesn’t already have access, you’ll be prompted to share the bank account before confirming the payer change. This ensures the new payer can reimburse expenses without delay. - -## What happens if I try to set a payer but neither of us has bank account access? - -You’ll be shown an error and prompted to ask the current payer to share the account with one of you before proceeding. - -
+Yes. Once an admin has validated the account (if required), they can share it with other admins. diff --git a/docs/articles/new-expensify/wallet-and-payments/Unlock-a-Business-Bank-Account.md b/docs/articles/new-expensify/wallet-and-payments/Unlock-a-Business-Bank-Account.md index c07317107ed1..d4a3013fd425 100644 --- a/docs/articles/new-expensify/wallet-and-payments/Unlock-a-Business-Bank-Account.md +++ b/docs/articles/new-expensify/wallet-and-payments/Unlock-a-Business-Bank-Account.md @@ -1,70 +1,90 @@ --- title: Unlock a Business Bank Account -description: Learn how to resolve issues with a locked business bank account in Expensify. -keywords: [New Expensify, unlock bank account, reimbursements, direct debit, ACH, bank rejected withdrawal, retry debit] +description: Request an unlock for a locked business bank account in Expensify and restore reimbursements and payments. +keywords: [New Expensify, locked bank account, unlock bank account, reimbursements failed, ACH rejected, bank error, fix bank account, Expensify Card locked, ACH company ID, unlock payment method] +internalScope: Audience is Workspace Admins with a locked business bank account. Covers requesting an unlock and preventing future lockouts. Does not cover initial bank account setup, validation, sharing, or unsharing. --- -When you reimburse a report, Expensify initiates a withdrawal from your connected business bank account. If your bank rejects this withdrawal—due to **insufficient funds**, **missing authorization**, or **direct debit restrictions**—your account will be **locked** until the issue is resolved. +# Unlock a Business Bank Account + +If your business bank account is locked, Expensify pauses reimbursements and payments to prevent failed debits. You’ll need to request an unlock before the account can be used again. --- -# Why is the business bank account locked? +## Why your business bank account is locked -Common reasons include: +Bank accounts may be locked after a failed debit attempt or a bank-side restriction. Common reasons include: -- Your bank account is not enabled for **ACH direct debits** -- There were **insufficient funds** available during a reimbursement attempt +- Insufficient funds at the time of reimbursement - Your bank blocked the debit attempt +- The bank account isn't enabled for ACH direct debits + +When a bank account is locked, you’ll see a **Locked** badge on the account in: -When this occurs, Expensify pauses reimbursements and locks the verified account as a precaution. + - **Settings > Wallet** in the **Bank accounts** section + - **Workspaces > [Workspace Name] > Workflows** in the **Payments** section --- -# How to request an unlock +## How to unlock a business bank account -To unlock your account: +1. In the navigation tabs (on the left on web, on the bottom on mobile) go to **Account > Wallet**. +2. Click the bank account with the **Locked** badge. +3. Click **Fix** or **Unlock account**. +4. Send the message to Concierge to request the unlock. -1. Go to **Workspaces > [Workspace Name] > Bank Account**. -2. Click the **Fix** button next to the locked account. +Concierge will walk you through the next steps to unlock the account, or confirm when your account is ready to use. -This sends a request to the Expensify support team. A Concierge agent will contact you with the next steps. + -> **Note:** Unlocking a bank account may take several business days due to ACH processing timelines and return periods. +--- -# When will the account unlock? +## What happens after you request to unlock a business bank account -After we receive confirmation from your bank that a debit has successfully settled, your business bank account will unlock automatically and reimbursements will resume. +Unlocking is tied to your bank's processing timeline: -> **Note:** If a reimbursement debit fails, we must wait for the bank to return the attempt before another retry can be triggered. We cannot manually initiate a new debit while a previous one is still processing. +1. Expensify must wait for the failed debit attempt to fully return from your bank. +2. Once returned, Concierge will notify you. +3. You confirm whether you'd like Expensify to retry the debit. +4. After a successful debit, the account unlocks automatically. -Once the previous attempt is returned by the bank, Concierge will notify you in your chat. At that point, you’ll be asked to confirm whether you’d like us to retry the debit. Once confirmed, we’ll proceed with the next attempt. +Expensify cannot manually retry debits until the bank returns the original attempt. --- -# Enable direct debit with your bank +## How to prevent your business bank account from locking again + +Ask your bank to allow ACH debits from the following originators: -To ensure your bank allows direct debit from Expensify, share the following ACH details with your financial institution: +**For reimbursements via Expensify:** +- ACH Company IDs: 1270239450, 4270239450, 2270239450 +- ACH Originator Name: Expensify -### For reimbursements via Expensify: +**For bill payments via Stripe:** +- ACH Company IDs: 1800948598, 4270465600 +- ACH Originator Name: expensify.com -- **ACH Company IDs**: - `1270239450`, `4270239450`, `2270239450` -- **ACH Originator Name**: `Expensify` +**For international reimbursements via CorPay:** +- ACH Company IDs: 1522304924, 2522304924 +- ACH Originator Name: Cambridge Global Payments -### For bill payments via Stripe: +--- -- **ACH Company IDs**: - `1800948598`, `4270465600` -- **ACH Originator Name**: `expensify.com` -- [Stripe ACH ID Reference](https://support.stripe.com/questions/ach-direct-debit-company-ids-for-stripe) +# FAQ -### For international reimbursements via CorPay: +## Will I be notified when my bank account is locked? -- **ACH Company IDs**: - `1522304924`, `2522304924` -- **ACH Originator Name**: `Cambridge Global Payments` +Yes. You’ll see a **Locked** badge in **Wallet** and **Workflows**, and Concierge will message you. ---- +## Can I delete a locked bank account? + +No. Locked accounts can't be deleted until they're resolved and unlocked. + +## Why hasn’t my account unlocked yet? -Once your bank has approved these IDs and your account is reviewed, Concierge will notify you when it is unlocked and reimbursements can resume. +The failed debit may still be processing with your bank. Concierge will notify you as soon as it can be retried. diff --git a/docs/articles/new-expensify/wallet-and-payments/Unshare-a-Business-Bank-Account.md b/docs/articles/new-expensify/wallet-and-payments/Unshare-a-Business-Bank-Account.md index b46a450e03ef..20f48dff1431 100644 --- a/docs/articles/new-expensify/wallet-and-payments/Unshare-a-Business-Bank-Account.md +++ b/docs/articles/new-expensify/wallet-and-payments/Unshare-a-Business-Bank-Account.md @@ -1,48 +1,50 @@ --- -title: Unshare a business bank account -description: Learn how to unshare a business bank account from other admins in New Expensify to revoke access instantly. -keywords: [unshare business bank account, remove bank access, New Expensify Wallet, Workspace Admin, revoke bank permission, Expensify Card settlement] +title: Unshare a Business Bank Account +description: Remove another member's access to a shared business bank account in Expensify. +keywords: [New Expensify, unshare business bank account, revoke access, remove admin, Wallet, bank access, shared business account, remove shared access] +internalScope: Audience is members with a shared business bank account. Covers revoking shared access to a business bank account. Does not cover initial bank account setup, validation, sharing, or unlocking. --- -
-# Unshare a business bank account +# Unshare a Business Bank Account -If other Workspace Admins have access to your business bank account in Expensify, you can unshare it at any time to revoke their access. Unsharing access is especially helpful when someone changes roles or leaves the company. +If you’ve shared a business bank account with other members, you can remove their access at any time. -Unsharing the account removes it from the other admin's Wallet immediately and prevents that admin from using it to reimburse reports, pay bills, or use it with the Expensify Card. We'll still complete any payments in process that the admin issued. +Unsharing removes the account from the other member’s Wallet immediately. Any payments already in progress will still complete. --- ## Who can unshare a business bank account -To unshare a business bank account, you must: -- Be a **Workspace Admin**. -- Have an active business bank account in your Wallet that at least one other admin can access. +Any member who has a validated business bank account in their **Wallet** that is shared with another member can unshare it. --- -## Where to find the unshare business bank account option +## How to unshare a business bank account -1. Select **Wallet** from the navigation tabs on the left. -2. Choose your business bank account and click the **More** (three-dot) menu. +1. In the navigation tabs (on the left on web, on the bottom on mobile) go to **Account > Wallet**. +2. In the **Bank accounts** section, find the verified business bank account and select the three dots **(⋮)** next to the account. 3. Select **Unshare**. +4. Select the members you want to remove. +5. Click **Unshare**. +6. Click **Confirm** + +You'll see a confirmation once the account has been unshared. --- -## How to unshare a business bank account +## What happens after you unshare a business bank account + + - The account is removed from the unshared member's **Wallet** immediately. + - The unshared member can no longer use the account for reimbursements, bill payments, or Expensify Card settlement. + - The unshared member receives a message from Concierge notifying them of the change. -1. Go to **Wallet** and open the **More** menu (three dots) on your business bank account. -2. Click **Unshare**. -3. A list of all admins with access to that bank account will appear. -4. Click **Unshare** next to the admin you want to remove. -5. Confirm in the pop-up modal. -6. That admin will immediately lose access and receive a Concierge message letting them know. +**Note:** Any payments already in progress before the bank account was unshared will still complete. -**Note:** Admins with access can unshare the business bank account from anyone else — even the person who originally added it. +-- ![Open the wallet page]({{site.url}}/assets/images/Help-UnshareBankAccount-1.png){:width="100%"} -![Open the three dot menu next to the bank account and tap unshare]({{site.url}}/assets/images/Help-UnshareBankAccount-2.png){:width="100%"} +![Open the three dots next to the bank account and tap unshare]({{site.url}}/assets/images/Help-UnshareBankAccount-2.png){:width="100%"} ![Tap unshare for the admin you want to unshare from]({{site.url}}/assets/images/Help-UnshareBankAccount-3.png){:width="100%"} @@ -50,12 +52,11 @@ To unshare a business bank account, you must: --- -## Unsharing when the account is set as Expensify Card settlement account +## What to do if you can’t unshare a business bank account -If the admin's copy of the bank account is currently set as the **Expensify Card settlement account**, you'll see an error message and won't be able to unshare from that admin. +If the bank account is set as an Expensify Card settlement account for that member, you won’t be able to unshare it. -To resolve this: -- Click **Reach out to Concierge** in the error message to get help switching the settlement account. +To resolve this, click **Reach out to Concierge** in the error message. Concierge will help switch the settlement account so you can proceed. --- @@ -63,18 +64,16 @@ To resolve this: ## Can I unshare a bank account I didn’t originally add? -Yes. As long as your copy of the business bank account is active, you can unshare it from any admin — including the original sharer. - -## Will the other admin be notified when I unshare the bank account? - -Yes. They’ll receive a message from Concierge letting them know they no longer have access to the bank account. - -## What happens to the unshared bank account? +Yes. As long as your copy of the business bank account is active, you can unshare it from any member. -The business bank account is removed from the admin's Wallet. The admin can no longer use the account for any payments or reimbursement features. We'll still complete any payments in process that the admin issued beforehand. +## Can a bank account be unshared with the original account owner? -
+Yes. Any member with access can unshare the bank account from another member, including the original account owner. +## Will the unshared member be notified when I unshare the bank account? +Yes. They'll receive a message from Concierge letting them know they no longer have access. +## What happens to processing reimbursements when a bank account is unshared? +Processing payments will still complete when the account is unshared. diff --git a/docs/articles/new-expensify/wallet-and-payments/Validate-a-Business-Bank-Account.md b/docs/articles/new-expensify/wallet-and-payments/Validate-a-Business-Bank-Account.md index cca49ccf3512..cca943d087af 100644 --- a/docs/articles/new-expensify/wallet-and-payments/Validate-a-Business-Bank-Account.md +++ b/docs/articles/new-expensify/wallet-and-payments/Validate-a-Business-Bank-Account.md @@ -1,48 +1,70 @@ --- title: Validate a Business Bank Account -description: Finish connecting your business bank account by verifying test transactions in Expensify. -keywords: [New Expensify, bank account verification, test deposits, ACH validation, business bank account, workspace payments] +description: Enter test transaction amounts to complete validation of a business bank account in Expensify. +keywords: [New Expensify, validate bank account, test deposits, ACH validation, business bank verification, confirm test transactions, micro-transactions, pending bank account] +internalScope: Audience is all members with a pending business bank account. Covers entering test transactions to validate a bank account. Does not cover initial bank account setup, sharing, unsharing, or unlocking. --- -Once your business bank account connection is approved in Expensify, you’ll receive a series of **test deposits** to confirm the connection. This step is required before you can start using the account for reimbursements or payments. +# Validate a Business Bank Account + +After your business bank account is approved, Expensify sends three test transactions to confirm ownership. You’ll also receive a message from Concierge that says “Your bank account is almost set up!”. + +The three transactions (two small withdrawals and one small deposit) typically arrive within one–two business days. Once they appear in your bank account, enter the exact amounts in Expensify to complete validation and start using the account for reimbursements or payments. + +--- + +## Who can validate a business bank account + +Any member who has a **Pending** business bank account in **Wallet** can validate it using the test transactions. --- -# How to Validate Your Bank Account +## When can a business bank account be validated + +After your business bank account is approved: + - Expensify sends three test transactions to your bank account - two small withdrawals and one small deposit. + - When the test transactions are sent, Concierge will send a message to notify you that says "Your bank account is almost set up!". + - The test transactions will arrive 1-2 days after sending. + +## How to validate a business bank account with test transactions -1. Go to **Workspaces > [Workspace Name] > Bank Account**. -2. Review the account status: - - **Verifying** – Expensify requested more info. Check your email for the next steps. - - **Pending** – Your account is ready for validation. Continue with the steps below. -3. Within **1–2 business days**, Expensify will send **three test transactions** to your business bank account. -4. Return to **Workspaces > [Workspace Name] > Bank Account**. -5. Enter the exact **transaction amounts** when prompted. +When you see the three test amounts post to your bank account: -Once submitted correctly, your account will be fully validated and ready to use. +1. In the navigation tabs (on the left on web, on the bottom on mobile) go to **Account > Wallet**. +2. In the **Bank accounts** section, click the business bank account with the **Pending** badge. +3. Enter all three amounts exactly as they appear. +4. Click **Validate**. + +Once the values are confirmed, your business bank account is fully connected and ready to use. --- -# FAQ +## What to do if test transactions don't arrive + +If the test deposits don't appear after two business days: + +1. Confirm that your bank account supports ACH direct debits. +2. Share the following ACH details with your bank: + - **ACH Company IDs:** 1270239450, 4270239450, 2270239450 + - **ACH Originator Name:** Expensify +3. If still missing, contact Concierge to re-trigger the test transactions. -## How Many Test Deposits Will I Receive? +--- + +# FAQ -You’ll see **three transactions**: -- **Two small withdrawals** -- **One small deposit** +## How many test transactions will I receive? -These are temporary and used to confirm your ownership of the account. +Three total: two small withdrawals and one small deposit. These are temporary and used only for verification. -## What If I Don’t See the Test Transactions? +## What happens if I enter the wrong amounts? -If the test deposits don’t appear after two business days: +You’ll be prompted to try again. Multiple failed attempts may temporarily lock validation. -- Your bank account may not support **ACH direct debits** yet. -- Provide your bank with the following information to enable ACH access: +## Can I skip validation if the account was shared with me? - - **ACH Company IDs**: - `1270239450`, `4270239450`, `2270239450` - - **ACH Originator Name**: - `Expensify` +No. US bank accounts require validation by entering test transactions. Some non-US accounts may not require this step. -Still not seeing them? Contact **Expensify Support via Concierge**, and we’ll help investigate. +## What if I don’t have access to the bank account? +Another member with access to the bank account must complete the validation. diff --git a/docs/articles/new-expensify/workspaces/Track-Taxes.md b/docs/articles/new-expensify/workspaces/Track-Taxes.md index 0c2f3d281c70..4fa555568146 100644 --- a/docs/articles/new-expensify/workspaces/Track-Taxes.md +++ b/docs/articles/new-expensify/workspaces/Track-Taxes.md @@ -89,6 +89,17 @@ If your receipt includes more than one tax rate, there are two ways you can hand - Many tax authorities do not require reporting tax amounts by rate; therefore, you can apply the highest rate on the receipt and then modify the tax amount on the receipt if necessary. Please check with your tax advisor to determine if this approach suits you. - Alternatively, you can apply each specific tax rate by splitting the expense by the applicable costs for each rate. To do this, open the expense and click **Split Expense**. Then, apply the correct tax rate to each. +## What happens if an expense with tax is moved to a workspace where tax tracking is disabled? + +If an expense that includes tax data is moved or merged into a workspace that does not have tax tracking enabled, the tax fields (**Tax** and **Tax amount**) will still appear on the expense with a "Tax no longer valid" violation. + +To resolve this, tap the **Tax** or **Tax amount** field on the expense. An alert will appear explaining that tax tracking is disabled on the workspace. You can then choose to: + +- **Delete tax** — Removes the tax rate and tax amount from the expense. +- **Cancel** — Keeps the tax data on the expense. + +Alternatively, you can enable tax tracking on the workspace to edit the tax details directly. + ## What if my workspace has multiple tax rates? You'll have the option to change the tax rate from within the expense as needed. diff --git a/docs/articles/travel/booking-travel/Arrange-Travel-for-Another-User.md b/docs/articles/travel/booking-travel/Arrange-Travel-for-Another-User.md index c0702e14ad2f..d8ea2244fe57 100644 --- a/docs/articles/travel/booking-travel/Arrange-Travel-for-Another-User.md +++ b/docs/articles/travel/booking-travel/Arrange-Travel-for-Another-User.md @@ -11,7 +11,7 @@ After you're assigned the Travel Arranger role, you can arrange travel for anoth ## Where to find Expensify Travel -Tap the green ➕ **Create** button at the bottom of your screen, then choose **Book travel**. +Tap the **➕ Create** button at the bottom of your screen, then choose **Book travel**. If you don’t see **Book travel**, ask a Workspace Admin to [enable Expensify Travel](https://help.expensify.com/articles/travel/company-setup/Enable-Travel-on-a-Workspace) on the workspace. diff --git a/docs/articles/travel/booking-travel/Book-Rail-Travel.md b/docs/articles/travel/booking-travel/Book-Rail-Travel.md index b7e1c5e88afd..711d54c0b471 100644 --- a/docs/articles/travel/booking-travel/Book-Rail-Travel.md +++ b/docs/articles/travel/booking-travel/Book-Rail-Travel.md @@ -10,7 +10,7 @@ Book train tickets directly in Expensify Travel. This guide covers how to search ## Where to find Expensify Travel -Tap the green ➕ **Create** button at the bottom of your screen, then choose **Book travel**. +Tap the **➕ Create** button at the bottom of your screen, then choose **Book travel**. If you don’t see **Book travel**, ask a Workspace Admin to [enable Expensify Travel](https://help.expensify.com/articles/travel/company-setup/Enable-Travel-on-a-Workspace) on the workspace. diff --git a/docs/articles/travel/booking-travel/Book-Travel-for-a-Guest.md b/docs/articles/travel/booking-travel/Book-Travel-for-a-Guest.md index b8754f782a59..343f14e69267 100644 --- a/docs/articles/travel/booking-travel/Book-Travel-for-a-Guest.md +++ b/docs/articles/travel/booking-travel/Book-Travel-for-a-Guest.md @@ -11,7 +11,7 @@ Any member with guest booking permission can book travel for someone who doesn ## Where to find Expensify Travel -Tap the green ➕ **Create** button at the bottom of your screen, then choose **Book travel**. +Tap the **➕ Create** button at the bottom of your screen, then choose **Book travel**. If you don’t see **Book travel**, ask a Workspace Admin to [enable Expensify Travel](https://help.expensify.com/articles/travel/company-setup/Enable-Travel-on-a-Workspace) on the workspace. diff --git a/docs/articles/travel/booking-travel/Book-a-Flight.md b/docs/articles/travel/booking-travel/Book-a-Flight.md index bf1183009815..07ba2885be33 100644 --- a/docs/articles/travel/booking-travel/Book-a-Flight.md +++ b/docs/articles/travel/booking-travel/Book-a-Flight.md @@ -11,7 +11,7 @@ Book flights easily with Expensify Travel. This guide walks you through finding ## Where to find Expensify Travel -Tap the green ➕ **Create** button at the bottom of your screen, then choose **Book travel**. +Tap the **➕ Create** button at the bottom of your screen, then choose **Book travel**. If you don’t see **Book travel**, ask a Workspace Admin to [enable Expensify Travel](https://help.expensify.com/articles/travel/company-setup/Enable-Travel-on-a-Workspace) on the workspace. @@ -65,7 +65,7 @@ Yes! The receipt is SmartScanned and added to an expense report once the flight Yes — but it depends on your fare and the airline’s policy. If changes are made through support, a $25 booking change fee applies. To modify or cancel a booking: -1. Tap the green ➕ **Create** button at the bottom of your screen, then choose **Book travel**. +1. Tap the **➕ Create** button at the bottom of your screen, then choose **Book travel**. 2. In the window that opens, click the **Trips** tab. 3. Find your trip and click **Modify or Cancel** diff --git a/docs/articles/travel/booking-travel/Book-a-Hotel.md b/docs/articles/travel/booking-travel/Book-a-Hotel.md index ea35d842b2b1..b3957baa9faa 100644 --- a/docs/articles/travel/booking-travel/Book-a-Hotel.md +++ b/docs/articles/travel/booking-travel/Book-a-Hotel.md @@ -10,7 +10,7 @@ Book hotels easily with Expensify Travel. This guide covers how to search for ho ## Where to find Expensify Travel -Tap the green ➕ **Create** button at the bottom of your screen, then choose **Book travel**. +Tap the **➕ Create** button at the bottom of your screen, then choose **Book travel**. If you don’t see **Book travel**, ask a Workspace Admin to [enable Expensify Travel](https://help.expensify.com/articles/travel/company-setup/Enable-Travel-on-a-Workspace) on the workspace. @@ -59,7 +59,7 @@ You can cancel or modify the booking if the hotel’s policy allows. Cancellation policies are shown during booking. Changes made through support may incur a fee of up to $25. To modify or cancel a booking: -1. Tap the green ➕ **Create** button at the bottom of your screen, then choose **Book travel**. +1. Tap the **➕ Create** button at the bottom of your screen, then choose **Book travel**. 2. In the window that opens, click the **Trips** tab. 3. Find your trip and click **Modify or Cancel** diff --git a/docs/articles/travel/booking-travel/Book-a-Rental-Car.md b/docs/articles/travel/booking-travel/Book-a-Rental-Car.md index a20ec6449aaf..944ac6bba680 100644 --- a/docs/articles/travel/booking-travel/Book-a-Rental-Car.md +++ b/docs/articles/travel/booking-travel/Book-a-Rental-Car.md @@ -10,7 +10,7 @@ Book rental cars quickly and easily through Expensify’s integrated travel port ## Where to find Expensify Travel -Tap the green ➕ **Create** button at the bottom of your screen, then choose **Book travel**. +Tap the **➕ Create** button at the bottom of your screen, then choose **Book travel**. If you don’t see **Book travel**, ask a Workspace Admin to [enable Expensify Travel](https://help.expensify.com/articles/travel/company-setup/Enable-Travel-on-a-Workspace) on the workspace. diff --git a/docs/articles/travel/company-setup/Adding-Supplier-Programs.md b/docs/articles/travel/company-setup/Adding-Supplier-Programs.md index 9690c7b84630..45dfc5fafa79 100644 --- a/docs/articles/travel/company-setup/Adding-Supplier-Programs.md +++ b/docs/articles/travel/company-setup/Adding-Supplier-Programs.md @@ -11,7 +11,7 @@ Travel admins can add supplier programs to ensure corporate discounts and direct ## How to access the travel tool -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. @@ -38,7 +38,7 @@ Under the **Air** tab, there are two configuration types: **Corporate discount c **Corporate discount codes** -1. Click the green **+** button. +1. Click the **+** button. 2. Fill out the required fields: - **Vendor name** - **Company name** diff --git a/docs/articles/travel/company-setup/Central-Billing.md b/docs/articles/travel/company-setup/Central-Billing.md index 89adfe4b11ae..8176218c55f0 100644 --- a/docs/articles/travel/company-setup/Central-Billing.md +++ b/docs/articles/travel/company-setup/Central-Billing.md @@ -10,7 +10,7 @@ Central Billing allows Workspace Admins to set up a company card for Expensify T ## How to access Expensify Travel -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. diff --git a/docs/articles/travel/company-setup/Configuring-Booking-Restrictions.md b/docs/articles/travel/company-setup/Configuring-Booking-Restrictions.md index 02546e16929c..ddffe6fa8c86 100644 --- a/docs/articles/travel/company-setup/Configuring-Booking-Restrictions.md +++ b/docs/articles/travel/company-setup/Configuring-Booking-Restrictions.md @@ -11,7 +11,7 @@ Travel admins can set up booking restrictions and apply them based on user profi ## How to access Expensify Travel -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. diff --git a/docs/articles/travel/company-setup/Configuring-Email-Notifications.md b/docs/articles/travel/company-setup/Configuring-Email-Notifications.md index 616b9facdfb1..8b98b77d4098 100644 --- a/docs/articles/travel/company-setup/Configuring-Email-Notifications.md +++ b/docs/articles/travel/company-setup/Configuring-Email-Notifications.md @@ -11,7 +11,7 @@ Travel admins can configure custom email notification rules to keep key stakehol ## How to access Expensify Travel -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. diff --git a/docs/articles/travel/company-setup/Creating-Departments.md b/docs/articles/travel/company-setup/Creating-Departments.md index 816d31c6cbea..f8afe616eb34 100644 --- a/docs/articles/travel/company-setup/Creating-Departments.md +++ b/docs/articles/travel/company-setup/Creating-Departments.md @@ -11,7 +11,7 @@ Departments help travel admins group employees together and apply travel policie ## How to access the travel tool -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Don’t see the travel feature?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. diff --git a/docs/articles/travel/company-setup/Importing-Corporate-Rates.md b/docs/articles/travel/company-setup/Importing-Corporate-Rates.md index 2368cbf8842c..7837e1bfe7c7 100644 --- a/docs/articles/travel/company-setup/Importing-Corporate-Rates.md +++ b/docs/articles/travel/company-setup/Importing-Corporate-Rates.md @@ -11,7 +11,7 @@ Corporate hotel rates can be added to Expensify Travel with help from your Accou ## How to access Expensify Travel -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. diff --git a/docs/articles/travel/company-setup/Managing-Users.md b/docs/articles/travel/company-setup/Managing-Users.md index deb216cbf837..07990388a101 100644 --- a/docs/articles/travel/company-setup/Managing-Users.md +++ b/docs/articles/travel/company-setup/Managing-Users.md @@ -11,7 +11,7 @@ Travel admins can view, edit, and configure traveler profiles under the **Users* ## How to access Expensify Travel -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. diff --git a/docs/articles/travel/event-management/Booking-Event-Travel.md b/docs/articles/travel/event-management/Booking-Event-Travel.md index f5f46e8cf485..48b704cb6188 100644 --- a/docs/articles/travel/event-management/Booking-Event-Travel.md +++ b/docs/articles/travel/event-management/Booking-Event-Travel.md @@ -24,7 +24,7 @@ Before booking travel, make sure: To get started: -- **In New Expensify:** Click the green **+** button in the bottom-left corner, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner, then select **Book travel**. - **In Classic Expensify:** Go to **Travel** in the left-hand menu, then choose **Book or manage travel**. **Haven’t enabled travel yet?** diff --git a/docs/articles/travel/event-management/Booking-Travel-for-Event-Attendees.md b/docs/articles/travel/event-management/Booking-Travel-for-Event-Attendees.md index f5f46e8cf485..48b704cb6188 100644 --- a/docs/articles/travel/event-management/Booking-Travel-for-Event-Attendees.md +++ b/docs/articles/travel/event-management/Booking-Travel-for-Event-Attendees.md @@ -24,7 +24,7 @@ Before booking travel, make sure: To get started: -- **In New Expensify:** Click the green **+** button in the bottom-left corner, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner, then select **Book travel**. - **In Classic Expensify:** Go to **Travel** in the left-hand menu, then choose **Book or manage travel**. **Haven’t enabled travel yet?** diff --git a/docs/articles/travel/event-management/Travel-Event-Management.md b/docs/articles/travel/event-management/Travel-Event-Management.md index 871c976f98ae..c6d28d1fdff6 100644 --- a/docs/articles/travel/event-management/Travel-Event-Management.md +++ b/docs/articles/travel/event-management/Travel-Event-Management.md @@ -11,7 +11,7 @@ Plan and coordinate group travel in one place with Expensify Classic Events. Thi ## How to access the travel tool -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. diff --git a/docs/articles/travel/event-management/View-Event-As-Employee-or-Guest.md b/docs/articles/travel/event-management/View-Event-As-Employee-or-Guest.md index daed19160df9..80d5ed2c59c9 100644 --- a/docs/articles/travel/event-management/View-Event-As-Employee-or-Guest.md +++ b/docs/articles/travel/event-management/View-Event-As-Employee-or-Guest.md @@ -12,7 +12,7 @@ If you’ve been invited to a company event, you can view event details and book ## How to Access the Travel Tool -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** diff --git a/docs/articles/travel/getting-started/How-to-Get-Expensify-Travel-Support-from-an-Agent.md b/docs/articles/travel/getting-started/How-to-Get-Expensify-Travel-Support-from-an-Agent.md index 9b242a7d7924..a8b917f02fa7 100644 --- a/docs/articles/travel/getting-started/How-to-Get-Expensify-Travel-Support-from-an-Agent.md +++ b/docs/articles/travel/getting-started/How-to-Get-Expensify-Travel-Support-from-an-Agent.md @@ -22,7 +22,7 @@ Anyone with access to Expensify Travel can contact travel agent support. ## How to access the travel tool -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Expensify Classic:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. diff --git a/docs/articles/travel/getting-started/setting-up-your-profile/Adding-Loyalty-Programs.md b/docs/articles/travel/getting-started/setting-up-your-profile/Adding-Loyalty-Programs.md index 807ac8018c0f..71493d01f4fc 100644 --- a/docs/articles/travel/getting-started/setting-up-your-profile/Adding-Loyalty-Programs.md +++ b/docs/articles/travel/getting-started/setting-up-your-profile/Adding-Loyalty-Programs.md @@ -11,7 +11,7 @@ Add your loyalty programs for air, hotel, car, and rail travel to earn rewards a ## How to access the travel tool -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. diff --git a/docs/articles/travel/getting-started/setting-up-your-profile/Adding-Your-Personal-Information.md b/docs/articles/travel/getting-started/setting-up-your-profile/Adding-Your-Personal-Information.md index 717dd6e24c44..0dc5adb14b25 100644 --- a/docs/articles/travel/getting-started/setting-up-your-profile/Adding-Your-Personal-Information.md +++ b/docs/articles/travel/getting-started/setting-up-your-profile/Adding-Your-Personal-Information.md @@ -11,7 +11,7 @@ Set up your personal travel profile to customize your Expensify Travel expense a ## How to access the travel tool -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. diff --git a/docs/articles/travel/getting-started/setting-up-your-profile/Adding-a-Company-Card-or-Personal-Card-for-Travel.md b/docs/articles/travel/getting-started/setting-up-your-profile/Adding-a-Company-Card-or-Personal-Card-for-Travel.md index e7c470932d64..f55b6669f7d9 100644 --- a/docs/articles/travel/getting-started/setting-up-your-profile/Adding-a-Company-Card-or-Personal-Card-for-Travel.md +++ b/docs/articles/travel/getting-started/setting-up-your-profile/Adding-a-Company-Card-or-Personal-Card-for-Travel.md @@ -11,7 +11,7 @@ Add a personal or company credit card to your travel profile so you can quickly ## How to access the travel tool -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. diff --git a/docs/articles/travel/getting-started/setting-up-your-profile/Adding-a-Secondary-Approver.md b/docs/articles/travel/getting-started/setting-up-your-profile/Adding-a-Secondary-Approver.md index dee624d0bf49..66181ccb2607 100644 --- a/docs/articles/travel/getting-started/setting-up-your-profile/Adding-a-Secondary-Approver.md +++ b/docs/articles/travel/getting-started/setting-up-your-profile/Adding-a-Secondary-Approver.md @@ -11,7 +11,7 @@ Add a secondary approver to your travel profile so that bookings can move forwar ## How to access the travel tool -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. diff --git a/docs/articles/travel/getting-started/setting-up-your-profile/Adding-a-Travel-Arranger.md b/docs/articles/travel/getting-started/setting-up-your-profile/Adding-a-Travel-Arranger.md index 0ce193cb456a..8de2d8b155f1 100644 --- a/docs/articles/travel/getting-started/setting-up-your-profile/Adding-a-Travel-Arranger.md +++ b/docs/articles/travel/getting-started/setting-up-your-profile/Adding-a-Travel-Arranger.md @@ -11,7 +11,7 @@ Assign travel arrangers to your profile so designated teammates can book trips o ## How to access the travel tool -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. diff --git a/docs/articles/travel/getting-started/setting-up-your-profile/Setting-Travel-Preferences.md b/docs/articles/travel/getting-started/setting-up-your-profile/Setting-Travel-Preferences.md index 1e02a490e0e6..86c9a2686172 100644 --- a/docs/articles/travel/getting-started/setting-up-your-profile/Setting-Travel-Preferences.md +++ b/docs/articles/travel/getting-started/setting-up-your-profile/Setting-Travel-Preferences.md @@ -11,7 +11,7 @@ Set your personal travel preferences for flights, hotels, cars, and trains so th ## How to access the travel tool -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. diff --git a/docs/articles/travel/managing-trips/Canceling-Travel-Arrangements.md b/docs/articles/travel/managing-trips/Canceling-Travel-Arrangements.md index 6fe1dadd231a..3a3eabfd12ee 100644 --- a/docs/articles/travel/managing-trips/Canceling-Travel-Arrangements.md +++ b/docs/articles/travel/managing-trips/Canceling-Travel-Arrangements.md @@ -11,7 +11,7 @@ You can cancel flights, hotels, cars, or rail bookings directly from your trip v ## How to access the travel tool -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. diff --git a/docs/articles/travel/managing-trips/Creating-Trips.md b/docs/articles/travel/managing-trips/Creating-Trips.md index 520430ffb426..81f6ded313e1 100644 --- a/docs/articles/travel/managing-trips/Creating-Trips.md +++ b/docs/articles/travel/managing-trips/Creating-Trips.md @@ -11,7 +11,7 @@ Create a trip in Expensify to bundle all your flights, hotels, and car rentals i ## How to access the travel tool -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. diff --git a/docs/articles/travel/managing-trips/Editing-Travel-Arrangements.md b/docs/articles/travel/managing-trips/Editing-Travel-Arrangements.md index b5c7ed39926c..1fbe4cf49fa9 100644 --- a/docs/articles/travel/managing-trips/Editing-Travel-Arrangements.md +++ b/docs/articles/travel/managing-trips/Editing-Travel-Arrangements.md @@ -11,7 +11,7 @@ Make changes to your travel bookings directly in Expensify without needing to co ## How to access the travel tool -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. diff --git a/docs/articles/travel/managing-trips/View-Upcoming-and-Past-Trips.md b/docs/articles/travel/managing-trips/View-Upcoming-and-Past-Trips.md index 48c5b1ac068a..7d9c086e1d96 100644 --- a/docs/articles/travel/managing-trips/View-Upcoming-and-Past-Trips.md +++ b/docs/articles/travel/managing-trips/View-Upcoming-and-Past-Trips.md @@ -11,7 +11,7 @@ Use the **Trips** dropdown in the travel tool to view and manage upcoming, compl ## How to access the travel tool -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. diff --git a/docs/articles/travel/travel-analytics/Overview-of-Analytics.md b/docs/articles/travel/travel-analytics/Overview-of-Analytics.md index ec3ff78ebe99..3c557ce5c732 100644 --- a/docs/articles/travel/travel-analytics/Overview-of-Analytics.md +++ b/docs/articles/travel/travel-analytics/Overview-of-Analytics.md @@ -11,7 +11,7 @@ Expensify Travel analytics give travelers, approvers, and admins real-time visib ## Where to find Expensify Travel -Tap the green ➕ **Create** button at the bottom of your screen, then choose **Book travel**. +Tap the **➕ Create** button at the bottom of your screen, then choose **Book travel**. If you don’t see **Book travel**, ask a Workspace Admin to [enable Expensify Travel](https://help.expensify.com/articles/travel/company-setup/Enable-Travel-on-a-Workspace) on the workspace. diff --git a/docs/articles/travel/travel-analytics/Traveler-Safety.md b/docs/articles/travel/travel-analytics/Traveler-Safety.md index 75f09e6333ad..ca447a06dde1 100644 --- a/docs/articles/travel/travel-analytics/Traveler-Safety.md +++ b/docs/articles/travel/travel-analytics/Traveler-Safety.md @@ -8,7 +8,7 @@ Expensify Travel supports your company’s duty of care obligations by giving ad ## How to access Expensify Travel -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. Note: Only Travel admins can access the duty of care and safety tool. diff --git a/docs/articles/travel/travel-analytics/Viewing-Carbon-Emissions.md b/docs/articles/travel/travel-analytics/Viewing-Carbon-Emissions.md index 02648b4f127c..bc3fbb640ad3 100644 --- a/docs/articles/travel/travel-analytics/Viewing-Carbon-Emissions.md +++ b/docs/articles/travel/travel-analytics/Viewing-Carbon-Emissions.md @@ -11,7 +11,7 @@ Use the Analytics dashboard in Expensify to monitor CO2 emissions from travel ac ## How to access the travel tool -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. diff --git a/docs/articles/travel/travel-policy/Approving-Travel.md b/docs/articles/travel/travel-policy/Approving-Travel.md index 9031bc9854b4..b18231b6de1a 100644 --- a/docs/articles/travel/travel-policy/Approving-Travel.md +++ b/docs/articles/travel/travel-policy/Approving-Travel.md @@ -10,7 +10,7 @@ Approving travel is easy and flexible, giving approvers full control over how an ## How to access Expensify Travel -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. diff --git a/docs/articles/travel/travel-policy/Configuring-Car-Settings.md b/docs/articles/travel/travel-policy/Configuring-Car-Settings.md index bbc57a673c22..32448fbe0b4f 100644 --- a/docs/articles/travel/travel-policy/Configuring-Car-Settings.md +++ b/docs/articles/travel/travel-policy/Configuring-Car-Settings.md @@ -8,7 +8,7 @@ Set rental car preferences that balance traveler convenience with company policy ## How to access Expensify Travel -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. diff --git a/docs/articles/travel/travel-policy/Configuring-Flight-Settings.md b/docs/articles/travel/travel-policy/Configuring-Flight-Settings.md index 1b4978ec812a..017136e6e416 100644 --- a/docs/articles/travel/travel-policy/Configuring-Flight-Settings.md +++ b/docs/articles/travel/travel-policy/Configuring-Flight-Settings.md @@ -11,7 +11,7 @@ Set clear, customizable flight rules to control how travelers book airfare in Ex ## Where to find Expensify Travel -Tap the green ➕ **Create** button at the bottom of your screen, then choose **Book travel**. +Tap the **➕ Create** button at the bottom of your screen, then choose **Book travel**. If you don’t see **Book travel**, ask a Workspace Admin to [enable Expensify Travel](https://help.expensify.com/articles/travel/company-setup/Enable-Travel-on-a-Workspace) on the workspace. diff --git a/docs/articles/travel/travel-policy/Configuring-Hotel-Settings.md b/docs/articles/travel/travel-policy/Configuring-Hotel-Settings.md index 63853e7fa069..d337c7093fea 100644 --- a/docs/articles/travel/travel-policy/Configuring-Hotel-Settings.md +++ b/docs/articles/travel/travel-policy/Configuring-Hotel-Settings.md @@ -10,7 +10,7 @@ Customize hotel booking policies to guide travelers toward compliant, cost-effec ## Where to find Expensify Travel -Tap the green ➕ **Create** button at the bottom of your screen, then choose **Book travel**. +Tap the **➕ Create** button at the bottom of your screen, then choose **Book travel**. If you don’t see **Book travel**, ask a Workspace Admin to [enable Expensify Travel](https://help.expensify.com/articles/travel/company-setup/Enable-Travel-on-a-Workspace) on the workspace. diff --git a/docs/articles/travel/travel-policy/Configuring-Rail-Settings.md b/docs/articles/travel/travel-policy/Configuring-Rail-Settings.md index 11760b499f92..307ca322b067 100644 --- a/docs/articles/travel/travel-policy/Configuring-Rail-Settings.md +++ b/docs/articles/travel/travel-policy/Configuring-Rail-Settings.md @@ -8,7 +8,7 @@ Streamline rail travel by defining booking rules based on class, cost, and timin ## How to access Expensify Travel -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. Note: Only Travel Admins can create or manage travel policies in Expensify Travel. diff --git a/docs/articles/travel/travel-policy/Deleting-a-Travel-Policy.md b/docs/articles/travel/travel-policy/Deleting-a-Travel-Policy.md index d3f6a0dc23f1..d4a7e0101c12 100644 --- a/docs/articles/travel/travel-policy/Deleting-a-Travel-Policy.md +++ b/docs/articles/travel/travel-policy/Deleting-a-Travel-Policy.md @@ -8,7 +8,7 @@ Remove outdated or unused travel policies to keep your Expensify Travel setup or ## How to access Expensify Travel -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. Note: Only Travel Admins can create or manage travel policies in Expensify Travel. diff --git a/docs/articles/travel/travel-policy/Multi-Policy-Configurations.md b/docs/articles/travel/travel-policy/Multi-Policy-Configurations.md index ec72c4f014fa..bf31caefb822 100644 --- a/docs/articles/travel/travel-policy/Multi-Policy-Configurations.md +++ b/docs/articles/travel/travel-policy/Multi-Policy-Configurations.md @@ -9,7 +9,7 @@ Set up multiple travel policies to manage different booking rules, traveler type ## How to access Expensify Travel -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. Note: Only Travel Admins can create or manage travel policies in Expensify Travel. diff --git a/docs/articles/travel/travel-policy/Setting-Approval-Types-and-Adding-Members.md b/docs/articles/travel/travel-policy/Setting-Approval-Types-and-Adding-Members.md index 1c614f9ad466..63bd0077936b 100644 --- a/docs/articles/travel/travel-policy/Setting-Approval-Types-and-Adding-Members.md +++ b/docs/articles/travel/travel-policy/Setting-Approval-Types-and-Adding-Members.md @@ -11,7 +11,7 @@ Expensify Travel policies give admins powerful tools to control booking behavior ## How to access Expensify Travel -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. Note: Only Travel Admins can create or manage travel policies in Expensify Travel. diff --git a/docs/assets/images/ExpensifyHelp-DuplicateExpense_01.png b/docs/assets/images/ExpensifyHelp-DuplicateExpense_01.png new file mode 100644 index 000000000000..95174b7cf5f5 Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-DuplicateExpense_01.png differ diff --git a/docs/assets/images/ExpensifyHelp-FixPersonalCards.png b/docs/assets/images/ExpensifyHelp-FixPersonalCards.png new file mode 100644 index 000000000000..b5c668925c98 Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-FixPersonalCards.png differ diff --git a/docs/assets/images/ExpensifyHelp-ResolveDuplicates_01.png b/docs/assets/images/ExpensifyHelp-ResolveDuplicates_01.png new file mode 100644 index 000000000000..bb657b74ee7f Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-ResolveDuplicates_01.png differ diff --git a/docs/assets/images/ExpensifyHelp-ResolveDuplicates_02.png b/docs/assets/images/ExpensifyHelp-ResolveDuplicates_02.png new file mode 100644 index 000000000000..c1d53ace72b5 Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-ResolveDuplicates_02.png differ diff --git a/docs/assets/images/ExpensifyHelp-ResolveDuplicates_03.png b/docs/assets/images/ExpensifyHelp-ResolveDuplicates_03.png new file mode 100644 index 000000000000..5fbecc06e2fe Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-ResolveDuplicates_03.png differ diff --git a/docs/assets/images/ExpensifyHelp-ResolveDuplicates_04.png b/docs/assets/images/ExpensifyHelp-ResolveDuplicates_04.png new file mode 100644 index 000000000000..7dfd2267daea Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-ResolveDuplicates_04.png differ diff --git a/docs/redirects.csv b/docs/redirects.csv index 8a9482a1ed4f..2ce209d94217 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -963,3 +963,8 @@ https://help.expensify.com/articles/new-expensify/reports-and-expenses/Delete-Ex https://help.expensify.com/articles/new-expensify/reports-and-expenses/Duplicate-Detection,https://help.expensify.com/articles/new-expensify/reports-and-expenses/How-to-Find-and-Resolve-Flagged-Duplicate-Expenses https://help.expensify.com/articles/new-expensify/reports-and-expenses/Merging-expenses,https://help.expensify.com/articles/new-expensify/reports-and-expenses/How-to-Merge-Expenses https://help.expensify.com/articles/new-expensify/reports-and-expenses/How-to-prevent-duplicate-expenses,https://help.expensify.com/articles/new-expensify/reports-and-expenses/Why-Expenses-Duplicate +https://help.expensify.com/articles/new-expensify/connect-credit-cards/Assign-and-Manage-Cards,https://help.expensify.com/articles/new-expensify/connect-credit-cards/Assign-Company-Cards +https://help.expensify.com/articles/new-expensify/connect-credit-cards/Commercial-feeds,https://help.expensify.com/articles/new-expensify/connect-credit-cards/Set-up-a-Commercial-Card-Feed-Connection +https://help.expensify.com/articles/new-expensify/connect-credit-cards/Company-Card-Settings,https://help.expensify.com/articles/new-expensify/connect-credit-cards/Configure-Company-Card-Settings +https://help.expensify.com/articles/new-expensify/connect-credit-cards/Direct-feeds,https://help.expensify.com/articles/new-expensify/connect-credit-cards/Set-up-a-Direct-Company-Card-Feed-Connection +https://help.expensify.com/articles/new-expensify/connect-credit-cards/Personal-Cards,https://help.expensify.com/articles/new-expensify/connect-credit-cards/Manage-Personal-Cards diff --git a/help/_config.yml b/help/_config.yml index 407dfe9fea91..c773bd89025c 100644 --- a/help/_config.yml +++ b/help/_config.yml @@ -9,3 +9,7 @@ github_username: expensify exclude: - README.md - GUIDELINES.md + - Gemfile + - Gemfile.lock + - package.json + - package-lock.json diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 407fae7bb9df..f16fe44553c2 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -23,7 +23,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.3.52 + 9.3.59 CFBundleSignature ???? CFBundleURLTypes @@ -44,7 +44,7 @@ CFBundleVersion - 9.3.52.0 + 9.3.59.3 FullStory OrgId @@ -128,11 +128,15 @@ UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight UISupportedInterfaceOrientations~ipad UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight UIViewControllerBasedStatusBarAppearance diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 3e90091d371d..42b5108cd9cb 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.3.52 + 9.3.59 CFBundleVersion - 9.3.52.0 + 9.3.59.3 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile.lock b/ios/Podfile.lock index ea0bb775ad6f..9c9a00e69093 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -176,8 +176,6 @@ PODS: - hermes-engine - React-Core - ReactCommon - - ExpoSecureStore (14.2.4): - - ExpoModulesCore - ExpoStoreReview (9.0.9): - ExpoModulesCore - ExpoTaskManager (55.0.2): @@ -310,12 +308,33 @@ PODS: - GoogleUtilities/Logger - GoogleUtilities/Privacy - group-ib-fp (2.6.0): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine - RCT-Folly + - RCT-Folly/Fabric - RCTRequired - RCTTypeSafety - - React-Codegen - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core + - SocketRocket + - Yoga - GTMAppAuth (4.1.1): - AppAuth/Core (~> 1.7) - GTMSessionFetcher/Core (< 4.0, >= 3.3) @@ -469,7 +488,6 @@ PODS: - React-RCTText (= 0.83.1) - React-RCTVibration (= 0.83.1) - React-callinvoker (0.83.1) - - React-Codegen (0.1.0) - React-Core (0.83.1): - boost - DoubleConversion @@ -490,7 +508,6 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger - - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -516,7 +533,6 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger - - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -541,7 +557,6 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger - - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -568,7 +583,6 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger - - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -594,7 +608,6 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger - - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -620,7 +633,6 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger - - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -646,7 +658,6 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger - - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -672,7 +683,6 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger - - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -698,7 +708,6 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger - - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -724,7 +733,6 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger - - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -750,7 +758,6 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger - - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -776,7 +783,6 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger - - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -802,7 +808,6 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger - - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -828,7 +833,6 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger - - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -3515,6 +3519,34 @@ PODS: - React-perflogger (= 0.83.1) - React-utils (= 0.83.1) - SocketRocket + - ReactNativeBiometrics (0.15.0): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SocketRocket + - Yoga - ReactNativeHybridApp (0.0.0): - boost - DoubleConversion @@ -4247,7 +4279,6 @@ DEPENDENCIES: - "ExpoLogBox (from `../node_modules/@expo/log-box`)" - ExpoModulesCore (from `../node_modules/expo-modules-core`) - ExpoModulesJSI (from `../node_modules/expo-modules-core`) - - ExpoSecureStore (from `../node_modules/expo-secure-store/ios`) - ExpoStoreReview (from `../node_modules/expo-store-review/ios`) - ExpoTaskManager (from `../node_modules/expo-task-manager/ios`) - ExpoVideo (from `../node_modules/expo-video/ios`) @@ -4356,6 +4387,7 @@ DEPENDENCIES: - ReactAppDependencyProvider (from `build/generated/ios/ReactAppDependencyProvider`) - ReactCodegen (from `build/generated/ios/ReactCodegen`) - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`) + - "ReactNativeBiometrics (from `../node_modules/@sbaiahmed1/react-native-biometrics`)" - "ReactNativeHybridApp (from `../node_modules/@expensify/react-native-hybrid-app`)" - "RNAppleAuthentication (from `../node_modules/@invertase/react-native-apple-authentication`)" - "RNCClipboard (from `../node_modules/@react-native-clipboard/clipboard`)" @@ -4415,7 +4447,6 @@ SPEC REPOS: - Plaid - PromisesObjC - PusherSwift - - React-Codegen - SDWebImage - SDWebImageAVIFCoder - SDWebImageSVGCoder @@ -4461,8 +4492,6 @@ EXTERNAL SOURCES: :path: "../node_modules/expo-modules-core" ExpoModulesJSI: :path: "../node_modules/expo-modules-core" - ExpoSecureStore: - :path: "../node_modules/expo-secure-store/ios" ExpoStoreReview: :path: "../node_modules/expo-store-review/ios" ExpoTaskManager: @@ -4676,6 +4705,8 @@ EXTERNAL SOURCES: :path: build/generated/ios/ReactCodegen ReactCommon: :path: "../node_modules/react-native/ReactCommon" + ReactNativeBiometrics: + :path: "../node_modules/@sbaiahmed1/react-native-biometrics" ReactNativeHybridApp: :path: "../node_modules/@expensify/react-native-hybrid-app" RNAppleAuthentication: @@ -4737,7 +4768,7 @@ SPEC CHECKSUMS: AirshipServiceExtension: 50d11b2f62c4a490d4e81a1c36f70e2ecb70a27e AppAuth: d4f13a8fe0baf391b2108511793e4b479691fb73 AppLogs: 3bc4e9b141dbf265b9464409caaa40416a9ee0e0 - boost: 659a89341ea4ab3df8259733813b52f26d8be9a5 + boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90 DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb EXConstants: b3c63be5f8648e4ab8e6ff5099b62f629247f969 expensify-react-native-background-task: 03c640e1f5649692d058cba48c0a138f024a6dd3 @@ -4753,7 +4784,6 @@ SPEC CHECKSUMS: ExpoLogBox: 89b634d5a8a64c4a6a7caad8f9985a28463c7002 ExpoModulesCore: 1158e7941ddcff1677846dfdec27630e036e3904 ExpoModulesJSI: 455acaa72cb963ceb247df889c8e8cce3e6bbfe6 - ExpoSecureStore: 3f1b632d6d40bcc62b4983ef9199cd079592a50a ExpoStoreReview: 32bb43b6fae9c8db3e33cad69996dff3785eef5f ExpoTaskManager: 23d8ea66d21da98ddcef977f258d1fd62359c2db ExpoVideo: 17748c0ee95e746b6ec75d8522a714cb3e614fa1 @@ -4773,11 +4803,11 @@ SPEC CHECKSUMS: GoogleAppMeasurement: 0dfca1a4b534d123de3945e28f77869d10d0d600 GoogleSignIn: d4281ab6cf21542b1cfaff85c191f230b399d2db GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 - group-ib-fp: 03efaa63a9996535431452a213ed5c7642576c55 + group-ib-fp: 42bc98218d2b4b18eac9a2dd0156a3074e0df082 GTMAppAuth: f69bd07d68cd3b766125f7e072c45d7340dea0de GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 GzipSwift: 893f3e48e597a1a4f62fafcb6514220fcf8287fa - hermes-engine: 197bf260539f6ee9f1156cb60db44a05533d1975 + hermes-engine: 5125891942d0cdeca799d3a5a1bb5e766a783790 libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7 libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 @@ -4804,8 +4834,7 @@ SPEC CHECKSUMS: RCTTypeSafety: 27927d0ca04e419ed9467578b3e6297e37210b5c React: 4bc1f928568ad4bcfd147260f907b4ea5873a03b React-callinvoker: 87f8728235a0dc62e9dc19b3851c829d9347d015 - React-Codegen: 4b8b4817cea7a54b83851d4c1f91f79aa73de30a - React-Core: 176a81025968530159589ec4a67fe2962909fce9 + React-Core: 76bed73b02821e5630e7f2cb2e82432ee964695d React-CoreModules: 752dbfdaeb096658aa0adc4a03ba6214815a08df React-cxxreact: b6798528aa601c6db66e6adc7e2da2b059c8be74 React-debug: b2c9f60a9b7a81cefd737cb61e31c2bc39fdfe17 @@ -4857,7 +4886,7 @@ SPEC CHECKSUMS: React-NativeModulesApple: e554252d69442010807867cc7d70c0008048ad20 React-networking: 669cb54cc7e5b65d7dafeeb36970a1421adc8bb3 React-oscompat: 80166b66da22e7af7fad94474e9997bd52d4c8c6 - React-perflogger: decbf4d10c1f77d687af1d83a6ba9dc1b23715d6 + React-perflogger: d6797918d2b1031e91a9d8f5e7fdd2c8728fb390 React-performancecdpmetrics: 7706707d5dd49d708518a91abe456dcb585a5865 React-performancetimeline: c9807b559901c4298a92f6bcb069f49f518b7020 React-RCTActionSheet: 3bd5f5db9f983cf38d51bb9a7a198e2ebea94821 @@ -4873,7 +4902,7 @@ SPEC CHECKSUMS: React-RCTSettings: 2c45623d6c0f30851a123f621eb9d32298bcbb0c React-RCTText: 0ee70f5dc18004b4d81b2c214267c6cbec058587 React-RCTVibration: 88557e21e7cc3fe76b5b174cba28ff45c6def997 - React-rendererconsistency: 5236fe878921773ef9274c44e189e057672ef5bc + React-rendererconsistency: ac8a9e9ee3eb299458cc848944133ff4be46cc41 React-renderercss: f04cbe3b06ee071c6ca724f41a3c3aa31332601e React-rendererdebug: 2a2e4f7d42abcbec2047e989a1afda5d62905679 React-RuntimeApple: ce2ae0ea88316a7c708be1e6601e4ec5f6febdce @@ -4887,6 +4916,7 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: 0eb286cc274abb059ee601b862ebddac2e681d01 ReactCodegen: d663254bf59e57e5ed7c65638bd45f358a373bba ReactCommon: 15e1e727fa34f760beb7dd52928687fda8edf8dc + ReactNativeBiometrics: a0cbd0e25b86964883d38331f32c7643022b7284 ReactNativeHybridApp: 16ebccf5382436fcb9303ab5f4b50d9942bccf5c RNAppleAuthentication: 9027af8aa92b4719ef1b6030a8e954d37079473a RNCClipboard: e560338bf6cc4656a09ff90610b62ddc0dbdad65 diff --git a/ios/ShareViewController/Info.plist b/ios/ShareViewController/Info.plist index a0b28ec5b41c..92c5a0d7eae8 100644 --- a/ios/ShareViewController/Info.plist +++ b/ios/ShareViewController/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.3.52 + 9.3.59 CFBundleVersion - 9.3.52.0 + 9.3.59.3 NSExtension NSExtensionAttributes diff --git a/ios/bundle-react-native-code-and-images.sh b/ios/bundle-react-native-code-and-images.sh index e1822c07b7d6..41e9d88fc7bd 100755 --- a/ios/bundle-react-native-code-and-images.sh +++ b/ios/bundle-react-native-code-and-images.sh @@ -29,4 +29,4 @@ if [[ -z "$BUNDLE_COMMAND" ]]; then export BUNDLE_COMMAND="bundle" fi -"$NODE_BINARY" --print "require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'" +eval "$("$NODE_BINARY" --print "require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'")" diff --git a/jest.config.js b/jest.config.js index c397bbc89b91..1e13b56d427d 100644 --- a/jest.config.js +++ b/jest.config.js @@ -14,7 +14,7 @@ module.exports = { '^.+\\.svg?$': 'jest-transformer-svg', }, transformIgnorePatterns: [ - '/node_modules/(?!.*(react-native|expo|@noble|react-navigation|uuid|@shopify\/flash-list).*/)', + '/node_modules/(?!.*(react-native|expo|react-navigation|uuid|@shopify\/flash-list).*/)', // Prevent Babel from transforming worklets in this file so they are treated as normal functions, otherwise FormatSelectionUtilsTest won't run. '/node_modules/@expensify/react-native-live-markdown/lib/commonjs/parseExpensiMark.js', ], @@ -35,8 +35,6 @@ module.exports = { moduleNameMapper: { '\\.(lottie)$': '/__mocks__/fileMock.ts', '^group-ib-fp$': '/__mocks__/group-ib-fp.ts', - '@noble/ed25519': '/node_modules/@noble/ed25519/index.ts', - '@noble/hashes/(.*)': '/node_modules/@noble/hashes/src/$1.ts', '^parse-imports-exports$': '/node_modules/parse-imports-exports/index.cjs', }, }; diff --git a/jest/setup.ts b/jest/setup.ts index ee2b1702ba1e..9255404f5d68 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -12,7 +12,6 @@ import mockStorage from 'react-native-onyx/dist/storage/__mocks__'; import type Animated from 'react-native-reanimated'; import 'setimmediate'; import {TextDecoder, TextEncoder} from 'util'; -import * as MockedSecureStore from '@src/libs/MultifactorAuthentication/NativeBiometrics/SecureStore/index.web'; import '@src/polyfills/PromiseWithResolvers'; import mockFSLibrary from './setupMockFullstoryLib'; import setupMockImages from './setupMockImages'; @@ -125,9 +124,6 @@ jest.mock('react-native-share', () => ({ default: jest.fn(), })); -// Jest has no access to the native secure store module, so we mock it with the web implementation. -jest.mock('@src/libs/MultifactorAuthentication/NativeBiometrics/SecureStore', () => MockedSecureStore); - jest.mock('react-native-reanimated', () => ({ ...jest.requireActual('react-native-reanimated/mock'), createAnimatedPropAdapter: jest.fn, @@ -322,6 +318,16 @@ jest.mock('@shopify/react-native-skia', () => ({ listFontFamilies: jest.fn(() => []), })); +jest.mock('@sbaiahmed1/react-native-biometrics', () => ({ + isSensorAvailable: jest.fn(() => Promise.resolve({available: false})), + createKeys: jest.fn(() => Promise.resolve({publicKey: ''})), + deleteKeys: jest.fn(() => Promise.resolve({success: true})), + getAllKeys: jest.fn(() => Promise.resolve({keys: []})), + signWithOptions: jest.fn(() => Promise.resolve({success: false})), + sha256: jest.fn(() => Promise.resolve({hash: ''})), + InputEncoding: {Base64: 'base64', Utf8: 'utf8'}, +})); + jest.mock('victory-native', () => ({ Bar: jest.fn(() => null), CartesianChart: jest.fn( diff --git a/modules/group-ib-fp/group-ib-fp.podspec b/modules/group-ib-fp/group-ib-fp.podspec index c4d6ead3f067..156ba7e656c3 100644 --- a/modules/group-ib-fp/group-ib-fp.podspec +++ b/modules/group-ib-fp/group-ib-fp.podspec @@ -1,7 +1,6 @@ require "json" package = JSON.parse(File.read(File.join(__dir__, "package.json"))) -folly_compiler_flags = '-DFOLLY_NO_CONFIG -DFOLLY_MOBILE=1 -DFOLLY_USE_LIBCPP=1 -Wno-comma -Wno-shorten-64-to-32' Pod::Spec.new do |s| s.name = "group-ib-fp" @@ -11,26 +10,12 @@ Pod::Spec.new do |s| s.license = package["license"] s.authors = package["author"] - s.platforms = { :ios => "11.0" } + s.platforms = { :ios => min_ios_version_supported } s.source = { :git => "https://group-ib.com.git", :tag => "#{s.version}" } s.source_files = "ios/*.{h,m,mm,swift}" - s.dependency "React-Core" s.vendored_frameworks = ['iOS/Frameworks/GIBMobileSdk.xcframework', 'iOS/Frameworks/FPAppsCapability.xcframework', 'iOS/Frameworks/FPCallCapability.xcframework'] - # Don't install the dependencies when we run `pod install` in the old architecture. - if ENV['RCT_NEW_ARCH_ENABLED'] == '1' then - s.compiler_flags = folly_compiler_flags + " -DRCT_NEW_ARCH_ENABLED=1" - s.pod_target_xcconfig = { - "HEADER_SEARCH_PATHS" => "\"$(PODS_ROOT)/boost\"", - "OTHER_CPLUSPLUSFLAGS" => "-DFOLLY_NO_CONFIG -DFOLLY_MOBILE=1 -DFOLLY_USE_LIBCPP=1", - "CLANG_CXX_LANGUAGE_STANDARD" => "c++17" - } - s.dependency "React-Codegen" - s.dependency "RCT-Folly" - s.dependency "RCTRequired" - s.dependency "RCTTypeSafety" - s.dependency "ReactCommon/turbomodule/core" - end + install_modules_dependencies(s) end diff --git a/package-lock.json b/package-lock.json index d7f56515a63f..2e592a13c799 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.3.52-0", + "version": "9.3.59-3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.3.52-0", + "version": "9.3.59-3", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -29,8 +29,6 @@ "@fullstory/react-native": "^1.9.0", "@gorhom/portal": "^1.0.14", "@invertase/react-native-apple-authentication": "^2.5.0", - "@noble/ed25519": "^3.0.0", - "@noble/hashes": "^2.0.0", "@onfido/react-native-sdk": "15.1.0", "@pusher/pusher-websocket-react-native": "^1.3.1", "@react-native-camera-roll/camera-roll": "7.4.0", @@ -48,6 +46,7 @@ "@react-navigation/stack": "7.8.5", "@react-ng/bounds-observer": "^0.2.1", "@rnmapbox/maps": "10.1.44", + "@sbaiahmed1/react-native-biometrics": "0.15.0", "@sentry/react-native": "8.2.0", "@shopify/flash-list": "2.3.0", "@shopify/react-native-skia": "^2.4.14", @@ -68,7 +67,6 @@ "expo-image-manipulator": "55.0.2", "expo-location": "55.0.3", "expo-modules-core": "55.0.4", - "expo-secure-store": "~14.2.4", "expo-store-review": "~9.0.8", "expo-task-manager": "55.0.2", "expo-video": "55.0.3", @@ -92,7 +90,7 @@ "react-content-loader": "^7.0.0", "react-dom": "19.2.0", "react-error-boundary": "^4.0.11", - "react-fast-pdf": "^1.0.29", + "react-fast-pdf": "^1.0.33", "react-is": "^18.3.1", "react-map-gl": "^7.1.3", "react-native": "0.83.1", @@ -116,7 +114,7 @@ "react-native-localize": "^3.5.4", "react-native-nitro-modules": "0.29.4", "react-native-nitro-sqlite": "9.2.0", - "react-native-onyx": "3.0.54", + "react-native-onyx": "3.0.59", "react-native-pager-view": "8.0.0", "react-native-pdf": "7.0.2", "react-native-permissions": "^5.4.0", @@ -10274,6 +10272,256 @@ "react": ">=16" } }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz", + "integrity": "sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.97", + "@napi-rs/canvas-darwin-arm64": "0.1.97", + "@napi-rs/canvas-darwin-x64": "0.1.97", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.97", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.97", + "@napi-rs/canvas-linux-arm64-musl": "0.1.97", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.97", + "@napi-rs/canvas-linux-x64-gnu": "0.1.97", + "@napi-rs/canvas-linux-x64-musl": "0.1.97", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.97", + "@napi-rs/canvas-win32-x64-msvc": "0.1.97" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.97.tgz", + "integrity": "sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.97.tgz", + "integrity": "sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.97.tgz", + "integrity": "sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.97.tgz", + "integrity": "sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.97.tgz", + "integrity": "sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.97.tgz", + "integrity": "sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.97.tgz", + "integrity": "sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.97.tgz", + "integrity": "sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.97.tgz", + "integrity": "sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.97.tgz", + "integrity": "sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.97.tgz", + "integrity": "sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", @@ -10320,27 +10568,6 @@ "eslint-scope": "5.1.1" } }, - "node_modules/@noble/ed25519": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.0.0.tgz", - "integrity": "sha512-QyteqMNm0GLqfa5SoYbSC3+Pvykwpn95Zgth4MFVSMKBB75ELl9tX1LAVsN4c3HXOrakHsF2gL4zWDAYCcsnzg==", - "license": "MIT", - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/hashes": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.0.tgz", - "integrity": "sha512-h8VUBlE8R42+XIDO229cgisD287im3kdY6nbNZJFjc6ZvKIXPYXe6Vc/t+kyjFdMFyt5JpapzTsEg8n63w5/lw==", - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "devOptional": true, @@ -13223,6 +13450,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@sbaiahmed1/react-native-biometrics": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@sbaiahmed1/react-native-biometrics/-/react-native-biometrics-0.15.0.tgz", + "integrity": "sha512-z/CWCcQZWGj8WRSsxu8flNydLAOS33Cv9tFPY4EuUm7Z47ADxjenoi/RFWNmJEQ5dIbGYC+q8WyzP0yWebawQA==", + "license": "MIT", + "workspaces": [ + "example" + ], + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/@sentry-internal/browser-utils": { "version": "10.39.0", "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.39.0.tgz", @@ -19809,6 +20049,8 @@ }, "node_modules/clsx": { "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "license": "MIT", "engines": { "node": ">=6" @@ -21339,31 +21581,6 @@ "node": ">=0.10" } }, - "node_modules/decompress-response": { - "version": "6.0.0", - "license": "MIT", - "optional": true, - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/decompress-response/node_modules/mimic-response": { - "version": "3.1.0", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/dedent": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", @@ -21381,14 +21598,6 @@ "node": ">=6" } }, - "node_modules/deep-extend": { - "version": "0.6.0", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/deep-is": { "version": "0.1.4", "dev": true, @@ -21911,14 +22120,6 @@ "iconv-lite": "^0.6.2" } }, - "node_modules/end-of-stream": { - "version": "1.4.4", - "license": "MIT", - "optional": true, - "dependencies": { - "once": "^1.4.0" - } - }, "node_modules/endent": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/endent/-/endent-2.1.0.tgz", @@ -23374,14 +23575,6 @@ "node": ">= 0.8.0" } }, - "node_modules/expand-template": { - "version": "2.0.3", - "license": "(MIT OR WTFPL)", - "optional": true, - "engines": { - "node": ">=6" - } - }, "node_modules/expect": { "version": "29.7.0", "dev": true, @@ -23746,15 +23939,6 @@ "react-native": "*" } }, - "node_modules/expo-secure-store": { - "version": "14.2.4", - "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-14.2.4.tgz", - "integrity": "sha512-ePaz4fnTitJJZjAiybaVYGfLWWyaEtepZC+vs9ZBMhQMfG5HUotIcVsDaSo3FnwpHmgwsLVPY2qFeryI6AtULw==", - "license": "MIT", - "peerDependencies": { - "expo": "*" - } - }, "node_modules/expo-server": { "version": "55.0.6", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-55.0.6.tgz", @@ -25369,11 +25553,6 @@ "node": ">= 0.6" } }, - "node_modules/fs-constants": { - "version": "1.0.0", - "license": "MIT", - "optional": true - }, "node_modules/fs-fingerprint": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/fs-fingerprint/-/fs-fingerprint-0.11.0.tgz", @@ -25698,11 +25877,6 @@ "node": ">=6" } }, - "node_modules/github-from-package": { - "version": "0.0.0", - "license": "MIT", - "optional": true - }, "node_modules/gl-matrix": { "version": "3.4.3", "license": "MIT" @@ -26676,11 +26850,6 @@ "version": "2.0.4", "license": "ISC" }, - "node_modules/ini": { - "version": "1.3.8", - "license": "ISC", - "optional": true - }, "node_modules/inline-style-prefixer": { "version": "7.0.1", "license": "MIT", @@ -30645,7 +30814,9 @@ } }, "node_modules/make-cancellable-promise": { - "version": "1.3.2", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/make-cancellable-promise/-/make-cancellable-promise-2.0.0.tgz", + "integrity": "sha512-3SEQqTpV9oqVsIWqAcmDuaNeo7yBO3tqPtqGRcKkEo0lrzD3wqbKG9mkxO65KoOgXqj+zH2phJ2LiAsdzlogSw==", "license": "MIT", "funding": { "url": "https://github.com/wojtekmaj/make-cancellable-promise?sponsor=1" @@ -30683,7 +30854,9 @@ "license": "ISC" }, "node_modules/make-event-props": { - "version": "1.6.2", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/make-event-props/-/make-event-props-2.0.0.tgz", + "integrity": "sha512-G/hncXrl4Qt7mauJEXSg3AcdYzmpkIITTNl5I+rH9sog5Yw0kK6vseJjCaPfOXqOqQuPUP89Rkhfz5kPS8ijtw==", "license": "MIT", "funding": { "url": "https://github.com/wojtekmaj/make-event-props?sponsor=1" @@ -30851,7 +31024,9 @@ } }, "node_modules/merge-refs": { - "version": "1.3.0", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-refs/-/merge-refs-2.0.0.tgz", + "integrity": "sha512-3+B21mYK2IqUWnd2EivABLT7ueDhb0b8/dGK8LoFQPrU61YITeCMn14F7y7qZafWNZhUEKb24cJdiT5Wxs3prg==", "license": "MIT", "funding": { "url": "https://github.com/wojtekmaj/merge-refs?sponsor=1" @@ -31997,11 +32172,6 @@ "node": ">=10" } }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "license": "MIT", - "optional": true - }, "node_modules/mrmime": { "version": "2.0.0", "dev": true, @@ -32084,11 +32254,6 @@ "picocolors": "^1.1.1" } }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "license": "MIT", - "optional": true - }, "node_modules/natural-compare": { "version": "1.4.0", "dev": true, @@ -32163,19 +32328,6 @@ "node": ">=12.0.0" } }, - "node_modules/node-abi": { - "version": "3.78.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.78.0.tgz", - "integrity": "sha512-E2wEyrgX/CqvicaQYU3Ze1PFGjc4QYPGsjUrlYkqAE0WjHEZwgOsGMPMzkMse4LjJbDmaEuDX3CM036j5K2DSQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-abort-controller": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", @@ -32227,6 +32379,13 @@ "version": "0.4.0", "license": "MIT" }, + "node_modules/node-readable-to-web-readable-stream": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz", + "integrity": "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==", + "license": "MIT", + "optional": true + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -33096,14 +33255,6 @@ "node": ">=8" } }, - "node_modules/path2d": { - "version": "0.2.2", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=6" - } - }, "node_modules/pathval": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", @@ -33126,34 +33277,18 @@ } }, "node_modules/pdfjs-dist": { - "version": "4.8.69", + "version": "5.6.205", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.6.205.tgz", + "integrity": "sha512-tlUj+2IDa7G1SbvBNN74UHRLJybZDWYom+k6p5KIZl7huBvsA4APi6mKL+zCxd3tLjN5hOOEE9Tv7VdzO88pfg==", "license": "Apache-2.0", "engines": { - "node": ">=18" + "node": ">=20.19.0 || >=22.13.0 || >=24" }, "optionalDependencies": { - "canvas": "^3.0.0-rc2", - "path2d": "^0.2.1" - } - }, - "node_modules/pdfjs-dist/node_modules/canvas": { - "version": "3.1.0", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-addon-api": "^7.0.0", - "prebuild-install": "^7.1.1" - }, - "engines": { - "node": "^18.12.0 || >= 20.9.0" + "@napi-rs/canvas": "^0.1.96", + "node-readable-to-web-readable-stream": "^0.4.2" } }, - "node_modules/pdfjs-dist/node_modules/node-addon-api": { - "version": "7.1.1", - "license": "MIT", - "optional": true - }, "node_modules/peggy": { "version": "4.0.3", "dev": true, @@ -33507,55 +33642,6 @@ "version": "2.0.0", "license": "ISC" }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "license": "MIT", - "optional": true, - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/prebuild-install/node_modules/simple-get": { - "version": "4.0.1", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "dev": true, @@ -33727,15 +33813,6 @@ "url": "https://github.com/sponsors/lupomontero" } }, - "node_modules/pump": { - "version": "3.0.0", - "license": "MIT", - "optional": true, - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, "node_modules/punycode": { "version": "2.3.1", "license": "MIT", @@ -33921,28 +33998,6 @@ "quickselect": "^2.0.0" } }, - "node_modules/rc": { - "version": "1.2.8", - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "optional": true, - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/rc/node_modules/strip-json-comments": { - "version": "2.0.1", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/react": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", @@ -34138,10 +34193,12 @@ "license": "MIT" }, "node_modules/react-fast-pdf": { - "version": "1.0.29", + "version": "1.0.33", + "resolved": "https://registry.npmjs.org/react-fast-pdf/-/react-fast-pdf-1.0.33.tgz", + "integrity": "sha512-AQxCXr3OAUr1Rks/+Mlr2AZBFR71rtojMojtStbUb5HryYFCFE5LVxv9vw5elf/me9ZbuhzqSd/X+YCdun862w==", "license": "MIT", "dependencies": { - "react-pdf": "9.2.0", + "react-pdf": "10.3.0", "react-window": "^1.8.11" }, "engines": { @@ -34150,7 +34207,6 @@ }, "peerDependencies": { "lodash": "4.x", - "pdfjs-dist": "4.8.69", "react": "18.x", "react-dom": "18.x" } @@ -34732,9 +34788,9 @@ } }, "node_modules/react-native-onyx": { - "version": "3.0.54", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-3.0.54.tgz", - "integrity": "sha512-202+t6reV9iQZnr5UOGHaJLCyO5X7Or0V2GHfvb5z10ZM1wnnZ0IKPkfUi+7WPZy4pFhEvDKymuaCIOu6++/rA==", + "version": "3.0.59", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-3.0.59.tgz", + "integrity": "sha512-HNm1kCSpY2bFC2gEdsr8gssJsZEaH9f/rNkI9AP2h464z74VMlkR/01a1UkCXPWwAmPnKeS2MAHdmOyfzjuSaQ==", "license": "MIT", "dependencies": { "ascii-table": "0.0.9", @@ -34755,8 +34811,7 @@ "react-native": ">=0.75.0", "react-native-device-info": "^10.3.0", "react-native-nitro-modules": ">=0.27.2", - "react-native-nitro-sqlite": "^9.2.0", - "react-native-performance": ">=5.1.0" + "react-native-nitro-sqlite": "^9.2.0" }, "peerDependenciesMeta": { "idb-keyval": { @@ -34770,9 +34825,6 @@ }, "react-native-nitro-sqlite": { "optional": true - }, - "react-native-performance": { - "optional": true } } }, @@ -34805,17 +34857,6 @@ "react-native-blob-util": ">=0.13.7" } }, - "node_modules/react-native-performance": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/react-native-performance/-/react-native-performance-6.0.0.tgz", - "integrity": "sha512-Sca75O8jhqXAnNbqvINnrw248Kv9cIwoGxToD8u2uX+BrkAxxXS+YhClEV5L3JdiOpdNCO1MJ5R9bgs2VkNpFg==", - "license": "MIT", - "optional": true, - "peer": true, - "peerDependencies": { - "react-native": "*" - } - }, "node_modules/react-native-permissions": { "version": "5.4.0", "license": "MIT", @@ -35201,15 +35242,17 @@ } }, "node_modules/react-pdf": { - "version": "9.2.0", + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-10.3.0.tgz", + "integrity": "sha512-2LQzC9IgNVAX8gM+6F+1t/70a9/5RWThYxc+CWAmT2LW/BRmnj+35x1os5j/nR2oldyf8L+hCAMBmVKU8wrYFA==", "license": "MIT", "dependencies": { "clsx": "^2.0.0", "dequal": "^2.0.3", - "make-cancellable-promise": "^1.3.1", - "make-event-props": "^1.6.0", - "merge-refs": "^1.3.0", - "pdfjs-dist": "4.8.69", + "make-cancellable-promise": "^2.0.0", + "make-event-props": "^2.0.0", + "merge-refs": "^2.0.0", + "pdfjs-dist": "5.4.296", "tiny-invariant": "^1.0.0", "warning": "^4.0.0" }, @@ -36496,25 +36539,6 @@ "version": "3.0.7", "license": "ISC" }, - "node_modules/simple-concat": { - "version": "1.0.1", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "optional": true - }, "node_modules/simple-git": { "version": "3.33.0", "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.33.0.tgz", @@ -37483,50 +37507,6 @@ "node": ">=18" } }, - "node_modules/tar-fs": { - "version": "2.1.2", - "license": "MIT", - "optional": true, - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-fs/node_modules/chownr": { - "version": "1.1.4", - "license": "ISC", - "optional": true - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "license": "MIT", - "optional": true, - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/tar-stream/node_modules/readable-stream": { - "version": "3.6.2", - "license": "MIT", - "optional": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/tar/node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", @@ -38185,17 +38165,6 @@ "node": ">=0.6.11 <=0.7.0 || >=0.7.3" } }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, "node_modules/tweetnacl": { "version": "1.0.3", "license": "Unlicense" @@ -38792,6 +38761,8 @@ }, "node_modules/warning": { "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", "license": "MIT", "dependencies": { "loose-envify": "^1.0.0" diff --git a/package.json b/package.json index 8b85112d8070..05567879c208 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.3.52-0", + "version": "9.3.59-3", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -92,8 +92,6 @@ "@fullstory/react-native": "^1.9.0", "@gorhom/portal": "^1.0.14", "@invertase/react-native-apple-authentication": "^2.5.0", - "@noble/ed25519": "^3.0.0", - "@noble/hashes": "^2.0.0", "@onfido/react-native-sdk": "15.1.0", "@pusher/pusher-websocket-react-native": "^1.3.1", "@react-native-camera-roll/camera-roll": "7.4.0", @@ -111,6 +109,7 @@ "@react-navigation/stack": "7.8.5", "@react-ng/bounds-observer": "^0.2.1", "@rnmapbox/maps": "10.1.44", + "@sbaiahmed1/react-native-biometrics": "0.15.0", "@sentry/react-native": "8.2.0", "@shopify/flash-list": "2.3.0", "@shopify/react-native-skia": "^2.4.14", @@ -131,7 +130,6 @@ "expo-image-manipulator": "55.0.2", "expo-location": "55.0.3", "expo-modules-core": "55.0.4", - "expo-secure-store": "~14.2.4", "expo-store-review": "~9.0.8", "expo-task-manager": "55.0.2", "expo-video": "55.0.3", @@ -155,7 +153,7 @@ "react-content-loader": "^7.0.0", "react-dom": "19.2.0", "react-error-boundary": "^4.0.11", - "react-fast-pdf": "^1.0.29", + "react-fast-pdf": "^1.0.33", "react-is": "^18.3.1", "react-map-gl": "^7.1.3", "react-native": "0.83.1", @@ -179,7 +177,7 @@ "react-native-localize": "^3.5.4", "react-native-nitro-modules": "0.29.4", "react-native-nitro-sqlite": "9.2.0", - "react-native-onyx": "3.0.54", + "react-native-onyx": "3.0.59", "react-native-pager-view": "8.0.0", "react-native-pdf": "7.0.2", "react-native-permissions": "^5.4.0", @@ -391,6 +389,7 @@ "eslint": "^9.36.0" }, "node-abi": "^3.78.0", + "pdfjs-dist": "^5.5.207", "lightningcss": "1.30.1", "@fullstory/react-native": { "expo": "55.0.0" @@ -419,9 +418,7 @@ "expo-keep-awake" ], "android": { - "buildFromSource": [ - "expo-secure-store" - ] + "buildFromSource": [] } } }, diff --git a/patches/expo-secure-store/details.md b/patches/expo-secure-store/details.md deleted file mode 100644 index d6d176d012a1..000000000000 --- a/patches/expo-secure-store/details.md +++ /dev/null @@ -1,76 +0,0 @@ -# `expo-secure-store` patches - -### [expo-secure-store+14.2.4+001+enable-device-fallback.patch](expo-secure-store+14.2.4+001+enable-device-fallback.patch) - -- Reason: - - ``` - We need to enable users to use any device screen lock instead of biometrics. - This is not enabled in the SecureStore, so this patch changes the required input to either biometrics or screen lock knowledge if the 'enableDeviceFallback' flag is set to true. - Additionally, support for screen locks can be checked using the new 'canUseDeviceCredentialsAuthentication' method. - ``` - -- Upstream PR/issue: https://github.com/expo/expo/pull/41409 -- E/App issue: https://github.com/Expensify/App/issues/75225 -- PR introducing patch: https://github.com/Expensify/App/pull/76288 - -### [expo-secure-store+14.2.4+002+return-auth-type.patch](expo-secure-store+14.2.4+002+return-auth-type.patch) - -- Reason: - - ``` - This patch adds the `returnUsedAuthenticationType` flag. - When this flag is set to true, the get methods of SecureStore will return a two-element array. - The first value will be the original value returned when this flag is set to false. - The second value is the authentication type used to read the value from the AUTH_TYPE object. - As for the set function, the returned value will simply be AUTH_TYPE. - It uses a pre-defined constant that mimics an enum and can also be imported directly from the app. - ``` - -- Upstream PR/issue: TBA -- E/App issue: https://github.com/Expensify/App/issues/75225 -- PR introducing patch: https://github.com/Expensify/App/pull/76288 - -### [expo-secure-store+14.2.4+003+force-authentication-on-save.patch](expo-secure-store+14.2.4+003+force-authentication-on-save.patch) - -- Reason: - - ``` - The iOS does not require authentication when a value is saved to the keychain. We cannot force iOS to do so. - However, to maintain consistency with Android, setting the 'forceAuthenticationOnSave' flag to true results in a prompt appearing - before the value is saved. This only works in the asynchronous version of the save method. - ``` - -- Upstream PR/issue: TBA -- E/App issue: https://github.com/Expensify/App/issues/75225 -- PR introducing patch: https://github.com/Expensify/App/pull/76288 - -### [expo-secure-store+14.2.4+004+fail-on-update.patch](expo-secure-store+14.2.4+004+fail-on-update.patch) - -- Reason: - - ``` - If a value already exists in the SecureStore and a new one is saved, the existing value is simply overwritten. - To avoid unexpected behaviour, set the 'failOnUpdate' flag to true to trigger an error when the given key is already in the store. - ``` - -- Upstream PR/issue: TBA -- E/App issue: https://github.com/Expensify/App/issues/75225 -- PR introducing patch: https://github.com/Expensify/App/pull/76288 - -### [expo-secure-store+14.2.4+005+force-read-authentication-on-simulators.patch](expo-secure-store+14.2.4+005+force-read-authentication-on-simulators.patch) - -- Reason: - - ``` - The LocalAuthentication behaves slightly differently on iOS simulators. - In numerous cases, the authentication prompts are skipped on simulators (as opposed to real devices). - Setting 'forceReadAuthenticationOnSimulators' flag to true forces the prompt to appear on simulators when a value with the `requireAuthentication` flag set to true is read. - The flag is added is purely for testing the app on simulators, in cases where the prompt does not appear when the value is read. - It has no effect on real devices. - ``` - -- Upstream PR/issue: TBA -- E/App issue: https://github.com/Expensify/App/issues/75225 -- PR introducing patch: https://github.com/Expensify/App/pull/76288 - diff --git a/patches/expo-secure-store/expo-secure-store+14.2.4+001+enable-device-fallback.patch b/patches/expo-secure-store/expo-secure-store+14.2.4+001+enable-device-fallback.patch deleted file mode 100644 index d5d5edad1a25..000000000000 --- a/patches/expo-secure-store/expo-secure-store+14.2.4+001+enable-device-fallback.patch +++ /dev/null @@ -1,435 +0,0 @@ -diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationHelper.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationHelper.kt -index a281b88..4a1a009 100644 ---- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationHelper.kt -+++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationHelper.kt -@@ -2,6 +2,7 @@ package expo.modules.securestore - - import android.annotation.SuppressLint - import android.app.Activity -+import android.app.KeyguardManager - import android.content.Context - import android.os.Build - import androidx.biometric.BiometricManager -@@ -19,9 +20,9 @@ class AuthenticationHelper( - ) { - private var isAuthenticating = false - -- suspend fun authenticateCipher(cipher: Cipher, requiresAuthentication: Boolean, title: String): Cipher { -+ suspend fun authenticateCipher(cipher: Cipher, requiresAuthentication: Boolean, title: String, enableDeviceFallback: Boolean): Cipher { - if (requiresAuthentication) { -- return openAuthenticationPrompt(cipher, title).cryptoObject?.cipher -+ return openAuthenticationPrompt(cipher, title, enableDeviceFallback).cryptoObject?.cipher - ?: throw AuthenticationException("Couldn't get cipher from authentication result") - } - return cipher -@@ -29,7 +30,8 @@ class AuthenticationHelper( - - private suspend fun openAuthenticationPrompt( - cipher: Cipher, -- title: String -+ title: String, -+ enableDeviceFallback: Boolean - ): BiometricPrompt.AuthenticationResult { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - throw AuthenticationException("Biometric authentication requires Android API 23") -@@ -41,11 +43,16 @@ class AuthenticationHelper( - isAuthenticating = true - - try { -- assertBiometricsSupport() -+ if (enableDeviceFallback) { -+ assertDeviceSecurity() -+ } else { -+ assertBiometricsSupport() -+ } -+ - val fragmentActivity = getCurrentActivity() as? FragmentActivity - ?: throw AuthenticationException("Cannot display biometric prompt when the app is not in the foreground") - -- val authenticationPrompt = AuthenticationPrompt(fragmentActivity, context, title) -+ val authenticationPrompt = AuthenticationPrompt(fragmentActivity, context, title, enableDeviceFallback) - - return withContext(Dispatchers.Main.immediate) { - return@withContext authenticationPrompt.authenticate(cipher) -@@ -56,6 +63,14 @@ class AuthenticationHelper( - } - } - -+ fun assertDeviceSecurity() { -+ val manager = context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager -+ val isSecure = manager.isDeviceSecure -+ if (!isSecure) { -+ throw AuthenticationException("No authentication method available") -+ } -+ } -+ - fun assertBiometricsSupport() { - val biometricManager = BiometricManager.from(context) - @SuppressLint("SwitchIntDef") // BiometricManager.BIOMETRIC_SUCCESS shouldn't do anything -diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationPrompt.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationPrompt.kt -index e5729cc..ce448d6 100644 ---- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationPrompt.kt -+++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationPrompt.kt -@@ -1,5 +1,7 @@ - package expo.modules.securestore - -+import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG -+import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL - import android.content.Context - import androidx.biometric.BiometricPrompt - import androidx.biometric.BiometricPrompt.PromptInfo -@@ -11,12 +13,23 @@ import kotlin.coroutines.resume - import kotlin.coroutines.resumeWithException - import kotlin.coroutines.suspendCoroutine - --class AuthenticationPrompt(private val currentActivity: FragmentActivity, context: Context, title: String) { -+class AuthenticationPrompt(private val currentActivity: FragmentActivity, context: Context, title: String, enableDeviceFallback: Boolean) { -+ private var authType: Int = if (enableDeviceFallback) BIOMETRIC_STRONG or DEVICE_CREDENTIAL else BIOMETRIC_STRONG - private var executor: Executor = ContextCompat.getMainExecutor(context) -- private var promptInfo = PromptInfo.Builder() -- .setTitle(title) -- .setNegativeButtonText(context.getString(android.R.string.cancel)) -- .build() -+ private var promptInfo = buildPromptInfo(context, title, enableDeviceFallback) -+ -+ private fun buildPromptInfo(context: Context, title: String, enableDeviceFallback: Boolean): PromptInfo { -+ var prompt = PromptInfo.Builder() -+ .setTitle(title) -+ .setAllowedAuthenticators(authType) -+ -+ if (!enableDeviceFallback) { -+ prompt = prompt. -+ setNegativeButtonText(context.getString(android.R.string.cancel)) -+ } -+ -+ return prompt.build() -+ } - - suspend fun authenticate(cipher: Cipher): BiometricPrompt.AuthenticationResult? = - suspendCoroutine { continuation -> -diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt -index 0fef884..bf9689b 100644 ---- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt -+++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt -@@ -75,6 +75,15 @@ open class SecureStoreModule : Module() { - } - } - -+ Function("canUseDeviceCredentialsAuthentication") { -+ return@Function try { -+ authenticationHelper.assertDeviceSecurity() -+ true -+ } catch (e: AuthenticationException) { -+ false -+ } -+ } -+ - OnCreate { - authenticationHelper = AuthenticationHelper(reactContext, appContext.legacyModuleRegistry) - hybridAESEncryptor = HybridAESEncryptor(reactContext, mAESEncryptor) -@@ -201,7 +210,7 @@ open class SecureStoreModule : Module() { - back a value. - */ - val secretKeyEntry: SecretKeyEntry = getOrCreateKeyEntry(SecretKeyEntry::class.java, mAESEncryptor, options, options.requireAuthentication) -- val encryptedItem = mAESEncryptor.createEncryptedItem(value, secretKeyEntry, options.requireAuthentication, options.authenticationPrompt, authenticationHelper) -+ val encryptedItem = mAESEncryptor.createEncryptedItem(value, secretKeyEntry, options.requireAuthentication, options.authenticationPrompt, authenticationHelper, options.enableDeviceFallback) - encryptedItem.put(SCHEME_PROPERTY, AESEncryptor.NAME) - saveEncryptedItem(encryptedItem, prefs, keychainAwareKey, options.requireAuthentication, options.keychainService) - -@@ -343,7 +352,11 @@ open class SecureStoreModule : Module() { - return getKeyEntry(keyStoreEntryClass, encryptor, options, requireAuthentication) ?: run { - // Android won't allow us to generate the keys if the device doesn't support biometrics or no biometrics are enrolled - if (requireAuthentication) { -- authenticationHelper.assertBiometricsSupport() -+ if (options.enableDeviceFallback) { -+ authenticationHelper.assertDeviceSecurity() -+ } else { -+ authenticationHelper.assertBiometricsSupport() -+ } - } - encryptor.initializeKeyStoreEntry(keyStore, options) - } -@@ -383,6 +396,7 @@ open class SecureStoreModule : Module() { - private const val KEYSTORE_ALIAS_PROPERTY = "keystoreAlias" - const val USES_KEYSTORE_SUFFIX_PROPERTY = "usesKeystoreSuffix" - const val DEFAULT_KEYSTORE_ALIAS = "key_v1" -+ const val DEFAULT_FALLBACK_KEYSTORE_ALIAS = "fallback_key_v1" - const val AUTHENTICATED_KEYSTORE_SUFFIX = "keystoreAuthenticated" - const val UNAUTHENTICATED_KEYSTORE_SUFFIX = "keystoreUnauthenticated" - } -diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt -index 79a600f..0455fd6 100644 ---- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt -+++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt -@@ -8,5 +8,6 @@ class SecureStoreOptions( - // Prompt can't be an empty string - @Field var authenticationPrompt: String = " ", - @Field var keychainService: String = SecureStoreModule.DEFAULT_KEYSTORE_ALIAS, -- @Field var requireAuthentication: Boolean = false -+ @Field var requireAuthentication: Boolean = false, -+ @Field var enableDeviceFallback: Boolean = false - ) : Record, Serializable -diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/AESEncryptor.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/AESEncryptor.kt -index 3a12dc9..1cec6c0 100644 ---- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/AESEncryptor.kt -+++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/AESEncryptor.kt -@@ -1,9 +1,10 @@ - package expo.modules.securestore.encryptors - --import android.annotation.TargetApi -+import android.os.Build - import android.security.keystore.KeyGenParameterSpec - import android.security.keystore.KeyProperties - import android.util.Base64 -+import androidx.annotation.RequiresApi - import expo.modules.securestore.AuthenticationHelper - import expo.modules.securestore.DecryptException - import expo.modules.securestore.SecureStoreModule -@@ -33,6 +34,11 @@ import javax.crypto.spec.GCMParameterSpec - class AESEncryptor : KeyBasedEncryptor { - override fun getKeyStoreAlias(options: SecureStoreOptions): String { - val baseAlias = options.keychainService -+ -+ if (baseAlias == SecureStoreModule.DEFAULT_KEYSTORE_ALIAS && options.enableDeviceFallback) { -+ return "$AES_CIPHER:${SecureStoreModule.DEFAULT_FALLBACK_KEYSTORE_ALIAS}" -+ } -+ - return "$AES_CIPHER:$baseAlias" - } - -@@ -50,17 +56,22 @@ class AESEncryptor : KeyBasedEncryptor { - return "${getKeyStoreAlias(options)}:$suffix" - } - -- @TargetApi(23) -+ @RequiresApi(Build.VERSION_CODES.R) - @Throws(GeneralSecurityException::class) - override fun initializeKeyStoreEntry(keyStore: KeyStore, options: SecureStoreOptions): KeyStore.SecretKeyEntry { - val extendedKeystoreAlias = getExtendedKeyStoreAlias(options, options.requireAuthentication) - val keyPurposes = KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT - -+ val authType = -+ if (options.enableDeviceFallback) KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL -+ else KeyProperties.AUTH_BIOMETRIC_STRONG -+ - val algorithmSpec: AlgorithmParameterSpec = KeyGenParameterSpec.Builder(extendedKeystoreAlias, keyPurposes) - .setKeySize(AES_KEY_SIZE_BITS) - .setBlockModes(KeyProperties.BLOCK_MODE_GCM) - .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) - .setUserAuthenticationRequired(options.requireAuthentication) -+ .setUserAuthenticationParameters(0, authType) - .build() - - val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, keyStore.provider) -@@ -78,14 +89,15 @@ class AESEncryptor : KeyBasedEncryptor { - keyStoreEntry: KeyStore.SecretKeyEntry, - requireAuthentication: Boolean, - authenticationPrompt: String, -- authenticationHelper: AuthenticationHelper -+ authenticationHelper: AuthenticationHelper, -+ enableDeviceFallback: Boolean, - ): JSONObject { - val secretKey = keyStoreEntry.secretKey - val cipher = Cipher.getInstance(AES_CIPHER) - cipher.init(Cipher.ENCRYPT_MODE, secretKey) - - val gcmSpec = cipher.parameters.getParameterSpec(GCMParameterSpec::class.java) -- val authenticatedCipher = authenticationHelper.authenticateCipher(cipher, requireAuthentication, authenticationPrompt) -+ val authenticatedCipher = authenticationHelper.authenticateCipher(cipher, requireAuthentication, authenticationPrompt, enableDeviceFallback) - - return createEncryptedItemWithCipher(plaintextValue, authenticatedCipher, gcmSpec) - } -@@ -128,7 +140,7 @@ class AESEncryptor : KeyBasedEncryptor { - throw DecryptException("Authentication tag length must be at least $MIN_GCM_AUTHENTICATION_TAG_LENGTH bits long", key, options.keychainService) - } - cipher.init(Cipher.DECRYPT_MODE, keyStoreEntry.secretKey, gcmSpec) -- val unlockedCipher = authenticationHelper.authenticateCipher(cipher, requiresAuthentication, options.authenticationPrompt) -+ val unlockedCipher = authenticationHelper.authenticateCipher(cipher, requiresAuthentication, options.authenticationPrompt, options.enableDeviceFallback) - return String(unlockedCipher.doFinal(ciphertextBytes), StandardCharsets.UTF_8) - } - -diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/HybridAESEncryptor.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/HybridAESEncryptor.kt -index fb42599..e996b39 100644 ---- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/HybridAESEncryptor.kt -+++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/HybridAESEncryptor.kt -@@ -51,6 +51,11 @@ class HybridAESEncryptor(private var mContext: Context, private val mAESEncrypto - - override fun getKeyStoreAlias(options: SecureStoreOptions): String { - val baseAlias = options.keychainService -+ -+ if (baseAlias == SecureStoreModule.DEFAULT_KEYSTORE_ALIAS && options.enableDeviceFallback) { -+ return "$RSA_CIPHER:${SecureStoreModule.DEFAULT_FALLBACK_KEYSTORE_ALIAS}" -+ } -+ - return "$RSA_CIPHER:$baseAlias" - } - -@@ -69,7 +74,8 @@ class HybridAESEncryptor(private var mContext: Context, private val mAESEncrypto - keyStoreEntry: KeyStore.PrivateKeyEntry, - requireAuthentication: Boolean, - authenticationPrompt: String, -- authenticationHelper: AuthenticationHelper -+ authenticationHelper: AuthenticationHelper, -+ enableDeviceFallback: Boolean, - ): JSONObject { - // This should never be called after we dropped Android SDK 22 support. - throw EncryptException( -diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/KeyBasedEncryptor.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/KeyBasedEncryptor.kt -index e493467..39459ff 100644 ---- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/KeyBasedEncryptor.kt -+++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/KeyBasedEncryptor.kt -@@ -25,7 +25,8 @@ interface KeyBasedEncryptor { - keyStoreEntry: E, - requireAuthentication: Boolean, - authenticationPrompt: String, -- authenticationHelper: AuthenticationHelper -+ authenticationHelper: AuthenticationHelper, -+ enableDeviceFallback: Boolean, - ): JSONObject - - @Throws(GeneralSecurityException::class, JSONException::class) -diff --git a/node_modules/expo-secure-store/build/SecureStore.d.ts b/node_modules/expo-secure-store/build/SecureStore.d.ts -index d5cd157..835a4d4 100644 ---- a/node_modules/expo-secure-store/build/SecureStore.d.ts -+++ b/node_modules/expo-secure-store/build/SecureStore.d.ts -@@ -78,6 +78,16 @@ export type SecureStoreOptions = { - * @platform ios - */ - accessGroup?: string; -+ /** -+ * This flag enables users to authenticate using Lock Screen Knowledge Factor (e.g. PIN, pattern or password). -+ * For sensitive apps, it is recommended not having biometric fall back to such factor. -+ * @see: https://developer.android.com/security/fraud-prevention/authentication -+ * -+ * @default false -+ * @platform android -+ * @platform ios -+ */ -+ enableDeviceFallback?: boolean; - }; - /** - * Returns whether the SecureStore API is enabled on the current device. This does not check the app -@@ -148,4 +158,11 @@ export declare function getItem(key: string, options?: SecureStoreOptions): stri - * @platform ios - */ - export declare function canUseBiometricAuthentication(): boolean; -+/** -+ * Checks whether any device credentials are configured on the device. -+ * @return `true` if the device has device credentials configured. Otherwise, returns `false`. -+ * @platform android -+ * @platform ios -+ */ -+export declare function canUseDeviceCredentialsAuthentication(): boolean; - //# sourceMappingURL=SecureStore.d.ts.map -\ No newline at end of file -diff --git a/node_modules/expo-secure-store/build/SecureStore.js b/node_modules/expo-secure-store/build/SecureStore.js -index 4d87b38..e11bd94 100644 ---- a/node_modules/expo-secure-store/build/SecureStore.js -+++ b/node_modules/expo-secure-store/build/SecureStore.js -@@ -143,6 +143,15 @@ export function getItem(key, options = {}) { - export function canUseBiometricAuthentication() { - return ExpoSecureStore.canUseBiometricAuthentication(); - } -+/** -+ * Checks whether any device credentials are configured on the device. -+ * @return `true` if the device has device credentials configured. Otherwise, returns `false`. -+ * @platform android -+ * @platform ios -+ */ -+export function canUseDeviceCredentialsAuthentication() { -+ return ExpoSecureStore.canUseDeviceCredentialsAuthentication(); -+} - function ensureValidKey(key) { - if (!isValidKey(key)) { - throw new Error(`Invalid key provided to SecureStore. Keys must not be empty and contain only alphanumeric characters, ".", "-", and "_".`); -diff --git a/node_modules/expo-secure-store/ios/SecureStoreModule.swift b/node_modules/expo-secure-store/ios/SecureStoreModule.swift -index 439b08d..1268979 100644 ---- a/node_modules/expo-secure-store/ios/SecureStoreModule.swift -+++ b/node_modules/expo-secure-store/ios/SecureStoreModule.swift -@@ -66,6 +66,14 @@ public final class SecureStoreModule: Module { - return isBiometricsSupported - #endif - } -+ -+ Function("canUseDeviceCredentialsAuthentication") { () -> Bool in -+ return areDeviceCredentialsEnabled() -+ } -+ } -+ -+ private func areDeviceCredentialsEnabled() -> Bool { -+ return LAContext().canEvaluatePolicy(LAPolicy.deviceOwnerAuthentication, error: nil) - } - - private func get(with key: String, options: SecureStoreOptions) throws -> String? { -@@ -99,12 +107,17 @@ public final class SecureStoreModule: Module { - if !options.requireAuthentication { - setItemQuery[kSecAttrAccessible as String] = accessibility - } else { -- guard let _ = Bundle.main.infoDictionary?["NSFaceIDUsageDescription"] as? String else { -- throw MissingPlistKeyException() -+ if (!options.enableDeviceFallback) { -+ guard let _ = Bundle.main.infoDictionary?["NSFaceIDUsageDescription"] as? String else { -+ throw MissingPlistKeyException() -+ } - } - - var error: Unmanaged? = nil -- guard let accessOptions = SecAccessControlCreateWithFlags(kCFAllocatorDefault, accessibility, .biometryCurrentSet, &error) else { -+ -+ let accessControlFlag: SecAccessControlCreateFlags = options.enableDeviceFallback ? .userPresence : .biometryCurrentSet -+ -+ guard let accessOptions = SecAccessControlCreateWithFlags(kCFAllocatorDefault, accessibility, accessControlFlag, &error) else { - let errorCode = error.map { CFErrorGetCode($0.takeRetainedValue()) } - throw SecAccessControlError(errorCode) - } -diff --git a/node_modules/expo-secure-store/ios/SecureStoreOptions.swift b/node_modules/expo-secure-store/ios/SecureStoreOptions.swift -index 7e3fa4d..c9e843b 100644 ---- a/node_modules/expo-secure-store/ios/SecureStoreOptions.swift -+++ b/node_modules/expo-secure-store/ios/SecureStoreOptions.swift -@@ -15,4 +15,7 @@ internal struct SecureStoreOptions: Record { - - @Field - var accessGroup: String? -+ -+ @Field -+ var enableDeviceFallback: Bool = false - } -diff --git a/node_modules/expo-secure-store/src/SecureStore.ts b/node_modules/expo-secure-store/src/SecureStore.ts -index ee43e04..4f253a1 100644 ---- a/node_modules/expo-secure-store/src/SecureStore.ts -+++ b/node_modules/expo-secure-store/src/SecureStore.ts -@@ -102,6 +102,17 @@ export type SecureStoreOptions = { - * @platform ios - */ - accessGroup?: string; -+ -+ /** -+ * This flag enables users to authenticate using Lock Screen Knowledge Factor (e.g. PIN, pattern or password). -+ * For sensitive apps, it is recommended not having biometric fall back to such factor. -+ * @see: https://developer.android.com/security/fraud-prevention/authentication -+ * -+ * @default false -+ * @platform android -+ * @platform ios -+ */ -+ enableDeviceFallback?: boolean; - }; - - // @needsAudit -@@ -226,6 +237,16 @@ export function canUseBiometricAuthentication(): boolean { - return ExpoSecureStore.canUseBiometricAuthentication(); - } - -+/** -+ * Checks whether any device credentials are configured on the device. -+ * @return `true` if the device has device credentials configured. Otherwise, returns `false`. -+ * @platform android -+ * @platform ios -+ */ -+export function canUseDeviceCredentialsAuthentication(): boolean { -+ return ExpoSecureStore.canUseDeviceCredentialsAuthentication(); -+} -+ - function ensureValidKey(key: string) { - if (!isValidKey(key)) { - throw new Error( diff --git a/patches/expo-secure-store/expo-secure-store+14.2.4+002+return-auth-type.patch b/patches/expo-secure-store/expo-secure-store+14.2.4+002+return-auth-type.patch deleted file mode 100644 index c8cdfcf5ac02..000000000000 --- a/patches/expo-secure-store/expo-secure-store+14.2.4+002+return-auth-type.patch +++ /dev/null @@ -1,901 +0,0 @@ -diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationHelper.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationHelper.kt -index 4a1a009..980ef0a 100644 ---- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationHelper.kt -+++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationHelper.kt -@@ -20,12 +20,12 @@ class AuthenticationHelper( - ) { - private var isAuthenticating = false - -- suspend fun authenticateCipher(cipher: Cipher, requiresAuthentication: Boolean, title: String, enableDeviceFallback: Boolean): Cipher { -- if (requiresAuthentication) { -- return openAuthenticationPrompt(cipher, title, enableDeviceFallback).cryptoObject?.cipher -- ?: throw AuthenticationException("Couldn't get cipher from authentication result") -+ suspend fun authenticateCipher(cipher: Cipher, title: String, enableDeviceFallback: Boolean): BiometricPrompt.AuthenticationResult { -+ val promptResult = openAuthenticationPrompt(cipher, title, enableDeviceFallback) -+ if (promptResult.cryptoObject?.cipher == null) { -+ throw AuthenticationException("Couldn't get cipher from authentication result") - } -- return cipher -+ return promptResult - } - - private suspend fun openAuthenticationPrompt( -diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt -index 3f21552..d209efc 100644 ---- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt -+++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt -@@ -36,23 +36,23 @@ open class SecureStoreModule : Module() { - - AsyncFunction("setValueWithKeyAsync") Coroutine { value: String?, key: String?, options: SecureStoreOptions -> - key ?: throw NullKeyException() -- return@Coroutine setItemImpl(key, value, options, false) -+ return@Coroutine narrowSecureStoreFeedback(SecureStoreFeedbackAction.SET, setItemImpl(key, value, options, false), options).value - } - - AsyncFunction("getValueWithKeyAsync") Coroutine { key: String, options: SecureStoreOptions -> -- return@Coroutine getItemImpl(key, options) -+ return@Coroutine narrowSecureStoreFeedback(SecureStoreFeedbackAction.GET,getItemImpl(key, options), options).value - } - - Function("setValueWithKeySync") { value: String?, key: String?, options: SecureStoreOptions -> - key ?: throw NullKeyException() - return@Function runBlocking { -- return@runBlocking setItemImpl(key, value, options, keyIsInvalidated = false) -+ return@runBlocking narrowSecureStoreFeedback(SecureStoreFeedbackAction.SET, setItemImpl(key, value, options, keyIsInvalidated = false), options).value - } - } - - Function("getValueWithKeySync") { key: String, options: SecureStoreOptions -> - return@Function runBlocking { -- return@runBlocking getItemImpl(key, options) -+ return@runBlocking narrowSecureStoreFeedback(SecureStoreFeedbackAction.GET, getItemImpl(key, options), options).value - } - } - -@@ -94,7 +94,23 @@ open class SecureStoreModule : Module() { - } - } - -- private suspend fun getItemImpl(key: String, options: SecureStoreOptions): String? { -+ private suspend fun narrowSecureStoreFeedback(action: String, feedback: SecureStoreOriginalFeedback, options: SecureStoreOptions): SecureStoreNarrowedFeedback { -+ if (!options.returnUsedAuthenticationType) { -+ return feedback -+ } -+ -+ if (action == SecureStoreFeedbackAction.GET) { -+ return SecureStoreGetFeedback(feedback.source, feedback.authenticationResult) -+ } -+ -+ if (action == SecureStoreFeedbackAction.SET) { -+ return SecureStoreSetFeedback(feedback.source, feedback.authenticationResult) -+ } -+ -+ return feedback -+ } -+ -+ private suspend fun getItemImpl(key: String, options: SecureStoreOptions): SecureStoreOriginalFeedback { - // We use a SecureStore-specific shared preferences file, which lets us do things like enumerate - // its entries or clear all of them - val prefs: SharedPreferences = getSharedPreferences() -@@ -104,10 +120,10 @@ open class SecureStoreModule : Module() { - } else if (prefs.contains(key)) { // For backwards-compatibility try to read using the old key format - return readJSONEncodedItem(key, prefs, options) - } -- return null -+ return SecureStoreOriginalFeedback(null) - } - -- private suspend fun readJSONEncodedItem(key: String, prefs: SharedPreferences, options: SecureStoreOptions): String? { -+ private suspend fun readJSONEncodedItem(key: String, prefs: SharedPreferences, options: SecureStoreOptions): SecureStoreOriginalFeedback { - val keychainAwareKey = createKeychainAwareKey(key, options.keychainService) - - val legacyEncryptedItemString = prefs.getString(key, null) -@@ -126,7 +142,7 @@ open class SecureStoreModule : Module() { - "" - } - -- encryptedItemString ?: return null -+ encryptedItemString ?: return SecureStoreOriginalFeedback(null) - - val encryptedItem: JSONObject = try { - JSONObject(encryptedItemString) -@@ -149,13 +165,13 @@ open class SecureStoreModule : Module() { - "This situation occurs when the app is reinstalled. The value will be removed to avoid future errors. Returning null" - ) - deleteItemImpl(key, options) -- return null -+ return SecureStoreOriginalFeedback(null) - } - return mAESEncryptor.decryptItem(key, encryptedItem, secretKeyEntry, options, authenticationHelper) - } - HybridAESEncryptor.NAME -> { - val privateKeyEntry = getKeyEntryCompat(PrivateKeyEntry::class.java, hybridAESEncryptor, options, requireAuthentication, usesKeystoreSuffix) -- ?: return null -+ ?: return SecureStoreOriginalFeedback(null) - return hybridAESEncryptor.decryptItem(key, encryptedItem, privateKeyEntry, options, authenticationHelper) - } - else -> { -@@ -164,7 +180,7 @@ open class SecureStoreModule : Module() { - } - } catch (e: KeyPermanentlyInvalidatedException) { - Log.w(TAG, "The requested key has been permanently invalidated. Returning null") -- return null -+ return SecureStoreOriginalFeedback(null) - } catch (e: BadPaddingException) { - // The key from the KeyStore is unable to decode the entry. This is because a new key was generated, but the entries are encrypted using the old one. - // This usually means that the user has reinstalled the app. We can safely remove the old value and return null as it's impossible to decrypt it. -@@ -174,7 +190,7 @@ open class SecureStoreModule : Module() { - "The entry in shared preferences is out of sync with the keystore. It will be removed, returning null." - ) - deleteItemImpl(key, options) -- return null -+ return SecureStoreOriginalFeedback(null) - } catch (e: GeneralSecurityException) { - throw (DecryptException(e.message, key, options.keychainService, e)) - } catch (e: CodedException) { -@@ -184,7 +200,7 @@ open class SecureStoreModule : Module() { - } - } - -- private suspend fun setItemImpl(key: String, value: String?, options: SecureStoreOptions, keyIsInvalidated: Boolean) { -+ private suspend fun setItemImpl(key: String, value: String?, options: SecureStoreOptions, keyIsInvalidated: Boolean): SecureStoreOriginalFeedback { - val keychainAwareKey = createKeychainAwareKey(key, options.keychainService) - val prefs: SharedPreferences = getSharedPreferences() - -@@ -193,7 +209,7 @@ open class SecureStoreModule : Module() { - if (!success) { - throw WriteException("Could not write a null value to SecureStore", key, options.keychainService) - } -- return -+ return SecureStoreOriginalFeedback(null) - } - - try { -@@ -210,7 +226,8 @@ open class SecureStoreModule : Module() { - back a value. - */ - val secretKeyEntry: SecretKeyEntry = getOrCreateKeyEntry(SecretKeyEntry::class.java, mAESEncryptor, options, options.requireAuthentication) -- val encryptedItem = mAESEncryptor.createEncryptedItem(value, secretKeyEntry, options.requireAuthentication, options.authenticationPrompt, authenticationHelper, options.enableDeviceFallback) -+ val encryptResult = mAESEncryptor.createEncryptedItem(value, secretKeyEntry, options.requireAuthentication, options.authenticationPrompt, authenticationHelper, options.enableDeviceFallback) -+ val encryptedItem = encryptResult.value - encryptedItem.put(SCHEME_PROPERTY, AESEncryptor.NAME) - saveEncryptedItem(encryptedItem, prefs, keychainAwareKey, options.requireAuthentication, options.keychainService) - -@@ -218,6 +235,9 @@ open class SecureStoreModule : Module() { - if (prefs.contains(key)) { - prefs.edit().remove(key).apply() - } -+ -+ return encryptResult -+ - } catch (e: KeyPermanentlyInvalidatedException) { - if (!keyIsInvalidated) { - Log.w(TAG, "Key has been invalidated, retrying with the key deleted") -diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt -index 0455fd6..4e30440 100644 ---- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt -+++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt -@@ -1,5 +1,6 @@ - package expo.modules.securestore - -+import androidx.biometric.BiometricPrompt - import expo.modules.kotlin.records.Field - import expo.modules.kotlin.records.Record - import java.io.Serializable -@@ -9,5 +10,58 @@ class SecureStoreOptions( - @Field var authenticationPrompt: String = " ", - @Field var keychainService: String = SecureStoreModule.DEFAULT_KEYSTORE_ALIAS, - @Field var requireAuthentication: Boolean = false, -- @Field var enableDeviceFallback: Boolean = false -+ @Field var enableDeviceFallback: Boolean = false, -+ @Field var returnUsedAuthenticationType: Boolean = false - ) : Record, Serializable -+ -+enum class SecureStoreAuthType(index: Int) { -+ UNKNOWN(BiometricPrompt.AUTHENTICATION_RESULT_TYPE_UNKNOWN), -+ CREDENTIAL(BiometricPrompt.AUTHENTICATION_RESULT_TYPE_DEVICE_CREDENTIAL), -+ BIOMETRIC(BiometricPrompt.AUTHENTICATION_RESULT_TYPE_BIOMETRIC), -+ -+ /** Prompt failed, no authentication was used at all */ -+ NONE(0) -+} -+ -+open class SecureStoreFeedbackAction { -+ companion object { -+ const val GET = "GET" -+ const val SET = "SET" -+ } -+} -+ -+abstract class SecureStoreFeedback( -+ val source: T, -+ val authenticationResult: BiometricPrompt.AuthenticationResult? -+) { -+ @Field var authType: SecureStoreAuthType = when (authenticationResult?.authenticationType) { -+ BiometricPrompt.AUTHENTICATION_RESULT_TYPE_UNKNOWN -> { -+ SecureStoreAuthType.UNKNOWN -+ } -+ BiometricPrompt.AUTHENTICATION_RESULT_TYPE_DEVICE_CREDENTIAL -> { -+ SecureStoreAuthType.CREDENTIAL -+ } -+ BiometricPrompt.AUTHENTICATION_RESULT_TYPE_BIOMETRIC -> { -+ SecureStoreAuthType.BIOMETRIC -+ } -+ else -> { -+ SecureStoreAuthType.NONE -+ } -+ } -+ abstract val value: R -+} -+ -+class SecureStoreGetFeedback(source: T, authenticationResult: BiometricPrompt.AuthenticationResult? = null): SecureStoreFeedback>(source, authenticationResult) { -+ /** Used to return easily convertible values to JS code */ -+ @Field override var value: Pair = Pair(source, authType.ordinal) -+} -+ -+class SecureStoreSetFeedback(source: T, authenticationResult: BiometricPrompt.AuthenticationResult? = null): SecureStoreFeedback(source, authenticationResult) { -+ @Field override var value: Int = authType.ordinal -+} -+ -+class SecureStoreOriginalFeedback(source: T, authenticationResult: BiometricPrompt.AuthenticationResult? = null): SecureStoreFeedback(source, authenticationResult) { -+ @Field override var value: T = source -+} -+ -+typealias SecureStoreNarrowedFeedback = SecureStoreFeedback -diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/AESEncryptor.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/AESEncryptor.kt -index 1cd187f..d927ff1 100644 ---- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/AESEncryptor.kt -+++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/AESEncryptor.kt -@@ -5,10 +5,12 @@ import android.security.keystore.KeyGenParameterSpec - import android.security.keystore.KeyProperties - import android.util.Base64 - import androidx.annotation.RequiresApi -+import androidx.biometric.BiometricPrompt - import expo.modules.securestore.AuthenticationHelper - import expo.modules.securestore.DecryptException - import expo.modules.securestore.SecureStoreModule - import expo.modules.securestore.SecureStoreOptions -+import expo.modules.securestore.SecureStoreOriginalFeedback - import org.json.JSONException - import org.json.JSONObject - import java.nio.charset.StandardCharsets -@@ -86,15 +88,23 @@ class AESEncryptor : KeyBasedEncryptor { - authenticationPrompt: String, - authenticationHelper: AuthenticationHelper, - enableDeviceFallback: Boolean, -- ): JSONObject { -+ ): SecureStoreOriginalFeedback { - val secretKey = keyStoreEntry.secretKey - val cipher = Cipher.getInstance(AES_CIPHER) - cipher.init(Cipher.ENCRYPT_MODE, secretKey) - - val gcmSpec = cipher.parameters.getParameterSpec(GCMParameterSpec::class.java) -- val authenticatedCipher = authenticationHelper.authenticateCipher(cipher, requireAuthentication, authenticationPrompt, enableDeviceFallback) -+ var promptResult: BiometricPrompt.AuthenticationResult? = null -+ val authenticatedCipher: Cipher -+ -+ if (requireAuthentication) { -+ promptResult = authenticationHelper.authenticateCipher(cipher, authenticationPrompt, enableDeviceFallback) -+ authenticatedCipher = promptResult.cryptoObject?.cipher ?: cipher -+ } else { -+ authenticatedCipher = cipher -+ } - -- return createEncryptedItemWithCipher(plaintextValue, authenticatedCipher, gcmSpec) -+ return SecureStoreOriginalFeedback(createEncryptedItemWithCipher(plaintextValue, authenticatedCipher, gcmSpec), promptResult) - } - - internal fun createEncryptedItemWithCipher( -@@ -121,7 +131,7 @@ class AESEncryptor : KeyBasedEncryptor { - keyStoreEntry: KeyStore.SecretKeyEntry, - options: SecureStoreOptions, - authenticationHelper: AuthenticationHelper -- ): String { -+ ): SecureStoreOriginalFeedback { - val ciphertext = encryptedItem.getString(CIPHERTEXT_PROPERTY) - val ivString = encryptedItem.getString(IV_PROPERTY) - val authenticationTagLength = encryptedItem.getInt(GCM_AUTHENTICATION_TAG_LENGTH_PROPERTY) -@@ -135,8 +145,17 @@ class AESEncryptor : KeyBasedEncryptor { - throw DecryptException("Authentication tag length must be at least $MIN_GCM_AUTHENTICATION_TAG_LENGTH bits long", key, options.keychainService) - } - cipher.init(Cipher.DECRYPT_MODE, keyStoreEntry.secretKey, gcmSpec) -- val unlockedCipher = authenticationHelper.authenticateCipher(cipher, requiresAuthentication, options.authenticationPrompt, options.enableDeviceFallback) -- return String(unlockedCipher.doFinal(ciphertextBytes), StandardCharsets.UTF_8) -+ var promptResult: BiometricPrompt.AuthenticationResult? = null -+ val unlockedCipher: Cipher -+ -+ if (requiresAuthentication) { -+ promptResult = authenticationHelper.authenticateCipher(cipher, options.authenticationPrompt, options.enableDeviceFallback) -+ unlockedCipher = promptResult.cryptoObject?.cipher ?: cipher -+ } else { -+ unlockedCipher = cipher -+ } -+ -+ return SecureStoreOriginalFeedback(String(unlockedCipher.doFinal(ciphertextBytes), StandardCharsets.UTF_8), promptResult) - } - - companion object { -diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/HybridAESEncryptor.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/HybridAESEncryptor.kt -index 5f8bbfd..7527001 100644 ---- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/HybridAESEncryptor.kt -+++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/HybridAESEncryptor.kt -@@ -9,6 +9,7 @@ import expo.modules.securestore.EncryptException - import expo.modules.securestore.KeyStoreException - import expo.modules.securestore.SecureStoreModule - import expo.modules.securestore.SecureStoreOptions -+import expo.modules.securestore.SecureStoreOriginalFeedback - import org.json.JSONException - import org.json.JSONObject - import java.security.GeneralSecurityException -@@ -71,7 +72,7 @@ class HybridAESEncryptor(private var mContext: Context, private val mAESEncrypto - authenticationPrompt: String, - authenticationHelper: AuthenticationHelper, - enableDeviceFallback: Boolean, -- ): JSONObject { -+ ): SecureStoreOriginalFeedback { - // This should never be called after we dropped Android SDK 22 support. - throw EncryptException( - "HybridAESEncryption should not be used on Android SDK >= 23. This shouldn't happen. " + -@@ -88,7 +89,7 @@ class HybridAESEncryptor(private var mContext: Context, private val mAESEncrypto - keyStoreEntry: KeyStore.PrivateKeyEntry, - options: SecureStoreOptions, - authenticationHelper: AuthenticationHelper -- ): String { -+ ): SecureStoreOriginalFeedback { - // Decrypt the encrypted symmetric key - val encryptedSecretKeyString = encryptedItem.getString(ENCRYPTED_SECRET_KEY_PROPERTY) - val encryptedSecretKeyBytes = Base64.decode(encryptedSecretKeyString, Base64.DEFAULT) -diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/KeyBasedEncryptor.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/KeyBasedEncryptor.kt -index 39459ff..a531aff 100644 ---- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/KeyBasedEncryptor.kt -+++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/KeyBasedEncryptor.kt -@@ -2,6 +2,7 @@ package expo.modules.securestore.encryptors - - import expo.modules.securestore.AuthenticationHelper - import expo.modules.securestore.SecureStoreOptions -+import expo.modules.securestore.SecureStoreOriginalFeedback - import org.json.JSONException - import org.json.JSONObject - import java.security.GeneralSecurityException -@@ -27,7 +28,7 @@ interface KeyBasedEncryptor { - authenticationPrompt: String, - authenticationHelper: AuthenticationHelper, - enableDeviceFallback: Boolean, -- ): JSONObject -+ ): SecureStoreOriginalFeedback - - @Throws(GeneralSecurityException::class, JSONException::class) - suspend fun decryptItem( -@@ -36,5 +37,5 @@ interface KeyBasedEncryptor { - keyStoreEntry: E, - options: SecureStoreOptions, - authenticationHelper: AuthenticationHelper -- ): String -+ ): SecureStoreOriginalFeedback - } -diff --git a/node_modules/expo-secure-store/build/SecureStore.d.ts b/node_modules/expo-secure-store/build/SecureStore.d.ts -index 835a4d4..d2da8d9 100644 ---- a/node_modules/expo-secure-store/build/SecureStore.d.ts -+++ b/node_modules/expo-secure-store/build/SecureStore.d.ts -@@ -1,4 +1,53 @@ -+type EmptyObject = Record; -+type SecureStoreSetFeedback = R['returnUsedAuthenticationType'] extends true ? AuthType : void; -+type SecureStoreGetFeedback = R['returnUsedAuthenticationType'] extends true ? [T | null, AuthType] : T | null; - export type KeychainAccessibilityConstant = number; -+/** -+ * Authentication type returned by the SecureStore after reading item or saving it to the store. -+ */ -+export declare const AUTH_TYPE: { -+ /** -+ * This is purely for backwards compatibility. -+ * Although it is not listed as a return value of the getAuthenticationType() method, -+ * it is still present in the Android code. -+ * @see https://developer.android.com/reference/android/hardware/biometrics/BiometricPrompt.AuthenticationResult#getAuthenticationType() -+ * @see https://developer.android.com/reference/androidx/biometric/BiometricPrompt#AUTHENTICATION_RESULT_TYPE_UNKNOWN() -+ * @platform android -+ */ -+ readonly UNKNOWN: -1; -+ /** -+ * Returned when the authentication fails -+ * @platform android -+ * @platform ios -+ */ -+ readonly NONE: 0; -+ /** -+ * Generic type, not specified whether it was a passcode or pattern. -+ * @platform android -+ * @platform ios -+ */ -+ readonly CREDENTIALS: 1; -+ /** -+ * Generic type, not specified whether it was a face scan or a fingerprint -+ * @platform android -+ */ -+ readonly BIOMETRICS: 2; -+ /** -+ * FaceID was used to authenticate -+ * @platform ios -+ */ -+ readonly FACE_ID: 3; -+ /** -+ * TouchID was used to authenticate -+ * @platform ios -+ */ -+ readonly TOUCH_ID: 4; -+ /** -+ * OpticID was used to authenticate (reserved by apple, used on Apple Vision Pro, not iOS) -+ */ -+ readonly OPTIC_ID: 5; -+}; -+type AuthType = (typeof AUTH_TYPE)[keyof typeof AUTH_TYPE]; - /** - * The data in the keychain item cannot be accessed after a restart until the device has been - * unlocked once by the user. This may be useful if you need to access the item when the phone -@@ -88,6 +137,22 @@ export type SecureStoreOptions = { - * @platform ios - */ - enableDeviceFallback?: boolean; -+ /** -+ * When this flag is set to true, the get methods of SecureStore will return a two-element array. The first value will be the original value returned when this flag is set to false. -+ * The second value is the authentication type used to read the value from the AUTH_TYPE object. -+ * As for the set function, the returned value will simply be AUTH_TYPE. -+ * -+ * @warning -+ * If the iOS device supports biometrics and the user falls back to device credentials, it will not be detected. -+ * This is not the case on Android, but we cannot specify the exact type of biometrics (e.g. fingerprint or face scan). -+ * Whether the type is detected correctly depends on the platform and its native implementation. -+ * This should be treated as more of a hint. -+ * -+ * @default false -+ * @platform android -+ * @platform ios -+ */ -+ returnUsedAuthenticationType?: boolean; - }; - /** - * Returns whether the SecureStore API is enabled on the current device. This does not check the app -@@ -119,7 +184,7 @@ export declare function deleteItemAsync(key: string, options?: SecureStoreOption - * > After a key has been invalidated, it becomes impossible to read its value. - * > This only applies to values stored with `requireAuthentication` set to `true`. - */ --export declare function getItemAsync(key: string, options?: SecureStoreOptions): Promise; -+export declare function getItemAsync(key: string, options?: R | EmptyObject): Promise>; - /** - * Stores a key–value pair. - * -@@ -129,7 +194,7 @@ export declare function getItemAsync(key: string, options?: SecureStoreOptions): - * - * @return A promise that rejects if value cannot be stored on the device. - */ --export declare function setItemAsync(key: string, value: string, options?: SecureStoreOptions): Promise; -+export declare function setItemAsync(key: string, value: string, options?: R | EmptyObject): Promise>; - /** - * Stores a key–value pair synchronously. - * > **Note:** This function blocks the JavaScript thread, so the application may not be interactive when the `requireAuthentication` option is set to `true` until the user authenticates. -@@ -139,7 +204,7 @@ export declare function setItemAsync(key: string, value: string, options?: Secur - * @param options An [`SecureStoreOptions`](#securestoreoptions) object. - * - */ --export declare function setItem(key: string, value: string, options?: SecureStoreOptions): void; -+export declare function setItem(key: string, value: string, options?: R | EmptyObject): SecureStoreSetFeedback; - /** - * Synchronously reads the stored value associated with the provided key. - * > **Note:** This function blocks the JavaScript thread, so the application may not be interactive when reading a value with `requireAuthentication` -@@ -150,7 +215,7 @@ export declare function setItem(key: string, value: string, options?: SecureStor - * @return Previously stored value. It resolves with `null` if there is no entry - * for the given key or if the key has been invalidated. - */ --export declare function getItem(key: string, options?: SecureStoreOptions): string | null; -+export declare function getItem(key: string, options?: R | EmptyObject): SecureStoreGetFeedback; - /** - * Checks if the value can be saved with `requireAuthentication` option enabled. - * @return `true` if the device supports biometric authentication and the enrolled method is sufficiently secure. Otherwise, returns `false`. Always returns false on tvOS. -diff --git a/node_modules/expo-secure-store/build/SecureStore.js b/node_modules/expo-secure-store/build/SecureStore.js -index e11bd94..940fc61 100644 ---- a/node_modules/expo-secure-store/build/SecureStore.js -+++ b/node_modules/expo-secure-store/build/SecureStore.js -@@ -1,6 +1,53 @@ - import ExpoSecureStore from './ExpoSecureStore'; - import { byteCountOverLimit, VALUE_BYTES_LIMIT } from './byteCounter'; - // @needsAudit -+ -+/** -+ * Authentication type returned by the SecureStore after reading item or saving it to the store. -+ */ -+export const AUTH_TYPE = { -+ /** -+ * This is purely for backwards compatibility. -+ * Although it is not listed as a return value of the getAuthenticationType() method, -+ * it is still present in the Android code. -+ * @see https://developer.android.com/reference/android/hardware/biometrics/BiometricPrompt.AuthenticationResult#getAuthenticationType() -+ * @see https://developer.android.com/reference/androidx/biometric/BiometricPrompt#AUTHENTICATION_RESULT_TYPE_UNKNOWN() -+ * @platform android -+ */ -+ UNKNOWN: -1, -+ /** -+ * Returned when the authentication fails -+ * @platform android -+ * @platform ios -+ */ -+ NONE: 0, -+ /** -+ * Generic type, not specified whether it was a passcode or pattern. -+ * @platform android -+ * @platform ios -+ */ -+ CREDENTIALS: 1, -+ /** -+ * Generic type, not specified whether it was a face scan or a fingerprint -+ * @platform android -+ */ -+ BIOMETRICS: 2, -+ /** -+ * FaceID was used to authenticate -+ * @platform ios -+ */ -+ FACE_ID: 3, -+ /** -+ * TouchID was used to authenticate -+ * @platform ios -+ */ -+ TOUCH_ID: 4, -+ /** -+ * OpticID was used to authenticate (reserved by apple, used on Apple Vision Pro, not iOS) -+ */ -+ OPTIC_ID: 5, -+}; -+// @needsAudit - /** - * The data in the keychain item cannot be accessed after a restart until the device has been - * unlocked once by the user. This may be useful if you need to access the item when the phone -@@ -102,7 +149,7 @@ export async function setItemAsync(key, value, options = {}) { - if (!isValidValue(value)) { - throw new Error(`Invalid value provided to SecureStore. Values must be strings; consider JSON-encoding your values if they are serializable.`); - } -- await ExpoSecureStore.setValueWithKeyAsync(value, key, options); -+ return await ExpoSecureStore.setValueWithKeyAsync(value, key, options); - } - /** - * Stores a key–value pair synchronously. -diff --git a/node_modules/expo-secure-store/ios/SecureStoreModule.swift b/node_modules/expo-secure-store/ios/SecureStoreModule.swift -index 1268979..7764c8e 100644 ---- a/node_modules/expo-secure-store/ios/SecureStoreModule.swift -+++ b/node_modules/expo-secure-store/ios/SecureStoreModule.swift -@@ -18,28 +18,36 @@ public final class SecureStoreModule: Module { - "WHEN_UNLOCKED_THIS_DEVICE_ONLY": SecureStoreAccessible.whenUnlockedThisDeviceOnly.rawValue - ]) - -- AsyncFunction("getValueWithKeyAsync") { (key: String, options: SecureStoreOptions) -> String? in -- return try get(with: key, options: options) -+ AsyncFunction("getValueWithKeyAsync") { (key: String, options: SecureStoreOptions) in -+ let result = try get(with: key, options: options) -+ -+ return wrapResultWithFeedback(action: .get, result: result, options: options).value - } - -- Function("getValueWithKeySync") { (key: String, options: SecureStoreOptions) -> String? in -- return try get(with: key, options: options) -+ Function("getValueWithKeySync") { (key: String, options: SecureStoreOptions) in -+ let result = try get(with: key, options: options) -+ -+ return wrapResultWithFeedback(action: .get, result: result, options: options).value - } - -- AsyncFunction("setValueWithKeyAsync") { (value: String, key: String, options: SecureStoreOptions) -> Bool in -+ AsyncFunction("setValueWithKeyAsync") { (value: String, key: String, options: SecureStoreOptions) in - guard let key = validate(for: key) else { - throw InvalidKeyException() - } - -- return try set(value: value, with: key, options: options) -+ let result = try set(value: value, with: key, options: options) -+ -+ return wrapResultWithFeedback(action: .set, result: result, options: options).value - } - -- Function("setValueWithKeySync") {(value: String, key: String, options: SecureStoreOptions) -> Bool in -+ Function("setValueWithKeySync") {(value: String, key: String, options: SecureStoreOptions) in - guard let key = validate(for: key) else { - throw InvalidKeyException() - } - -- return try set(value: value, with: key, options: options) -+ let result = try set(value: value, with: key, options: options) -+ -+ return wrapResultWithFeedback(action: .set, result: result, options: options).value - } - - AsyncFunction("deleteValueWithKeyAsync") { (key: String, options: SecureStoreOptions) in -@@ -53,18 +61,7 @@ public final class SecureStoreModule: Module { - } - - Function("canUseBiometricAuthentication") {() -> Bool in -- #if os(tvOS) -- return false -- #else -- let context = LAContext() -- var error: NSError? -- let isBiometricsSupported: Bool = context.canEvaluatePolicy(LAPolicy.deviceOwnerAuthenticationWithBiometrics, error: &error) -- -- if error != nil { -- return false -- } -- return isBiometricsSupported -- #endif -+ return areBiometricsEnabled() - } - - Function("canUseDeviceCredentialsAuthentication") { () -> Bool in -@@ -76,6 +73,41 @@ public final class SecureStoreModule: Module { - return LAContext().canEvaluatePolicy(LAPolicy.deviceOwnerAuthentication, error: nil) - } - -+ private func getAuthType() -> AuthType { -+ if !areBiometricsEnabled() {return AuthType.credentials} -+ let biometryType = LAContext().biometryType -+ -+ switch biometryType { -+ case .faceID: return .faceID -+ case .touchID: return .touchID -+ case .opticID: return .opticID // available since iOS 17 -+ case .none: fallthrough // this one continues to the next line -+ @unknown default: return .credentials -+ } -+ } -+ -+ private func wrapResultWithFeedback(action: SecureStoreFeedbackAction, result: T, options: SecureStoreOptions) -> any SecureStoreFeedback { -+ let authType = getAuthType().rawValue -+ -+ if (!options.returnUsedAuthenticationType) { -+ return SecureStoreOriginalFeedback(source: result, authType: authType) -+ } -+ -+ if (action == .get) { -+ return SecureStoreGetFeedback(source: result, authType: authType) -+ } -+ -+ return SecureStoreSetFeedback(source: result, authType: authType) -+ } -+ -+ private func areBiometricsEnabled() -> Bool { -+ #if os(tvOS) -+ return false -+ #else -+ return LAContext().canEvaluatePolicy(LAPolicy.deviceOwnerAuthenticationWithBiometrics, error: nil) -+ #endif -+ } -+ - private func get(with key: String, options: SecureStoreOptions) throws -> String? { - guard let key = validate(for: key) else { - throw InvalidKeyException() -diff --git a/node_modules/expo-secure-store/ios/SecureStoreOptions.swift b/node_modules/expo-secure-store/ios/SecureStoreOptions.swift -index c9e843b..b95eca7 100644 ---- a/node_modules/expo-secure-store/ios/SecureStoreOptions.swift -+++ b/node_modules/expo-secure-store/ios/SecureStoreOptions.swift -@@ -18,4 +18,71 @@ internal struct SecureStoreOptions: Record { - - @Field - var enableDeviceFallback: Bool = false -+ -+ @Field -+ var returnUsedAuthenticationType: Bool = false -+} -+ -+@available(iOS 11.2, *) -+public enum AuthType: Int, @unchecked Sendable { -+ /// The device does not support biometry. -+ case none = 0 -+ -+ /// The device supports device credentials -+ case credentials = 1 -+ -+ /// Generic type, not specified whether it was a faceID or touchID -+ case biometrics = 2 -+ -+ /// The device supports Face ID. -+ case faceID = 3 -+ -+ /// The device supports Touch ID. -+ case touchID = 4 -+ -+ /// The device supports Optic ID -+ case opticID = 5 -+} -+ -+public enum SecureStoreFeedbackAction: String { -+ case set -+ case get -+} -+ -+protocol SecureStoreFeedback { -+ associatedtype Source -+ associatedtype Value -+ var source: Source { get } -+ var authType: Int { get } -+ var value: Value { get } -+} -+ -+struct SecureStoreGetFeedback: SecureStoreFeedback { -+ typealias Source = T -+ typealias Value = Array -+ var source: Source -+ var authType: Int = AuthType.none.rawValue -+ var value: Value { -+ get { return [source, authType] } -+ } -+} -+ -+struct SecureStoreOriginalFeedback: SecureStoreFeedback { -+ typealias Source = T -+ typealias Value = T -+ var source: Source -+ var authType: Int = AuthType.none.rawValue -+ var value: Value { -+ get { return source } -+ } -+} -+ -+struct SecureStoreSetFeedback: SecureStoreFeedback { -+ typealias Source = T -+ typealias Value = Int -+ var source: Source -+ var authType: Int = AuthType.none.rawValue -+ var value: Value { -+ get { return authType } -+ } - } -diff --git a/node_modules/expo-secure-store/src/SecureStore.ts b/node_modules/expo-secure-store/src/SecureStore.ts -index 4f253a1..d44c0bb 100644 ---- a/node_modules/expo-secure-store/src/SecureStore.ts -+++ b/node_modules/expo-secure-store/src/SecureStore.ts -@@ -1,8 +1,63 @@ - import ExpoSecureStore from './ExpoSecureStore'; - import { byteCountOverLimit, VALUE_BYTES_LIMIT } from './byteCounter'; - -+type EmptyObject = Record; -+type SecureStoreSetFeedback = -+ R['returnUsedAuthenticationType'] extends true ? AuthType : void; -+type SecureStoreGetFeedback< -+ T, -+ R extends SecureStoreOptions, -+> = R['returnUsedAuthenticationType'] extends true ? [T | null, AuthType] : T | null; - export type KeychainAccessibilityConstant = number; - -+/** -+ * Authentication type returned by the SecureStore after reading item or saving it to the store. -+ */ -+export const AUTH_TYPE = { -+ /** -+ * This is purely for backwards compatibility. -+ * Although it is not listed as a return value of the getAuthenticationType() method, -+ * it is still present in the Android code. -+ * @see https://developer.android.com/reference/android/hardware/biometrics/BiometricPrompt.AuthenticationResult#getAuthenticationType() -+ * @see https://developer.android.com/reference/androidx/biometric/BiometricPrompt#AUTHENTICATION_RESULT_TYPE_UNKNOWN() -+ * @platform android -+ */ -+ UNKNOWN: -1, -+ /** -+ * Returned when the authentication fails -+ * @platform android -+ * @platform ios -+ */ -+ NONE: 0, -+ /** -+ * Generic type, not specified whether it was a passcode or pattern. -+ * @platform android -+ * @platform ios -+ */ -+ CREDENTIALS: 1, -+ /** -+ * Generic type, not specified whether it was a face scan or a fingerprint -+ * @platform android -+ */ -+ BIOMETRICS: 2, -+ /** -+ * FaceID was used to authenticate -+ * @platform ios -+ */ -+ FACE_ID: 3, -+ /** -+ * TouchID was used to authenticate -+ * @platform ios -+ */ -+ TOUCH_ID: 4, -+ /** -+ * OpticID was used to authenticate (reserved by apple, used on Apple Vision Pro, not iOS) -+ */ -+ OPTIC_ID: 5, -+} as const; -+ -+type AuthType = (typeof AUTH_TYPE)[keyof typeof AUTH_TYPE]; -+ - // @needsAudit - /** - * The data in the keychain item cannot be accessed after a restart until the device has been -@@ -113,6 +168,23 @@ export type SecureStoreOptions = { - * @platform ios - */ - enableDeviceFallback?: boolean; -+ -+ /** -+ * When this flag is set to true, the get methods of SecureStore will return a two-element array. The first value will be the original value returned when this flag is set to false. -+ * The second value is the authentication type used to read the value from the AUTH_TYPE object. -+ * As for the set function, the returned value will simply be AUTH_TYPE. -+ * -+ * @warning -+ * If the iOS device supports biometrics and the user falls back to device credentials, it will not be detected. -+ * This is not the case on Android, but we cannot specify the exact type of biometrics (e.g. fingerprint or face scan). -+ * Whether the type is detected correctly depends on the platform and its native implementation. -+ * This should be treated as more of a hint. -+ * -+ * @default false -+ * @platform android -+ * @platform ios -+ */ -+ returnUsedAuthenticationType?: boolean; - }; - - // @needsAudit -@@ -159,10 +231,10 @@ export async function deleteItemAsync( - * > After a key has been invalidated, it becomes impossible to read its value. - * > This only applies to values stored with `requireAuthentication` set to `true`. - */ --export async function getItemAsync( -+export async function getItemAsync( - key: string, -- options: SecureStoreOptions = {} --): Promise { -+ options: R | EmptyObject = {} -+): Promise> { - ensureValidKey(key); - return await ExpoSecureStore.getValueWithKeyAsync(key, options); - } -@@ -177,11 +249,11 @@ export async function getItemAsync( - * - * @return A promise that rejects if value cannot be stored on the device. - */ --export async function setItemAsync( -+export async function setItemAsync( - key: string, - value: string, -- options: SecureStoreOptions = {} --): Promise { -+ options: R | EmptyObject = {} -+): Promise> { - ensureValidKey(key); - if (!isValidValue(value)) { - throw new Error( -@@ -189,7 +261,7 @@ export async function setItemAsync( - ); - } - -- await ExpoSecureStore.setValueWithKeyAsync(value, key, options); -+ return await ExpoSecureStore.setValueWithKeyAsync(value, key, options); - } - - /** -@@ -201,7 +273,11 @@ export async function setItemAsync( - * @param options An [`SecureStoreOptions`](#securestoreoptions) object. - * - */ --export function setItem(key: string, value: string, options: SecureStoreOptions = {}): void { -+export function setItem( -+ key: string, -+ value: string, -+ options: R | EmptyObject = {} -+): SecureStoreSetFeedback { - ensureValidKey(key); - if (!isValidValue(value)) { - throw new Error( -@@ -222,7 +298,10 @@ export function setItem(key: string, value: string, options: SecureStoreOptions - * @return Previously stored value. It resolves with `null` if there is no entry - * for the given key or if the key has been invalidated. - */ --export function getItem(key: string, options: SecureStoreOptions = {}): string | null { -+export function getItem( -+ key: string, -+ options: R | EmptyObject = {} -+): SecureStoreGetFeedback { - ensureValidKey(key); - return ExpoSecureStore.getValueWithKeySync(key, options); - } diff --git a/patches/expo-secure-store/expo-secure-store+14.2.4+003+force-authentication-on-save.patch b/patches/expo-secure-store/expo-secure-store+14.2.4+003+force-authentication-on-save.patch deleted file mode 100644 index 59e74054b162..000000000000 --- a/patches/expo-secure-store/expo-secure-store+14.2.4+003+force-authentication-on-save.patch +++ /dev/null @@ -1,124 +0,0 @@ -diff --git a/node_modules/expo-secure-store/build/SecureStore.d.ts b/node_modules/expo-secure-store/build/SecureStore.d.ts -index 7097a8a..975d75b 100644 ---- a/node_modules/expo-secure-store/build/SecureStore.d.ts -+++ b/node_modules/expo-secure-store/build/SecureStore.d.ts -@@ -153,6 +153,20 @@ export type SecureStoreOptions = { - * @platform ios - */ - returnUsedAuthenticationType?: boolean; -+ /** -+ * On iOS, the system does not ask for auth when saving the value to the SecureStore. -+ * The Android however, displays the authentication prompt when saving the value. -+ * To keep the behavior on every platform similar as much as possible, -+ * setting this flag to true will ensure that authentication is required when saving a value to the store. -+ * -+ * @warning: This flag only works for the asynchronous version of the SecureStore save method. -+ * It should only be considered an improvement to the user experience; it does not prevent the user -+ * from saving the value without authenticating using other methods (e.g. by directly modifying the keychain). -+ * For it to take effect, the 'requireAuthentication' flag must be set to true. -+ * @default false -+ * @platform ios -+ */ -+ forceAuthenticationOnSave?: boolean; - }; - /** - * Returns whether the SecureStore API is enabled on the current device. This does not check the app -diff --git a/node_modules/expo-secure-store/ios/SecureStoreModule.swift b/node_modules/expo-secure-store/ios/SecureStoreModule.swift -index 7764c8e..02f837c 100644 ---- a/node_modules/expo-secure-store/ios/SecureStoreModule.swift -+++ b/node_modules/expo-secure-store/ios/SecureStoreModule.swift -@@ -35,6 +35,10 @@ public final class SecureStoreModule: Module { - throw InvalidKeyException() - } - -+ if options.requireAuthentication && options.forceAuthenticationOnSave { -+ try await triggerPolicy(options: options) -+ } -+ - let result = try set(value: value, with: key, options: options) - - return wrapResultWithFeedback(action: .set, result: result, options: options).value -@@ -190,6 +194,32 @@ public final class SecureStoreModule: Module { - } - } - -+ @MainActor -+ private func triggerPolicy(options: SecureStoreOptions) async throws { -+ let isPolicyAvailable = options.enableDeviceFallback ? areDeviceCredentialsEnabled() : areBiometricsEnabled() -+ -+ guard isPolicyAvailable else { -+ throw SecureStoreRuntimeError("No authentication method available") -+ } -+ -+ let localAuthPolicy: LAPolicy = options.enableDeviceFallback ? .deviceOwnerAuthentication : .deviceOwnerAuthenticationWithBiometrics -+ let localizedReason: String = options.authenticationPrompt ?? "Authentication required" -+ -+ let success: Bool = try await withCheckedThrowingContinuation { continuation in -+ LAContext().evaluatePolicy(localAuthPolicy, localizedReason: localizedReason) { success, error in -+ if let error = error { -+ continuation.resume(throwing: error) -+ } else { -+ continuation.resume(returning: success) -+ } -+ } -+ } -+ -+ guard success else { -+ throw SecureStoreRuntimeError("Unable to authenticate") -+ } -+ } -+ - private func searchKeyChain(with key: String, options: SecureStoreOptions, requireAuthentication: Bool? = nil) throws -> Data? { - var query = query(with: key, options: options, requireAuthentication: requireAuthentication) - -diff --git a/node_modules/expo-secure-store/ios/SecureStoreOptions.swift b/node_modules/expo-secure-store/ios/SecureStoreOptions.swift -index b95eca7..321c7e5 100644 ---- a/node_modules/expo-secure-store/ios/SecureStoreOptions.swift -+++ b/node_modules/expo-secure-store/ios/SecureStoreOptions.swift -@@ -21,6 +21,9 @@ internal struct SecureStoreOptions: Record { - - @Field - var returnUsedAuthenticationType: Bool = false -+ -+ @Field -+ var forceAuthenticationOnSave: Bool = false - } - - @available(iOS 11.2, *) -@@ -86,3 +89,10 @@ struct SecureStoreSetFeedback: SecureStoreFeedback { - get { return authType } - } - } -+ -+struct SecureStoreRuntimeError: LocalizedError { -+ let errorDescription: String? -+ init(_ description: String) { -+ self.errorDescription = description -+ } -+} -diff --git a/node_modules/expo-secure-store/src/SecureStore.ts b/node_modules/expo-secure-store/src/SecureStore.ts -index d44c0bb..c4b95ae 100644 ---- a/node_modules/expo-secure-store/src/SecureStore.ts -+++ b/node_modules/expo-secure-store/src/SecureStore.ts -@@ -185,6 +185,21 @@ export type SecureStoreOptions = { - * @platform ios - */ - returnUsedAuthenticationType?: boolean; -+ -+ /** -+ * On iOS, the system does not ask for auth when saving the value to the SecureStore. -+ * The Android however, displays the authentication prompt when saving the value. -+ * To keep the behavior on every platform similar as much as possible, -+ * setting this flag to true will ensure that authentication is required when saving a value to the store. -+ * -+ * @warning: This flag only works for the asynchronous version of the SecureStore save method. -+ * It should only be considered an improvement to the user experience; it does not prevent the user -+ * from saving the value without authenticating using other methods (e.g. by directly modifying the keychain). -+ * For it to take effect, the 'requireAuthentication' flag must be set to true. -+ * @default false -+ * @platform ios -+ */ -+ forceAuthenticationOnSave?: boolean; - }; - - // @needsAudit diff --git a/patches/expo-secure-store/expo-secure-store+14.2.4+004+fail-on-update.patch b/patches/expo-secure-store/expo-secure-store+14.2.4+004+fail-on-update.patch deleted file mode 100644 index c3acba54c616..000000000000 --- a/patches/expo-secure-store/expo-secure-store+14.2.4+004+fail-on-update.patch +++ /dev/null @@ -1,100 +0,0 @@ -diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt -index d209efc..e362aef 100644 ---- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt -+++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt -@@ -212,6 +212,10 @@ open class SecureStoreModule : Module() { - return SecureStoreOriginalFeedback(null) - } - -+ if (prefs.contains(keychainAwareKey) && options.failOnUpdate) { -+ throw WriteException("Key already exists", key, options.keychainService) -+ } -+ - try { - if (keyIsInvalidated) { - // Invalidated keys will block writing even though it's not possible to re-validate them -diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt -index 4e30440..16674b8 100644 ---- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt -+++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt -@@ -11,7 +11,8 @@ class SecureStoreOptions( - @Field var keychainService: String = SecureStoreModule.DEFAULT_KEYSTORE_ALIAS, - @Field var requireAuthentication: Boolean = false, - @Field var enableDeviceFallback: Boolean = false, -- @Field var returnUsedAuthenticationType: Boolean = false -+ @Field var returnUsedAuthenticationType: Boolean = false, -+ @Field var failOnUpdate: Boolean = false - ) : Record, Serializable - - enum class SecureStoreAuthType(index: Int) { -diff --git a/node_modules/expo-secure-store/build/SecureStore.d.ts b/node_modules/expo-secure-store/build/SecureStore.d.ts -index f1e3610..8954c59 100644 ---- a/node_modules/expo-secure-store/build/SecureStore.d.ts -+++ b/node_modules/expo-secure-store/build/SecureStore.d.ts -@@ -167,6 +167,16 @@ export type SecureStoreOptions = { - * @platform ios - */ - forceAuthenticationOnSave?: boolean; -+ /** -+ * If the key has already been stored, the save function will throw an error instead of overwriting it. -+ * The behaviour differs slightly depending on the platform. -+ * On Android, an error is thrown before the authentication prompt. On iOS, an error is thrown after authentication. -+ * -+ * @default false -+ * @platform ios -+ * @platform android -+ */ -+ failOnUpdate?: boolean; - }; - /** - * Returns whether the SecureStore API is enabled on the current device. This does not check the app -diff --git a/node_modules/expo-secure-store/ios/SecureStoreModule.swift b/node_modules/expo-secure-store/ios/SecureStoreModule.swift -index 02f837c..47282db 100644 ---- a/node_modules/expo-secure-store/ios/SecureStoreModule.swift -+++ b/node_modules/expo-secure-store/ios/SecureStoreModule.swift -@@ -169,6 +169,9 @@ public final class SecureStoreModule: Module { - SecItemDelete(query(with: key, options: options, requireAuthentication: !options.requireAuthentication) as CFDictionary) - return true - case errSecDuplicateItem: -+ if options.failOnUpdate { -+ throw SecureStoreRuntimeError("Key already exists") -+ } - return try update(value: value, with: key, options: options) - default: - throw KeyChainException(status) -diff --git a/node_modules/expo-secure-store/ios/SecureStoreOptions.swift b/node_modules/expo-secure-store/ios/SecureStoreOptions.swift -index 321c7e5..6a787b0 100644 ---- a/node_modules/expo-secure-store/ios/SecureStoreOptions.swift -+++ b/node_modules/expo-secure-store/ios/SecureStoreOptions.swift -@@ -24,6 +24,9 @@ internal struct SecureStoreOptions: Record { - - @Field - var forceAuthenticationOnSave: Bool = false -+ -+ @Field -+ var failOnUpdate: Bool = false - } - - @available(iOS 11.2, *) -diff --git a/node_modules/expo-secure-store/src/SecureStore.ts b/node_modules/expo-secure-store/src/SecureStore.ts -index c4b95ae..0bedcaa 100644 ---- a/node_modules/expo-secure-store/src/SecureStore.ts -+++ b/node_modules/expo-secure-store/src/SecureStore.ts -@@ -200,6 +200,17 @@ export type SecureStoreOptions = { - * @platform ios - */ - forceAuthenticationOnSave?: boolean; -+ -+ /** -+ * If the key has already been stored, the save function will throw an error instead of overwriting it. -+ * The behaviour differs slightly depending on the platform. -+ * On Android, an error is thrown before the authentication prompt. On iOS, an error is thrown after authentication. -+ * -+ * @default false -+ * @platform ios -+ * @platform android -+ */ -+ failOnUpdate?: boolean; - }; - - // @needsAudit diff --git a/patches/expo-secure-store/expo-secure-store+14.2.4+005+force-read-authentication-on-simulators.patch b/patches/expo-secure-store/expo-secure-store+14.2.4+005+force-read-authentication-on-simulators.patch deleted file mode 100644 index f65e0a31540a..000000000000 --- a/patches/expo-secure-store/expo-secure-store+14.2.4+005+force-read-authentication-on-simulators.patch +++ /dev/null @@ -1,77 +0,0 @@ -diff --git a/node_modules/expo-secure-store/build/SecureStore.d.ts b/node_modules/expo-secure-store/build/SecureStore.d.ts -index 836958c..29531c5 100644 ---- a/node_modules/expo-secure-store/build/SecureStore.d.ts -+++ b/node_modules/expo-secure-store/build/SecureStore.d.ts -@@ -177,6 +177,18 @@ export type SecureStoreOptions = { - * @platform android - */ - failOnUpdate?: boolean; -+ /** -+ * The LocalAuthentication behaves slightly differently on iOS simulators. -+ * In numerous cases, the authentication prompts are skipped on simulators (as opposed to real devices). -+ * Setting this flag to true forces the prompt to appear on simulators when a value with the `requireAuthentication` flag set to true is read. -+ * This is purely for testing the app on simulators, in cases where the prompt does not appear when the value is read. -+ * This has no effect on real devices. -+ * -+ * @warning: This flag only works for the asynchronous version of the SecureStore read method. -+ * @default false -+ * @platform ios -+ */ -+ forceReadAuthenticationOnSimulators?: boolean; - }; - /** - * Returns whether the SecureStore API is enabled on the current device. This does not check the app -diff --git a/node_modules/expo-secure-store/ios/SecureStoreModule.swift b/node_modules/expo-secure-store/ios/SecureStoreModule.swift -index 47282db..dcbe242 100644 ---- a/node_modules/expo-secure-store/ios/SecureStoreModule.swift -+++ b/node_modules/expo-secure-store/ios/SecureStoreModule.swift -@@ -19,6 +19,11 @@ public final class SecureStoreModule: Module { - ]) - - AsyncFunction("getValueWithKeyAsync") { (key: String, options: SecureStoreOptions) in -+ #if targetEnvironment(simulator) -+ if options.requireAuthentication && options.forceReadAuthenticationOnSimulators { -+ try await triggerPolicy(options: options) -+ } -+ #endif - let result = try get(with: key, options: options) - - return wrapResultWithFeedback(action: .get, result: result, options: options).value -diff --git a/node_modules/expo-secure-store/ios/SecureStoreOptions.swift b/node_modules/expo-secure-store/ios/SecureStoreOptions.swift -index 6a787b0..8f110dd 100644 ---- a/node_modules/expo-secure-store/ios/SecureStoreOptions.swift -+++ b/node_modules/expo-secure-store/ios/SecureStoreOptions.swift -@@ -27,6 +27,9 @@ internal struct SecureStoreOptions: Record { - - @Field - var failOnUpdate: Bool = false -+ -+ @Field -+ var forceReadAuthenticationOnSimulators: Bool = false - } - - @available(iOS 11.2, *) -diff --git a/node_modules/expo-secure-store/src/SecureStore.ts b/node_modules/expo-secure-store/src/SecureStore.ts -index 0bedcaa..bae5580 100644 ---- a/node_modules/expo-secure-store/src/SecureStore.ts -+++ b/node_modules/expo-secure-store/src/SecureStore.ts -@@ -211,6 +211,19 @@ export type SecureStoreOptions = { - * @platform android - */ - failOnUpdate?: boolean; -+ -+ /** -+ * The LocalAuthentication behaves slightly differently on iOS simulators. -+ * In numerous cases, the authentication prompts are skipped on simulators (as opposed to real devices). -+ * Setting this flag to true forces the prompt to appear on simulators when a value with the `requireAuthentication` flag set to true is read. -+ * This is purely for testing the app on simulators, in cases where the prompt does not appear when the value is read. -+ * This has no effect on real devices. -+ * -+ * @warning: This flag only works for the asynchronous version of the SecureStore read method. -+ * @default false -+ * @platform ios -+ */ -+ forceReadAuthenticationOnSimulators?: boolean; - }; - - // @needsAudit diff --git a/patches/react-native-draggable-flatlist/react-native-draggable-flatlist+4.0.3+001+listfooter-constraint.patch b/patches/react-native-draggable-flatlist/react-native-draggable-flatlist+4.0.3+001+listfooter-constraint.patch index 3925c00cc187..9706c9d87989 100644 --- a/patches/react-native-draggable-flatlist/react-native-draggable-flatlist+4.0.3+001+listfooter-constraint.patch +++ b/patches/react-native-draggable-flatlist/react-native-draggable-flatlist+4.0.3+001+listfooter-constraint.patch @@ -34,24 +34,29 @@ index 2f59c7a..a324da2 100644 const onListContentSizeChange = (w: number, h: number) => { scrollViewSize.value = props.horizontal ? w : h; props.onContentSizeChange?.(w, h); -@@ -357,6 +366,16 @@ function DraggableFlatListInner(props: DraggableFlatListProps) { +@@ -357,6 +366,21 @@ function DraggableFlatListInner(props: DraggableFlatListProps) { props.onViewableItemsChanged?.(info); }); + // Wrap the provided ListFooterComponent to add an onLayout + const wrappedListFooterComponent = ListFooterComponent ? -+ + {ListFooterComponent} + + : undefined ++ ++ // Reset footer size when footer is removed so stale values don't affect drag constraints ++ if (!ListFooterComponent) { ++ footerSize.value = 0; ++ } + return ( (props: DraggableFlatListProps) { +@@ -388,6 +411,7 @@ function DraggableFlatListInner(props: DraggableFlatListProps) { scrollEventThrottle={16} simultaneousHandlers={props.simultaneousHandlers} removeClippedSubviews={false} diff --git a/patches/react-native-keyboard-controller/details.md b/patches/react-native-keyboard-controller/details.md new file mode 100644 index 000000000000..c55b5f8d3f75 --- /dev/null +++ b/patches/react-native-keyboard-controller/details.md @@ -0,0 +1,13 @@ +# `react-native-keyboard-controller` patches + +### [react-native-keyboard-controller+1.21.0-beta.1+001+fix-insets-in-landscape.patch](react-native-keyboard-controller+1.21.0-beta.1+001+fix-insets-in-landscape.patch) + +- Reason: + + ``` + In EdgeToEdgeReactViewGroup.setupWindowInsets(), the library unconditionally applies navBarInsets.left and navBarInsets.right as margins on the content view, even though the bottom margin is already conditional on navigationBarTranslucent. In landscape mode on Android, the navigation bar moves to the side of the screen, and these unconditional margins shrink the content area by the nav bar width. This causes useSafeAreaFrame() to report a reduced windowWidth and useSafeAreaInsets() to return 0 for left/right insets, since the SafeAreaProvider view no longer overlaps with the nav bar area. The patch makes left/right margins respect the same shouldApplyZeroPaddingBottom condition used for the bottom margin. + ``` + +- Upstream PR/issue: - +- E/App issue: https://github.com/Expensify/App/issues/87307 +- PR introducing patch: https://github.com/Expensify/App/pull/87376 \ No newline at end of file diff --git a/patches/react-native-keyboard-controller/react-native-keyboard-controller+1.21.0-beta.1+001+fix-insets-in-landscape.patch b/patches/react-native-keyboard-controller/react-native-keyboard-controller+1.21.0-beta.1+001+fix-insets-in-landscape.patch new file mode 100644 index 000000000000..b63ed87575eb --- /dev/null +++ b/patches/react-native-keyboard-controller/react-native-keyboard-controller+1.21.0-beta.1+001+fix-insets-in-landscape.patch @@ -0,0 +1,28 @@ +diff --git a/node_modules/react-native-keyboard-controller/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt b/node_modules/react-native-keyboard-controller/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt +index 003890b..90d3375 100644 +--- a/node_modules/react-native-keyboard-controller/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt ++++ b/node_modules/react-native-keyboard-controller/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt +@@ -116,13 +116,21 @@ class EdgeToEdgeReactViewGroup( + val systemBarInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + + params.setMargins( +- navBarInsets.left, ++ if (shouldApplyZeroPaddingBottom) { ++ 0 ++ } else { ++ navBarInsets.left ++ }, + if (shouldApplyZeroPaddingTop) { + 0 + } else { + systemBarInsets.top + }, +- navBarInsets.right, ++ if (shouldApplyZeroPaddingBottom) { ++ 0 ++ } else { ++ navBarInsets.right ++ }, + if (shouldApplyZeroPaddingBottom) { + 0 + } else { diff --git a/patches/react-native-onyx/details.md b/patches/react-native-onyx/details.md new file mode 100644 index 000000000000..1e0e4b90c91a --- /dev/null +++ b/patches/react-native-onyx/details.md @@ -0,0 +1,7 @@ +# `react-native-onyx` patches + +### [react-native-onyx+3.0.59.patch](react-native-onyx+3.0.59.patch) + +- Reason: Onyx v3.0.59 ([PR #756](https://github.com/Expensify/react-native-onyx/pull/756)) added a state reset inside the `subscribe` callback of `useOnyx` to fix stale data when keys change dynamically. However, this reset runs unconditionally — including on initial mount — which causes `useSyncExternalStore` to see a new snapshot reference after subscription, triggering one extra render per `useOnyx` hook. This patch guards the reset with a `hasMountedRef` flag so it only runs on key-change re-subscriptions, not on initial mount. +- E/App issue: https://github.com/Expensify/App/issues/85416 +- Upstream PR/issue: N/A diff --git a/patches/react-native-onyx/react-native-onyx+3.0.59.patch b/patches/react-native-onyx/react-native-onyx+3.0.59.patch new file mode 100644 index 000000000000..6c7f9e68c773 --- /dev/null +++ b/patches/react-native-onyx/react-native-onyx+3.0.59.patch @@ -0,0 +1,35 @@ +diff --git a/node_modules/react-native-onyx/dist/useOnyx.js b/node_modules/react-native-onyx/dist/useOnyx.js +index 8e0f03a..9e29ace 100644 +--- a/node_modules/react-native-onyx/dist/useOnyx.js ++++ b/node_modules/react-native-onyx/dist/useOnyx.js +@@ -97,6 +97,9 @@ function useOnyx(key, options, dependencies = []) { + // after cleanup), so the hook automatically enters first-connection mode for the new key without any + // explicit reset logic — eliminating the race condition where cleanup could clobber a boolean flag. + const connectedKeyRef = (0, react_1.useRef)(null); ++ // Tracks whether the hook has completed its initial mount subscription. ++ // Unlike connectedKeyRef (which gets nulled by cleanup), this persists across re-subscriptions. ++ const hasMountedRef = (0, react_1.useRef)(false); + // Indicates if the hook is connecting to an Onyx key. + const isConnectingRef = (0, react_1.useRef)(false); + // Stores the `onStoreChange()` function, which can be used to trigger a `getSnapshot()` update when desired. +@@ -234,11 +237,15 @@ function useOnyx(key, options, dependencies = []) { + const subscribe = (0, react_1.useCallback)((onStoreChange) => { + // Reset internal state so the hook properly transitions through loading + // for the new key instead of preserving stale state from the previous one. +- previousValueRef.current = null; +- newValueRef.current = null; +- shouldGetCachedValueRef.current = true; +- sourceValueRef.current = undefined; +- resultRef.current = [undefined, { status: (options === null || options === void 0 ? void 0 : options.initWithStoredValues) === false ? 'loaded' : 'loading' }]; ++ // Only reset when the key has actually changed (not on initial mount). ++ if (hasMountedRef.current) { ++ previousValueRef.current = null; ++ newValueRef.current = null; ++ shouldGetCachedValueRef.current = true; ++ sourceValueRef.current = undefined; ++ resultRef.current = [undefined, { status: (options === null || options === void 0 ? void 0 : options.initWithStoredValues) === false ? 'loaded' : 'loading' }]; ++ } ++ hasMountedRef.current = true; + isConnectingRef.current = true; + onStoreChangeFnRef.current = onStoreChange; + connectionRef.current = OnyxConnectionManager_1.default.connect({ diff --git a/patches/react-native-web/details.md b/patches/react-native-web/details.md index 95e8d920ab2d..df0cad52449e 100644 --- a/patches/react-native-web/details.md +++ b/patches/react-native-web/details.md @@ -134,17 +134,3 @@ - Upstream PR/issue: https://github.com/necolas/react-native-web/issues/2817 - E/App issue: https://github.com/Expensify/App/issues/73782 - PR introducing patch: https://github.com/Expensify/App/pull/76332 - -### [react-native-web+0.21.2+013+fix-non-interactive-pressable-a11y.patch](react-native-web+0.21.2+013+fix-non-interactive-pressable-a11y.patch) - -- Reason: - ``` - Removes the onClick handler from Pressable when no onPress or onLongPress callbacks - are configured. TalkBack on Android mWeb uses the presence of a click event listener - to determine whether an element is actionable, causing it to announce "double tap to - activate" on non-interactive Pressables. Removing onClick when there are no press - handlers prevents this incorrect announcement. - ``` -- Upstream PR/issue: 🛑 -- E/App issue: https://github.com/Expensify/App/issues/79243 -- PR introducing patch: https://github.com/Expensify/App/pull/86731 diff --git a/patches/react-native-web/react-native-web+0.21.2+013+fix-non-interactive-pressable-a11y.patch b/patches/react-native-web/react-native-web+0.21.2+013+fix-non-interactive-pressable-a11y.patch deleted file mode 100644 index b2a140b2bfa1..000000000000 --- a/patches/react-native-web/react-native-web+0.21.2+013+fix-non-interactive-pressable-a11y.patch +++ /dev/null @@ -1,14 +0,0 @@ -diff --git a/node_modules/react-native-web/dist/exports/Pressable/index.js b/node_modules/react-native-web/dist/exports/Pressable/index.js -index 2837507..a1b2c3d 100644 ---- a/node_modules/react-native-web/dist/exports/Pressable/index.js -+++ b/node_modules/react-native-web/dist/exports/Pressable/index.js -@@ -69,6 +69,9 @@ function Pressable(props, forwardedRef) { - onPressEnd: onPressOut - }), [delayLongPress, delayPressIn, delayPressOut, disabled, onLongPress, onPress, onPressIn, onPressMove, onPressOut, setPressed]); - var pressEventHandlers = usePressEvents(hostRef, pressConfig); -+ if (onPress == null && onLongPress == null) { -+ pressEventHandlers = _objectWithoutPropertiesLoose(pressEventHandlers, ["onClick"]); -+ } - var onContextMenuPress = pressEventHandlers.onContextMenu, - onKeyDownPress = pressEventHandlers.onKeyDown; - useHover(hostRef, { diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 7cd99d9a7d16..22a56ed0053b 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -271,6 +271,12 @@ const CONST = { ANIMATION_PAID_BUTTON_HIDE_DELAY: 300, BACKGROUND_IMAGE_TRANSITION_DURATION: 1000, SCREEN_TRANSITION_END_TIMEOUT: 1000, + + // Delay before pre-inserting the Search fullscreen route under the RHP on the confirmation screen. + // Chosen to be long enough for the RHP entrance animation to complete (~250ms) and avoid jank + // from concurrent navigation state mutations, but short enough to finish well before most users + // tap submit. If the user submits before this fires, the fallback (non-pre-insert) path is used. + PRE_INSERT_FULLSCREEN_DELAY: 300, LIMIT_TIMEOUT: 2147483647, ARROW_HIDE_DELAY: 3000, MAX_IMAGE_CANVAS_AREA: 16777216, @@ -279,6 +285,19 @@ const CONST = { MAX_IMAGE_PIXEL_COUNT: 50000000, CHUNK_LOAD_ERROR: 'ChunkLoadError', + CHRONOS: { + TIMER_COMMAND: { + START: 'start', + STOP: 'stop', + }, + OOO_DURATION_UNITS: { + HOUR: 'hours', + DAY: 'days', + WEEK: 'weeks', + MONTH: 'months', + }, + }, + RECEIPT_CAMERA: { PHOTO_WIDTH: 4032, PHOTO_HEIGHT: 3024, @@ -859,9 +878,10 @@ const CONST = { PAY_INVOICE_VIA_EXPENSIFY: 'payInvoiceViaExpensify', PERSONAL_CARD_IMPORT: 'personalCardImport', SUGGESTED_FOLLOWUPS: 'suggestedFollowups', - FREEZE_CARD: 'freezeCard', + GUSTO: 'gustoNewDot', BULK_EDIT: 'bulkEdit', NEW_MANUAL_EXPENSE_FLOW: 'newManualExpenseFlow', + BULK_SUBMIT_APPROVE_PAY: 'bulkSubmitApprovePay', }, BUTTON_STATES: { DEFAULT: 'default', @@ -1088,6 +1108,7 @@ const CONST = { NZD: 'NZD', EUR: 'EUR', }, + DEFAULT_CURRENCY_DECIMALS: 2, SCA_CURRENCIES: new Set(['GBP', 'EUR']), get DIRECT_REIMBURSEMENT_CURRENCIES() { return [this.CURRENCY.USD, this.CURRENCY.AUD, this.CURRENCY.CAD, this.CURRENCY.GBP, this.CURRENCY.EUR]; @@ -1189,6 +1210,7 @@ const CONST = { BULK_UPLOAD_HELP_URL: 'https://help.expensify.com/articles/new-expensify/reports-and-expenses/Create-an-Expense#option-4-bulk-upload-receipts-desktop-only', ENCRYPTION_AND_SECURITY_HELP_URL: 'https://help.expensify.com/articles/new-expensify/settings/Encryption-and-Data-Security', PLAN_TYPES_AND_PRICING_HELP_URL: 'https://help.expensify.com/articles/new-expensify/billing-and-subscriptions/Plan-types-and-pricing', + PERSONAL_AND_CORPORATE_KARMA_HELP_URL: 'https://help.expensify.com/articles/expensify-classic/expensify-billing/Personal-and-Corporate-Karma', COLLECT_UPGRADE_HELP_URL: 'https://help.expensify.com/Hidden/collect-upgrade', MERGE_ACCOUNT_HELP_URL: 'https://help.expensify.com/articles/new-expensify/settings/Merge-Accounts', CONNECT_A_BUSINESS_BANK_ACCOUNT_HELP_URL: 'https://help.expensify.com/articles/new-expensify/expenses-&-payments/Connect-a-Business-Bank-Account', @@ -1361,6 +1383,14 @@ const CONST = { DUPLICATE: 'duplicate', MOVE_EXPENSE: 'moveExpense', }, + SELECTED_TRANSACTIONS_BULK_ACTION_TYPES: { + HOLD: 'hold', + UNHOLD: 'unhold', + MOVE: 'move', + MERGE: 'merge', + SPLIT: 'split', + DUPLICATE: 'duplicate', + }, ADD_EXPENSE_OPTIONS: { CREATE_NEW_EXPENSE: 'createNewExpense', ADD_UNREPORTED_EXPENSE: 'addUnreportedExpense', @@ -1394,6 +1424,8 @@ const CONST = { CARD_REPLACED_VIRTUAL: 'CARDREPLACEDVIRTUAL', CARD_REPLACED: 'CARDREPLACED', CARD_ASSIGNED: 'CARDASSIGNED', + CARD_FROZEN: 'CARDFROZEN', + CARD_UNFROZEN: 'CARDUNFROZEN', PERSONAL_CARD_CONNECTION_BROKEN: 'PERSONALCARDCONNECTIONBROKEN', CHANGE_FIELD: 'CHANGEFIELD', // OldDot Action CHANGE_POLICY: 'CHANGEPOLICY', @@ -1938,6 +1970,9 @@ const CONST = { SPAN_SEND_MESSAGE: 'ManualSendMessage', SPAN_NOT_FOUND_PAGE: 'ManualNotFoundPage', SPAN_SKELETON: 'ManualSkeleton', + SPAN_ODOMETER_TO_CONFIRMATION: 'ManualOdometerToConfirmation', + SPAN_ODOMETER_IMAGE_STITCH: 'ManualOdometerImageStitch', + SPAN_ODOMETER_IMAGE_CAPTURE: 'ManualOdometerImageCapture', SPAN_NAVIGATION_ROOT_READY: 'NavigationRootReady', SPAN_BOOTSPLASH: { ROOT: 'BootsplashVisible', @@ -1990,6 +2025,7 @@ const CONST = { ATTRIBUTE_TRIGGER: 'trigger', ATTRIBUTE_PLATFORM: 'platform', ATTRIBUTE_IS_MULTI_SCAN: 'is_multi_scan', + ATTRIBUTE_ODOMETER_IMAGE_TYPE: 'odometer_image_type', /** Follow-up action after expense submit (action-based; used as submit_follow_up_action in span). */ SUBMIT_FOLLOW_UP_ACTION: { DISMISS_MODAL_AND_OPEN_REPORT: 'dismiss_modal_and_open_report', @@ -2704,6 +2740,14 @@ const CONST = { }, }, + GUSTO: { + APPROVAL_MODE: { + BASIC: 'basic', + MANAGER: 'manager', + CUSTOM: 'custom', + }, + }, + QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE: { VENDOR_BILL: 'bill', CHECK: 'check', @@ -3618,6 +3662,7 @@ const CONST = { NETSUITE: 'netsuite', SAGE_INTACCT: 'intacct', CERTINIA: 'certinia', + GUSTO: 'gusto', }, SUPPORTED_ONLY_ON_OLDDOT: { FINANCIALFORCE: 'financialForce', @@ -3632,6 +3677,7 @@ const CONST = { SAGE_INTACCT: 'sage-intacct', QBD: 'quickbooks-desktop', CERTINIA: 'certinia', + GUSTO: 'gusto', }, NAME_USER_FRIENDLY: { netsuite: 'NetSuite', @@ -3641,6 +3687,7 @@ const CONST = { intacct: 'Sage Intacct', financialForce: 'FinancialForce', certinia: 'Certinia', + gusto: 'Gusto', billCom: 'Bill.com', zenefits: 'Zenefits', sap: 'SAP', @@ -3648,8 +3695,14 @@ const CONST = { microsoftDynamics: 'Microsoft Dynamics', other: 'Other', }, + get ACCOUNTING_CONNECTION_NAMES() { + return [this.NAME.QBO, this.NAME.QBD, this.NAME.XERO, this.NAME.NETSUITE, this.NAME.SAGE_INTACCT, this.NAME.CERTINIA] as const; + }, + get HR_CONNECTION_NAMES() { + return [this.NAME.GUSTO] as const; + }, get EXPORTED_TO_INTEGRATION_DISPLAY_NAMES(): string[] { - return Object.values(this.NAME).map((name) => this.NAME_USER_FRIENDLY[name as keyof typeof this.NAME_USER_FRIENDLY]); + return this.ACCOUNTING_CONNECTION_NAMES.map((name) => this.NAME_USER_FRIENDLY[name as keyof typeof this.NAME_USER_FRIENDLY]); }, CORPORATE: ['quickbooksDesktop', 'netsuite', 'intacct', 'oracle', 'sap', 'microsoftDynamics', 'other'], AUTH_HELP_LINKS: { @@ -4233,6 +4286,45 @@ const CONST = { TAX: 'tax', }, }, + SPEND_RULES: { + CATEGORIES: { + AIRLINES: 'airlines', + ALCOHOL_AND_BARS: 'alcoholAndBars', + AMAZON_AND_BOOKSTORES: 'amazonAndBookstores', + AUTOMOTIVE: 'automotive', + CAR_RENTALS: 'carRentals', + DINING: 'dining', + FUEL_AND_GAS: 'fuelAndGas', + GOVERNMENT_AND_NON_PROFITS: 'governmentAndNonProfits', + GROCERIES: 'groceries', + GYMS_AND_FITNESS: 'gymsAndFitness', + HEALTHCARE: 'healthcare', + HOTELS: 'hotels', + INTERNET_AND_PHONE: 'internetAndPhone', + OFFICE_SUPPLIES: 'officeSupplies', + PARKING_AND_TOLLS: 'parkingAndTolls', + PROFESSIONAL_SERVICES: 'professionalServices', + RETAIL: 'retail', + SHIPPING_AND_DELIVERY: 'shippingAndDelivery', + SOFTWARE: 'software', + TRANSIT_AND_RIDESHARE: 'transitAndRideshare', + TRAVEL_AGENCIES: 'travelAgencies', + }, + FORM: { + FIELDS: { + CARD_IDS: 'cardIDs', + RESTRICTION_ACTION: 'restrictionAction', + MERCHANT_NAMES: 'merchantNames', + MERCHANT_MATCH_TYPES: 'merchantMatchTypes', + CATEGORIES: 'categories', + MAX_AMOUNT: 'maxAmount', + }, + }, + ACTION: { + ALLOW: 'allow', + BLOCK: 'block', + }, + }, get SUBSCRIPTION_PRICES() { return { @@ -4503,7 +4595,7 @@ const CONST = { FORM_CHARACTER_LIMIT: 50, STANDARD_LENGTH_LIMIT: 100, STANDARD_LIST_ITEM_LIMIT: 12, - APPROVAL_WORKFLOW_SEARCH_LIMIT: 3, + SEARCH_BAR_THRESHOLD: 3, LEGAL_NAMES_CHARACTER_LIMIT: 150, LOGIN_CHARACTER_LIMIT: 254, CATEGORY_NAME_LIMIT: 256, @@ -5979,11 +6071,13 @@ const CONST = { FLAG_SEVERITY_ASSAULT: 'assault', }, EMOJI_PICKER_TEXT_INPUT_SIZES: 152, + EMOJI_PICKER_SKIN_TONE_LIST_HEIGHT: 56, TEXT_INPUT_SYMBOL_POSITION: { PREFIX: 'prefix', SUFFIX: 'suffix', }, QR: { + DEFAULT_LOGO_SIZE: 120, DEFAULT_LOGO_SIZE_RATIO: 0.25, DEFAULT_LOGO_MARGIN_RATIO: 0.02, EXPENSIFY_LOGO_SIZE_RATIO: 0.22, @@ -6069,6 +6163,8 @@ const CONST = { NAVIGATION: 'navigation', /** Use for Tooltips */ TOOLTIP: 'tooltip', + /** Use for dialog/modal elements */ + DIALOG: 'dialog', /** Use for data table containers. */ TABLE: 'table', /** Use for table rows. */ @@ -6201,6 +6297,7 @@ const CONST = { /** These action types are custom for RootNavigator */ DISMISS_MODAL: 'DISMISS_MODAL', REPLACE_FULLSCREEN_UNDER_RHP: 'REPLACE_FULLSCREEN_UNDER_RHP', + REMOVE_FULLSCREEN_UNDER_RHP: 'REMOVE_FULLSCREEN_UNDER_RHP', OPEN_WORKSPACE_SPLIT: 'OPEN_WORKSPACE_SPLIT', OPEN_DOMAIN_SPLIT: 'OPEN_DOMAIN_SPLIT', PUSH_PARAMS: 'PUSH_PARAMS', @@ -6534,8 +6631,10 @@ const CONST = { RHP_CONCIERGE_DM: 'rhpConciergeDm', RHP_ADMINS_ROOM: 'rhpAdminsRoom', RHP_HOME_PAGE: 'rhpHomePage', + TRACK_EXPENSES_WITH_CONCIERGE: 'trackExpensesWithConcierge', CONTROL: 'control', }, + ONBOARDING_JOINABLE_WORKSPACES_LIMIT: 5, ACTIONABLE_TRACK_EXPENSE_WHISPER_MESSAGE: 'What would you like to do with this expense?', ONBOARDING_ACCOUNTING_MAPPING, @@ -7303,6 +7402,7 @@ const CONST = { PNR_STATUS: { CANCELLED: 'CANCELLED', + CANCELLED_STATUS: 'CANCELLED_STATUS', VOIDED: 'VOIDED', }, @@ -7381,6 +7481,7 @@ const CONST = { VIEW: 'view', SUBMIT: 'submit', APPROVE: 'approve', + CHANGE_APPROVER: 'changeApprover', PAY: 'pay', DONE: 'done', EXPORT_TO_ACCOUNTING: 'exportToAccounting', @@ -7397,6 +7498,7 @@ const CONST = { EDIT: 'edit', EXPORT: 'export', APPROVE: 'approve', + CHANGE_APPROVER: 'changeApprover', PAY: 'pay', SUBMIT: 'submit', HOLD: 'hold', @@ -7406,6 +7508,7 @@ const CONST = { REJECT: 'reject', CHANGE_REPORT: 'changeReport', SPLIT: 'split', + DUPLICATE: 'duplicate', }, TRANSACTION_TYPE: { CASH: 'cash', @@ -8421,6 +8524,9 @@ const CONST = { SIGN_UP: 'sign_up', WORKSPACE_CREATED: 'workspace_created', PAID_ADOPTION: 'paid_adoption', + PRODUCT_TRAINING_SCAN_TEST_TOOLTIP_SHOWN: 'training_scan_test_tooltip_shown', + PRODUCT_TRAINING_SCAN_TEST_TOOLTIP_DISMISSED: 'training_scan_test_tooltip_dismissed', + PRODUCT_TRAINING_SCAN_TEST_TOOLTIP_CONFIRMED: 'training_scan_test_tooltip_confirmed', }, }, @@ -8504,6 +8610,7 @@ const CONST = { isExtraSmallScreenWidth: false, isSmallScreen: false, onboardingIsMediumOrLargerScreenWidth: false, + isInLandscapeMode: false, } as ResponsiveLayoutResult, }, @@ -8608,6 +8715,8 @@ const CONST = { MODAL_EVENTS: { CLOSED: 'modalClosed', + DISABLE_RHP_ANIMATION: 'disableRHPAnimation', + RESTORE_RHP_ANIMATION: 'restoreRHPAnimation', }, LIST_BEHAVIOR: { @@ -8708,6 +8817,7 @@ const CONST = { }, HEADER_VIEW: { BACK_BUTTON: 'HeaderView-BackButton', + CHRONOS_TIMER_BUTTON: 'HeaderView-ChronosTimerButton', DETAILS_BUTTON: 'HeaderView-DetailsButton', }, SEARCH: { @@ -8725,8 +8835,10 @@ const CONST = { FILTER_FROM: 'Search-FilterFrom', FILTER_WORKSPACE: 'Search-FilterWorkspace', FILTER_GROUP_BY: 'Search-FilterGroupBy', + FILTER_SORT_BY: 'Search-FilterSortBy', FILTER_GROUP_CURRENCY: 'Search-FilterGroupCurrency', FILTER_VIEW: 'Search-FilterView', + FILTER_LIMIT: 'Search-FilterLimit', FILTER_FEED: 'Search-FilterFeed', FILTER_POSTED: 'Search-FilterPosted', FILTER_WITHDRAWN: 'Search-FilterWithdrawn', @@ -8749,10 +8861,19 @@ const CONST = { FILTER_POPUP_APPLY_MULTI_SELECT: 'Search-FilterPopupApplyMultiSelect', FILTER_POPUP_RESET_TEXT_INPUT: 'Search-FilterPopupResetTextInput', FILTER_POPUP_APPLY_TEXT_INPUT: 'Search-FilterPopupApplyTextInput', + FILTER_POPUP_RESET_AMOUNT: 'Search-FilterPopupResetAmount', + FILTER_POPUP_APPLY_AMOUNT: 'Search-FilterPopupApplyAmount', + FILTER_POPUP_SAVE_AMOUNT: 'Search-FilterPopupSaveAmount', FILTER_POPUP_RESET_DATE: 'Search-FilterPopupResetDate', FILTER_POPUP_APPLY_DATE: 'Search-FilterPopupApplyDate', FILTER_POPUP_RESET_USER: 'Search-FilterPopupResetUser', FILTER_POPUP_APPLY_USER: 'Search-FilterPopupApplyUser', + FILTER_POPUP_RESET_REPORT: 'Search-FilterPopupResetReport', + FILTER_POPUP_APPLY_REPORT: 'Search-FilterPopupApplyReport', + FILTER_POPUP_RESET_REPORT_FIELD: 'Search-FilterPopupResetReportField', + FILTER_POPUP_APPLY_REPORT_FIELD: 'Search-FilterPopupApplyReportField', + FILTER_POPUP_RESET_CARD: 'Search-FilterPopupResetCard', + FILTER_POPUP_APPLY_CARD: 'Search-FilterPopupApplyCard', TRANSACTION_LIST_ITEM_CHECKBOX: 'Search-TransactionListItemCheckbox', EXPANDED_TRANSACTION_ROW: 'Search-ExpandedTransactionRow', EXPANDED_TRANSACTION_ROW_CHECKBOX: 'Search-ExpandedTransactionRowCheckbox', @@ -8981,11 +9102,15 @@ const CONST = { }, HOME_PAGE: { WIDGET_ITEM: 'HomePage-WidgetItem', + GETTING_STARTED_ROW: 'HomePage-GettingStartedRow', }, CALENDAR_PICKER: { YEAR_PICKER: 'CalendarPicker-YearPicker', + MONTH_PICKER: 'CalendarPicker-MonthPicker', PREV_MONTH: 'CalendarPicker-PrevMonth', NEXT_MONTH: 'CalendarPicker-NextMonth', + PREV_YEAR: 'CalendarPicker-PrevYear', + NEXT_YEAR: 'CalendarPicker-NextYear', DAY: 'CalendarPicker-Day', }, PREV_NEXT_BUTTONS: { @@ -9185,6 +9310,7 @@ const CONST = { THREE_DOT_MENU: 'WorkspaceAccounting-ThreeDotMenu', }, RULES: { + ADD_SPEND_RULE: 'WorkspaceRules-AddSpendRule', INDIVIDUAL_EXPENSES_MENU_ITEM: 'WorkspaceRules-IndividualExpensesMenuItem', SPEND_RULE_ITEM: 'WorkspaceRules-SpendRuleItem', MERCHANT_RULE_ITEM: 'WorkspaceRules-MerchantRuleItem', @@ -9194,6 +9320,9 @@ const CONST = { MERCHANT_RULE_PREVIEW_MATCHES: 'WorkspaceRules-MerchantRulePreviewMatches', MERCHANT_RULE_DELETE: 'WorkspaceRules-MerchantRuleDelete', CATEGORY_SELECTOR: 'WorkspaceRules-CategorySelector', + SPEND_RULE_SECTION_ITEM: 'WorkspaceRules-SpendRuleSectionItem', + SPEND_RULE_SAVE: 'WorkspaceRules-SpendRuleSave', + SPEND_RULE_RESTRICTION_TYPE: 'WorkspaceRules-SpendRuleRestrictionType', }, EXPENSIFY_CARD: { ISSUE_CARD_BUTTON: 'WorkspaceExpensifyCard-IssueCardButton', @@ -9408,6 +9537,9 @@ const CONST = { PROFILE_PAGE: { AVATAR: 'ProfilePage-Avatar', }, + SAFE_AREA: { + DISMISS_KEYBOARD_LANDSCAPE_MODE: 'SafeArea-DismissKeyboardLandscapeMode', + }, }, DOMAIN: { @@ -9468,6 +9600,15 @@ const CONST = { CACHE_NAME: { AUTH_IMAGES: 'auth-images', }, + + MODAL_MAX_HEIGHT_TO_WINDOW_HEIGHT_RATIO_LANDSCAPE_MODE: 0.75, + + MAP_VIEW_LAYERS: { + USER_LOCATION_SOURCE: 'user-location-source', + USER_LOCATION: 'user-location', + ROUTE_SOURCE: 'route-source', + ROUTE_FILL: 'route-fill', + }, } as const; const CONTINUATION_DETECTION_SEARCH_FILTER_KEYS = [ diff --git a/src/DeepLinkHandler.tsx b/src/DeepLinkHandler.tsx index 4d4010bb744b..81b35023ba35 100644 --- a/src/DeepLinkHandler.tsx +++ b/src/DeepLinkHandler.tsx @@ -11,6 +11,7 @@ import Log from './libs/Log'; import {endSpan} from './libs/telemetry/activeSpans'; import ONYXKEYS from './ONYXKEYS'; import type {Route} from './ROUTES'; +import {hasSeenTourSelector} from './selectors/Onboarding'; import isLoadingOnyxValue from './types/utils/isLoadingOnyxValue'; type DeepLinkHandlerProps = { @@ -30,11 +31,12 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { const [, sessionMetadata] = useOnyx(ONYXKEYS.SESSION); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const [isSelfTourViewed, isSelfTourViewedMetadata] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); const [betas] = useOnyx(ONYXKEYS.BETAS); const isAuthenticated = useIsAuthenticated(); useEffect(() => { - if (isLoadingOnyxValue(sessionMetadata)) { + if (isLoadingOnyxValue(sessionMetadata, isSelfTourViewedMetadata)) { return; } // If the app is opened from a deep link, get the reportID (if exists) from the deep link and navigate to the chat report @@ -48,7 +50,7 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { if (introSelected === undefined) { Log.info('[Deep link] introSelected is undefined when processing initial URL', false, {url}); } - openReportFromDeepLink(url, allReports, isAuthenticated, conciergeReportID, introSelected, betas); + openReportFromDeepLink(url, allReports, isAuthenticated, conciergeReportID, introSelected, isSelfTourViewed, betas); } else { Report.doneCheckingPublicRoom(); } @@ -65,14 +67,14 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { Log.info('[Deep link] introSelected is undefined when processing URL change', false, {url: state.url}); } const isCurrentlyAuthenticated = hasAuthToken(); - openReportFromDeepLink(state.url, allReports, isCurrentlyAuthenticated, conciergeReportID, introSelected, betas); + openReportFromDeepLink(state.url, allReports, isCurrentlyAuthenticated, conciergeReportID, introSelected, isSelfTourViewed, betas); }); return () => { linkingChangeListener.current?.remove(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- we only want this effect to re-run when conciergeReportID changes - }, [sessionMetadata?.status, conciergeReportID, introSelected, betas]); + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally excluding allReports, isAuthenticated, and onInitialUrl to avoid re-triggering deep link handling on every report update + }, [sessionMetadata?.status, conciergeReportID, introSelected, isSelfTourViewedMetadata, betas]); return null; } diff --git a/src/DelegateAccessHandler.tsx b/src/DelegateAccessHandler.tsx index e1477201bd7c..301dba78b20c 100644 --- a/src/DelegateAccessHandler.tsx +++ b/src/DelegateAccessHandler.tsx @@ -7,6 +7,7 @@ import {disconnect} from './libs/actions/Delegate'; import Log from './libs/Log'; import ONYXKEYS from './ONYXKEYS'; import {accountIDSelector, emailSelector} from './selectors/Session'; +import isLoadingOnyxValue from './types/utils/isLoadingOnyxValue'; /** * Component that does not render anything but isolates delegate-access–related Onyx subscriptions @@ -22,7 +23,7 @@ function DelegateAccessHandler() { const [stashedCredentials = CONST.EMPTY_OBJECT] = useOnyx(ONYXKEYS.STASHED_CREDENTIALS); const [stashedSession] = useOnyx(ONYXKEYS.STASHED_SESSION); const [hasLoadedApp] = useOnyx(ONYXKEYS.HAS_LOADED_APP); - const [isLoadingApp] = useOnyx(ONYXKEYS.RAM_ONLY_IS_LOADING_APP); + const [isLoadingApp, isLoadingAppMetadata] = useOnyx(ONYXKEYS.IS_LOADING_APP); const [sessionAccountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); const [sessionEmail] = useOnyx(ONYXKEYS.SESSION, {selector: emailSelector}); const {isOffline} = useNetwork(); @@ -59,7 +60,7 @@ function DelegateAccessHandler() { // Recovery: if isLoadingApp is missing after the app is ready, re-open the app useEffect(() => { - if (hasHandledMissingIsLoadingAppRef.current || !hasLoadedApp || isLoadingApp !== undefined || isOffline) { + if (hasHandledMissingIsLoadingAppRef.current || !hasLoadedApp || isLoadingApp !== undefined || isOffline || isLoadingOnyxValue(isLoadingAppMetadata)) { return; } hasHandledMissingIsLoadingAppRef.current = true; @@ -69,7 +70,7 @@ function DelegateAccessHandler() { }); confirmReadyToOpenApp(); openApp(); - }, [hasLoadedApp, isLoadingApp, isOffline, sessionAccountID]); + }, [hasLoadedApp, isLoadingApp, isOffline, sessionAccountID, isLoadingAppMetadata]); return null; } diff --git a/src/HybridAppHandler.tsx b/src/HybridAppHandler.tsx index 050ef5f261ef..7369fc347b21 100644 --- a/src/HybridAppHandler.tsx +++ b/src/HybridAppHandler.tsx @@ -22,6 +22,8 @@ function HybridAppHandler() { setupNewDotAfterTransitionFromOldDot(hybridAppSettings, tryNewDot).then(() => { if (loggedOutFromOldDot) { + endSpan(CONST.TELEMETRY.SPAN_APP_STARTUP); + endSpan(CONST.TELEMETRY.SPAN_BOOTSPLASH.ROOT); endSpan(CONST.TELEMETRY.SPAN_OD_ND_TRANSITION_LOGGED_OUT); } else { setSplashScreenState(CONST.BOOT_SPLASH_STATE.READY_TO_BE_HIDDEN); diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 613ecec40ac0..71afedaef8ba 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -224,6 +224,9 @@ const ONYXKEYS = { /** Store the state of the subscription */ NVP_PRIVATE_SUBSCRIPTION: 'nvp_private_subscription', + /** Store the state of Personal Karma donations */ + NVP_PERSONAL_OFFSETS: 'nvp_expensify_enablePersonalOffsets', + /** Store the applied Expensify promo code */ NVP_PRIVATE_PROMO_CODE: 'nvp_private_promoCode', @@ -373,13 +376,16 @@ const ONYXKEYS = { SHARE_BANK_ACCOUNT: 'shareBankAccount', /** Is report data loading? */ - RAM_ONLY_IS_LOADING_REPORT_DATA: 'isLoadingReportData', + IS_LOADING_REPORT_DATA: 'isLoadingReportData', /** Set when we are loading bank accounts for share page */ IS_LOADING_SHARE_BANK_ACCOUNTS: 'isLoadingShareBankAccounts', + /** Set when we are loading the bulk change approver page */ + IS_LOADING_BULK_CHANGE_APPROVER_PAGE: 'isLoadingBulkChangeApproverPage', + /** Is report data loading? */ - RAM_ONLY_IS_LOADING_APP: 'isLoadingApp', + IS_LOADING_APP: 'isLoadingApp', /** Is loading policy rules preview? */ IS_LOADING_POLICY_CODING_RULES_PREVIEW: 'isLoadingPolicyCodingRulesPreview', @@ -387,6 +393,9 @@ const ONYXKEYS = { /** Set when we are loading fresh subscription/billing data from the server */ IS_LOADING_SUBSCRIPTION_DATA: 'isLoadingSubscriptionData', + /** Set while UpdatePersonalKarma is in flight (optimistic UI for Save The World toggle) */ + IS_PENDING_UPDATE_PERSONAL_KARMA: 'isPendingUpdatePersonalKarma', + /** Set whether we are loading the search filters card data */ IS_SEARCH_FILTERS_CARD_DATA_LOADED: 'isSearchFiltersCardDataLoaded', @@ -881,6 +890,8 @@ const ONYXKEYS = { ONBOARDING_WORKSPACE_DETAILS_FORM_DRAFT: 'onboardingWorkspaceDetailsFormDraft', ROOM_NAME_FORM: 'roomNameForm', ROOM_NAME_FORM_DRAFT: 'roomNameFormDraft', + CHRONOS_SCHEDULE_OOO_FORM: 'chronosScheduleOOOForm', + CHRONOS_SCHEDULE_OOO_FORM_DRAFT: 'chronosScheduleOOOFormDraft', REPORT_DESCRIPTION_FORM: 'reportDescriptionForm', REPORT_DESCRIPTION_FORM_DRAFT: 'reportDescriptionFormDraft', LEGAL_NAME_FORM: 'legalNameForm', @@ -1063,6 +1074,8 @@ const ONYXKEYS = { EXPENSE_RULE_FORM_DRAFT: 'expenseRuleFormDraft', MERCHANT_RULE_FORM: 'merchantRuleForm', MERCHANT_RULE_FORM_DRAFT: 'merchantRuleFormDraft', + SPEND_RULE_FORM: 'spendRuleForm', + SPEND_RULE_FORM_DRAFT: 'spendRuleFormDraft', ADD_DOMAIN_MEMBER_FORM: 'addDomainMemberForm', ADD_DOMAIN_MEMBER_FORM_DRAFT: 'addDomainMemberFormDraft', ADD_WORK_EMAIL_FORM: 'addWorkEmailForm', @@ -1078,6 +1091,7 @@ const ONYXKEYS = { CARD_FEED_ERRORS: 'cardFeedErrors', TODOS: 'todos', RAM_ONLY_SORTED_REPORT_ACTIONS: 'sortedReportActions', + OPEN_AND_SUBMITTED_REPORTS_BY_POLICY_ID: 'openAndSubmittedReportsByPolicyID', }, /** Stores HybridApp specific state required to interoperate with OldDot */ @@ -1107,6 +1121,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.DISPLAY_NAME_FORM]: FormTypes.DisplayNameForm; [ONYXKEYS.FORMS.ONBOARDING_PERSONAL_DETAILS_FORM]: FormTypes.DisplayNameForm; [ONYXKEYS.FORMS.ROOM_NAME_FORM]: FormTypes.RoomNameForm; + [ONYXKEYS.FORMS.CHRONOS_SCHEDULE_OOO_FORM]: FormTypes.ChronosScheduleOOOForm; [ONYXKEYS.FORMS.REPORT_DESCRIPTION_FORM]: FormTypes.ReportDescriptionForm; [ONYXKEYS.FORMS.LEGAL_NAME_FORM]: FormTypes.LegalNameForm; [ONYXKEYS.FORMS.WORKSPACE_INVITE_MESSAGE_FORM]: FormTypes.WorkspaceInviteMessageForm; @@ -1205,6 +1220,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.SPLIT_EXPENSE_EDIT_DATES]: FormTypes.SplitExpenseEditDateForm; [ONYXKEYS.FORMS.EXPENSE_RULE_FORM]: FormTypes.ExpenseRuleForm; [ONYXKEYS.FORMS.MERCHANT_RULE_FORM]: FormTypes.MerchantRuleForm; + [ONYXKEYS.FORMS.SPEND_RULE_FORM]: FormTypes.SpendRuleForm; [ONYXKEYS.FORMS.ADD_DOMAIN_MEMBER_FORM]: FormTypes.AddDomainMemberForm; [ONYXKEYS.FORMS.ADD_WORK_EMAIL_FORM]: FormTypes.AddWorkEmailForm; }; @@ -1368,6 +1384,7 @@ type OnyxValuesMapping = { [ONYXKEYS.NVP_DISMISSED_REFERRAL_BANNERS]: OnyxTypes.DismissedReferralBanners; [ONYXKEYS.NVP_HAS_SEEN_TRACK_TRAINING]: boolean; [ONYXKEYS.NVP_PRIVATE_SUBSCRIPTION]: OnyxTypes.PrivateSubscription; + [ONYXKEYS.NVP_PERSONAL_OFFSETS]: boolean; [ONYXKEYS.NVP_PRIVATE_PROMO_CODE]: string | null; [ONYXKEYS.NVP_PRIVATE_PROMO_DISCOUNT]: OnyxTypes.PrivatePromoDiscount | null; [ONYXKEYS.NVP_PRIVATE_PROMO_CODE_VALID_BILLING_CYCLES]: number | null; @@ -1394,13 +1411,15 @@ type OnyxValuesMapping = { [ONYXKEYS.REIMBURSEMENT_ACCOUNT_WORKSPACE_ID]: string; [ONYXKEYS.IS_LOADING_PAYMENT_METHODS]: boolean; [ONYXKEYS.IS_LOADING_SHARE_BANK_ACCOUNTS]: boolean; + [ONYXKEYS.IS_LOADING_BULK_CHANGE_APPROVER_PAGE]: boolean; [ONYXKEYS.IS_LOADING_POLICY_CODING_RULES_PREVIEW]: boolean; - [ONYXKEYS.RAM_ONLY_IS_LOADING_REPORT_DATA]: boolean; + [ONYXKEYS.IS_LOADING_REPORT_DATA]: boolean; [ONYXKEYS.IS_SEARCH_FILTERS_CARD_DATA_LOADED]: boolean; [ONYXKEYS.IS_LOADING_SUBSCRIPTION_DATA]: boolean; + [ONYXKEYS.IS_PENDING_UPDATE_PERSONAL_KARMA]: boolean; [ONYXKEYS.IS_SEARCH_PAGE_DATA_LOADED]: boolean; [ONYXKEYS.IS_TEST_TOOLS_MODAL_OPEN]: boolean; - [ONYXKEYS.RAM_ONLY_IS_LOADING_APP]: boolean; + [ONYXKEYS.IS_LOADING_APP]: boolean; [ONYXKEYS.HAS_LOADED_APP]: boolean; [ONYXKEYS.WALLET_TRANSFER]: OnyxTypes.WalletTransfer; [ONYXKEYS.LAST_ACCESSED_WORKSPACE_POLICY_ID]: string; @@ -1524,6 +1543,7 @@ type OnyxDerivedValuesMapping = { [ONYXKEYS.DERIVED.CARD_FEED_ERRORS]: OnyxTypes.CardFeedErrorsDerivedValue; [ONYXKEYS.DERIVED.TODOS]: OnyxTypes.TodosDerivedValue; [ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS]: OnyxTypes.SortedReportActionsDerivedValue; + [ONYXKEYS.DERIVED.OPEN_AND_SUBMITTED_REPORTS_BY_POLICY_ID]: OnyxTypes.OpenAndSubmittedReportsByPolicyIDDerivedValue; }; type OnyxValues = OnyxValuesMapping & OnyxCollectionValuesMapping & OnyxFormValuesMapping & OnyxFormDraftValuesMapping & OnyxDerivedValuesMapping; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 28d2b18b6a0f..299776537aac 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -111,11 +111,89 @@ const DYNAMIC_ROUTES = { path: 'visibility', entryScreens: [SCREENS.REPORT_SETTINGS.ROOT], }, + POLICY_ACCOUNTING_NETSUITE_EXPORT_EXPENSES_VENDOR_SELECT: { + path: 'vendor/select', + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_EXPORT_EXPENSES], + }, + POLICY_ACCOUNTING_NETSUITE_EXPORT_EXPENSES_PAYABLE_ACCOUNT_SELECT: { + path: 'payable-account/select', + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_EXPORT_EXPENSES], + }, + POLICY_ACCOUNTING_NETSUITE_EXPORT_EXPENSES_JOURNAL_POSTING_PREFERENCE_SELECT: { + path: 'journal-posting-preference/select', + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_EXPORT_EXPENSES], + }, + POLICY_ACCOUNTING_NETSUITE_RECEIVABLE_ACCOUNT_SELECT: { + path: 'receivable-account/select', + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_EXPORT], + }, + POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_PREFERRED_EXPORTER: { + path: 'preferred-exporter', + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_EXPORT], + }, + POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT_OUT_OF_POCKET_EXPENSES: { + path: 'out-of-pocket-expense', + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_EXPORT], + }, + POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT_OUT_OF_POCKET_EXPENSES_ACCOUNT_SELECT: { + path: 'account-select', + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_ONLINE_EXPORT_OUT_OF_POCKET_EXPENSES], + }, + POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT_OUT_OF_POCKET_EXPENSES_SELECT: { + path: 'entity-select', + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_ONLINE_EXPORT_OUT_OF_POCKET_EXPENSES], + }, + POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT_DATE_SELECT: { + path: 'date-select', + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_EXPORT], + }, + POLICY_ACCOUNTING_SAGE_INTACCT_NON_REIMBURSABLE_DESTINATION: { + path: 'destination', + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSES], + }, + POLICY_ACCOUNTING_SAGE_INTACCT_DEFAULT_VENDOR: { + path: 'default-vendor', + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_REIMBURSABLE_EXPENSES, SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSES], + getRoute: (reimbursable: string) => getUrlWithParams('default-vendor', {reimbursable}), + queryParams: ['reimbursable'], + }, + POLICY_ACCOUNTING_SAGE_INTACCT_NON_REIMBURSABLE_CREDIT_CARD_ACCOUNT: { + path: 'credit-card-account', + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSES], + }, + POLICY_ACCOUNTING_SAGE_INTACCT_AUTO_SYNC: { + path: 'sage-intacct-autosync', + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_ADVANCED], + }, + POLICY_ACCOUNTING_SAGE_INTACCT_ACCOUNTING_METHOD: { + path: 'sage-intacct-accounting-method', + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_SAGE_INTACCT_AUTO_SYNC, SCREENS.WORKSPACE.ACCOUNTING.CARD_RECONCILIATION_SAGE_INTACCT_AUTO_SYNC], + }, + POLICY_ACCOUNTING_XERO_EXPORT_BANK_ACCOUNT_SELECT: { + path: 'bank-account-select', + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.XERO_EXPORT], + }, + POLICY_ACCOUNTING_XERO_BILL_STATUS_SELECTOR: { + path: 'purchase-bill-status-selector', + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.XERO_EXPORT], + }, + POLICY_ACCOUNTING_XERO_AUTO_SYNC: { + path: 'xero-autosync', + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.XERO_ADVANCED, SCREENS.WORKSPACE.ACCOUNTING.CARD_RECONCILIATION], + }, + POLICY_ACCOUNTING_XERO_ACCOUNTING_METHOD: { + path: 'accounting-method', + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.CARD_RECONCILIATION, SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_AUTO_SYNC], + }, + WORKSPACE_ACCOUNTING_RECONCILIATION_ACCOUNT_SETTINGS: { + path: 'account-reconciliation-settings', + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.CARD_RECONCILIATION, SCREENS.WORKSPACE.EXPENSIFY_CARD_SETTINGS_ACCOUNT], + }, ADDRESS_COUNTRY: { path: 'country', entryScreens: [ SCREENS.SETTINGS.PROFILE.ADDRESS, - SCREENS.WORKSPACE.ADDRESS, + SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_OVERVIEW_ADDRESS, SCREENS.SETTINGS.WALLET.CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS, SCREENS.DOMAIN_CARD.DOMAIN_CARD_UPDATE_ADDRESS, SCREENS.TRAVEL.WORKSPACE_ADDRESS, @@ -124,10 +202,20 @@ const DYNAMIC_ROUTES = { getRoute: (country = '') => `country?country=${country}`, queryParams: ['country'], }, + NOTIFICATION_PREFERENCES: { + path: 'notification-preferences', + entryScreens: [SCREENS.REPORT_SETTINGS.ROOT, SCREENS.PROFILE_ROOT], + getRoute: (reportID: string) => getUrlWithParams('notification-preferences', {reportID}), + queryParams: ['reportID'], + }, WORKSPACE_OVERVIEW_PLAN: { path: 'plan', entryScreens: [SCREENS.WORKSPACE.PROFILE], }, + WORKSPACE_OVERVIEW_ADDRESS: { + path: 'workspace-address', + entryScreens: [SCREENS.WORKSPACE.PROFILE], + }, WORKSPACE_INVITE: { path: 'invite', entryScreens: [SCREENS.WORKSPACE.PROFILE, SCREENS.WORKSPACE.MEMBERS], @@ -167,6 +255,11 @@ const DYNAMIC_ROUTES = { }), queryParams: ['fieldName', 'fieldValue', 'policyID'], }, + FLAG_COMMENT: { + path: 'flag/:reportID/:reportActionID', + entryScreens: [SCREENS.REPORT, SCREENS.RIGHT_MODAL.SEARCH_REPORT, SCREENS.RIGHT_MODAL.EXPENSE_REPORT, SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT], + getRoute: (reportID: string, reportActionID: string) => `flag/${reportID}/${reportActionID}`, + }, } as const satisfies DynamicRoutes; const ROUTES = { @@ -289,17 +382,13 @@ const ROUTES = { return `search/move-transactions/search/${encodeURIComponent(backTo)}` as const; }, }, + CHANGE_APPROVER_SEARCH_RHP: 'search/change-approver', + CHANGE_APPROVER_ADD_APPROVER_SEARCH_RHP: 'search/change-approver/add', // This is a utility route used to go to the user's concierge chat, or the sign-in page if the user's not authenticated CONCIERGE: 'concierge', TRACK_EXPENSE: 'track-expense', SUBMIT_EXPENSE: 'submit-expense', - FLAG_COMMENT: { - route: 'flag/:reportID/:reportActionID', - - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - getRoute: (reportID: string, reportActionID: string, backTo?: string) => getUrlWithBackToParam(`flag/${reportID}/${reportActionID}` as const, backTo), - }, PROFILE: { route: 'a/:accountID', getRoute: (accountID?: number, backTo?: string, login?: string) => { @@ -733,6 +822,7 @@ const ROUTES = { }, SETTINGS_SAVE_THE_WORLD: 'settings/teachersunite', + SETTINGS_SAVE_THE_WORLD_ADD_PAYMENT_CARD: 'settings/teachersunite/add-payment-card', NEW: 'new', NEW_CHAT: 'new/chat', @@ -890,11 +980,11 @@ const ROUTES = { // eslint-disable-next-line no-restricted-syntax -- Legacy route generation getRoute: (reportID: string, backTo?: string) => getUrlWithBackToParam(`r/${reportID}/settings` as const, backTo), }, - REPORT_SETTINGS_NOTIFICATION_PREFERENCES: { - route: 'r/:reportID/settings/notification-preferences', + REPORT_SETTINGS_NAME: { + route: 'r/:reportID/settings/name', // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - getRoute: (reportID: string, backTo?: string) => getUrlWithBackToParam(`r/${reportID}/settings/notification-preferences` as const, backTo), + getRoute: (reportID: string, backTo?: string) => getUrlWithBackToParam(`r/${reportID}/settings/name` as const, backTo), }, REPORT_CHANGE_APPROVER: { route: 'r/:reportID/change-approver', @@ -914,6 +1004,10 @@ const ROUTES = { route: 'r/:reportID/settings/columns', getRoute: (reportID: string) => `r/${reportID}/settings/columns` as const, }, + CHRONOS_SCHEDULE_OOO: { + route: 'r/:reportID/chronos/schedule-ooo', + getRoute: (reportID: string) => `r/${reportID}/chronos/schedule-ooo` as const, + }, SPLIT_BILL_DETAILS: { route: 'r/:reportID/split/:reportActionID', getRoute: (reportID: string | undefined, reportActionID: string, backTo?: string) => { @@ -1658,17 +1752,6 @@ const ROUTES = { return getUrlWithBackToParam(`workspaces/${policyID}/overview` as const, backTo); }, }, - WORKSPACE_OVERVIEW_ADDRESS: { - route: 'workspaces/:policyID/overview/address', - getRoute: (policyID: string | undefined, backTo?: string) => { - if (!policyID) { - Log.warn('Invalid policyID is used to build the WORKSPACE_OVERVIEW_ADDRESS route'); - } - - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - return getUrlWithBackToParam(`workspaces/${policyID}/overview/address` as const, backTo); - }, - }, WORKSPACE_ACCOUNTING: { route: 'workspaces/:policyID/accounting', getRoute: (policyID: string) => `workspaces/${policyID}/accounting` as const, @@ -1749,36 +1832,6 @@ const ROUTES = { route: 'workspaces/:policyID/accounting/quickbooks-online/export/travel-invoicing/payable-account', getRoute: (policyID: string) => `workspaces/${policyID}/accounting/quickbooks-online/export/travel-invoicing/payable-account` as const, }, - POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_PREFERRED_EXPORTER: { - route: 'workspaces/:policyID/accounting/quickbooks-online/export/preferred-exporter', - - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - getRoute: (policyID: string, backTo?: string) => getUrlWithBackToParam(`workspaces/${policyID}/accounting/quickbooks-online/export/preferred-exporter` as const, backTo), - }, - POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT_OUT_OF_POCKET_EXPENSES: { - route: 'workspaces/:policyID/accounting/quickbooks-online/export/out-of-pocket-expense', - - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - getRoute: (policyID: string, backTo?: string) => getUrlWithBackToParam(`workspaces/${policyID}/accounting/quickbooks-online/export/out-of-pocket-expense` as const, backTo), - }, - POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT_OUT_OF_POCKET_EXPENSES_ACCOUNT_SELECT: { - route: 'workspaces/:policyID/accounting/quickbooks-online/export/out-of-pocket-expense/account-select', - getRoute: (policyID: string, backTo?: string) => - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - getUrlWithBackToParam(`workspaces/${policyID}/accounting/quickbooks-online/export/out-of-pocket-expense/account-select` as const, backTo), - }, - POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT_OUT_OF_POCKET_EXPENSES_SELECT: { - route: 'workspaces/:policyID/accounting/quickbooks-online/export/out-of-pocket-expense/entity-select', - getRoute: (policyID: string, backTo?: string) => - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - getUrlWithBackToParam(`workspaces/${policyID}/accounting/quickbooks-online/export/out-of-pocket-expense/entity-select` as const, backTo), - }, - POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT_DATE_SELECT: { - route: 'workspaces/:policyID/accounting/quickbooks-online/export/date-select', - - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - getRoute: (policyID: string, backTo?: string) => getUrlWithBackToParam(`workspaces/${policyID}/accounting/quickbooks-online/export/date-select` as const, backTo), - }, POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_ACCOUNT_SELECT: { route: 'workspaces/:policyID/accounting/quickbooks-desktop/export/company-card-expense-account/account-select', getRoute: (policyID?: string, backTo?: string) => { @@ -1938,6 +1991,10 @@ const ROUTES = { return getUrlWithBackToParam(`workspaces/${policyID}/accounting/quickbooks-desktop/export` as const, backTo, false); }, }, + POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_EXISTING_CONNECTIONS: { + route: 'workspaces/:policyID/accounting/quickbooks-desktop/existing-connections', + getRoute: (policyID: string) => `workspaces/${policyID}/accounting/quickbooks-desktop/existing-connections` as const, + }, POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_SETUP_MODAL: { route: 'workspaces/:policyID/accounting/quickbooks-desktop/setup-modal', getRoute: (policyID: string) => `workspaces/${policyID}/accounting/quickbooks-desktop/setup-modal` as const, @@ -2183,17 +2240,6 @@ const ROUTES = { return `workspaces/${policyID}/accounting/${connection as string}/card-reconciliation` as const; }, }, - WORKSPACE_ACCOUNTING_RECONCILIATION_ACCOUNT_SETTINGS: { - route: 'workspaces/:policyID/accounting/:connection/card-reconciliation/account', - getRoute: (policyID: string | undefined, connection?: ValueOf, backTo?: string) => { - if (!policyID) { - Log.warn('Invalid policyID is used to build the WORKSPACE_ACCOUNTING_RECONCILIATION_ACCOUNT_SETTINGS route'); - } - - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - return getUrlWithBackToParam(`workspaces/${policyID}/accounting/${connection as string}/card-reconciliation/account` as const, backTo); - }, - }, WORKSPACE_CATEGORIES: { route: 'workspaces/:policyID/categories', getRoute: (policyID: string | undefined) => { @@ -2705,10 +2751,17 @@ const ROUTES = { }, WORKSPACE_EXPENSIFY_CARD_SELECT_FEED: { route: 'workspaces/:policyID/expensify-card/select-feed', - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation getRoute: (policyID: string, backTo?: string) => getUrlWithBackToParam(`workspaces/${policyID}/expensify-card/select-feed`, backTo), }, + WORKSPACE_EXPENSIFY_CARD_ADD_WORK_EMAIL: { + route: 'workspaces/:policyID/expensify-card/:fundID/work-email', + getRoute: (policyID: string, fundID: number) => `workspaces/${policyID}/expensify-card/${encodeURIComponent(fundID)}/work-email` as const, + }, + WORKSPACE_EXPENSIFY_CARD_VERIFY_WORK_EMAIL: { + route: 'workspaces/:policyID/expensify-card/:fundID/verify-work-email', + getRoute: (policyID: string, fundID: number) => `workspaces/${policyID}/expensify-card/${encodeURIComponent(fundID)}/verify-work-email` as const, + }, WORKSPACE_EXPENSIFY_CARD_SETTINGS_FREQUENCY: { route: 'workspaces/:policyID/expensify-card/settings/frequency', getRoute: (policyID: string) => `workspaces/${policyID}/expensify-card/settings/frequency` as const, @@ -2780,8 +2833,8 @@ const ROUTES = { }, WORKSPACE_CREATE_DISTANCE_RATE_UPGRADE: { route: 'workspaces/:policyID/distance-rates/new/upgrade', - getRoute: (policyID: string, transactionID?: string, reportID?: string) => - `workspaces/${policyID}/distance-rates/new/upgrade${transactionID ? `?transactionID=${transactionID}` : ''}${reportID ? `&reportID=${reportID}` : ''}` as const, + getRoute: (policyID: string, transactionID?: string, reportID?: string, iouType?: string, action?: string) => + `workspaces/${policyID}/distance-rates/new/upgrade${transactionID ? `?transactionID=${transactionID}` : ''}${reportID ? `&reportID=${reportID}` : ''}${iouType ? `&iouType=${iouType}` : ''}${action ? `&action=${action}` : ''}` as const, }, WORKSPACE_DISTANCE_RATES_SETTINGS: { route: 'workspaces/:policyID/distance-rates/settings', @@ -2966,6 +3019,34 @@ const ROUTES = { route: 'workspaces/:policyID/rules/merchant-rules/new', getRoute: (policyID: string) => `workspaces/${policyID}/rules/merchant-rules/new` as const, }, + RULES_SPEND_NEW: { + route: 'workspaces/:policyID/rules/spend-rules/new', + getRoute: (policyID: string) => `workspaces/${policyID}/rules/spend-rules/new` as const, + }, + RULES_SPEND_EDIT: { + route: 'workspaces/:policyID/rules/spend-rules/:ruleID', + getRoute: (policyID: string, ruleID: string) => `workspaces/${policyID}/rules/spend-rules/${ruleID}` as const, + }, + RULES_SPEND_CARD: { + route: 'workspaces/:policyID/rules/spend-rules/:ruleID/card', + getRoute: (policyID: string, ruleID?: string) => `workspaces/${policyID}/rules/spend-rules/${ruleID ?? ROUTES.NEW}/card` as const, + }, + RULES_SPEND_CATEGORY: { + route: 'workspaces/:policyID/rules/spend-rules/:ruleID/category', + getRoute: (policyID: string, ruleID?: string) => `workspaces/${policyID}/rules/spend-rules/${ruleID ?? ROUTES.NEW}/category` as const, + }, + RULES_SPEND_MAX_AMOUNT: { + route: 'workspaces/:policyID/rules/spend-rules/:ruleID/max-amount', + getRoute: (policyID: string, ruleID?: string) => `workspaces/${policyID}/rules/spend-rules/${ruleID ?? ROUTES.NEW}/max-amount` as const, + }, + RULES_SPEND_MERCHANTS: { + route: 'workspaces/:policyID/rules/spend-rules/:ruleID/merchants', + getRoute: (policyID: string, ruleID?: string) => `workspaces/${policyID}/rules/spend-rules/${ruleID ?? ROUTES.NEW}/merchants` as const, + }, + RULES_SPEND_MERCHANT_EDIT: { + route: 'workspaces/:policyID/rules/spend-rules/:ruleID/merchants/:merchantIndex', + getRoute: (policyID: string, ruleID: string, merchantIndex: string) => `workspaces/${policyID}/rules/spend-rules/${ruleID}/merchants/${merchantIndex}` as const, + }, RULES_MERCHANT_MERCHANT_TO_MATCH: { route: 'workspaces/:policyID/rules/merchant-rules/:ruleID/merchant-to-match', getRoute: (policyID: string, ruleID?: string) => `workspaces/${policyID}/rules/merchant-rules/${ruleID ?? 'new'}/merchant-to-match` as const, @@ -3387,12 +3468,6 @@ const ROUTES = { // eslint-disable-next-line no-restricted-syntax -- Legacy route generation getRoute: (policyID: string, backTo?: string) => getUrlWithBackToParam(`workspaces/${policyID}/accounting/xero/export/purchase-bill-date-select` as const, backTo), }, - POLICY_ACCOUNTING_XERO_EXPORT_BANK_ACCOUNT_SELECT: { - route: 'workspaces/:policyID/accounting/xero/export/bank-account-select', - - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - getRoute: (policyID: string, backTo?: string) => getUrlWithBackToParam(`workspaces/${policyID}/accounting/xero/export/bank-account-select` as const, backTo), - }, POLICY_ACCOUNTING_XERO_ADVANCED: { route: 'workspaces/:policyID/accounting/xero/advanced', getRoute: (policyID: string | undefined) => { @@ -3406,34 +3481,6 @@ const ROUTES = { route: 'workspaces/:policyID/accounting/claim-offer/:integration', getRoute: (policyID: string, integration: string) => `workspaces/${policyID}/accounting/claim-offer/${integration}` as const, }, - POLICY_ACCOUNTING_XERO_AUTO_SYNC: { - route: 'workspaces/:policyID/accounting/xero/advanced/autosync', - getRoute: (policyID: string | undefined, backTo?: string) => { - if (!policyID) { - Log.warn('Invalid policyID is used to build the POLICY_ACCOUNTING_XERO_AUTO_SYNC route'); - } - - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - return getUrlWithBackToParam(`workspaces/${policyID}/accounting/xero/advanced/autosync` as const, backTo); - }, - }, - POLICY_ACCOUNTING_XERO_ACCOUNTING_METHOD: { - route: 'workspaces/:policyID/accounting/xero/advanced/autosync/accounting-method', - getRoute: (policyID: string | undefined, backTo?: string) => { - if (!policyID) { - Log.warn('Invalid policyID is used to build the POLICY_ACCOUNTING_XERO_ACCOUNTING_METHOD route'); - } - - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - return getUrlWithBackToParam(`workspaces/${policyID}/accounting/xero/advanced/autosync/accounting-method` as const, backTo); - }, - }, - POLICY_ACCOUNTING_XERO_BILL_STATUS_SELECTOR: { - route: 'workspaces/:policyID/accounting/xero/export/purchase-bill-status-selector', - - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - getRoute: (policyID: string, backTo?: string) => getUrlWithBackToParam(`workspaces/${policyID}/accounting/xero/export/purchase-bill-status-selector` as const, backTo), - }, POLICY_ACCOUNTING_XERO_INVOICE_SELECTOR: { route: 'workspaces/:policyID/accounting/xero/advanced/invoice-account-selector', getRoute: (policyID: string | undefined) => { @@ -3656,30 +3703,6 @@ const ROUTES = { // eslint-disable-next-line no-restricted-syntax -- Legacy route generation getUrlWithBackToParam(`workspaces/${policyID}/connections/netsuite/export/expenses/${expenseType as string}/destination/select` as const, backTo), }, - POLICY_ACCOUNTING_NETSUITE_EXPORT_EXPENSES_VENDOR_SELECT: { - route: 'workspaces/:policyID/connections/netsuite/export/expenses/:expenseType/vendor/select', - getRoute: (policyID: string, expenseType: ValueOf, backTo?: string) => - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - getUrlWithBackToParam(`workspaces/${policyID}/connections/netsuite/export/expenses/${expenseType as string}/vendor/select` as const, backTo), - }, - POLICY_ACCOUNTING_NETSUITE_EXPORT_EXPENSES_PAYABLE_ACCOUNT_SELECT: { - route: 'workspaces/:policyID/connections/netsuite/export/expenses/:expenseType/payable-account/select', - getRoute: (policyID: string, expenseType: ValueOf, backTo?: string) => - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - getUrlWithBackToParam(`workspaces/${policyID}/connections/netsuite/export/expenses/${expenseType as string}/payable-account/select` as const, backTo), - }, - POLICY_ACCOUNTING_NETSUITE_EXPORT_EXPENSES_JOURNAL_POSTING_PREFERENCE_SELECT: { - route: 'workspaces/:policyID/connections/netsuite/export/expenses/:expenseType/journal-posting-preference/select', - getRoute: (policyID: string, expenseType: ValueOf, backTo?: string) => - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - getUrlWithBackToParam(`workspaces/${policyID}/connections/netsuite/export/expenses/${expenseType as string}/journal-posting-preference/select` as const, backTo), - }, - POLICY_ACCOUNTING_NETSUITE_RECEIVABLE_ACCOUNT_SELECT: { - route: 'workspaces/:policyID/connections/netsuite/export/receivable-account/select', - - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - getRoute: (policyID: string, backTo?: string) => getUrlWithBackToParam(`workspaces/${policyID}/connections/netsuite/export/receivable-account/select` as const, backTo), - }, POLICY_ACCOUNTING_NETSUITE_INVOICE_ITEM_PREFERENCE_SELECT: { route: 'workspaces/:policyID/connections/netsuite/export/invoice-item-preference/select', getRoute: (policyID: string | undefined, backTo?: string) => { @@ -3859,24 +3882,6 @@ const ROUTES = { // eslint-disable-next-line no-restricted-syntax -- Legacy route generation getRoute: (policyID: string, backTo?: string) => getUrlWithBackToParam(`workspaces/${policyID}/accounting/sage-intacct/export/reimbursable/destination` as const, backTo), }, - POLICY_ACCOUNTING_SAGE_INTACCT_NON_REIMBURSABLE_DESTINATION: { - route: 'workspaces/:policyID/accounting/sage-intacct/export/nonreimbursable/destination', - - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - getRoute: (policyID: string, backTo?: string) => getUrlWithBackToParam(`workspaces/${policyID}/accounting/sage-intacct/export/nonreimbursable/destination` as const, backTo), - }, - POLICY_ACCOUNTING_SAGE_INTACCT_DEFAULT_VENDOR: { - route: 'workspaces/:policyID/accounting/sage-intacct/export/:reimbursable/default-vendor', - getRoute: (policyID: string, reimbursable: string, backTo?: string) => - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - getUrlWithBackToParam(`workspaces/${policyID}/accounting/sage-intacct/export/${reimbursable}/default-vendor` as const, backTo), - }, - POLICY_ACCOUNTING_SAGE_INTACCT_NON_REIMBURSABLE_CREDIT_CARD_ACCOUNT: { - route: 'workspaces/:policyID/accounting/sage-intacct/export/nonreimbursable/credit-card-account', - - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - getRoute: (policyID: string, backTo?: string) => getUrlWithBackToParam(`workspaces/${policyID}/accounting/sage-intacct/export/nonreimbursable/credit-card-account` as const, backTo), - }, POLICY_ACCOUNTING_SAGE_INTACCT_ADVANCED: { route: 'workspaces/:policyID/accounting/sage-intacct/advanced', getRoute: (policyID: string | undefined) => { @@ -3895,25 +3900,6 @@ const ROUTES = { return `workspaces/${policyID}/accounting/sage-intacct/advanced/payment-account` as const; }, }, - POLICY_ACCOUNTING_SAGE_INTACCT_AUTO_SYNC: { - route: 'workspaces/:policyID/accounting/sage-intacct/advanced/autosync', - getRoute: (policyID: string | undefined, backTo?: string) => { - if (!policyID) { - Log.warn('Invalid policyID is used to build the POLICY_ACCOUNTING_SAGE_INTACCT_AUTO_SYNC route'); - } - // eslint-disable-next-line no-restricted-syntax - return getUrlWithBackToParam(`workspaces/${policyID}/accounting/sage-intacct/advanced/autosync` as const, backTo); - }, - }, - POLICY_ACCOUNTING_SAGE_INTACCT_ACCOUNTING_METHOD: { - route: 'workspaces/:policyID/accounting/sage-intacct/advanced/autosync/accounting-method', - getRoute: (policyID: string | undefined) => { - if (!policyID) { - Log.warn('Invalid policyID is used to build the POLICY_ACCOUNTING_SAGE_INTACCT_ACCOUNTING_METHOD route'); - } - return `workspaces/${policyID}/accounting/sage-intacct/advanced/autosync/accounting-method` as const; - }, - }, ADD_UNREPORTED_EXPENSE: { route: 'search/r/:reportID/add-unreported-expense/:backToReport?', getRoute: (reportID: string | undefined, backToReport?: string) => `search/r/${reportID}/add-unreported-expense/${backToReport ?? ''}` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 0b34df90b022..8216ca68090c 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -109,6 +109,10 @@ const SCREENS = { EDIT_MULTIPLE_REIMBURSABLE_RHP: 'Search_Edit_Multiple_Reimbursable_RHP', EDIT_MULTIPLE_TAX_RHP: 'Search_Edit_Multiple_Tax_RHP', TRANSACTIONS_CHANGE_REPORT_SEARCH_RHP: 'Search_Transactions_Change_Report_Search', + CHANGE_APPROVER: { + ROOT: 'Search_Change_Approver_Root', + ADD_APPROVER: 'Search_Change_Approver_Add_Approver', + }, }, SETTINGS: { ROOT: 'Settings_Root', @@ -275,6 +279,7 @@ const SCREENS = { }, SAVE_THE_WORLD: { ROOT: 'SaveTheWorld_Root', + ADD_PAYMENT_CARD: 'SaveTheWorld_Add_Payment_Card', }, RIGHT_MODAL: { SETTINGS: 'Settings', @@ -334,6 +339,7 @@ const SCREENS = { DOMAIN: 'Domain', EXPENSE_REPORT: 'ExpenseReport', MULTIFACTOR_AUTHENTICATION: 'MultifactorAuthentication', + CHRONOS_SCHEDULE_OOO: 'Chronos_Schedule_OOO', }, REPORT_CARD_ACTIVATE: 'Report_Card_Activate_Root', SAML_SIGN_IN: 'SAMLSignIn', @@ -462,8 +468,8 @@ const SCREENS = { REPORT_SETTINGS: { ROOT: 'Report_Settings_Root', + DYNAMIC_NOTIFICATION_PREFERENCES: 'Dynamic_Report_Settings_Notification_Preferences', DYNAMIC_SETTINGS_NAME: 'Dynamic_Report_Settings_Name', - NOTIFICATION_PREFERENCES: 'Report_Settings_Notification_Preferences', DYNAMIC_SETTINGS_WRITE_CAPABILITY: 'Dynamic_Report_Settings_Write_Capability', VISIBILITY: 'Report_Settings_Visibility', DYNAMIC_SETTINGS_VISIBILITY: 'Dynamic_Report_Settings_Visibility', @@ -525,19 +531,19 @@ const SCREENS = { QUICKBOOKS_ONLINE_LOCATIONS: 'Policy_Accounting_Quickbooks_Online_Import_Locations', QUICKBOOKS_ONLINE_TAXES: 'Policy_Accounting_Quickbooks_Online_Import_Taxes', QUICKBOOKS_ONLINE_EXPORT: 'Workspace_Accounting_Quickbooks_Online_Export', - QUICKBOOKS_ONLINE_EXPORT_DATE_SELECT: 'Workspace_Accounting_Quickbooks_Online_Export_Date_Select', + DYNAMIC_QUICKBOOKS_ONLINE_EXPORT_DATE_SELECT: 'Dynamic_Workspace_Accounting_Quickbooks_Online_Export_Date_Select', QUICKBOOKS_ONLINE_EXPORT_INVOICE_ACCOUNT_SELECT: 'Workspace_Accounting_Quickbooks_Online_Export_Invoice_Account_Select', QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT: 'Workspace_Accounting_Quickbooks_Online_Export_Company_Card_Expense', QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT_SELECT: 'Workspace_Accounting_Quickbooks_Online_Export_Company_Card_Expense_Account_Select', QUICKBOOKS_ONLINE_NON_REIMBURSABLE_DEFAULT_VENDOR_SELECT: 'Workspace_Accounting_Quickbooks_Online_Export_Non_Reimbursable_Default_Vendor_Select', QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT_COMPANY_CARD_SELECT: 'Workspace_Accounting_Quickbooks_Online_Export_Company_Card_Expense_Select', - QUICKBOOKS_ONLINE_EXPORT_PREFERRED_EXPORTER: 'Workspace_Accounting_Quickbooks_Online_Export_Preferred_Exporter', + DYNAMIC_QUICKBOOKS_ONLINE_EXPORT_PREFERRED_EXPORTER: 'Dynamic_Workspace_Accounting_Quickbooks_Online_Export_Preferred_Exporter', QUICKBOOKS_ONLINE_TRAVEL_INVOICING_CONFIGURATION: 'Workspace_Accounting_Quickbooks_Online_Travel_Invoicing_Configuration', QUICKBOOKS_ONLINE_TRAVEL_INVOICING_VENDOR_SELECT: 'Workspace_Accounting_Quickbooks_Online_Travel_Invoicing_Vendor_Select', QUICKBOOKS_ONLINE_TRAVEL_INVOICING_PAYABLE_ACCOUNT_SELECT: 'Workspace_Accounting_Quickbooks_Online_Travel_Invoicing_Payable_Account_Select', - QUICKBOOKS_ONLINE_EXPORT_OUT_OF_POCKET_EXPENSES: 'Workspace_Accounting_Quickbooks_Online_Export_Out_Of_Pocket_Expenses', - QUICKBOOKS_ONLINE_EXPORT_OUT_OF_POCKET_EXPENSES_SELECT: 'Workspace_Accounting_Quickbooks_Online_Export_Out_Of_Pocket_Expenses_Select', - QUICKBOOKS_ONLINE_EXPORT_OUT_OF_POCKET_EXPENSES_ACCOUNT_SELECT: 'Workspace_Accounting_Quickbooks_Online_Export_Out_Of_Pocket_Expenses_Account_Select', + DYNAMIC_QUICKBOOKS_ONLINE_EXPORT_OUT_OF_POCKET_EXPENSES: 'Dynamic_Workspace_Accounting_Quickbooks_Online_Export_Out_Of_Pocket_Expenses', + DYNAMIC_QUICKBOOKS_ONLINE_EXPORT_OUT_OF_POCKET_EXPENSES_SELECT: 'Dynamic_Workspace_Accounting_Quickbooks_Online_Export_Out_Of_Pocket_Expenses_Select', + DYNAMIC_QUICKBOOKS_ONLINE_EXPORT_OUT_OF_POCKET_EXPENSES_ACCOUNT_SELECT: 'Dynamic_Workspace_Accounting_Quickbooks_Online_Export_Out_Of_Pocket_Expenses_Account_Select', QUICKBOOKS_ONLINE_ADVANCED: 'Policy_Accounting_Quickbooks_Online_Advanced', QUICKBOOKS_ONLINE_ACCOUNT_SELECTOR: 'Policy_Accounting_Quickbooks_Online_Account_Selector', QUICKBOOKS_ONLINE_INVOICE_ACCOUNT_SELECTOR: 'Policy_Accounting_Quickbooks_Online_Invoice_Account_Selector', @@ -559,6 +565,7 @@ const SCREENS = { QUICKBOOKS_DESKTOP_EXPORT_OUT_OF_POCKET_EXPENSES_SELECT: 'Workspace_Accounting_Quickbooks_Desktop_Export_Out_Of_Pocket_Expenses_Select', QUICKBOOKS_DESKTOP_EXPORT_OUT_OF_POCKET_EXPENSES_ACCOUNT_SELECT: 'Workspace_Accounting_Quickbooks_Desktop_Export_Out_Of_Pocket_Expenses_Account_Select', QUICKBOOKS_DESKTOP_EXPORT: 'Workspace_Accounting_Quickbooks_Desktop_Export', + QUICKBOOKS_DESKTOP_REUSE_EXISTING_CONNECTIONS: 'Policy_Accounting_Quickbooks_Desktop_Reuse_Existing_Connections', QUICKBOOKS_DESKTOP_SETUP_MODAL: 'Policy_Accounting_Quickbooks_Desktop_Setup_Modal', QUICKBOOKS_DESKTOP_SETUP_REQUIRED_DEVICE_MODAL: 'Policy_Accounting_Quickbooks_Desktop_Setup_Required_Device_Modal', QUICKBOOKS_DESKTOP_TRIGGER_FIRST_SYNC: 'Policy_Accounting_Quickbooks_Desktop_Trigger_First_Sync', @@ -580,13 +587,13 @@ const SCREENS = { XERO_EXPORT: 'Policy_Accounting_Xero_Export', XERO_EXPORT_PURCHASE_BILL_DATE_SELECT: 'Policy_Accounting_Xero_Export_Purchase_Bill_Date_Select', XERO_ADVANCED: 'Policy_Accounting_Xero_Advanced', - XERO_AUTO_SYNC: 'Policy_Accounting_Xero_Auto_Sync', - XERO_ACCOUNTING_METHOD: 'Policy_Accounting_Xero_Accounting_Method', - XERO_BILL_STATUS_SELECTOR: 'Policy_Accounting_Xero_Export_Bill_Status_Selector', + DYNAMIC_XERO_AUTO_SYNC: 'Dynamic_Policy_Accounting_Xero_Auto_Sync', + DYNAMIC_XERO_ACCOUNTING_METHOD: 'Dynamic_Policy_Accounting_Xero_Accounting_Method', + DYNAMIC_XERO_BILL_STATUS_SELECTOR: 'Dynamic_Policy_Accounting_Xero_Export_Bill_Status_Selector', XERO_INVOICE_ACCOUNT_SELECTOR: 'Policy_Accounting_Xero_Invoice_Account_Selector', XERO_EXPORT_PREFERRED_EXPORTER_SELECT: 'Workspace_Accounting_Xero_Export_Preferred_Exporter_Select', XERO_BILL_PAYMENT_ACCOUNT_SELECTOR: 'Policy_Accounting_Xero_Bill_Payment_Account_Selector', - XERO_EXPORT_BANK_ACCOUNT_SELECT: 'Policy_Accounting_Xero_Export_Bank_Account_Select', + DYNAMIC_XERO_EXPORT_BANK_ACCOUNT_SELECT: 'Dynamic_Policy_Accounting_Xero_Export_Bank_Account_Select', NETSUITE_IMPORT_MAPPING: 'Policy_Accounting_NetSuite_Import_Mapping', NETSUITE_IMPORT_CUSTOM_FIELD: 'Policy_Accounting_NetSuite_Import_Custom_Field', NETSUITE_IMPORT_CUSTOM_FIELD_VIEW: 'Policy_Accounting_NetSuite_Import_Custom_Field_View', @@ -604,10 +611,10 @@ const SCREENS = { NETSUITE_DATE_SELECT: 'Policy_Accounting_NetSuite_Date_Select', NETSUITE_EXPORT_EXPENSES: 'Policy_Accounting_NetSuite_Export_Expenses', NETSUITE_EXPORT_EXPENSES_DESTINATION_SELECT: 'Policy_Accounting_NetSuite_Export_Expenses_Destination_Select', - NETSUITE_EXPORT_EXPENSES_VENDOR_SELECT: 'Policy_Accounting_NetSuite_Export_Expenses_Vendor_Select', - NETSUITE_EXPORT_EXPENSES_PAYABLE_ACCOUNT_SELECT: 'Policy_Accounting_NetSuite_Export_Expenses_Payable_Account_Select', - NETSUITE_EXPORT_EXPENSES_JOURNAL_POSTING_PREFERENCE_SELECT: 'Policy_Accounting_NetSuite_Export_Expenses_Journal_Posting_Preference_Select', - NETSUITE_RECEIVABLE_ACCOUNT_SELECT: 'Policy_Accounting_NetSuite_Receivable_Account_Select', + DYNAMIC_NETSUITE_EXPORT_EXPENSES_VENDOR_SELECT: 'Dynamic_Policy_Accounting_NetSuite_Export_Expenses_Vendor_Select', + DYNAMIC_NETSUITE_EXPORT_EXPENSES_PAYABLE_ACCOUNT_SELECT: 'Dynamic_Policy_Accounting_NetSuite_Export_Expenses_Payable_Account_Select', + DYNAMIC_NETSUITE_EXPORT_EXPENSES_JOURNAL_POSTING_PREFERENCE_SELECT: 'Dynamic_Policy_Accounting_NetSuite_Export_Expenses_Journal_Posting_Preference_Select', + DYNAMIC_NETSUITE_RECEIVABLE_ACCOUNT_SELECT: 'Dynamic_Policy_Accounting_NetSuite_Receivable_Account_Select', NETSUITE_INVOICE_ITEM_PREFERENCE_SELECT: 'Policy_Accounting_NetSuite_Invoice_Item_Preference_Select', NETSUITE_INVOICE_ITEM_SELECT: 'Policy_Accounting_NetSuite_Invoice_Item_Select', NETSUITE_TAX_POSTING_ACCOUNT_SELECT: 'Policy_Accounting_NetSuite_Tax_Posting_Account_Select', @@ -640,17 +647,17 @@ const SCREENS = { SAGE_INTACCT_REIMBURSABLE_EXPENSES: 'Policy_Accounting_Sage_Intacct_Reimbursable_Expenses', SAGE_INTACCT_NON_REIMBURSABLE_EXPENSES: 'Policy_Accounting_Sage_Intacct_Non_Reimbursable_Expenses', SAGE_INTACCT_REIMBURSABLE_DESTINATION: 'Policy_Accounting_Sage_Intacct_Reimbursable_Destination', - SAGE_INTACCT_NON_REIMBURSABLE_DESTINATION: 'Policy_Accounting_Sage_Intacct_Non_Reimbursable_Destination', - SAGE_INTACCT_DEFAULT_VENDOR: 'Policy_Accounting_Sage_Intacct_Default_Vendor', - SAGE_INTACCT_NON_REIMBURSABLE_CREDIT_CARD_ACCOUNT: 'Policy_Accounting_Sage_Intacct_Non_Reimbursable_Credit_Card_Account', + DYNAMIC_SAGE_INTACCT_NON_REIMBURSABLE_DESTINATION: 'Dynamic_Policy_Accounting_Sage_Intacct_Non_Reimbursable_Destination', + DYNAMIC_SAGE_INTACCT_DEFAULT_VENDOR: 'Dynamic_Policy_Accounting_Sage_Intacct_Default_Vendor', + DYNAMIC_SAGE_INTACCT_NON_REIMBURSABLE_CREDIT_CARD_ACCOUNT: 'Dynamic_Policy_Accounting_Sage_Intacct_Non_Reimbursable_Credit_Card_Account', SAGE_INTACCT_ADVANCED: 'Policy_Accounting_Sage_Intacct_Advanced', - SAGE_INTACCT_AUTO_SYNC: 'Policy_Accounting_Sage_Intacct_Auto_Sync', - SAGE_INTACCT_ACCOUNTING_METHOD: 'Policy_Accounting_Sage_Intacct_Accounting_Method', + DYNAMIC_SAGE_INTACCT_AUTO_SYNC: 'Dynamic_Policy_Accounting_Sage_Intacct_Auto_Sync', + DYNAMIC_SAGE_INTACCT_ACCOUNTING_METHOD: 'Dynamic_Policy_Accounting_Sage_Intacct_Accounting_Method', SAGE_INTACCT_PAYMENT_ACCOUNT: 'Policy_Accounting_Sage_Intacct_Payment_Account', CARD_RECONCILIATION: 'Policy_Accounting_Card_Reconciliation', CARD_RECONCILIATION_QUICKBOOKS_DESKTOP_AUTO_SYNC: 'Policy_Accounting_Card_Reconciliation_Quickbooks_Desktop_Auto_Sync', CARD_RECONCILIATION_SAGE_INTACCT_AUTO_SYNC: 'Policy_Accounting_Card_Reconciliation_Sage_Intacct_Auto_Sync', - RECONCILIATION_ACCOUNT_SETTINGS: 'Policy_Accounting_Reconciliation_Account_Settings', + DYNAMIC_RECONCILIATION_ACCOUNT_SETTINGS: 'Dynamic_Policy_Accounting_Reconciliation_Account_Settings', }, INITIAL: 'Workspace_Initial', PROFILE: 'Workspace_Overview', @@ -692,6 +699,8 @@ const SCREENS = { EXPENSIFY_CARD_ISSUE_NEW_CONFIRM_MAGIC_CODE: 'Workspace_ExpensifyCard_New_Confirm_Magic_Code', EXPENSIFY_CARD_NAME: 'Workspace_ExpensifyCard_Name', EXPENSIFY_CARD_SELECT_FEED: 'Workspace_ExpensifyCard_Select_Feed', + EXPENSIFY_CARD_ADD_WORK_EMAIL: 'Workspace_ExpensifyCard_Add_Work_Email', + EXPENSIFY_CARD_VERIFY_WORK_EMAIL: 'Workspace_ExpensifyCard_Verify_Work_Email', EXPENSIFY_CARD_LIMIT_TYPE: 'Workspace_ExpensifyCard_LimitType', EXPENSIFY_CARD_BANK_ACCOUNT: 'Workspace_ExpensifyCard_BankAccount', EXPENSIFY_CARD_SETTINGS: 'Workspace_ExpensifyCard_Settings', @@ -743,7 +752,7 @@ const SCREENS = { TAG_LIST_VIEW: 'Tag_List_View', TAG_GL_CODE: 'Tag_GL_Code', CURRENCY: 'Workspace_Overview_Currency', - ADDRESS: 'Workspace_Overview_Address', + DYNAMIC_WORKSPACE_OVERVIEW_ADDRESS: 'Dynamic_Workspace_Overview_Address', DYNAMIC_WORKSPACE_OVERVIEW_PLAN: 'Dynamic_Workspace_Overview_Plan_Type', WORKFLOWS: 'Workspace_Workflows', WORKFLOWS_PAYER: 'Workspace_Workflows_Payer', @@ -818,6 +827,11 @@ const SCREENS = { RULES_CUSTOM: 'Rules_Custom', RULES_PROHIBITED_DEFAULT: 'Rules_Prohibited_Default', RULES_MERCHANT_NEW: 'Rules_Merchant_New', + RULES_SPEND_NEW: 'Rules_Spend_New', + RULES_SPEND_EDIT: 'Rules_Spend_Edit', + RULES_SPEND_CARD: 'Rules_Spend_Card', + RULES_SPEND_CATEGORY: 'Rules_Spend_Category', + RULES_SPEND_MAX_AMOUNT: 'Rules_Spend_Max_Amount', RULES_MERCHANT_MERCHANT_TO_MATCH: 'Rules_Merchant_Merchant_To_Match', RULES_MERCHANT_MATCH_TYPE: 'Rules_Merchant_Match_Type', RULES_MERCHANT_MERCHANT: 'Rules_Merchant_Merchant', @@ -829,6 +843,8 @@ const SCREENS = { RULES_MERCHANT_BILLABLE: 'Rules_Merchant_Billable', RULES_MERCHANT_PREVIEW_MATCHES: 'Rules_Merchant_Preview_Matches', RULES_MERCHANT_EDIT: 'Rules_Merchant_Edit', + RULES_SPEND_MERCHANTS: 'Rules_Spend_Merchants', + RULES_SPEND_MERCHANT_EDIT: 'Rules_Spend_Merchant_Edit', PER_DIEM: 'Per_Diem', PER_DIEM_IMPORT: 'Per_Diem_Import', PER_DIEM_IMPORTED: 'Per_Diem_Imported', @@ -911,6 +927,7 @@ const SCREENS = { AUTO_SUBMIT_ROOT: 'AutoSubmit_Modal_Root', CHANGE_POLICY_EDUCATIONAL_ROOT: 'ChangePolicyEducational_Root', REPORT_DESCRIPTION_ROOT: 'Report_Description_Root', + CHRONOS_SCHEDULE_OOO_ROOT: 'Chronos_Schedule_OOO_Root', REPORT_PARTICIPANTS: { ROOT: 'ReportParticipants_Root', INVITE: 'ReportParticipants_Invite', @@ -922,7 +939,7 @@ const SCREENS = { INVITE: 'RoomMembers_Invite', DETAILS: 'RoomMember_Details', }, - FLAG_COMMENT_ROOT: 'FlagComment_Root', + DYNAMIC_FLAG_COMMENT: 'Dynamic_Flag_Comment', REIMBURSEMENT_ACCOUNT: 'ReimbursementAccount', REIMBURSEMENT_ACCOUNT_NON_USD: 'Reimbursement_Account_Non_USD', REIMBURSEMENT_ACCOUNT_ENTER_SIGNER_INFO: 'Reimbursement_Account_Signer_Info', diff --git a/src/components/AccountSwitcher.tsx b/src/components/AccountSwitcher.tsx index 6f2cc49373f0..0247f8cd19c1 100644 --- a/src/components/AccountSwitcher.tsx +++ b/src/components/AccountSwitcher.tsx @@ -137,7 +137,7 @@ function AccountSwitcher({isScreenFocused}: AccountSwitcherProps) { ): PopoverMenuItem => { const error = Object.values(errors ?? {}).at(0) ?? ''; return { - text: personalDetails?.displayName ?? personalDetails?.login ?? '', + text: formatPhoneNumber(personalDetails?.displayName ?? personalDetails?.login ?? ''), description: Str.removeSMSDomain(personalDetails?.login ?? ''), avatarID: personalDetails?.accountID ?? CONST.DEFAULT_NUMBER_ID, icon: personalDetails?.avatar ?? '', diff --git a/src/components/AddPaymentMethodMenu.tsx b/src/components/AddPaymentMethodMenu.tsx index b4481d4cabdc..dedc943544ce 100644 --- a/src/components/AddPaymentMethodMenu.tsx +++ b/src/components/AddPaymentMethodMenu.tsx @@ -6,7 +6,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import {completePaymentOnboarding} from '@libs/actions/IOU'; +import {completePaymentOnboarding} from '@libs/actions/IOU/PayMoneyRequest'; import {hasRequestFromCurrentAccount} from '@libs/ReportActionsUtils'; import {isExpenseReport, isIOUReport} from '@libs/ReportUtils'; import CONST from '@src/CONST'; @@ -69,7 +69,7 @@ function AddPaymentMethodMenu({ // Users can choose to pay with business bank account in case of Expense reports or in case of P2P IOU report // which then starts a bottom up flow and creates a Collect workspace where the payer is an admin and payee is an employee. const isIOU = isIOUReport(iouReport); - const canUseBusinessBankAccount = isExpenseReport(iouReport) || (isIOU && !hasRequestFromCurrentAccount(iouReport?.reportID, session?.accountID ?? CONST.DEFAULT_NUMBER_ID)); + const canUseBusinessBankAccount = isExpenseReport(iouReport) || (isIOU && !hasRequestFromCurrentAccount(iouReport, session?.accountID ?? CONST.DEFAULT_NUMBER_ID)); const canUsePersonalBankAccount = shouldShowPersonalBankAccountOption || isIOU; diff --git a/src/components/AddPlaidBankAccount.tsx b/src/components/AddPlaidBankAccount.tsx index fc011a18ee2a..c68624505d5a 100644 --- a/src/components/AddPlaidBankAccount.tsx +++ b/src/components/AddPlaidBankAccount.tsx @@ -83,19 +83,18 @@ function AddPlaidBankAccount({ const subscribedKeyboardShortcuts = useRef void>>([]); const previousNetworkState = useRef(undefined); const [selectedPlaidAccountMask, setSelectedPlaidAccountMask] = useState(defaultSelectedPlaidAccountMask); - const [plaidLinkToken] = useOnyx(ONYXKEYS.PLAID_LINK_TOKEN); - const [isPlaidTokenReady, setIsPlaidTokenReady] = useState(false); + const [plaidLinkToken] = useOnyx(ONYXKEYS.PLAID_LINK_TOKEN, {initWithStoredValues: false}); const [isPlaidDisabled] = useOnyx(ONYXKEYS.IS_PLAID_DISABLED); const {translate} = useLocalize(); const {isOffline} = useNetwork(); const getPlaidLinkToken = (): string | undefined => { - if (receivedRedirectURI && plaidLinkOAuthToken) { - return plaidLinkOAuthToken; + if (plaidLinkToken) { + return plaidLinkToken; } - if (isPlaidTokenReady && plaidLinkToken) { - return plaidLinkToken; + if (receivedRedirectURI && plaidLinkOAuthToken) { + return plaidLinkOAuthToken; } }; @@ -141,11 +140,9 @@ function AddPlaidBankAccount({ // If we're coming from Plaid OAuth flow then we need to reuse the existing plaidLinkToken if (isAuthenticatedWithPlaid()) { - setIsPlaidTokenReady(true); return unsubscribeToNavigationShortcuts; } openPlaidBankLogin(allowDebit, bankAccountID); - setIsPlaidTokenReady(true); return unsubscribeToNavigationShortcuts; // disabling this rule, as we want this to run only on the first render diff --git a/src/components/AddUnreportedExpenseFooter.tsx b/src/components/AddUnreportedExpenseFooter.tsx index 14a42a1af963..80ff3519cc02 100644 --- a/src/components/AddUnreportedExpenseFooter.tsx +++ b/src/components/AddUnreportedExpenseFooter.tsx @@ -7,7 +7,7 @@ import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; import {isIOUReport} from '@libs/ReportUtils'; import Navigation from '@navigation/Navigation'; -import {convertBulkTrackedExpensesToIOU} from '@userActions/IOU'; +import {convertBulkTrackedExpensesToIOU} from '@userActions/IOU/TrackExpense'; import {changeTransactionsReport} from '@userActions/Transaction'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -36,7 +36,7 @@ type AddUnreportedExpenseFooterProps = { }; function AddUnreportedExpenseFooter({selectedIds, report, reportToConfirm, reportNextStep, policy, policyCategories, errorMessage, setErrorMessage}: AddUnreportedExpenseFooterProps) { - const {translate, toLocaleDigit} = useLocalize(); + const {translate} = useLocalize(); const styles = useThemeStyles(); const {isBetaEnabled} = usePermissions(); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); @@ -93,8 +93,6 @@ function AddUnreportedExpenseFooter({selectedIds, report, reportToConfirm, repor reportNextStep, policyCategories, allTransactions: selectedTransactions, - translate, - toLocaleDigit, }); } }); diff --git a/src/components/AddressSearch/isCurrentTargetInsideContainer.ts b/src/components/AddressSearch/isCurrentTargetInsideContainer.ts index ffb82b3aac3e..852b3c482a1b 100644 --- a/src/components/AddressSearch/isCurrentTargetInsideContainer.ts +++ b/src/components/AddressSearch/isCurrentTargetInsideContainer.ts @@ -1,4 +1,3 @@ -import type {ReadOnlyNode} from 'react-native'; import type {IsCurrentTargetInsideContainerType} from './types'; const isCurrentTargetInsideContainer: IsCurrentTargetInsideContainerType = (event, containerRef) => { @@ -9,7 +8,7 @@ const isCurrentTargetInsideContainer: IsCurrentTargetInsideContainerType = (even return false; } - return !!containerRef.current.contains(event.relatedTarget as Node & ReadOnlyNode); + return !!containerRef.current.contains(event.relatedTarget as never); }; export default isCurrentTargetInsideContainer; diff --git a/src/components/AmountForm.tsx b/src/components/AmountForm.tsx index d8acc426172f..357d29eba9ae 100644 --- a/src/components/AmountForm.tsx +++ b/src/components/AmountForm.tsx @@ -46,6 +46,15 @@ type AmountFormProps = { /** Whether to hide the currency symbol */ hideCurrencySymbol?: boolean; + /** When true, shows the trailing dropdown (same as currency picker in IOU amount flows) */ + shouldShowCurrencyButton?: boolean; + + /** Text on the trailing dropdown button. Use with `shouldShowCurrencyButton` when the suffix is not a currency code (e.g. duration unit). */ + currencyButtonLabel?: string; + + /** Accessibility label for the trailing dropdown */ + currencyButtonAccessibilityLabel?: string; + /** Whether the input should be disabled */ disabled?: boolean; @@ -77,6 +86,9 @@ function AmountForm({ label, decimals: decimalsProp, hideCurrencySymbol = false, + shouldShowCurrencyButton = false, + currencyButtonLabel, + currencyButtonAccessibilityLabel, disabled = false, autoFocus, autoGrowExtraSpace, @@ -94,7 +106,7 @@ function AmountForm({ return ( )} - {/** - These are the actionable buttons that appear at the bottom of a Concierge message - for example: Invite a user mentioned but not a member of the room - https://github.com/Expensify/App/issues/32741 - */} {actionableItemButtons.length > 0 && ( { return ( prevProps.personalPolicyID === nextProps.personalPolicyID && prevProps.displayAsGroup === nextProps.displayAsGroup && - prevProps.isMostRecentIOUReportAction === nextProps.isMostRecentIOUReportAction && prevProps.shouldDisplayNewMarker === nextProps.shouldDisplayNewMarker && deepEqual(prevProps.action, nextProps.action) && deepEqual(prevProps.report?.pendingFields, nextProps.report?.pendingFields) && diff --git a/src/pages/inbox/report/ReactionList/PopoverReactionList/BasePopoverReactionList.tsx b/src/pages/inbox/report/ReactionList/PopoverReactionList/BasePopoverReactionList.tsx index 604d980e439c..6f65e72888e6 100644 --- a/src/pages/inbox/report/ReactionList/PopoverReactionList/BasePopoverReactionList.tsx +++ b/src/pages/inbox/report/ReactionList/PopoverReactionList/BasePopoverReactionList.tsx @@ -40,6 +40,7 @@ function BasePopoverReactionList({emojiName, reportActionID, currentUserPersonal shouldSetModalVisibility={false} fullscreen anchorRef={reactionListRef} + shouldWrapModalChildrenInScrollViewIfBottomDockedInLandscapeMode={false} > void; + onSelectionChange?: (event: TextInputSelectionChangeEvent) => void; + triggerHotkeyActions: (event: KeyboardEvent) => boolean | undefined; + updateShouldShowSuggestionMenuToFalse: (shouldShowSuggestionMenu?: boolean) => void; + setShouldBlockSuggestionCalc: (shouldBlock: boolean) => void; + getSuggestions: () => Mention[] | Emoji[]; + getIsSuggestionsMenuVisible: () => boolean; +}; + +// Hot — changes on every keystroke +type ComposerText = string; + +// Warm — changes on interaction +type ComposerState = { + isFocused: boolean; + isMenuVisible: boolean; + isFullComposerAvailable: boolean; +}; + +// Warm — changes based on content + policy +type ComposerSendState = { + isSendDisabled: boolean; + exceededMaxLength: number | null; + hasExceededMaxTaskTitleLength: boolean; + isBlockedFromConcierge: boolean; +}; + +// Frozen — stable references, never changes after mount +type ComposerActions = { + setValue: (v: string) => void; + setMenuVisibility: (v: boolean) => void; + setIsFullComposerAvailable: (v: boolean) => void; + setComposerRef: (ref: ComposerRef | null) => void; + focus: () => void; + onBlur: (event: BlurEvent) => void; + onFocus: () => void; + onAddActionPressed: () => void; + onItemSelected: () => void; + onTriggerAttachmentPicker: () => void; + clearComposer: () => void; +}; + +// Infrequent — changes only when send logic changes +type ComposerSendActions = { + handleSendMessage: () => void; + onValueChange: (value: string) => void; +}; + +// Frozen — stable refs, set once +type ComposerMeta = { + containerRef: RefObject; + composerRef: RefObject; + suggestionsRef: RefObject; + actionButtonRef: RefObject; + isNextModalWillOpenRef: RefObject; + attachmentFileRef: RefObject; +}; + +const noop = () => {}; + +const ComposerTextContext = createContext(''); + +const defaultState: ComposerState = { + isFocused: false, + isMenuVisible: false, + isFullComposerAvailable: false, +}; +const ComposerStateContext = createContext(defaultState); + +const defaultSendState: ComposerSendState = { + isSendDisabled: true, + exceededMaxLength: null, + hasExceededMaxTaskTitleLength: false, + isBlockedFromConcierge: false, +}; +const ComposerSendStateContext = createContext(defaultSendState); + +const defaultActions: ComposerActions = { + setValue: noop, + setMenuVisibility: noop, + setIsFullComposerAvailable: noop, + setComposerRef: noop, + focus: noop, + onBlur: noop, + onFocus: noop, + onAddActionPressed: noop, + onItemSelected: noop, + onTriggerAttachmentPicker: noop, + clearComposer: noop, +}; +const ComposerActionsContext = createContext(defaultActions); + +const defaultSendActions: ComposerSendActions = { + handleSendMessage: noop, + onValueChange: noop, +}; +const ComposerSendActionsContext = createContext(defaultSendActions); + +const ComposerMetaContext = createContext(null); + +function useComposerText() { + return useContext(ComposerTextContext); +} + +function useComposerState() { + return useContext(ComposerStateContext); +} + +function useComposerSendState() { + return useContext(ComposerSendStateContext); +} + +function useComposerActions() { + return useContext(ComposerActionsContext); +} + +function useComposerSendActions() { + return useContext(ComposerSendActionsContext); +} + +function useComposerMeta() { + const ctx = useContext(ComposerMetaContext); + if (!ctx) { + throw new Error('useComposerMeta must be used inside ComposerProvider'); + } + return ctx; +} + +export { + ComposerTextContext, + ComposerStateContext, + ComposerSendStateContext, + ComposerActionsContext, + ComposerSendActionsContext, + ComposerMetaContext, + useComposerText, + useComposerState, + useComposerSendState, + useComposerActions, + useComposerSendActions, + useComposerMeta, +}; +export type {SuggestionsRef, ComposerText, ComposerState, ComposerSendState, ComposerActions, ComposerSendActions, ComposerMeta}; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx new file mode 100644 index 000000000000..64cdd5731924 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx @@ -0,0 +1,195 @@ +import lodashDebounce from 'lodash/debounce'; +import React, {useRef, useState} from 'react'; +import type {View} from 'react-native'; +import {useSharedValue} from 'react-native-reanimated'; +import {scheduleOnUI} from 'react-native-worklets'; +import useHandleExceedMaxCommentLength from '@hooks/useHandleExceedMaxCommentLength'; +import useHandleExceedMaxTaskTitleLength from '@hooks/useHandleExceedMaxTaskTitleLength'; +import useOnyx from '@hooks/useOnyx'; +import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; +import {chatIncludesConcierge} from '@libs/ReportUtils'; +import {setIsComposerFullSize} from '@userActions/Report'; +import {isBlockedFromConcierge as isBlockedFromConciergeUserAction} from '@userActions/User'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {FileObject} from '@src/types/utils/Attachment'; +import {ComposerActionsContext, ComposerMetaContext, ComposerSendActionsContext, ComposerSendStateContext, ComposerStateContext, ComposerTextContext} from './ComposerContext'; +import type {SuggestionsRef} from './ComposerContext'; +import type {ComposerRef} from './ComposerWithSuggestions/ComposerWithSuggestions'; +import useComposerFocus from './useComposerFocus'; + +const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus(); + +type ComposerProviderProps = { + reportID: string; + children: React.ReactNode; +}; + +function ComposerProvider({children, reportID}: ComposerProviderProps) { + const [blockedFromConcierge] = useOnyx(ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE); + const [shouldShowComposeInput = true] = useOnyx(ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT); + const [initialModalState] = useOnyx(ONYXKEYS.MODAL); + const [draftComment] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); + + const shouldFocusComposerOnScreenFocus = shouldFocusInputOnScreenFocus || !!draftComment; + + const initialFocused = shouldFocusComposerOnScreenFocus && shouldShowComposeInput && !initialModalState?.isVisible && !initialModalState?.willAlertModalBecomeVisible; + + const [isFullComposerAvailable, setIsFullComposerAvailable] = useState(isComposerFullSize); + const [isMenuVisible, setMenuVisibility] = useState(false); + + const [value, setValue] = useState(() => { + return draftComment ?? ''; + }); + + const isEmpty = !value || !!value.match(CONST.REGEX.EMPTY_COMMENT); + + const includesConcierge = chatIncludesConcierge({participants: report?.participants}); + const userBlockedFromConcierge = isBlockedFromConciergeUserAction(blockedFromConcierge); + const isBlockedFromConcierge = includesConcierge && userBlockedFromConcierge; + + const {hasExceededMaxCommentLength, validateCommentMaxLength, setHasExceededMaxCommentLength} = useHandleExceedMaxCommentLength(); + const {hasExceededMaxTaskTitleLength, validateTaskTitleMaxLength, setHasExceededMaxTitleLength} = useHandleExceedMaxTaskTitleLength(); + + let exceededMaxLength: number | null = null; + if (hasExceededMaxTaskTitleLength) { + exceededMaxLength = CONST.TITLE_CHARACTER_LIMIT; + } else if (hasExceededMaxCommentLength) { + exceededMaxLength = CONST.MAX_COMMENT_LENGTH; + } + + const isSendDisabled = isEmpty || isBlockedFromConcierge || !!exceededMaxLength; + + const validateMaxLength = (v: string) => { + const taskCommentMatch = v?.match(CONST.REGEX.TASK_TITLE_WITH_OPTIONAL_SHORT_MENTION); + if (taskCommentMatch) { + const title = taskCommentMatch?.[3] ? taskCommentMatch[3].trim().replaceAll('\n', ' ') : ''; + setHasExceededMaxCommentLength(false); + return validateTaskTitleMaxLength(title); + } + setHasExceededMaxTitleLength(false); + return validateCommentMaxLength(v, {reportID}); + }; + + const debouncedValidate = lodashDebounce(validateMaxLength, CONST.TIMING.COMMENT_LENGTH_DEBOUNCE_TIME, {leading: true}); + + const containerRef = useRef(null); + const suggestionsRef = useRef(null); + const composerRef = useRef(null); + const actionButtonRef = useRef(null); + const attachmentFileRef = useRef(null); + + const composerRefShared = useSharedValue>({}); + + const {isFocused, onBlur, onFocus, focus, onAddActionPressed, onItemSelected, onTriggerAttachmentPicker, isNextModalWillOpenRef} = useComposerFocus({ + composerRef, + suggestionsRef, + actionButtonRef, + initialFocused, + }); + + const clearComposer = () => { + const clearWorklet = composerRefShared.get().clearWorklet; + if (!clearWorklet) { + throw new Error('The composerRef.clearWorklet function is not set yet. This should never happen, and indicates a developer error.'); + } + scheduleOnUI(clearWorklet); + }; + + const handleSendMessage = () => { + if (isSendDisabled || !debouncedValidate.flush()) { + return; + } + + composerRef.current?.resetHeight(); + if (isComposerFullSize) { + setIsComposerFullSize(reportID, false); + } + + scheduleOnUI(() => { + const {clearWorklet} = composerRefShared.get(); + + if (!clearWorklet) { + throw new Error('The composerRef.clearWorklet function is not set yet. This should never happen, and indicates a developer error.'); + } + + clearWorklet?.(); + }); + }; + + const setComposerRef = (ref: ComposerRef | null) => { + composerRef.current = ref; + composerRefShared.set({ + clearWorklet: ref?.clearWorklet, + }); + }; + + const onValueChange = (v: string) => { + if (v.length === 0 && isComposerFullSize) { + setIsComposerFullSize(reportID, false); + } + debouncedValidate(v); + }; + + const text = value; + + const composerState = { + isFocused, + isMenuVisible, + isFullComposerAvailable, + }; + + const composerSendState = { + isSendDisabled, + exceededMaxLength, + hasExceededMaxTaskTitleLength, + isBlockedFromConcierge, + }; + + const composerActions = { + setValue, + setMenuVisibility, + setIsFullComposerAvailable, + setComposerRef, + focus, + onBlur, + onFocus, + onAddActionPressed, + onItemSelected, + onTriggerAttachmentPicker, + clearComposer, + }; + + const composerSendActions = { + handleSendMessage, + onValueChange, + }; + + const composerMeta = { + containerRef, + composerRef, + suggestionsRef, + actionButtonRef, + isNextModalWillOpenRef, + attachmentFileRef, + }; + + return ( + + + + + + {children} + + + + + + ); +} + +export default ComposerProvider; +export type {ComposerProviderProps}; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index 5b957d6a45d3..ff903d77e125 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -22,6 +22,7 @@ import Composer from '@components/Composer'; import type {CustomSelectionChangeEvent, TextSelection} from '@components/Composer/types'; import {useWideRHPState} from '@components/WideRHPContextProvider'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useIsInSidePanel from '@hooks/useIsInSidePanel'; import useKeyboardState from '@hooks/useKeyboardState'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -263,6 +264,7 @@ function ComposerWithSuggestions({ const mobileInputScrollPosition = useRef(0); const cursorPositionValue = useSharedValue({x: 0, y: 0}); const tag = useSharedValue(-1); + const isInSidePanel = useIsInSidePanel(); const [draftComment = ''] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`); const [value, setValue] = useState(() => { if (draftComment) { @@ -683,6 +685,17 @@ function ComposerWithSuggestions({ }; }, [focus, route.key, shouldAutoFocus, shouldDelayAutoFocus]); + /** + * Tracks whether there is a composer input inside the side panel on the screen. + */ + const handleSidePanelFocus = useCallback(() => { + if (!isInSidePanel) { + ReportActionComposeFocusManager.sidePanelComposerRef.current = null; + } else { + ReportActionComposeFocusManager.sidePanelComposerRef.current = textInputRef.current; + } + }, [isInSidePanel]); + /** * Set focus callback * @param shouldTakeOverFocus - Whether this composer should gain focus priority @@ -690,6 +703,7 @@ function ComposerWithSuggestions({ const setUpComposeFocusManager = useCallback( (shouldTakeOverFocus = false) => { ReportActionComposeFocusManager.onComposerFocus((shouldFocusForNonBlurInputOnTapOutside = false) => { + handleSidePanelFocus(); if ((!willBlurTextInputOnTapOutside && !shouldFocusForNonBlurInputOnTapOutside) || !isFocused || !isSidePanelHiddenOrLargeScreen) { return; } @@ -697,7 +711,7 @@ function ComposerWithSuggestions({ focus(true); }, shouldTakeOverFocus); }, - [focus, isFocused, isSidePanelHiddenOrLargeScreen], + [focus, isFocused, isSidePanelHiddenOrLargeScreen, handleSidePanelFocus], ); /** @@ -797,6 +811,11 @@ function ComposerWithSuggestions({ return; } + // Do not focus side panels composer if it wasn't focused before + if (isInSidePanel && !ReportActionComposeFocusManager.sidePanelComposerRef.current) { + return; + } + // Do not focus the composer if the Side Panel is visible if (!isSidePanelHiddenOrLargeScreen) { return; @@ -814,7 +833,7 @@ function ComposerWithSuggestions({ return; } focus(true); - }, [focus, prevIsFocused, editFocused, prevIsModalVisible, isFocused, modal?.isVisible, isNextModalWillOpenRef, shouldAutoFocus, isSidePanelHiddenOrLargeScreen]); + }, [focus, prevIsFocused, editFocused, prevIsModalVisible, isFocused, modal?.isVisible, isNextModalWillOpenRef, shouldAutoFocus, isSidePanelHiddenOrLargeScreen, isInSidePanel]); useEffect(() => { // Scrolls the composer to the bottom and sets the selection to the end, so that longer drafts are easier to edit @@ -913,10 +932,10 @@ function ComposerWithSuggestions({ ); const handleFocus = useCallback(() => { - // The last composer that had focus should re-gain focus - setUpComposeFocusManager(true); + handleSidePanelFocus(); + setUpComposeFocusManager(!isInSidePanel); onFocus(); - }, [onFocus, setUpComposeFocusManager]); + }, [onFocus, setUpComposeFocusManager, handleSidePanelFocus, isInSidePanel]); // When using the suggestions box (Suggestions) we need to imperatively // set the cursor to the end of the suggestion/mention after it's selected. diff --git a/src/pages/inbox/report/ReportActionCompose/useAttachmentUploadValidation.ts b/src/pages/inbox/report/ReportActionCompose/useAttachmentUploadValidation.ts index a0215ce1fbba..ef6a55969a36 100644 --- a/src/pages/inbox/report/ReportActionCompose/useAttachmentUploadValidation.ts +++ b/src/pages/inbox/report/ReportActionCompose/useAttachmentUploadValidation.ts @@ -1,5 +1,5 @@ import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; -import {useCallback, useContext, useMemo, useRef} from 'react'; +import {useCallback, useContext, useRef} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import useFilesValidation from '@hooks/useFilesValidation'; import useLocalize from '@hooks/useLocalize'; @@ -11,7 +11,8 @@ import {isSelfDM} from '@libs/ReportUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; import Navigation from '@navigation/Navigation'; import AttachmentModalContext from '@pages/media/AttachmentModalScreen/AttachmentModalContext'; -import {initMoneyRequest, replaceReceipt, setMoneyRequestParticipantsFromReport, setMoneyRequestReceipt} from '@userActions/IOU'; +import {initMoneyRequest, setMoneyRequestParticipantsFromReport} from '@userActions/IOU'; +import {replaceReceipt, setMoneyRequestReceipt} from '@userActions/IOU/Receipt'; import {buildOptimisticTransactionAndCreateDraft} from '@userActions/TransactionEdit'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -57,9 +58,8 @@ function useAttachmentUploadValidation({ const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); const personalPolicy = usePersonalPolicy(); - const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const [hasOnlyPersonalPolicies = true] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: hasOnlyPersonalPoliciesUtil}); const [userBillingGracePeriodEnds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); - const hasOnlyPersonalPolicies = useMemo(() => hasOnlyPersonalPoliciesUtil(allPolicies), [allPolicies]); const [draftTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector}); const reportAttachmentsContext = useContext(AttachmentModalContext); diff --git a/src/pages/inbox/report/ReportActionCompose/useComposerFocus.ts b/src/pages/inbox/report/ReportActionCompose/useComposerFocus.ts new file mode 100644 index 000000000000..78ca74dba6e3 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/useComposerFocus.ts @@ -0,0 +1,63 @@ +import {useRef, useState} from 'react'; +import type {RefObject} from 'react'; +import type {BlurEvent, View} from 'react-native'; +import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside'; +import type {SuggestionsRef} from './ComposerContext'; +import type {ComposerRef} from './ComposerWithSuggestions/ComposerWithSuggestions'; + +const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc(); + +type UseComposerFocusParams = { + composerRef: RefObject; + suggestionsRef: RefObject; + actionButtonRef: RefObject; + initialFocused: boolean; +}; + +function useComposerFocus({composerRef, suggestionsRef, actionButtonRef, initialFocused}: UseComposerFocusParams) { + const [isFocused, setIsFocused] = useState(initialFocused); + const isKeyboardVisibleWhenShowingModalRef = useRef(false); + const isNextModalWillOpenRef = useRef(false); + + const focus = () => { + if (composerRef.current === null) { + return; + } + composerRef.current?.focus(true); + }; + + const onAddActionPressed = () => { + if (!willBlurTextInputOnTapOutside) { + isKeyboardVisibleWhenShowingModalRef.current = !!composerRef.current?.isFocused(); + } + composerRef.current?.blur(); + }; + + const onItemSelected = () => { + isKeyboardVisibleWhenShowingModalRef.current = false; + }; + + const onTriggerAttachmentPicker = () => { + isNextModalWillOpenRef.current = true; + isKeyboardVisibleWhenShowingModalRef.current = true; + }; + + const onBlur = (event: BlurEvent) => { + const webEvent = event as unknown as FocusEvent; + setIsFocused(false); + if (suggestionsRef.current) { + suggestionsRef.current.resetSuggestions(); + } + if (webEvent.relatedTarget && webEvent.relatedTarget === actionButtonRef.current) { + isKeyboardVisibleWhenShowingModalRef.current = true; + } + }; + + const onFocus = () => { + setIsFocused(true); + }; + + return {isFocused, onBlur, onFocus, focus, onAddActionPressed, onItemSelected, onTriggerAttachmentPicker, isNextModalWillOpenRef}; +} + +export default useComposerFocus; diff --git a/src/pages/inbox/report/ReportActionItemCreated.tsx b/src/pages/inbox/report/ReportActionItemCreated.tsx index 03742ae1dad5..fd09bd3eb800 100644 --- a/src/pages/inbox/report/ReportActionItemCreated.tsx +++ b/src/pages/inbox/report/ReportActionItemCreated.tsx @@ -1,3 +1,4 @@ +import {hasSeenTourSelector} from '@selectors/Onboarding'; import React, {memo} from 'react'; import {View} from 'react-native'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -34,6 +35,7 @@ function ReportActionItemCreated({reportID, policyID}: ReportActionItemCreatedPr const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [betas] = useOnyx(ONYXKEYS.BETAS); + const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); if (!isChatReport(report)) { @@ -47,7 +49,7 @@ function ReportActionItemCreated({reportID, policyID}: ReportActionItemCreatedPr pendingAction={report?.pendingFields?.addWorkspaceRoom ?? report?.pendingFields?.createChat} errors={report?.errorFields?.addWorkspaceRoom ?? report?.errorFields?.createChat} errorRowStyles={[styles.ml10, styles.mr2]} - onClose={() => clearCreateChatError(report, conciergeReportID, introSelected, currentUserAccountID, betas)} + onClose={() => clearCreateChatError(report, conciergeReportID, introSelected, currentUserAccountID, betas, isSelfTourViewed)} > diff --git a/src/pages/inbox/report/ReportActionItemParentAction.tsx b/src/pages/inbox/report/ReportActionItemParentAction.tsx index 5fc1b4dd85ac..8d21b7af6445 100644 --- a/src/pages/inbox/report/ReportActionItemParentAction.tsx +++ b/src/pages/inbox/report/ReportActionItemParentAction.tsx @@ -247,7 +247,6 @@ function ReportActionItemParentAction({ transactionThreadReport={transactionThreadReport} action={ancestorReportAction} displayAsGroup={false} - isMostRecentIOUReportAction={false} shouldDisplayNewMarker={ancestor.shouldDisplayNewMarker} index={index} isFirstVisibleReportAction={isFirstVisibleReportAction} diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 6c009ff51b46..32689dec4c96 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -25,6 +25,7 @@ import useScrollToEndOnNewMessageReceived from '@hooks/useScrollToEndOnNewMessag import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import {isSafari} from '@libs/Browser'; +import {isConsecutiveChronosAutomaticTimerAction} from '@libs/ChronosUtils'; import DateUtils from '@libs/DateUtils'; import FS from '@libs/Fullstory'; import durationHighlightItem from '@libs/Navigation/helpers/getDurationHighlightItem'; @@ -35,7 +36,6 @@ import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigat import { getFirstVisibleReportActionID, isConsecutiveActionMadeByPreviousActor, - isConsecutiveChronosAutomaticTimerAction, isCurrentActionUnread, isDeletedParentAction, isReportPreviewAction, @@ -98,9 +98,6 @@ type ReportActionsListProps = { /** Sorted actions that should be visible to the user */ sortedVisibleReportActions: OnyxTypes.ReportAction[]; - /** The ID of the most recent IOU report action connected with the shown report */ - mostRecentIOUReportActionID?: string | null; - /** Callback executed on list layout */ onLayout: (event: LayoutChangeEvent) => void; @@ -166,7 +163,6 @@ function ReportActionsList({ sortedReportActions, sortedVisibleReportActions, onScroll, - mostRecentIOUReportActionID = '', loadNewerChats, loadOlderChats, onLayout, @@ -716,7 +712,6 @@ function ReportActionsList({ !isConsecutiveChronosAutomaticTimerAction(sortedVisibleReportActions, index, chatIncludesChronosWithID(reportAction?.reportID), isOffline) && isConsecutiveActionMadeByPreviousActor(sortedVisibleReportActions, index, isOffline) } - mostRecentIOUReportActionID={mostRecentIOUReportActionID} shouldHideThreadDividerLine={shouldHideThreadDividerLine} shouldDisplayNewMarker={reportAction.reportActionID === unreadMarkerReportActionID} shouldDisplayReplyDivider={sortedVisibleReportActions.length > 1} @@ -743,7 +738,6 @@ function ReportActionsList({ transactionThreadReport, linkedReportActionID, sortedVisibleReportActions, - mostRecentIOUReportActionID, shouldHideThreadDividerLine, unreadMarkerReportActionID, firstVisibleReportActionID, diff --git a/src/pages/inbox/report/ReportActionsListItemRenderer.tsx b/src/pages/inbox/report/ReportActionsListItemRenderer.tsx index bff15ac726ce..a6f38bec95b2 100644 --- a/src/pages/inbox/report/ReportActionsListItemRenderer.tsx +++ b/src/pages/inbox/report/ReportActionsListItemRenderer.tsx @@ -31,9 +31,6 @@ type ReportActionsListItemRendererProps = { /** Should the comment have the appearance of being grouped with the previous comment? */ displayAsGroup: boolean; - /** The ID of the most recent IOU report action connected with the shown report */ - mostRecentIOUReportActionID?: string | null; - /** If the thread divider line should be hidden */ shouldHideThreadDividerLine: boolean; @@ -89,7 +86,6 @@ function ReportActionsListItemRenderer({ report, transactionThreadReport, displayAsGroup, - mostRecentIOUReportActionID = '', shouldHideThreadDividerLine, shouldDisplayNewMarker, linkedReportActionID = '', @@ -221,7 +217,6 @@ function ReportActionsListItemRenderer({ linkedReportActionID={linkedReportActionID} displayAsGroup={displayAsGroup} shouldDisplayNewMarker={shouldDisplayNewMarker} - isMostRecentIOUReportAction={reportAction.reportActionID === mostRecentIOUReportActionID} index={index} isFirstVisibleReportAction={isFirstVisibleReportAction} shouldUseThreadDividerLine={shouldUseThreadDividerLine} diff --git a/src/pages/inbox/report/ReportActionsView.tsx b/src/pages/inbox/report/ReportActionsView.tsx index c85c87e3f7c3..8f4ce82ebfd6 100755 --- a/src/pages/inbox/report/ReportActionsView.tsx +++ b/src/pages/inbox/report/ReportActionsView.tsx @@ -31,7 +31,6 @@ import {generateNewRandomInt, rand64} from '@libs/NumberUtils'; import { getCombinedReportActions, getFilteredReportActionsForReportView, - getMostRecentIOURequestActionID, getOneTransactionThreadReportID, getOriginalMessage, getSortedReportActionsForDisplay, @@ -139,7 +138,7 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { [getTransactionThreadReportActions], ); const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`); - const [isLoadingApp] = useOnyx(ONYXKEYS.RAM_ONLY_IS_LOADING_APP); + const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); const prevTransactionThreadReport = usePrevious(transactionThreadReport); const prevReportActionID = usePrevious(reportActionID); @@ -275,7 +274,6 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { ); const newestReportAction = useMemo(() => reportActions?.at(0), [reportActions]); - const mostRecentIOUReportActionID = useMemo(() => getMostRecentIOURequestActionID(reportActions), [reportActions]); const lastActionCreated = visibleReportActions.at(0)?.created; const isNewestAction = (actionCreated: string | undefined, lastVisibleActionCreated: string | undefined) => actionCreated && lastVisibleActionCreated ? actionCreated >= lastVisibleActionCreated : actionCreated === lastVisibleActionCreated; @@ -413,7 +411,6 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { onLayout={recordTimeToMeasureItemLayout} sortedReportActions={conciergeSidePanelFilteredReportActions} sortedVisibleReportActions={conciergeSidePanelFilteredVisibleActions} - mostRecentIOUReportActionID={mostRecentIOUReportActionID} loadOlderChats={loadOlderChats} loadNewerChats={loadNewerChats} listID={listID} diff --git a/src/pages/inbox/report/ReportDetailsExportPage.tsx b/src/pages/inbox/report/ReportDetailsExportPage.tsx index 08abf2e7d883..0c93c33e325e 100644 --- a/src/pages/inbox/report/ReportDetailsExportPage.tsx +++ b/src/pages/inbox/report/ReportDetailsExportPage.tsx @@ -40,7 +40,7 @@ function ReportDetailsExportPage({route}: ReportDetailsExportPageProps) { const {showConfirmModal} = useConfirmModal(); const styles = useThemeStyles(); const lazyIllustrations = useMemoizedLazyIllustrations(['LaptopWithSecondScreenAndHourglass']); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['XeroSquare', 'QBOSquare', 'NetSuiteSquare', 'IntacctSquare', 'QBDSquare', 'CertiniaSquare']); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['XeroSquare', 'QBOSquare', 'NetSuiteSquare', 'IntacctSquare', 'QBDSquare', 'CertiniaSquare', 'GustoSquare']); const iconToDisplay = getIntegrationIcon(connectionName, expensifyIcons); const canBeExported = canBeExportedUtil(report); diff --git a/src/pages/inbox/report/SystemChatReportFooterMessage.tsx b/src/pages/inbox/report/SystemChatReportFooterMessage.tsx index 58cacaa0edf5..7d138a460f40 100644 --- a/src/pages/inbox/report/SystemChatReportFooterMessage.tsx +++ b/src/pages/inbox/report/SystemChatReportFooterMessage.tsx @@ -1,5 +1,6 @@ import {emailSelector} from '@selectors/Session'; -import React, {useMemo} from 'react'; +import React, {useCallback, useMemo} from 'react'; +import type {OnyxCollection} from 'react-native-onyx'; import Banner from '@components/Banner'; import RenderHTML from '@components/RenderHTML'; import Text from '@components/Text'; @@ -12,6 +13,7 @@ import {shouldShowPolicy} from '@libs/PolicyUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {Policy} from '@src/types/onyx'; function SystemChatReportFooterMessage() { const {translate} = useLocalize(); @@ -20,16 +22,22 @@ function SystemChatReportFooterMessage() { const {environmentURL} = useEnvironment(); const [currentUserLogin] = useOnyx(ONYXKEYS.SESSION, {selector: emailSelector}); const [choice] = useOnyx(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED); - const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); - const adminChatReportID = useMemo(() => { - const adminPolicy = activePolicyID - ? policies?.[`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`] - : Object.values(policies ?? {}).find((policy) => shouldShowPolicy(policy, false, currentUserLogin) && policy?.role === CONST.POLICY.ROLE.ADMIN && policy?.chatReportIDAdmins); + const adminChatReportIDSelector = useCallback( + (allPolicies: OnyxCollection) => { + const adminPolicy = activePolicyID + ? allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`] + : Object.values(allPolicies ?? {}).find( + (policy) => shouldShowPolicy(policy, false, currentUserLogin) && policy?.role === CONST.POLICY.ROLE.ADMIN && policy?.chatReportIDAdmins, + ); - return String(adminPolicy?.chatReportIDAdmins ?? CONST.DEFAULT_NUMBER_ID); - }, [activePolicyID, policies, currentUserLogin]); + return String(adminPolicy?.chatReportIDAdmins ?? CONST.DEFAULT_NUMBER_ID); + }, + [activePolicyID, currentUserLogin], + ); + + const [adminChatReportID] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: adminChatReportIDSelector}); const [adminChatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${adminChatReportID}`); diff --git a/src/pages/inbox/report/actionContents/ApprovalFlowContent.tsx b/src/pages/inbox/report/actionContents/ApprovalFlowContent.tsx new file mode 100644 index 000000000000..a711068df2b2 --- /dev/null +++ b/src/pages/inbox/report/actionContents/ApprovalFlowContent.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import RenderHTML from '@components/RenderHTML'; +import useLocalize from '@hooks/useLocalize'; +import {hasDynamicExternalWorkflow} from '@libs/PolicyUtils'; +import {getOriginalMessage, hasPendingDEWApprove, hasPendingDEWSubmit, isActionOfType, isMarkAsClosedAction} from '@libs/ReportActionsUtils'; +import ReportActionItemBasicMessage from '@pages/inbox/report/ReportActionItemBasicMessage'; +import ReportActionItemMessageWithExplain from '@pages/inbox/report/ReportActionItemMessageWithExplain'; +import CONST from '@src/CONST'; +import type * as OnyxTypes from '@src/types/onyx'; + +type ApprovalFlowContentProps = { + action: OnyxTypes.ReportAction; + policy: OnyxEntry; + reportMetadata: OnyxEntry; + childReport: OnyxEntry; + originalReport: OnyxEntry; +}; + +function isApprovalFlowAction(action: OnyxTypes.ReportAction): boolean { + return ( + isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.SUBMITTED) || + isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.SUBMITTED_AND_CLOSED) || + isMarkAsClosedAction(action) || + isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.APPROVED) || + isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.FORWARDED) + ); +} + +function ApprovalFlowContent({action, policy, reportMetadata, childReport, originalReport}: ApprovalFlowContentProps) { + const {translate} = useLocalize(); + const isDEWPolicy = hasDynamicExternalWorkflow(policy); + const isPendingAdd = action?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD; + + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.SUBMITTED) || isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.SUBMITTED_AND_CLOSED) || isMarkAsClosedAction(action)) { + const wasSubmittedViaHarvesting = !isMarkAsClosedAction(action) ? (getOriginalMessage(action)?.harvesting ?? false) : false; + + if (wasSubmittedViaHarvesting) { + return ( + + ); + } + + if (hasPendingDEWSubmit(reportMetadata, isDEWPolicy) && isPendingAdd) { + return ; + } + + if (isDEWPolicy) { + // Don't show a memo for DEW actions, it's shown in the Concierge action below + return ; + } + + return ; + } + + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.APPROVED)) { + const wasAutoApproved = getOriginalMessage(action)?.automaticAction ?? false; + + if (wasAutoApproved) { + return ( + + ); + } + + if (hasPendingDEWApprove(reportMetadata, isDEWPolicy) && isPendingAdd) { + return ; + } + + return ; + } + + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.FORWARDED)) { + const wasAutoForwarded = getOriginalMessage(action)?.automaticAction ?? false; + + if (wasAutoForwarded) { + return ( + + ${translate('iou.automaticallyForwarded')}`} /> + + ); + } + + return ; + } + + return null; +} + +ApprovalFlowContent.displayName = 'ApprovalFlowContent'; + +export default ApprovalFlowContent; +export {isApprovalFlowAction}; diff --git a/src/pages/inbox/report/actionContents/ConfirmWhisperContent.tsx b/src/pages/inbox/report/actionContents/ConfirmWhisperContent.tsx new file mode 100644 index 000000000000..7a2c9d9c4e5b --- /dev/null +++ b/src/pages/inbox/report/actionContents/ConfirmWhisperContent.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import MentionReportContext from '@components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext'; +import type {ActionableItem} from '@components/ReportActionItem/ActionableItemButtons'; +import ActionableItemButtons from '@components/ReportActionItem/ActionableItemButtons'; +import useReportIsArchived from '@hooks/useReportIsArchived'; +import {resolveActionableMentionConfirmWhisper} from '@libs/actions/Report'; +import ReportActionItemMessage from '@pages/inbox/report/ReportActionItemMessage'; +import CONST from '@src/CONST'; +import type {Report, ReportAction} from '@src/types/onyx'; + +type ConfirmWhisperContentProps = { + action: ReportAction; + reportID: string | undefined; + originalReportID: string | undefined; + report: OnyxEntry; + originalReport: OnyxEntry; +}; + +function ConfirmWhisperContent({action, reportID, originalReportID, report, originalReport}: ConfirmWhisperContentProps) { + const reportActionReport = originalReport ?? report; + const isOriginalReportArchived = useReportIsArchived(originalReportID); + const mentionReportContextValue = {currentReportID: report?.reportID, exactlyMatch: true}; + + const buttons: ActionableItem[] = [ + { + text: 'common.buttonConfirm', + key: `${action.reportActionID}-actionableReportMentionConfirmWhisper-${CONST.REPORT.ACTIONABLE_MENTION_INVITE_TO_SUBMIT_EXPENSE_CONFIRM_WHISPER.DONE}`, + onPress: () => + resolveActionableMentionConfirmWhisper(reportActionReport, action, CONST.REPORT.ACTIONABLE_MENTION_INVITE_TO_SUBMIT_EXPENSE_CONFIRM_WHISPER.DONE, isOriginalReportArchived), + isPrimary: true, + }, + ]; + + return ( + + + + + + + ); +} + +ConfirmWhisperContent.displayName = 'ConfirmWhisperContent'; + +export default ConfirmWhisperContent; diff --git a/src/pages/inbox/report/actionContents/FraudAlertContent.tsx b/src/pages/inbox/report/actionContents/FraudAlertContent.tsx new file mode 100644 index 000000000000..cfd144b6db55 --- /dev/null +++ b/src/pages/inbox/report/actionContents/FraudAlertContent.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import {View} from 'react-native'; +import type {ActionableItem} from '@components/ReportActionItem/ActionableItemButtons'; +import ActionableItemButtons from '@components/ReportActionItem/ActionableItemButtons'; +import useLocalize from '@hooks/useLocalize'; +import {getActionableCardFraudAlertMessage, getOriginalMessage} from '@libs/ReportActionsUtils'; +import ReportActionItemBasicMessage from '@pages/inbox/report/ReportActionItemBasicMessage'; +import {resolveFraudAlert} from '@userActions/Card'; +import CONST from '@src/CONST'; +import type {ReportAction} from '@src/types/onyx'; + +type FraudAlertContentProps = { + action: ReportAction; + reportID: string | undefined; +}; + +function FraudAlertContent({action, reportID}: FraudAlertContentProps) { + const {translate, getLocalDateFromDatetime} = useLocalize(); + + const reportActionID = action?.reportActionID; + const originalMessage = getOriginalMessage(action); + const cardID = originalMessage?.cardID; + + const buttons: ActionableItem[] = originalMessage?.resolution + ? [] + : [ + { + text: 'cardPage.cardFraudAlert.confirmButtonText', + key: `${action.reportActionID}-cardFraudAlert-confirm`, + onPress: () => { + resolveFraudAlert(cardID, false, reportID, reportActionID); + }, + isPrimary: true, + }, + { + text: 'cardPage.cardFraudAlert.reportFraudButtonText', + key: `${action.reportActionID}-cardFraudAlert-reportFraud`, + onPress: () => { + resolveFraudAlert(cardID, true, reportID, reportActionID); + }, + }, + ]; + const message = getActionableCardFraudAlertMessage(translate, action, getLocalDateFromDatetime); + + return ( + + + {buttons.length > 0 && ( + + )} + + ); +} + +FraudAlertContent.displayName = 'FraudAlertContent'; + +export default FraudAlertContent; diff --git a/src/pages/inbox/report/actionContents/JoinRequestContent.tsx b/src/pages/inbox/report/actionContents/JoinRequestContent.tsx new file mode 100644 index 000000000000..5e9baef9e8d7 --- /dev/null +++ b/src/pages/inbox/report/actionContents/JoinRequestContent.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import type {ActionableItem} from '@components/ReportActionItem/ActionableItemButtons'; +import ActionableItemButtons from '@components/ReportActionItem/ActionableItemButtons'; +import useLocalize from '@hooks/useLocalize'; +import {getJoinRequestMessage, getOriginalMessage} from '@libs/ReportActionsUtils'; +import ReportActionItemBasicMessage from '@pages/inbox/report/ReportActionItemBasicMessage'; +import {acceptJoinRequest, declineJoinRequest} from '@userActions/Policy/Member'; +import CONST from '@src/CONST'; +import type {Policy, ReportAction} from '@src/types/onyx'; +import type {JoinWorkspaceResolution} from '@src/types/onyx/OriginalMessage'; + +type JoinRequestContentProps = { + action: ReportAction; + reportID: string | undefined; + originalReportID: string; + policy: OnyxEntry; +}; + +function JoinRequestContent({action, reportID, originalReportID, policy}: JoinRequestContentProps) { + const {translate} = useLocalize(); + + const reportActionReportID = originalReportID ?? reportID; + const buttons: ActionableItem[] = + getOriginalMessage(action)?.choice !== ('' as JoinWorkspaceResolution) + ? [] + : [ + { + text: 'actionableMentionJoinWorkspaceOptions.accept', + key: `${action.reportActionID}-actionableMentionJoinWorkspace-${CONST.REPORT.ACTIONABLE_MENTION_JOIN_WORKSPACE_RESOLUTION.ACCEPT}`, + onPress: () => acceptJoinRequest(reportActionReportID, action), + isPrimary: true, + }, + { + text: 'actionableMentionJoinWorkspaceOptions.decline', + key: `${action.reportActionID}-actionableMentionJoinWorkspace-${CONST.REPORT.ACTIONABLE_MENTION_JOIN_WORKSPACE_RESOLUTION.DECLINE}`, + onPress: () => declineJoinRequest(reportActionReportID, action), + }, + ]; + + return ( + + + {buttons.length > 0 && ( + + )} + + ); +} + +JoinRequestContent.displayName = 'JoinRequestContent'; + +export default JoinRequestContent; diff --git a/src/pages/inbox/report/actionContents/MentionWhisperContent.tsx b/src/pages/inbox/report/actionContents/MentionWhisperContent.tsx new file mode 100644 index 000000000000..c0ecdc74c5cf --- /dev/null +++ b/src/pages/inbox/report/actionContents/MentionWhisperContent.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import RenderHTML from '@components/RenderHTML'; +import type {ActionableItem} from '@components/ReportActionItem/ActionableItemButtons'; +import ActionableItemButtons from '@components/ReportActionItem/ActionableItemButtons'; +import useLocalize from '@hooks/useLocalize'; +import useReportIsArchived from '@hooks/useReportIsArchived'; +import {isPolicyAdmin, isPolicyMember, isPolicyOwner} from '@libs/PolicyUtils'; +import {getActionableMentionWhisperMessage, getOriginalMessage, isSystemUserMentioned} from '@libs/ReportActionsUtils'; +import ReportActionItemBasicMessage from '@pages/inbox/report/ReportActionItemBasicMessage'; +import CONST from '@src/CONST'; +import type {Policy, Report, ReportAction} from '@src/types/onyx'; + +type MentionWhisperContentProps = { + action: ReportAction; + report: OnyxEntry; + originalReport: OnyxEntry; + policy: OnyxEntry; + currentUserAccountID: number; + personalPolicyID: string | undefined; + originalReportID: string | undefined; + resolveActionableMentionWhisper: ( + report: OnyxEntry, + reportAction: OnyxEntry, + resolution: ValueOf, + isReportArchived: boolean, + ) => void; +}; + +function MentionWhisperContent({ + action, + report, + originalReport, + policy, + currentUserAccountID, + personalPolicyID, + originalReportID, + resolveActionableMentionWhisper, +}: MentionWhisperContentProps) { + const {translate} = useLocalize(); + const isOriginalReportArchived = useReportIsArchived(originalReportID); + + const reportActionReport = originalReport ?? report; + const reportPolicyID = report?.policyID; + + const isReportInPolicy = !!reportPolicyID && reportPolicyID !== CONST.POLICY.ID_FAKE && personalPolicyID !== reportPolicyID; + const hasMentionedPolicyMembers = getOriginalMessage(action)?.inviteeEmails?.every((login) => isPolicyMember(policy, login)); + + const buttons: ActionableItem[] = []; + if ((isPolicyAdmin(policy) || isPolicyOwner(policy, currentUserAccountID)) && isReportInPolicy && !isSystemUserMentioned(action) && !hasMentionedPolicyMembers) { + buttons.push({ + text: 'actionableMentionWhisperOptions.inviteToSubmitExpense', + key: `${action.reportActionID}-actionableMentionWhisper-${CONST.REPORT.ACTIONABLE_MENTION_WHISPER_RESOLUTION.INVITE_TO_SUBMIT_EXPENSE}`, + onPress: () => resolveActionableMentionWhisper(reportActionReport, action, CONST.REPORT.ACTIONABLE_MENTION_WHISPER_RESOLUTION.INVITE_TO_SUBMIT_EXPENSE, isOriginalReportArchived), + }); + } + buttons.push( + { + text: 'actionableMentionWhisperOptions.inviteToChat', + key: `${action.reportActionID}-actionableMentionWhisper-${CONST.REPORT.ACTIONABLE_MENTION_WHISPER_RESOLUTION.INVITE}`, + onPress: () => resolveActionableMentionWhisper(reportActionReport, action, CONST.REPORT.ACTIONABLE_MENTION_WHISPER_RESOLUTION.INVITE, isOriginalReportArchived), + }, + { + text: 'actionableMentionWhisperOptions.nothing', + key: `${action.reportActionID}-actionableMentionWhisper-${CONST.REPORT.ACTIONABLE_MENTION_WHISPER_RESOLUTION.NOTHING}`, + onPress: () => resolveActionableMentionWhisper(reportActionReport, action, CONST.REPORT.ACTIONABLE_MENTION_WHISPER_RESOLUTION.NOTHING, isOriginalReportArchived), + }, + ); + + return ( + + + {buttons.length > 0 && ( + + )} + + ); +} + +MentionWhisperContent.displayName = 'MentionWhisperContent'; + +export default MentionWhisperContent; diff --git a/src/pages/inbox/report/actionContents/PaymentContent.tsx b/src/pages/inbox/report/actionContents/PaymentContent.tsx new file mode 100644 index 000000000000..4487d0b875b5 --- /dev/null +++ b/src/pages/inbox/report/actionContents/PaymentContent.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import RenderHTML from '@components/RenderHTML'; +import useLocalize from '@hooks/useLocalize'; +import {convertToDisplayString} from '@libs/CurrencyUtils'; +import {getBankAccountLastFourDigits} from '@libs/PaymentUtils'; +import {getOriginalMessage} from '@libs/ReportActionsUtils'; +import ReportActionItemBasicMessage from '@pages/inbox/report/ReportActionItemBasicMessage'; +import CONST from '@src/CONST'; +import type * as OnyxTypes from '@src/types/onyx'; + +type PaymentContentProps = { + action: OnyxTypes.ReportAction; + bankAccountList: OnyxTypes.BankAccountList | undefined; + policy: OnyxEntry; +}; + +function PaymentContent({action, bankAccountList, policy}: PaymentContentProps) { + const {translate} = useLocalize(); + const originalMessage = getOriginalMessage(action); + + if (!originalMessage) { + return null; + } + + const paymentType = originalMessage.paymentType; + const wasAutoPaid = originalMessage.automaticAction ?? false; + + if (paymentType === CONST.IOU.PAYMENT_TYPE.ELSEWHERE) { + return ; + } + + if (paymentType === CONST.IOU.PAYMENT_TYPE.VBBA) { + const last4Digits = getBankAccountLastFourDigits(originalMessage.bankAccountID, bankAccountList, policy); + if (wasAutoPaid) { + const translation = translate('iou.automaticallyPaidWithBusinessBankAccount', '', last4Digits); + return ( + + ${translation}`} /> + + ); + } + return ; + } + + if (wasAutoPaid) { + return ( + + ${translate('iou.automaticallyPaidWithExpensify')}`} /> + + ); + } + + const amount = convertToDisplayString(Math.abs(originalMessage.amount ?? 0), originalMessage.currency); + + if (originalMessage.bankAccountID) { + const bankAccount = bankAccountList?.[originalMessage.bankAccountID]; + return ( + + ); + } + + return ; +} + +PaymentContent.displayName = 'PaymentContent'; + +export default PaymentContent; diff --git a/src/pages/inbox/report/actionContents/PolicyChangeLogContent.tsx b/src/pages/inbox/report/actionContents/PolicyChangeLogContent.tsx new file mode 100644 index 000000000000..45efcc416348 --- /dev/null +++ b/src/pages/inbox/report/actionContents/PolicyChangeLogContent.tsx @@ -0,0 +1,244 @@ +import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import type {LocaleContextProps} from '@components/LocaleContextProvider'; +import RenderHTML from '@components/RenderHTML'; +import useLocalize from '@hooks/useLocalize'; +import {getCleanedTagName} from '@libs/PolicyUtils'; +import { + getAddedApprovalRuleMessage, + getAddedBudgetMessage, + getAddedCardFeedMessage, + getAddedConnectionMessage, + getAssignedCompanyCardMessage, + getAutoPayApprovedReportsEnabledMessage, + getAutoReimbursementMessage, + getCompanyAddressUpdateMessage, + getCurrencyDefaultTaxUpdateMessage, + getCustomTaxNameUpdateMessage, + getDefaultApproverUpdateMessage, + getDeletedApprovalRuleMessage, + getDeletedBudgetMessage, + getForeignCurrencyDefaultTaxUpdateMessage, + getForwardsToUpdateMessage, + getInvoiceCompanyNameUpdateMessage, + getInvoiceCompanyWebsiteUpdateMessage, + getPolicyChangeLogAddEmployeeMessage, + getPolicyChangeLogDefaultBillableMessage, + getPolicyChangeLogDefaultReimbursableMessage, + getPolicyChangeLogDefaultTitleEnforcedMessage, + getPolicyChangeLogDeleteMemberMessage, + getPolicyChangeLogMaxExpenseAgeMessage, + getPolicyChangeLogMaxExpenseAmountMessage, + getPolicyChangeLogMaxExpenseAmountNoReceiptMessage, + getPolicyChangeLogUpdateEmployee, + getReimburserUpdateMessage, + getRemovedCardFeedMessage, + getRemovedConnectionMessage, + getRenamedCardFeedMessage, + getSetAutoJoinMessage, + getSubmitsToUpdateMessage, + getTagListNameUpdatedMessage, + getTagListUpdatedMessage, + getTagListUpdatedRequiredMessage, + getUnassignedCompanyCardMessage, + getUpdateACHAccountMessage, + getUpdatedApprovalRuleMessage, + getUpdatedAuditRateMessage, + getUpdatedAutoHarvestingMessage, + getUpdatedBudgetMessage, + getUpdatedCardFeedLiabilityMessage, + getUpdatedCardFeedStatementPeriodMessage, + getUpdatedDefaultTitleMessage, + getUpdatedIndividualBudgetNotificationMessage, + getUpdatedManualApprovalThresholdMessage, + getUpdatedOwnershipMessage, + getUpdatedProhibitedExpensesMessage, + getUpdatedReimbursementChoiceMessage, + getUpdatedSharedBudgetNotificationMessage, + getUpdatedTimeEnabledMessage, + getUpdatedTimeRateMessage, + getWorkspaceAttendeeTrackingUpdateMessage, + getWorkspaceCategoriesUpdatedMessage, + getWorkspaceCategoryUpdateMessage, + getWorkspaceCurrencyUpdateMessage, + getWorkspaceCustomUnitRateAddedMessage, + getWorkspaceCustomUnitRateDeletedMessage, + getWorkspaceCustomUnitRateImportedMessage, + getWorkspaceCustomUnitRateUpdatedMessage, + getWorkspaceCustomUnitSubRateDeletedMessage, + getWorkspaceCustomUnitSubRateUpdatedMessage, + getWorkspaceCustomUnitUpdatedMessage, + getWorkspaceFeatureEnabledMessage, + getWorkspaceFrequencyUpdateMessage, + getWorkspaceReimbursementUpdateMessage, + getWorkspaceReportFieldAddMessage, + getWorkspaceReportFieldDeleteMessage, + getWorkspaceReportFieldUpdateMessage, + getWorkspaceTagUpdateMessage, + getWorkspaceTaxUpdateMessage, + getWorkspaceUpdateFieldMessage, +} from '@libs/ReportActionsUtils'; +import {getWorkspaceNameUpdatedMessage} from '@libs/ReportUtils'; +import ReportActionItemBasicMessage from '@pages/inbox/report/ReportActionItemBasicMessage'; +import CONST from '@src/CONST'; +import type * as OnyxTypes from '@src/types/onyx'; + +type PolicyChangeLogMessageResult = string | {html: string}; + +type ResolverFn = (translate: LocaleContextProps['translate'], action: OnyxTypes.ReportAction, policy: OnyxEntry) => PolicyChangeLogMessageResult; + +// Reusable resolvers for action types that share the same handler +const categoryResolver: ResolverFn = (translate, action, policy) => getWorkspaceCategoryUpdateMessage(translate, action, policy); +const taxResolver: ResolverFn = (translate, action) => getWorkspaceTaxUpdateMessage(translate, action); +const tagModificationResolver: ResolverFn = (translate, action) => getCleanedTagName(getWorkspaceTagUpdateMessage(translate, action)); + +const POLICY_CHANGE_LOG_RESOLVERS: Record = { + // Simple string translations + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.CORPORATE_UPGRADE]: (translate) => translate('workspaceActions.upgradedWorkspace'), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.TEAM_DOWNGRADE]: (translate) => translate('workspaceActions.downgradedWorkspace'), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.IMPORT_TAGS]: (translate) => translate('workspaceActions.importTags'), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_ALL_TAGS]: (translate) => translate('workspaceActions.deletedAllTags'), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.LEAVE_ROOM]: (translate) => translate('report.actions.type.leftTheChat'), + + // HTML results + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.CORPORATE_FORCE_UPGRADE]: (translate) => ({html: `${translate('workspaceActions.forcedCorporateUpgrade')}`}), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.INDIVIDUAL_BUDGET_NOTIFICATION]: (translate, action) => ({ + html: `${getUpdatedIndividualBudgetNotificationMessage(translate, action)}`, + }), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.SHARED_BUDGET_NOTIFICATION]: (translate, action) => ({ + html: `${getUpdatedSharedBudgetNotificationMessage(translate, action)}`, + }), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_OWNERSHIP]: (translate, action, policy) => ({ + html: `${getUpdatedOwnershipMessage(translate, action, policy)}`, + }), + + // Standard message functions (translate, action) + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_NAME]: (translate, action) => getWorkspaceNameUpdatedMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CURRENCY]: (translate, action) => getWorkspaceCurrencyUpdateMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_REPORTING_FREQUENCY]: (translate, action) => getWorkspaceFrequencyUpdateMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORIES]: (translate, action) => getWorkspaceCategoriesUpdatedMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CUSTOM_TAX_NAME]: (translate, action) => getCustomTaxNameUpdateMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CURRENCY_DEFAULT_TAX]: (translate, action) => getCurrencyDefaultTaxUpdateMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_FOREIGN_CURRENCY_DEFAULT_TAX]: (translate, action) => getForeignCurrencyDefaultTaxUpdateMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CUSTOM_UNIT]: (translate, action) => getWorkspaceCustomUnitUpdatedMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.IMPORT_CUSTOM_UNIT_RATES]: (translate, action) => getWorkspaceCustomUnitRateImportedMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_CUSTOM_UNIT_RATE]: (translate, action) => getWorkspaceCustomUnitRateAddedMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CUSTOM_UNIT_RATE]: (translate, action) => getWorkspaceCustomUnitRateUpdatedMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_CUSTOM_UNIT_RATE]: (translate, action) => getWorkspaceCustomUnitRateDeletedMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CUSTOM_UNIT_SUB_RATE]: (translate, action) => getWorkspaceCustomUnitSubRateUpdatedMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_CUSTOM_UNIT_SUB_RATE]: (translate, action) => getWorkspaceCustomUnitSubRateDeletedMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_REPORT_FIELD]: (translate, action) => getWorkspaceReportFieldAddMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_REPORT_FIELD]: (translate, action) => getWorkspaceReportFieldUpdateMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_REPORT_FIELD]: (translate, action) => getWorkspaceReportFieldDeleteMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_FIELD]: (translate, action) => getWorkspaceUpdateFieldMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_FEATURE_ENABLED]: (translate, action) => getWorkspaceFeatureEnabledMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_IS_ATTENDEE_TRACKING_ENABLED]: (translate, action) => getWorkspaceAttendeeTrackingUpdateMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_APPROVER]: (translate, action) => getDefaultApproverUpdateMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_SUBMITS_TO]: (translate, action) => getSubmitsToUpdateMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_FORWARDS_TO]: (translate, action) => getForwardsToUpdateMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_PAY_APPROVED_REPORTS_ENABLED]: (translate, action) => getAutoPayApprovedReportsEnabledMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_REIMBURSEMENT]: (translate, action) => getAutoReimbursementMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_INVOICE_COMPANY_NAME]: (translate, action) => getInvoiceCompanyNameUpdateMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_INVOICE_COMPANY_WEBSITE]: (translate, action) => getInvoiceCompanyWebsiteUpdateMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_REIMBURSER]: (translate, action) => getReimburserUpdateMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_REIMBURSEMENT_ENABLED]: (translate, action) => getWorkspaceReimbursementUpdateMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_ACH_ACCOUNT]: (translate, action) => getUpdateACHAccountMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_ADDRESS]: (translate, action) => getCompanyAddressUpdateMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AMOUNT_NO_RECEIPT]: (translate, action) => getPolicyChangeLogMaxExpenseAmountNoReceiptMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AMOUNT]: (translate, action) => getPolicyChangeLogMaxExpenseAmountMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AGE]: (translate, action) => getPolicyChangeLogMaxExpenseAgeMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_BILLABLE]: (translate, action) => getPolicyChangeLogDefaultBillableMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_REIMBURSABLE]: (translate, action) => getPolicyChangeLogDefaultReimbursableMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_TITLE_ENFORCED]: (translate, action) => getPolicyChangeLogDefaultTitleEnforcedMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_EMPLOYEE]: (translate, action) => getPolicyChangeLogAddEmployeeMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EMPLOYEE]: (translate, action) => getPolicyChangeLogUpdateEmployee(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_EMPLOYEE]: (translate, action) => getPolicyChangeLogDeleteMemberMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_APPROVER_RULE]: (translate, action) => getAddedApprovalRuleMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_APPROVER_RULE]: (translate, action) => getDeletedApprovalRuleMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_APPROVER_RULE]: (translate, action) => getUpdatedApprovalRuleMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_INTEGRATION]: (translate, action) => getAddedConnectionMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_INTEGRATION]: (translate, action) => getRemovedConnectionMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_CARD_FEED]: (translate, action) => getAddedCardFeedMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_CARD_FEED]: (translate, action) => getRemovedCardFeedMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.RENAME_CARD_FEED]: (translate, action) => getRenamedCardFeedMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ASSIGN_COMPANY_CARD]: (translate, action) => getAssignedCompanyCardMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UNASSIGN_COMPANY_CARD]: (translate, action) => getUnassignedCompanyCardMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CARD_FEED_LIABILITY]: (translate, action) => getUpdatedCardFeedLiabilityMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CARD_FEED_STATEMENT_PERIOD]: (translate, action) => getUpdatedCardFeedStatementPeriodMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUDIT_RATE]: (translate, action) => getUpdatedAuditRateMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MANUAL_APPROVAL_THRESHOLD]: (translate, action) => getUpdatedManualApprovalThresholdMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_TIME_ENABLED]: (translate, action) => getUpdatedTimeEnabledMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_TIME_RATE]: (translate, action) => getUpdatedTimeRateMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_PROHIBITED_EXPENSES]: (translate, action) => getUpdatedProhibitedExpensesMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_REIMBURSEMENT_CHOICE]: (translate, action) => getUpdatedReimbursementChoiceMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.SET_AUTO_JOIN]: (translate, action) => getSetAutoJoinMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_TITLE]: (translate, action) => getUpdatedDefaultTitleMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_HARVESTING]: (translate, action) => getUpdatedAutoHarvestingMessage(translate, action), + + // Message functions that need policy (translate, action, policy) + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_CATEGORY]: categoryResolver, + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_CATEGORY]: categoryResolver, + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORY]: categoryResolver, + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.SET_CATEGORY_NAME]: categoryResolver, + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_BUDGET]: (translate, action, policy) => getAddedBudgetMessage(translate, action, policy), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_BUDGET]: (translate, action, policy) => getUpdatedBudgetMessage(translate, action, policy), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_BUDGET]: (translate, action, policy) => getDeletedBudgetMessage(translate, action, policy), + + // Tax action types + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_TAX]: taxResolver, + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_TAX]: taxResolver, + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_TAX]: taxResolver, + + // Tag-cleaned results + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_TAG_LIST_NAME]: (translate, action) => getCleanedTagName(getTagListNameUpdatedMessage(translate, action)), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_TAG_LIST]: (translate, action) => getCleanedTagName(getTagListUpdatedMessage(translate, action)), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_TAG_LIST_REQUIRED]: (translate, action) => getCleanedTagName(getTagListUpdatedRequiredMessage(translate, action)), + + // Tag modification types + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_TAG]: tagModificationResolver, + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_TAG_ENABLED]: tagModificationResolver, + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_TAG_NAME]: tagModificationResolver, + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_TAG]: tagModificationResolver, + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_MULTIPLE_TAGS]: tagModificationResolver, + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_TAG]: tagModificationResolver, +}; + +/** + * The set of action type strings that PolicyChangeLogContent handles. + */ +const HANDLED_POLICY_CHANGE_LOG_ACTIONS = new Set(Object.keys(POLICY_CHANGE_LOG_RESOLVERS)); + +function isHandledPolicyChangeLogAction(action: OnyxTypes.ReportAction): boolean { + return HANDLED_POLICY_CHANGE_LOG_ACTIONS.has(action.actionName); +} + +type PolicyChangeLogContentProps = { + action: OnyxTypes.ReportAction; + policy: OnyxEntry; +}; + +function PolicyChangeLogContent({action, policy}: PolicyChangeLogContentProps) { + const {translate} = useLocalize(); + + const resolver = POLICY_CHANGE_LOG_RESOLVERS[action.actionName]; + if (!resolver) { + return null; + } + + const message = resolver(translate, action, policy); + + if (typeof message === 'object') { + return ( + + + + ); + } + + return ; +} + +PolicyChangeLogContent.displayName = 'PolicyChangeLogContent'; + +export {isHandledPolicyChangeLogAction, HANDLED_POLICY_CHANGE_LOG_ACTIONS}; +export default PolicyChangeLogContent; diff --git a/src/pages/inbox/report/actionContents/ReportMentionWhisperContent.tsx b/src/pages/inbox/report/actionContents/ReportMentionWhisperContent.tsx new file mode 100644 index 000000000000..56d28342d49a --- /dev/null +++ b/src/pages/inbox/report/actionContents/ReportMentionWhisperContent.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import MentionReportContext from '@components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext'; +import type {ActionableItem} from '@components/ReportActionItem/ActionableItemButtons'; +import ActionableItemButtons from '@components/ReportActionItem/ActionableItemButtons'; +import {getOriginalMessage} from '@libs/ReportActionsUtils'; +import ReportActionItemMessage from '@pages/inbox/report/ReportActionItemMessage'; +import CONST from '@src/CONST'; +import type {Report, ReportAction} from '@src/types/onyx'; + +type ReportMentionWhisperContentProps = { + action: ReportAction; + reportID: string | undefined; + report: OnyxEntry; + originalReport: OnyxEntry; + isReportArchived: boolean; + resolveActionableReportMentionWhisper: ( + report: OnyxEntry, + reportAction: OnyxEntry, + resolution: ValueOf, + isReportArchived?: boolean, + ) => void; +}; + +function ReportMentionWhisperContent({action, reportID, report, originalReport, isReportArchived, resolveActionableReportMentionWhisper}: ReportMentionWhisperContentProps) { + const reportActionReport = originalReport ?? report; + const resolution = getOriginalMessage(action)?.resolution; + const mentionReportContextValue = {currentReportID: report?.reportID, exactlyMatch: true}; + + const buttons: ActionableItem[] = resolution + ? [] + : [ + { + text: 'common.yes', + key: `${action.reportActionID}-actionableReportMentionWhisper-${CONST.REPORT.ACTIONABLE_REPORT_MENTION_WHISPER_RESOLUTION.CREATE}`, + onPress: () => resolveActionableReportMentionWhisper(reportActionReport, action, CONST.REPORT.ACTIONABLE_REPORT_MENTION_WHISPER_RESOLUTION.CREATE, isReportArchived), + isPrimary: true, + }, + { + text: 'common.no', + key: `${action.reportActionID}-actionableReportMentionWhisper-${CONST.REPORT.ACTIONABLE_REPORT_MENTION_WHISPER_RESOLUTION.NOTHING}`, + onPress: () => resolveActionableReportMentionWhisper(reportActionReport, action, CONST.REPORT.ACTIONABLE_REPORT_MENTION_WHISPER_RESOLUTION.NOTHING, isReportArchived), + }, + ]; + + return ( + + + + {buttons.length > 0 && ( + + )} + + + ); +} + +ReportMentionWhisperContent.displayName = 'ReportMentionWhisperContent'; + +export default ReportMentionWhisperContent; diff --git a/src/pages/inbox/report/actionContents/SimpleMessageContent.tsx b/src/pages/inbox/report/actionContents/SimpleMessageContent.tsx new file mode 100644 index 000000000000..6e07046cc843 --- /dev/null +++ b/src/pages/inbox/report/actionContents/SimpleMessageContent.tsx @@ -0,0 +1,115 @@ +import React from 'react'; +import useLocalize from '@hooks/useLocalize'; +import { + getActionableCard3DSTransactionApprovalMessage, + getDemotedFromWorkspaceMessage, + getDismissedViolationMessageText, + getMarkedReimbursedMessage, + getOriginalMessage, + getRemovedFromApprovalChainMessage, + getReportActionText, + isActionOfType, + isRejectedAction, + isUnapprovedAction, +} from '@libs/ReportActionsUtils'; +import {getDeletedTransactionMessage, getPolicyChangeMessage} from '@libs/ReportUtils'; +import ReportActionItemBasicMessage from '@pages/inbox/report/ReportActionItemBasicMessage'; +import CONST from '@src/CONST'; +import type * as OnyxTypes from '@src/types/onyx'; + +type SimpleMessageContentProps = { + action: OnyxTypes.ReportAction; +}; + +const SIMPLE_MESSAGE_ACTION_TYPES = new Set([ + CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED, + CONST.REPORT.ACTIONS.TYPE.HOLD, + CONST.REPORT.ACTIONS.TYPE.HOLD_COMMENT, + CONST.REPORT.ACTIONS.TYPE.UNHOLD, + CONST.REPORT.ACTIONS.TYPE.REJECTEDTRANSACTION_THREAD, + CONST.REPORT.ACTIONS.TYPE.REJECTED_TRANSACTION_MARKASRESOLVED, + CONST.REPORT.ACTIONS.TYPE.RETRACTED, + CONST.REPORT.ACTIONS.TYPE.REOPENED, + CONST.REPORT.ACTIONS.TYPE.CHANGE_POLICY, + CONST.REPORT.ACTIONS.TYPE.DELETED_TRANSACTION, + CONST.REPORT.ACTIONS.TYPE.MERGED_WITH_CASH_TRANSACTION, + CONST.REPORT.ACTIONS.TYPE.DISMISSED_VIOLATION, + CONST.REPORT.ACTIONS.TYPE.RESOLVED_DUPLICATES, + CONST.REPORT.ACTIONS.TYPE.RECEIPT_SCAN_FAILED, + CONST.REPORT.ACTIONS.TYPE.DEMOTED_FROM_WORKSPACE, + CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_CARD_3DS_TRANSACTION_APPROVAL, + CONST.REPORT.ACTIONS.TYPE.REMOVED_FROM_APPROVAL_CHAIN, +]); + +function isSimpleMessageAction(action: OnyxTypes.ReportAction): boolean { + return SIMPLE_MESSAGE_ACTION_TYPES.has(action.actionName) || isUnapprovedAction(action) || isRejectedAction(action); +} + +function SimpleMessageContent({action}: SimpleMessageContentProps) { + const {translate} = useLocalize(); + + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED)) { + return ; + } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.HOLD)) { + return ; + } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.HOLD_COMMENT)) { + return ; + } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.UNHOLD)) { + return ; + } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.REJECTEDTRANSACTION_THREAD)) { + return ; + } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.REJECTED_TRANSACTION_MARKASRESOLVED)) { + return ; + } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.RETRACTED)) { + return ; + } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.REOPENED)) { + return ; + } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.CHANGE_POLICY)) { + return ; + } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.DELETED_TRANSACTION)) { + return ; + } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.MERGED_WITH_CASH_TRANSACTION)) { + return ; + } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.DISMISSED_VIOLATION)) { + return ; + } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.RESOLVED_DUPLICATES)) { + return ; + } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.RECEIPT_SCAN_FAILED)) { + return ; + } + if (isUnapprovedAction(action)) { + return ; + } + if (isRejectedAction(action)) { + return ; + } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.DEMOTED_FROM_WORKSPACE)) { + return ; + } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_CARD_3DS_TRANSACTION_APPROVAL)) { + return ; + } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.REMOVED_FROM_APPROVAL_CHAIN)) { + return ; + } + + return null; +} + +SimpleMessageContent.displayName = 'SimpleMessageContent'; + +export default SimpleMessageContent; +export {isSimpleMessageAction}; diff --git a/src/pages/inbox/report/comment/TextCommentFragment.tsx b/src/pages/inbox/report/comment/TextCommentFragment.tsx index e4e3ad5c2d86..2653c374cb86 100644 --- a/src/pages/inbox/report/comment/TextCommentFragment.tsx +++ b/src/pages/inbox/report/comment/TextCommentFragment.tsx @@ -111,7 +111,7 @@ function TextCommentFragment({fragment, styleAsDeleted, reportActionID, styleAsM } return ( - + ) : ( diff --git a/src/pages/inbox/report/withReportAndReportActionOrNotFound.tsx b/src/pages/inbox/report/withReportAndReportActionOrNotFound.tsx index 485f94b7d6e7..36bc7712ca5c 100644 --- a/src/pages/inbox/report/withReportAndReportActionOrNotFound.tsx +++ b/src/pages/inbox/report/withReportAndReportActionOrNotFound.tsx @@ -22,7 +22,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; type WithReportAndReportActionOrNotFoundProps = PlatformStackScreenProps< FlagCommentNavigatorParamList & SplitDetailsNavigatorParamList, - typeof SCREENS.FLAG_COMMENT_ROOT | typeof SCREENS.SPLIT_DETAILS.ROOT + typeof SCREENS.DYNAMIC_FLAG_COMMENT | typeof SCREENS.SPLIT_DETAILS.ROOT > & { /** The report currently being looked at */ report: OnyxTypes.Report; @@ -43,7 +43,7 @@ export default function | PlatformStackScreenProps | PlatformStackScreenProps + | PlatformStackScreenProps | PlatformStackScreenProps | PlatformStackScreenProps | PlatformStackScreenProps @@ -72,7 +73,7 @@ export default function (shouldRequireReportID = true): { if (value !== false) { return; @@ -38,7 +38,7 @@ function BaseSidebarScreen() { const {shouldUseNarrowLayout} = useResponsiveLayout(); const shouldDisplayLHB = !shouldUseNarrowLayout; - const [isLoadingApp = true] = useOnyx(ONYXKEYS.RAM_ONLY_IS_LOADING_APP); + const [isLoadingApp = true] = useOnyx(ONYXKEYS.IS_LOADING_APP); const shouldShowSkeleton = isLoadingApp && !hasEverFinishedLoading; // Must be called unconditionally so openApp() can proceed even when diff --git a/src/pages/inbox/sidebar/FABPopoverContent/FABButtons.tsx b/src/pages/inbox/sidebar/FABPopoverContent/FABButtons.tsx index d5ee5153e96b..b94ddef4ebf1 100644 --- a/src/pages/inbox/sidebar/FABPopoverContent/FABButtons.tsx +++ b/src/pages/inbox/sidebar/FABPopoverContent/FABButtons.tsx @@ -16,7 +16,7 @@ type FABButtonsProps = { function FABButtons({isActive, fabRef, onPress}: FABButtonsProps) { const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const {startScan, startQuickScan} = useScanActions(); + const {startScan, startQuickScan, canUseAction} = useScanActions(); return ( <> @@ -34,7 +34,7 @@ function FABButtons({isActive, fabRef, onPress}: FABButtonsProps) { isActive={isActive} ref={fabRef} onPress={onPress} - onLongPress={startScan} + onLongPress={canUseAction ? startScan : undefined} sentryLabel={CONST.SENTRY_LABEL.NAVIGATION_TAB_BAR.FLOATING_ACTION_BUTTON} /> diff --git a/src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx b/src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx index 831037122180..b843c41f52bd 100644 --- a/src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx +++ b/src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx @@ -1,5 +1,5 @@ -import React, {useState} from 'react'; -import type {RefObject} from 'react'; +import React, {Activity, useState} from 'react'; +import type {ActivityProps, RefObject} from 'react'; import {View} from 'react-native'; import FocusTrapForModal from '@components/FocusTrap/FocusTrapForModal'; import PopoverWithMeasuredContent from '@components/PopoverWithMeasuredContent'; @@ -40,6 +40,7 @@ function FABPopoverMenu({isVisible, onClose, onItemSelected, anchorRef, animatio const {shouldUseNarrowLayout} = useResponsiveLayout(); const {windowHeight} = useWindowDimensions(); const anchorPosition = styles.createMenuPositionSidebar(windowHeight); + const [contentActivityMode, setContentActivityMode] = useState(isVisible ? 'visible' : 'hidden'); const [registeredSet, setRegisteredSet] = useState>(new Set()); @@ -112,6 +113,8 @@ function FABPopoverMenu({isVisible, onClose, onItemSelected, anchorRef, animatio }} onClose={handleClose} isVisible={isVisible} + onModalWillShow={() => setContentActivityMode('visible')} + onModalHide={() => setContentActivityMode('hidden')} fromSidebarMediumScreen={!shouldUseNarrowLayout} animationIn="fadeIn" animationOut="fadeOut" @@ -125,9 +128,11 @@ function FABPopoverMenu({isVisible, onClose, onItemSelected, anchorRef, animatio active={isVisible} shouldReturnFocus > - - {children} - + + + {children} + + diff --git a/src/pages/inbox/sidebar/FABPopoverContent/menuItems/CreateReportMenuItem.tsx b/src/pages/inbox/sidebar/FABPopoverContent/menuItems/CreateReportMenuItem.tsx index b8b079d40c7e..64dc67d0a143 100644 --- a/src/pages/inbox/sidebar/FABPopoverContent/menuItems/CreateReportMenuItem.tsx +++ b/src/pages/inbox/sidebar/FABPopoverContent/menuItems/CreateReportMenuItem.tsx @@ -54,7 +54,7 @@ function CreateReportMenuItem() { const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const icons = useMemoizedLazyExpensifyIcons(['Document']); - const {shouldRedirectToExpensifyClassic, showRedirectToExpensifyClassicModal} = useRedirectToExpensifyClassic(); + const {shouldRedirectToExpensifyClassic, canRedirectToExpensifyClassic, showRedirectToExpensifyClassicModal} = useRedirectToExpensifyClassic(); const [activePolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`); const [session] = useOnyx(ONYXKEYS.SESSION, {selector: sessionEmailAndAccountIDSelector}); const [allBetas] = useOnyx(ONYXKEYS.BETAS); @@ -69,8 +69,9 @@ function CreateReportMenuItem() { const [groupPoliciesWithChatEnabled = CONST.EMPTY_ARRAY] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: chatEnabledPaidGroupPolicies}, [session?.email]); const [userBillingGracePeriodEnds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); + const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); - const isVisible = shouldRedirectToExpensifyClassic || groupPoliciesWithChatEnabled.length > 0; + const isVisible = canRedirectToExpensifyClassic || groupPoliciesWithChatEnabled.length > 0; const defaultChatEnabledPolicy = getDefaultChatEnabledPolicy(groupPoliciesWithChatEnabled as Array>, activePolicy); @@ -125,7 +126,9 @@ function CreateReportMenuItem() { onPress={() => { interceptAnonymousUser(() => { if (shouldRedirectToExpensifyClassic) { - showRedirectToExpensifyClassicModal(); + if (canRedirectToExpensifyClassic) { + showRedirectToExpensifyClassicModal(); + } return; } @@ -134,13 +137,14 @@ function CreateReportMenuItem() { // If we couldn't guess the workspace to create the report, or a guessed workspace is past its grace period and we have other workspaces to choose from if ( !workspaceIDForReportCreation || - (shouldRestrictUserBillableActions(workspaceIDForReportCreation, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds) && groupPoliciesWithChatEnabled.length > 1) + (shouldRestrictUserBillableActions(workspaceIDForReportCreation, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed) && + groupPoliciesWithChatEnabled.length > 1) ) { Navigation.navigate(ROUTES.NEW_REPORT_WORKSPACE_SELECTION.getRoute()); return; } - if (!shouldRestrictUserBillableActions(workspaceIDForReportCreation, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds)) { + if (!shouldRestrictUserBillableActions(workspaceIDForReportCreation, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed)) { // Check if empty report confirmation should be shown if (shouldShowEmptyReportConfirmation) { openCreateReportConfirmation(); diff --git a/src/pages/inbox/sidebar/FABPopoverContent/menuItems/ExpenseMenuItem.tsx b/src/pages/inbox/sidebar/FABPopoverContent/menuItems/ExpenseMenuItem.tsx index 214cfc689423..62bfb1863a49 100644 --- a/src/pages/inbox/sidebar/FABPopoverContent/menuItems/ExpenseMenuItem.tsx +++ b/src/pages/inbox/sidebar/FABPopoverContent/menuItems/ExpenseMenuItem.tsx @@ -23,24 +23,27 @@ function ExpenseMenuItem({reportID}: ExpenseMenuItemProps) { const {shouldUseNarrowLayout} = useResponsiveLayout(); const icons = useMemoizedLazyExpensifyIcons(['Coins', 'Receipt', 'Cash', 'Transfer', 'MoneyCircle']); const [draftTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector}); - const {shouldRedirectToExpensifyClassic, showRedirectToExpensifyClassicModal} = useRedirectToExpensifyClassic(); + const {shouldRedirectToExpensifyClassic, canRedirectToExpensifyClassic, canUseAction, showRedirectToExpensifyClassicModal} = useRedirectToExpensifyClassic(); return ( interceptAnonymousUser(() => { if (shouldRedirectToExpensifyClassic) { - showRedirectToExpensifyClassicModal(); + if (canRedirectToExpensifyClassic) { + showRedirectToExpensifyClassicModal(); + } return; } startMoneyRequest(CONST.IOU.TYPE.CREATE, reportID, draftTransactionIDs, undefined, undefined, undefined, true); }) } - shouldCallAfterModalHide={shouldRedirectToExpensifyClassic || shouldUseNarrowLayout} + shouldCallAfterModalHide={canRedirectToExpensifyClassic || shouldUseNarrowLayout} /> ); } diff --git a/src/pages/inbox/sidebar/FABPopoverContent/menuItems/InvoiceMenuItem.tsx b/src/pages/inbox/sidebar/FABPopoverContent/menuItems/InvoiceMenuItem.tsx index 1f8befacea34..f7aec7eea7a3 100644 --- a/src/pages/inbox/sidebar/FABPopoverContent/menuItems/InvoiceMenuItem.tsx +++ b/src/pages/inbox/sidebar/FABPopoverContent/menuItems/InvoiceMenuItem.tsx @@ -26,7 +26,7 @@ function InvoiceMenuItem({reportID}: InvoiceMenuItemProps) { const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const icons = useMemoizedLazyExpensifyIcons(['InvoiceGeneric']); - const {shouldRedirectToExpensifyClassic, showRedirectToExpensifyClassicModal} = useRedirectToExpensifyClassic(); + const {shouldRedirectToExpensifyClassic, canRedirectToExpensifyClassic, canUseAction, showRedirectToExpensifyClassicModal} = useRedirectToExpensifyClassic(); const [allPolicies] = useMappedPolicies(policyMapper); const [sessionEmail] = useOnyx(ONYXKEYS.SESSION, {selector: emailSelector}); const [draftTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector}); @@ -35,20 +35,22 @@ function InvoiceMenuItem({reportID}: InvoiceMenuItemProps) { return ( interceptAnonymousUser(() => { if (shouldRedirectToExpensifyClassic) { - showRedirectToExpensifyClassicModal(); + if (canRedirectToExpensifyClassic) { + showRedirectToExpensifyClassicModal(); + } return; } startMoneyRequest(CONST.IOU.TYPE.INVOICE, reportID, draftTransactionIDs, undefined, undefined, undefined, true); }) } - shouldCallAfterModalHide={shouldRedirectToExpensifyClassic || shouldUseNarrowLayout} + shouldCallAfterModalHide={canRedirectToExpensifyClassic || shouldUseNarrowLayout} /> ); } diff --git a/src/pages/inbox/sidebar/FABPopoverContent/menuItems/NewWorkspaceMenuItem.tsx b/src/pages/inbox/sidebar/FABPopoverContent/menuItems/NewWorkspaceMenuItem.tsx index f796a132bf5f..4d660811e6b1 100644 --- a/src/pages/inbox/sidebar/FABPopoverContent/menuItems/NewWorkspaceMenuItem.tsx +++ b/src/pages/inbox/sidebar/FABPopoverContent/menuItems/NewWorkspaceMenuItem.tsx @@ -27,7 +27,7 @@ function NewWorkspaceMenuItem() { const {shouldUseNarrowLayout} = useResponsiveLayout(); const icons = useMemoizedLazyExpensifyIcons(['NewWorkspace']); const {isOffline} = useNetwork(); - const [isLoading = false] = useOnyx(ONYXKEYS.RAM_ONLY_IS_LOADING_APP); + const [isLoading = false] = useOnyx(ONYXKEYS.IS_LOADING_APP); const [sessionEmail] = useOnyx(ONYXKEYS.SESSION, {selector: emailSelector}); const [allPolicies] = useMappedPolicies(policyMapper); const {isRestrictedPolicyCreation} = usePreferredPolicy(); diff --git a/src/pages/inbox/sidebar/FABPopoverContent/menuItems/QuickActionMenuItem.tsx b/src/pages/inbox/sidebar/FABPopoverContent/menuItems/QuickActionMenuItem.tsx index d80e74c56b5a..d744da977361 100644 --- a/src/pages/inbox/sidebar/FABPopoverContent/menuItems/QuickActionMenuItem.tsx +++ b/src/pages/inbox/sidebar/FABPopoverContent/menuItems/QuickActionMenuItem.tsx @@ -61,6 +61,7 @@ function QuickActionMenuItem({reportID}: QuickActionMenuItemProps) { const [reportAttributes] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, {selector: reportAttributesSelector}); const [userBillingGracePeriodEnds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); + const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); const isValidReport = !(isEmptyObject(quickActionReport) || isReportArchived); @@ -107,7 +108,11 @@ function QuickActionMenuItem({reportID}: QuickActionMenuItemProps) { const quickActionReportPolicyID = quickActionReport?.policyID; const selectOption = (onSelected: () => void, shouldRestrictAction: boolean) => { - if (shouldRestrictAction && quickActionReportPolicyID && shouldRestrictUserBillableActions(quickActionReportPolicyID, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds)) { + if ( + shouldRestrictAction && + quickActionReportPolicyID && + shouldRestrictUserBillableActions(quickActionReportPolicyID, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed) + ) { Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(quickActionReportPolicyID)); return; } @@ -184,7 +189,7 @@ function QuickActionMenuItem({reportID}: QuickActionMenuItemProps) { interceptAnonymousUser(() => { if ( policyChatForActivePolicy?.policyID && - shouldRestrictUserBillableActions(policyChatForActivePolicy.policyID, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds) + shouldRestrictUserBillableActions(policyChatForActivePolicy.policyID, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed) ) { Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policyChatForActivePolicy.policyID)); return; diff --git a/src/pages/inbox/sidebar/FABPopoverContent/menuItems/TrackDistanceMenuItem.tsx b/src/pages/inbox/sidebar/FABPopoverContent/menuItems/TrackDistanceMenuItem.tsx index bba7938f43d3..1200465d3fdf 100644 --- a/src/pages/inbox/sidebar/FABPopoverContent/menuItems/TrackDistanceMenuItem.tsx +++ b/src/pages/inbox/sidebar/FABPopoverContent/menuItems/TrackDistanceMenuItem.tsx @@ -23,18 +23,21 @@ function TrackDistanceMenuItem({reportID}: TrackDistanceMenuItemProps) { const icons = useMemoizedLazyExpensifyIcons(['Location']); const [lastDistanceExpenseType] = useOnyx(ONYXKEYS.NVP_LAST_DISTANCE_EXPENSE_TYPE); const [draftTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector}); - const {shouldRedirectToExpensifyClassic, showRedirectToExpensifyClassicModal} = useRedirectToExpensifyClassic(); + const {shouldRedirectToExpensifyClassic, canRedirectToExpensifyClassic, canUseAction, showRedirectToExpensifyClassicModal} = useRedirectToExpensifyClassic(); return ( interceptAnonymousUser(() => { if (shouldRedirectToExpensifyClassic) { - showRedirectToExpensifyClassicModal(); + if (canRedirectToExpensifyClassic) { + showRedirectToExpensifyClassicModal(); + } return; } // Start the flow to start tracking a distance request diff --git a/src/pages/inbox/sidebar/FABPopoverContent/useRedirectToExpensifyClassic.ts b/src/pages/inbox/sidebar/FABPopoverContent/useRedirectToExpensifyClassic.ts index 557a0fecfeb4..eb8634e3674c 100644 --- a/src/pages/inbox/sidebar/FABPopoverContent/useRedirectToExpensifyClassic.ts +++ b/src/pages/inbox/sidebar/FABPopoverContent/useRedirectToExpensifyClassic.ts @@ -4,6 +4,7 @@ import useConfirmModal from '@hooks/useConfirmModal'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import {openOldDotLink} from '@libs/actions/Link'; +import {shouldHideOldAppRedirect} from '@libs/TryNewDotUtils'; import {closeReactNativeApp} from '@userActions/HybridApp'; import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; @@ -11,6 +12,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import {isTrackingSelector} from '@src/selectors/GPSDraftDetails'; import {shouldRedirectToExpensifyClassicSelector} from '@src/selectors/Policy'; import type * as OnyxTypes from '@src/types/onyx'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; type PolicySelector = Pick; @@ -30,14 +32,22 @@ function useRedirectToExpensifyClassic() { const {translate} = useLocalize(); const {showConfirmModal} = useConfirmModal(); const [isTrackingGPS = false] = useOnyx(ONYXKEYS.GPS_DRAFT_DETAILS, {selector: isTrackingSelector}); + const [tryNewDot, tryNewDotMetadata] = useOnyx(ONYXKEYS.NVP_TRY_NEW_DOT); /** * There are scenarios where users who have not yet had their group workspace-chats in NewDot (isPolicyExpenseChatEnabled). In those scenarios, things can get confusing if they try to submit/track expenses. To address this, we block them from Creating, Tracking, Submitting expenses from NewDot if they are: * 1. on at least one group policy * 2. none of the group policies they are a member of have isPolicyExpenseChatEnabled=true */ const [shouldRedirectToExpensifyClassic = false] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: shouldRedirectToExpensifyClassicSelector}); + const isLoadingTryNewDot = isLoadingOnyxValue(tryNewDotMetadata); + const canRedirectToExpensifyClassic = shouldRedirectToExpensifyClassic && !shouldHideOldAppRedirect(tryNewDot, isLoadingTryNewDot, CONFIG.IS_HYBRID_APP); + const canUseAction = !shouldRedirectToExpensifyClassic || canRedirectToExpensifyClassic; const showRedirectToExpensifyClassicModal = async () => { + if (!canRedirectToExpensifyClassic) { + return; + } + const {action} = await showConfirmModal({ title: translate('sidebarScreen.redirectToExpensifyClassicModal.title'), prompt: translate('sidebarScreen.redirectToExpensifyClassicModal.description'), @@ -54,7 +64,7 @@ function useRedirectToExpensifyClassic() { openOldDotLink(CONST.OLDDOT_URLS.INBOX); }; - return {shouldRedirectToExpensifyClassic, showRedirectToExpensifyClassicModal}; + return {shouldRedirectToExpensifyClassic, canRedirectToExpensifyClassic, canUseAction, showRedirectToExpensifyClassicModal}; } export type {PolicySelector}; diff --git a/src/pages/inbox/sidebar/FABPopoverContent/useScanActions.ts b/src/pages/inbox/sidebar/FABPopoverContent/useScanActions.ts index 3b9b3614be12..c4b88cde456d 100644 --- a/src/pages/inbox/sidebar/FABPopoverContent/useScanActions.ts +++ b/src/pages/inbox/sidebar/FABPopoverContent/useScanActions.ts @@ -27,7 +27,8 @@ function useScanActions() { const [userBillingGracePeriodEnds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); - const {shouldRedirectToExpensifyClassic, showRedirectToExpensifyClassicModal} = useRedirectToExpensifyClassic(); + const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); + const {shouldRedirectToExpensifyClassic, canRedirectToExpensifyClassic, canUseAction, showRedirectToExpensifyClassicModal} = useRedirectToExpensifyClassic(); // useState lazy initializer generates the ID once on mount and keeps it stable across renders const [reportID] = useState(() => generateReportID()); @@ -38,7 +39,9 @@ function useScanActions() { const startScan = () => { interceptAnonymousUser(() => { if (shouldRedirectToExpensifyClassic) { - showRedirectToExpensifyClassicModal(); + if (canRedirectToExpensifyClassic) { + showRedirectToExpensifyClassicModal(); + } return; } startMoneyRequest(CONST.IOU.TYPE.CREATE, reportID, draftTransactionIDs, CONST.IOU.REQUEST_TYPE.SCAN, false, undefined, true); @@ -50,7 +53,7 @@ function useScanActions() { const startQuickScan = () => { interceptAnonymousUser(() => { - if (policyChatPolicyID && shouldRestrictUserBillableActions(policyChatPolicyID, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds)) { + if (policyChatPolicyID && shouldRestrictUserBillableActions(policyChatPolicyID, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed)) { Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policyChatPolicyID)); return; } @@ -61,7 +64,7 @@ function useScanActions() { }); }; - return {startScan, startQuickScan}; + return {startScan, startQuickScan, canUseAction}; } export default useScanActions; diff --git a/src/pages/inbox/sidebar/ProfileAvatarWithIndicator.tsx b/src/pages/inbox/sidebar/ProfileAvatarWithIndicator.tsx index 799dd44218b9..ef3f2e6e0950 100644 --- a/src/pages/inbox/sidebar/ProfileAvatarWithIndicator.tsx +++ b/src/pages/inbox/sidebar/ProfileAvatarWithIndicator.tsx @@ -20,7 +20,7 @@ type ProfileAvatarWithIndicatorProps = { function ProfileAvatarWithIndicator({isSelected = false, containerStyles}: ProfileAvatarWithIndicatorProps) { const styles = useThemeStyles(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const [isLoading = true] = useOnyx(ONYXKEYS.RAM_ONLY_IS_LOADING_APP); + const [isLoading = true] = useOnyx(ONYXKEYS.IS_LOADING_APP); return ( { ReportActionContextMenu.hideContextMenu(false); @@ -84,18 +85,26 @@ function SidebarLinks({insets, optionListItems, priorityMode = CONST.PRIORITY_MO // eslint-disable-next-line react-hooks/exhaustive-deps const contentContainerStyles = useMemo(() => StyleSheet.flatten([styles.pt2, {paddingBottom: StyleUtils.getSafeAreaMargins(insets).marginBottom}]), [insets]); + const shouldShowEmptyLHN = optionListItems.length === 0; + return ( - + {shouldShowEmptyLHN ? ( + + + + ) : ( + + )} {isLoadingReportData && optionListItems?.length === 0 && ( & { +type MoneyRequestAmountFormProps = Omit & { /** Calculated tax amount based on selected tax rate */ taxAmount?: number; @@ -116,12 +116,20 @@ function MoneyRequestAmountForm({ const absoluteAmount = Math.abs(amount); + const onFormatAmount = useCallback( + (amountAsInt: number, currencyParam?: string) => { + const decimals = getCurrencyDecimals(currencyParam); + return convertToFrontendAmountAsString(amountAsInt, decimals); + }, + [getCurrencyDecimals], + ); + const initializeAmount = useCallback( (newAmount: number) => { - const frontendAmount = newAmount ? convertToFrontendAmountAsString(newAmount, currency) : ''; + const frontendAmount = newAmount ? onFormatAmount(newAmount, currency) : ''; moneyRequestAmountInputRef.current?.updateNumber(frontendAmount); }, - [currency], + [currency, onFormatAmount], ); const toggleNegative = useCallback(() => { @@ -268,6 +276,7 @@ function MoneyRequestAmountForm({ shouldUseDynamicFontSize isCurrencyPressable={isCurrencyPressable} onCurrencyButtonPress={onCurrencyButtonPress} + onFormatAmount={onFormatAmount} onAmountChange={() => { if (!formError) { return; diff --git a/src/pages/iou/RejectReasonPage.tsx b/src/pages/iou/RejectReasonPage.tsx index 0864eed6a8e1..432b7a13826a 100644 --- a/src/pages/iou/RejectReasonPage.tsx +++ b/src/pages/iou/RejectReasonPage.tsx @@ -15,7 +15,7 @@ import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavig import type {MoneyRequestNavigatorParamList, SearchReportActionsParamList} from '@libs/Navigation/types'; import {getFieldRequiredErrors} from '@libs/ValidationUtils'; import {clearErrorFields, clearErrors} from '@userActions/FormActions'; -import {rejectMoneyRequest} from '@userActions/IOU'; +import {rejectMoneyRequest} from '@userActions/IOU/RejectMoneyRequest'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/MoneyRequestRejectReasonForm'; diff --git a/src/pages/iou/SplitBillDetailsPage.tsx b/src/pages/iou/SplitBillDetailsPage.tsx index a7b43fe8e85f..ca06aa66d81d 100644 --- a/src/pages/iou/SplitBillDetailsPage.tsx +++ b/src/pages/iou/SplitBillDetailsPage.tsx @@ -1,4 +1,3 @@ -import {privateIsArchivedSelector} from '@selectors/ReportNameValuePairs'; import React, {useCallback, useState} from 'react'; import {View} from 'react-native'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; @@ -13,6 +12,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import useReportAttributes from '@hooks/useReportAttributes'; +import useReportIsArchived from '@hooks/useReportIsArchived'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {completeSplitBill, setDraftSplitTransaction} from '@libs/actions/IOU/Split'; @@ -21,19 +21,10 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SplitDetailsNavigatorParamList} from '@libs/Navigation/types'; import {getParticipantsOption, getPolicyExpenseReportOption} from '@libs/OptionsListUtils'; -import Parser from '@libs/Parser'; import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; -import {getTransactionDetails, isPolicyExpenseChat} from '@libs/ReportUtils'; +import {isPolicyExpenseChat} from '@libs/ReportUtils'; import type {OptionData} from '@libs/ReportUtils'; -import { - areRequiredFieldsEmpty, - hasReceipt, - isDistanceRequest as isDistanceRequestUtil, - isGPSDistanceRequest as isGPSDistanceRequestUtil, - isManualDistanceRequest as isManualDistanceRequestUtil, - isMapDistanceRequest as isMapDistanceRequestUtil, - isScanning, -} from '@libs/TransactionUtils'; +import {areRequiredFieldsEmpty, hasReceipt, isDistanceRequest as isDistanceRequestUtil, isMapDistanceRequest as isMapDistanceRequestUtil, isScanning} from '@libs/TransactionUtils'; import withReportAndReportActionOrNotFound from '@pages/inbox/report/withReportAndReportActionOrNotFound'; import type {WithReportAndReportActionOrNotFoundProps} from '@pages/inbox/report/withReportAndReportActionOrNotFound'; import variables from '@styles/variables'; @@ -65,7 +56,7 @@ function SplitBillDetailsPage({route, report, reportAction}: SplitBillDetailsPag const reportAttributesDerived = useReportAttributes(); const [betas] = useOnyx(ONYXKEYS.BETAS); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`); - const [privateIsArchived] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, {selector: privateIsArchivedSelector}); + const privateIsArchived = useReportIsArchived(reportID); // In case this is workspace split expense, we manually add the workspace as the second participant of the split expense // because we don't save any accountID in the report action's originalMessage other than the payee's accountID @@ -86,23 +77,11 @@ function SplitBillDetailsPage({route, report, reportAction}: SplitBillDetailsPag const isDistanceRequest = isDistanceRequestUtil(transaction); const isEditingSplitBill = session?.accountID === actorAccountID && (areRequiredFieldsEmpty(transaction, transactionReport) || (transaction?.amount === 0 && !hasReceipt(transaction))) && !isDistanceRequest; - const isManualDistanceRequest = isManualDistanceRequestUtil(transaction); - const isGPSDistanceRequest = isGPSDistanceRequestUtil(transaction); const isMapDistanceRequest = isMapDistanceRequestUtil(transaction); const [isConfirmed, setIsConfirmed] = useState(false); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); - const { - amount: splitAmount, - currency: splitCurrency, - comment: splitComment, - merchant: splitMerchant, - created: splitCreated, - category: splitCategory, - billable: splitBillable, - } = getTransactionDetails(isEditingSplitBill && draftTransaction ? draftTransaction : transaction) ?? {}; - const onConfirm = useCallback(() => { setIsConfirmed(true); completeSplitBill( @@ -148,22 +127,12 @@ function SplitBillDetailsPage({route, report, reportAction}: SplitBillDetailsPag { @@ -132,14 +134,8 @@ function SplitExpensePage({route}: SplitExpensePageProps) { } return transactionDetails.amount; }, [transactionDetails.amount]); - const sumOfSplitExpenses = (draftTransaction?.comment?.splitExpenses ?? []).reduce((acc, item) => acc + (item.amount ?? 0), 0); const splitExpenses = draftTransaction?.comment?.splitExpenses ?? []; - const invalidSplit = splitExpenses.find((split) => { - // A split is only invalid if it has the same sign as the total and its magnitude exceeds the total - const sameSign = (split.amount >= 0 && transactionDetailsAmount >= 0) || (split.amount < 0 && transactionDetailsAmount < 0); - return sameSign && Math.abs(split.amount) > Math.abs(transactionDetailsAmount); - }); - const difference = sumOfSplitExpenses - transactionDetailsAmount; + const sumOfSplitExpenses = splitExpenses.reduce((acc, item) => acc + (item.amount ?? 0), 0); const currencySymbol = getCurrencySymbol(transactionDetails.currency ?? '') ?? transactionDetails.currency ?? CONST.CURRENCY.USD; useEffect(() => { @@ -241,10 +237,6 @@ function SplitExpensePage({route}: SplitExpensePageProps) { return; } - if (splitExpenses.length > CONST.IOU.SPLITS_LIMIT) { - setErrorMessage(translate('iou.error.manySplitsProvided')); - return; - } if (splitExpenses.length <= 1 && !childTransactions.length) { const splitFieldDataFromOriginalTransactionWithoutID = {...splitFieldDataFromOriginalTransaction, transactionID: ''}; const splitExpenseWithoutID = {...splitExpenses.at(0), transactionID: ''}; @@ -266,28 +258,17 @@ function SplitExpensePage({route}: SplitExpensePageProps) { clearSplitTransactionDraftErrors(transactionID); } - if (invalidSplit && sumOfSplitExpenses !== transactionDetailsAmount && !isDistance) { - if (difference > 0) { - setErrorMessage(translate('iou.totalAmountGreaterThanOriginal', convertToDisplayString(Math.abs(difference), transactionDetails?.currency))); - } else { - setErrorMessage(translate('iou.totalAmountLessThanOriginal', convertToDisplayString(Math.abs(difference), transactionDetails?.currency))); - } - return; - } - - if (sumOfSplitExpenses > transactionDetailsAmount && !isDistance) { - const greaterThanDifference = sumOfSplitExpenses - transactionDetailsAmount; - setErrorMessage(translate('iou.totalAmountGreaterThanOriginal', convertToDisplayString(greaterThanDifference, transactionDetails?.currency))); - return; - } - if (sumOfSplitExpenses < transactionDetailsAmount && (isPerDiem || isCard) && !isDistance) { - const lessThanDifference = transactionDetailsAmount - sumOfSplitExpenses; - setErrorMessage(translate('iou.totalAmountLessThanOriginal', convertToDisplayString(lessThanDifference, transactionDetails?.currency))); - return; - } - - if (splitExpenses.find((item) => item.amount === 0)) { - setErrorMessage(translate('iou.splitExpenseZeroAmount')); + const saveError = computeSplitSaveErrorMessage({ + splitExpenses, + transactionDetailsAmount, + currency: transactionDetails?.currency ?? CONST.CURRENCY.USD, + isDistance, + isPerDiem, + isCard, + translate, + }); + if (saveError) { + setErrorMessage(saveError); return; } @@ -413,19 +394,12 @@ function SplitExpensePage({route}: SplitExpensePageProps) { ); - let warningMessage = ''; - - if (invalidSplit && sumOfSplitExpenses !== transactionDetailsAmount) { - if (difference > 0) { - warningMessage = translate('iou.totalAmountGreaterThanOriginal', convertToDisplayString(Math.abs(difference), transactionDetails?.currency)); - } else { - warningMessage = translate('iou.totalAmountLessThanOriginal', convertToDisplayString(Math.abs(difference), transactionDetails?.currency)); - } - } else if (difference < 0) { - warningMessage = translate('iou.totalAmountLessThanOriginal', convertToDisplayString(Math.abs(difference), transactionDetails.currency)); - } else if (difference > 0) { - warningMessage = translate('iou.totalAmountGreaterThanOriginal', convertToDisplayString(Math.abs(difference), transactionDetails?.currency)); - } + const warningMessage = computeSplitWarningMessage({ + splitExpenses, + transactionDetailsAmount, + currency: transactionDetails?.currency ?? CONST.CURRENCY.USD, + translate, + }); const footerContent = ( diff --git a/src/pages/iou/request/DistanceRequestStartPage.tsx b/src/pages/iou/request/DistanceRequestStartPage.tsx index e19365d2416e..2bb5e0eaf108 100644 --- a/src/pages/iou/request/DistanceRequestStartPage.tsx +++ b/src/pages/iou/request/DistanceRequestStartPage.tsx @@ -10,7 +10,7 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePersonalPolicy from '@hooks/usePersonalPolicy'; -import usePolicy from '@hooks/usePolicy'; +import usePolicyForTransaction from '@hooks/usePolicyForTransaction'; import usePrevious from '@hooks/usePrevious'; import useThemeStyles from '@hooks/useThemeStyles'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; @@ -42,7 +42,7 @@ type DistanceRequestStartPageProps = WithWritableReportOrNotFoundProps hasOnlyPersonalPoliciesUtil(allPolicies), [allPolicies]); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); @@ -109,6 +110,7 @@ function DistanceRequestStartPage({ policy, personalPolicy, isFromGlobalCreate, + isTrackDistanceExpense, isFromFloatingActionButton: transaction?.isFromFloatingActionButton ?? transaction?.isFromGlobalCreate ?? isFromGlobalCreate, currentIouRequestType: transaction?.iouRequestType, newIouRequestType: newIOUType, @@ -134,6 +136,7 @@ function DistanceRequestStartPage({ currentDate, lastSelectedDistanceRates, currentUserPersonalDetails, + isTrackDistanceExpense, hasOnlyPersonalPolicies, draftTransactionIDs, ], diff --git a/src/pages/iou/request/IOURequestStartPage.tsx b/src/pages/iou/request/IOURequestStartPage.tsx index e756308f32da..fdbb92cf32e5 100644 --- a/src/pages/iou/request/IOURequestStartPage.tsx +++ b/src/pages/iou/request/IOURequestStartPage.tsx @@ -26,6 +26,7 @@ import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import getPlatform from '@libs/getPlatform'; import type Platform from '@libs/getPlatform/types'; +import GoogleTagManager from '@libs/GoogleTagManager'; import Navigation from '@libs/Navigation/Navigation'; import OnyxTabNavigator, {TabScreenWithFocusTrapWrapper, TopTab} from '@libs/Navigation/OnyxTabNavigator'; import { @@ -97,7 +98,6 @@ function IOURequestStartPage({ }); const [lastSelectedDistanceRates] = useOnyx(ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES); - const [isMultiScanEnabled, setIsMultiScanEnabled] = useState(false); const [currentDate] = useOnyx(ONYXKEYS.CURRENT_DATE); const {isOffline} = useNetwork(); const [hasUserSubmittedExpenseOrScannedReceipt] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING, {selector: isTestReceiptTooltipDismissedSelector}); @@ -129,6 +129,7 @@ function IOURequestStartPage({ const isFromGlobalCreate = isEmptyObject(report?.reportID); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const accountID = currentUserPersonalDetails.accountID; const policiesWithPerDiemEnabledAndHasRates = useMemo( () => getActivePoliciesWithExpenseChatAndPerDiemEnabledAndHasRates(allPolicies, currentUserPersonalDetails.login), [allPolicies, currentUserPersonalDetails.login], @@ -186,7 +187,6 @@ function IOURequestStartPage({ if (transaction?.iouRequestType === newIOUType) { return; } - setIsMultiScanEnabled(false); initMoneyRequest({ reportID, policy, @@ -262,11 +262,16 @@ function IOURequestStartPage({ // The test receipt image is served via our server on web so it requires internet connection !hasUserSubmittedExpenseOrScannedReceipt && isBetaEnabled(CONST.BETAS.NEWDOT_MANAGER_MCTEST) && selectedTab === CONST.TAB_REQUEST.SCAN && !(isOffline && isWeb), { + onShown: () => { + GoogleTagManager.publishEvent(CONST.ANALYTICS.EVENT.PRODUCT_TRAINING_SCAN_TEST_TOOLTIP_SHOWN, accountID); + }, onConfirm: () => { setTestReceiptAndNavigateRef?.current?.(); + GoogleTagManager.publishEvent(CONST.ANALYTICS.EVENT.PRODUCT_TRAINING_SCAN_TEST_TOOLTIP_CONFIRMED, accountID); }, onDismiss: () => { dismissProductTraining(CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.SCAN_TEST_TOOLTIP, true); + GoogleTagManager.publishEvent(CONST.ANALYTICS.EVENT.PRODUCT_TRAINING_SCAN_TEST_TOOLTIP_DISMISSED, accountID); }, }, ); @@ -340,14 +345,12 @@ function IOURequestStartPage({ {() => ( { setTestReceiptAndNavigateRef.current = setTestReceiptAndNavigate; }} - isMultiScanEnabled={isMultiScanEnabled} - setIsMultiScanEnabled={setIsMultiScanEnabled} - isStartingScan /> )} diff --git a/src/pages/iou/request/MoneyRequestAccountantSelector.tsx b/src/pages/iou/request/MoneyRequestAccountantSelector.tsx index 3b427160995f..17e95ed4270f 100644 --- a/src/pages/iou/request/MoneyRequestAccountantSelector.tsx +++ b/src/pages/iou/request/MoneyRequestAccountantSelector.tsx @@ -72,6 +72,7 @@ function MoneyRequestAccountantSelector({onFinish, onAccountantSelected, iouType const currentUserEmail = currentUserPersonalDetails.email ?? ''; const currentUserAccountID = currentUserPersonalDetails.accountID; const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const privateIsArchivedMap = usePrivateIsArchivedMap(); useEffect(() => { @@ -94,6 +95,7 @@ function MoneyRequestAccountantSelector({onFinish, onAccountantSelected, iouType loginList, currentUserAccountID, currentUserEmail, + conciergeReportID, { betas, excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, @@ -123,6 +125,7 @@ function MoneyRequestAccountantSelector({onFinish, onAccountantSelected, iouType countryCode, currentUserAccountID, currentUserEmail, + conciergeReportID, personalDetails, ]); diff --git a/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx b/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx index 8e95c38c2b23..c833f68f19f4 100644 --- a/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx +++ b/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx @@ -124,6 +124,7 @@ function MoneyRequestParticipantsSelector({ const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`]; const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); + const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); const [isSearchingForReports] = useOnyx(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUserLogin = currentUserPersonalDetails.login; @@ -531,7 +532,7 @@ function MoneyRequestParticipantsSelector({ const onSelectRow = useCallback( (option: Participant) => { - if (option.isPolicyExpenseChat && option.policyID && shouldRestrictUserBillableActions(option.policyID, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds)) { + if (option.isPolicyExpenseChat && option.policyID && shouldRestrictUserBillableActions(option.policyID, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed)) { Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(option.policyID)); return; } @@ -543,7 +544,7 @@ function MoneyRequestParticipantsSelector({ addSingleParticipant(option); }, - [isIOUSplit, addParticipantToSelection, addSingleParticipant, userBillingGracePeriodEnds, ownerBillingGracePeriodEnd], + [isIOUSplit, addParticipantToSelection, addSingleParticipant, userBillingGracePeriodEnds, ownerBillingGracePeriodEnd, amountOwed], ); const importContactsButtonComponent = useMemo(() => { diff --git a/src/pages/iou/request/step/BaseRequestStepWorkspace.tsx b/src/pages/iou/request/step/BaseRequestStepWorkspace.tsx index 7afb770145df..f8c09a24a70a 100644 --- a/src/pages/iou/request/step/BaseRequestStepWorkspace.tsx +++ b/src/pages/iou/request/step/BaseRequestStepWorkspace.tsx @@ -46,6 +46,7 @@ function BaseRequestStepWorkspace({transaction, getPolicies, onSelectWorkspace}: const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [userBillingGracePeriodEnds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); + const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); const selectedWorkspace = transaction?.participants?.[0]; const customUnitPolicy = getPolicyByCustomUnitID(transaction, allPolicies); const initiallyFocusedKey = selectedWorkspace?.policyID ?? customUnitPolicy?.id; @@ -80,7 +81,7 @@ function BaseRequestStepWorkspace({transaction, getPolicies, onSelectWorkspace}: const selectWorkspace = (item: WorkspaceListItem) => { const policyID = item.policyID; - if (shouldRestrictUserBillableActions(policyID, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds)) { + if (shouldRestrictUserBillableActions(policyID, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed)) { Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policyID)); return; } diff --git a/src/pages/iou/request/step/IOURequestEditReport.tsx b/src/pages/iou/request/step/IOURequestEditReport.tsx index 26402fd8c840..ca2436b3bc37 100644 --- a/src/pages/iou/request/step/IOURequestEditReport.tsx +++ b/src/pages/iou/request/step/IOURequestEditReport.tsx @@ -5,7 +5,6 @@ import {useSearchActionsContext, useSearchStateContext} from '@components/Search import type {ListItem} from '@components/SelectionList/types'; import useConditionalCreateEmptyReportConfirmation from '@hooks/useConditionalCreateEmptyReportConfirmation'; import useHasPerDiemTransactions from '@hooks/useHasPerDiemTransactions'; -import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; @@ -34,7 +33,6 @@ type IOURequestEditReportProps = WithWritableReportOrNotFoundProps { const restrictionPolicyID = hasPerDiemTransactions ? selectedReport?.policyID : policyForMovingExpensesID; - if (restrictionPolicyID && shouldRestrictUserBillableActions(restrictionPolicyID, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds)) { + if (restrictionPolicyID && shouldRestrictUserBillableActions(restrictionPolicyID, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed)) { Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(restrictionPolicyID)); return; } diff --git a/src/pages/iou/request/step/IOURequestEditReportCommon.tsx b/src/pages/iou/request/step/IOURequestEditReportCommon.tsx index 0acf42158ed4..da07d152f929 100644 --- a/src/pages/iou/request/step/IOURequestEditReportCommon.tsx +++ b/src/pages/iou/request/step/IOURequestEditReportCommon.tsx @@ -73,6 +73,7 @@ function IOURequestEditReportCommon({ const [allTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); const [userBillingGracePeriodEnds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); + const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const [selectedReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${selectedReportID}`); const resolvedReportOwnerAccountID = useMemo(() => { @@ -87,11 +88,10 @@ function IOURequestEditReportCommon({ return currentUserPersonalDetails.accountID; }, [targetOwnerAccountID, selectedReport?.ownerAccountID, currentUserPersonalDetails.accountID]); const reportPolicy = usePolicy(selectedReport?.policyID); - // Pass the expense's policyID so that the "Create report" button shows the correct workspace - // instead of defaulting to the user's active workspace - // we need to fall back to transactionPolicyID because for a new workspace there is no report created yet - // and if we choose this workspace as participant we want to create a new report in the chosen workspace - const {policyForMovingExpenses} = usePolicyForMovingExpenses(isPerDiemRequest, isTimeRequest, selectedReport?.policyID ?? transactionPolicyID); + // Use the caller-provided transactionPolicyID so that the "Create report" button shows the correct workspace. + // Each caller is responsible for passing the appropriate policyID (e.g., selectedReport?.policyID ?? transactionPolicyID). + // When no transactionPolicyID is provided (e.g., from IOURequestEditReport), the hook falls back to the user's default workspace. + const {policyForMovingExpenses} = usePolicyForMovingExpenses(isPerDiemRequest, isTimeRequest, transactionPolicyID); const [perDiemWarningModalVisible, setPerDiemWarningModalVisible] = useState(false); @@ -225,7 +225,7 @@ function IOURequestEditReportCommon({ return; } - if (item?.policyID && shouldRestrictUserBillableActions(item.policyID, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, undefined)) { + if (item?.policyID && shouldRestrictUserBillableActions(item.policyID, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed)) { Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(item.policyID)); return; } diff --git a/src/pages/iou/request/step/IOURequestStepAccountant.tsx b/src/pages/iou/request/step/IOURequestStepAccountant.tsx index ed94a455c051..221b52ec8f54 100644 --- a/src/pages/iou/request/step/IOURequestStepAccountant.tsx +++ b/src/pages/iou/request/step/IOURequestStepAccountant.tsx @@ -1,10 +1,11 @@ -import {activeAdminPoliciesSelector} from '@selectors/Policy'; +import {activeAdminPoliciesSelector, lastWorkspaceNumberSelector} from '@selectors/Policy'; import React, {useCallback} from 'react'; import type {OnyxCollection} from 'react-native-onyx'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import {setMoneyRequestAccountant} from '@libs/actions/IOU'; +import {newGenerateDefaultWorkspaceName} from '@libs/actions/Policy/Policy'; import Navigation from '@libs/Navigation/Navigation'; import {createDraftWorkspaceAndNavigateToConfirmationScreen} from '@libs/ReportUtils'; import MoneyRequestAccountantSelector from '@pages/iou/request/MoneyRequestAccountantSelector'; @@ -25,14 +26,16 @@ function IOURequestStepAccountant({ }, }: IOURequestStepAccountantProps) { const {translate} = useLocalize(); - const {login} = useCurrentUserPersonalDetails(); + const {accountID, login, email = ''} = useCurrentUserPersonalDetails(); const selector = useCallback( (policies: OnyxCollection) => { return activeAdminPoliciesSelector(policies, login ?? ''); }, [login], ); + const lastWorkspaceNumberWithEmailSelector = useCallback((policies: OnyxCollection) => lastWorkspaceNumberSelector(policies, email), [email]); const [adminPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector}); + const [lastWorkspaceNumber] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: lastWorkspaceNumberWithEmailSelector}); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const setAccountant = useCallback( @@ -46,12 +49,19 @@ function IOURequestStepAccountant({ // Sharing with an accountant involves inviting them to the workspace and that requires admin access. const hasActiveAdminWorkspaces = (adminPolicies?.length ?? 0) > 0; if (!hasActiveAdminWorkspaces) { - createDraftWorkspaceAndNavigateToConfirmationScreen(introSelected, transactionID, action); + createDraftWorkspaceAndNavigateToConfirmationScreen( + introSelected, + transactionID, + action, + newGenerateDefaultWorkspaceName(email, lastWorkspaceNumber, translate), + accountID, + email, + ); return; } Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(iouType, transactionID, reportID, Navigation.getActiveRoute(), action)); - }, [adminPolicies?.length, iouType, transactionID, reportID, action, introSelected]); + }, [adminPolicies?.length, iouType, transactionID, reportID, action, introSelected, email, lastWorkspaceNumber, translate]); const navigateBack = useCallback(() => { Navigation.goBack(backTo); diff --git a/src/pages/iou/request/step/IOURequestStepAmount.tsx b/src/pages/iou/request/step/IOURequestStepAmount.tsx index 020550c4628e..a31f6ceb5113 100644 --- a/src/pages/iou/request/step/IOURequestStepAmount.tsx +++ b/src/pages/iou/request/step/IOURequestStepAmount.tsx @@ -17,30 +17,33 @@ import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import usePrivateIsArchivedMap from '@hooks/usePrivateIsArchivedMap'; import useReportAttributes from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; +import useReportTransactions from '@hooks/useReportTransactions'; import useSelfDMReport from '@hooks/useSelfDMReport'; import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep'; +import {requestMoney} from '@libs/actions/IOU/TrackExpense'; import {setTransactionReport} from '@libs/actions/Transaction'; import {convertToBackendAmount} from '@libs/CurrencyUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {calculateDefaultReimbursable, getExistingTransactionID, isMovingTransactionFromTrackExpense, navigateToConfirmationPage, navigateToParticipantPage} from '@libs/IOUUtils'; +import { + calculateDefaultReimbursable, + getExistingTransactionID, + isMovingTransactionFromTrackExpense, + navigateToConfirmationPage, + navigateToParticipantPage, + resolveOptimisticChatReportID, +} from '@libs/IOUUtils'; +import dismissModalAndOpenReportInInboxTabHelper from '@libs/Navigation/helpers/dismissModalAndOpenReportInInboxTab'; import Navigation from '@libs/Navigation/Navigation'; import {getParticipantsOption, getReportOption} from '@libs/OptionsListUtils'; import {getPolicyExpenseChat, getReportOrDraftReport, getTransactionDetails, isMoneyRequestReport, isPolicyExpenseChat, isSelfDM, shouldEnableNegative} from '@libs/ReportUtils'; import shouldUseDefaultExpensePolicy from '@libs/shouldUseDefaultExpensePolicy'; import {calculateTaxAmount, getAmount, getCurrency, getDefaultTaxCode, getRequestType, getTaxValue, hasReceipt, isDistanceRequest, isExpenseUnreported} from '@libs/TransactionUtils'; import MoneyRequestAmountForm from '@pages/iou/MoneyRequestAmountForm'; -import { - getMoneyRequestParticipantsFromReport, - requestMoney, - setMoneyRequestAmount, - setMoneyRequestParticipantsFromReport, - setMoneyRequestTaxAmount, - setMoneyRequestTaxRate, - trackExpense, - updateMoneyRequestAmountAndCurrency, -} from '@userActions/IOU'; +import {getMoneyRequestParticipantsFromReport, setMoneyRequestAmount, setMoneyRequestParticipantsFromReport, setMoneyRequestTaxAmount, setMoneyRequestTaxRate} from '@userActions/IOU'; import {sendMoneyElsewhere, sendMoneyWithWallet} from '@userActions/IOU/SendMoney'; import {resetSplitShares, setDraftSplitTransaction, setSplitShares} from '@userActions/IOU/Split'; +import {trackExpense} from '@userActions/IOU/TrackExpense'; +import {updateMoneyRequestAmountAndCurrency} from '@userActions/IOU/UpdateMoneyRequest'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -91,6 +94,7 @@ function IOURequestStepAmount({ const selfDMReport = useSelfDMReport(); const isReportArchived = useReportIsArchived(report?.reportID); + const reportTransactions = useReportTransactions(report?.reportID); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.parentReportID)}`); @@ -245,11 +249,26 @@ function IOURequestStepAmount({ transactionReportID: report?.reportID, }); if (iouType === CONST.IOU.TYPE.PAY || iouType === CONST.IOU.TYPE.SEND) { + const {optimisticChatReportID, chatReportID} = resolveOptimisticChatReportID( + [participants.at(0)?.accountID ?? CONST.DEFAULT_NUMBER_ID, currentUserAccountIDParam], + report, + ); + const sendMoneyParams = { + report, + quickAction, + amount: backendAmount, + currency: selectedCurrency, + comment: '', + currentUserAccountID: currentUserAccountIDParam, + recipient: participants.at(0) ?? {}, + optimisticChatReportID, + }; if (paymentMethod && paymentMethod === CONST.IOU.PAYMENT_TYPE.EXPENSIFY) { - sendMoneyWithWallet(report, quickAction, backendAmount, selectedCurrency, '', currentUserAccountIDParam, participants.at(0) ?? {}); - return; + sendMoneyWithWallet(sendMoneyParams); + } else { + sendMoneyElsewhere(sendMoneyParams); } - sendMoneyElsewhere(report, quickAction, backendAmount, selectedCurrency, '', currentUserAccountIDParam, participants.at(0) ?? {}); + dismissModalAndOpenReportInInboxTabHelper(chatReportID, undefined, reportTransactions.length > 0); return; } if (iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.REQUEST) { diff --git a/src/pages/iou/request/step/IOURequestStepAttendees.tsx b/src/pages/iou/request/step/IOURequestStepAttendees.tsx index 7269b09c6fe7..2cca1d44e1c0 100644 --- a/src/pages/iou/request/step/IOURequestStepAttendees.tsx +++ b/src/pages/iou/request/step/IOURequestStepAttendees.tsx @@ -7,7 +7,8 @@ import usePermissions from '@hooks/usePermissions'; import usePrevious from '@hooks/usePrevious'; import useRestartOnReceiptFailure from '@hooks/useRestartOnReceiptFailure'; import useTransactionViolations from '@hooks/useTransactionViolations'; -import {setMoneyRequestAttendees, updateMoneyRequestAttendees} from '@libs/actions/IOU'; +import {setMoneyRequestAttendees} from '@libs/actions/IOU'; +import {updateMoneyRequestAttendees} from '@libs/actions/IOU/UpdateMoneyRequest'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Navigation from '@libs/Navigation/Navigation'; import {getOriginalAttendees} from '@libs/TransactionUtils'; diff --git a/src/pages/iou/request/step/IOURequestStepCategory.tsx b/src/pages/iou/request/step/IOURequestStepCategory.tsx index 97923f09ec4d..d207e5b6dd82 100644 --- a/src/pages/iou/request/step/IOURequestStepCategory.tsx +++ b/src/pages/iou/request/step/IOURequestStepCategory.tsx @@ -20,8 +20,9 @@ import usePolicyForTransaction from '@hooks/usePolicyForTransaction'; import useRestartOnReceiptFailure from '@hooks/useRestartOnReceiptFailure'; import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep'; import useThemeStyles from '@hooks/useThemeStyles'; -import {getIOURequestPolicyID, setMoneyRequestCategory, updateMoneyRequestCategory} from '@libs/actions/IOU'; +import {getIOURequestPolicyID, setMoneyRequestCategory} from '@libs/actions/IOU'; import {setDraftSplitTransaction} from '@libs/actions/IOU/Split'; +import {updateMoneyRequestCategory} from '@libs/actions/IOU/UpdateMoneyRequest'; import {enablePolicyCategories, getPolicyCategories} from '@libs/actions/Policy/Category'; import {isCategoryMissing} from '@libs/CategoryUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; diff --git a/src/pages/iou/request/step/IOURequestStepCompanyInfo.tsx b/src/pages/iou/request/step/IOURequestStepCompanyInfo.tsx index 3d995285fa55..86d471ae01c3 100644 --- a/src/pages/iou/request/step/IOURequestStepCompanyInfo.tsx +++ b/src/pages/iou/request/step/IOURequestStepCompanyInfo.tsx @@ -103,6 +103,7 @@ function IOURequestStepCompanyInfo({route, report, transaction}: IOURequestStepC policyRecentlyUsedCategories, policyRecentlyUsedTags, isFromGlobalCreate: transaction?.isFromFloatingActionButton ?? transaction?.isFromGlobalCreate, + senderPolicyTags: policyTags ?? {}, }); }; diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 8632d9269dd6..42ea33f93207 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -1,4 +1,4 @@ -import {useIsFocused} from '@react-navigation/native'; +import {delegateEmailSelector} from '@selectors/Account'; import {hasSeenTourSelector} from '@selectors/Onboarding'; import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; @@ -32,33 +32,37 @@ import usePermissions from '@hooks/usePermissions'; import usePolicyForTransaction from '@hooks/usePolicyForTransaction'; import usePrivateIsArchivedMap from '@hooks/usePrivateIsArchivedMap'; import useReportAttributes from '@hooks/useReportAttributes'; +import useReportTransactions from '@hooks/useReportTransactions'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {completeTestDriveTask} from '@libs/actions/Task'; import {isMobileSafari} from '@libs/Browser'; import {getCurrencySymbol} from '@libs/CurrencyUtils'; import DateUtils from '@libs/DateUtils'; +import {reserveDeferredWriteChannel} from '@libs/deferredLayoutWrite'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; -import {isLocalFile as isLocalFileFileUtils} from '@libs/fileDownload/FileUtils'; -import validateReceiptFile from '@libs/fileDownload/validateReceiptFile'; import getCurrentPosition from '@libs/getCurrentPosition'; +import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getGPSCoordinates} from '@libs/GPSDraftDetailsUtils'; import { getExistingTransactionID, isMovingTransactionFromTrackExpense as isMovingTransactionFromTrackExpenseIOUUtils, navigateToStartMoneyRequestStep, + resolveOptimisticChatReportID, shouldShowReceiptEmptyState, shouldUseTransactionDraft, } from '@libs/IOUUtils'; import Log from '@libs/Log'; +import dismissModalAndOpenReportInInboxTabHelper from '@libs/Navigation/helpers/dismissModalAndOpenReportInInboxTab'; +import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator'; +import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; +import navigateAfterExpenseCreate from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; import navigateAfterInteraction from '@libs/Navigation/navigateAfterInteraction'; import Navigation from '@libs/Navigation/Navigation'; import {rand64, roundToTwoDecimalPlaces} from '@libs/NumberUtils'; -import {getOdometerImageName, getOdometerImageType, getOdometerImageUri} from '@libs/OdometerImageUtils'; import {getParticipantsOption, getReportOption} from '@libs/OptionsListUtils'; -import {isPaidGroupPolicy} from '@libs/PolicyUtils'; import { findSelfDMReportID, generateReportID, @@ -69,13 +73,13 @@ import { isReportOutstanding, isSelectedManagerMcTest, } from '@libs/ReportUtils'; -import stitchOdometerImages from '@libs/stitchOdometerImages'; -import {cancelSpan, endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpans'; +import {buildCannedSearchQuery} from '@libs/SearchQueryUtils'; +import {endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpans'; import getSubmitExpenseScenario from '@libs/telemetry/getSubmitExpenseScenario'; import markSubmitExpenseEnd from '@libs/telemetry/markSubmitExpenseEnd'; +import {setPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import { - getAttendees, getDefaultTaxCode, getRateID, getRequestType, @@ -92,22 +96,18 @@ import type {GpsPoint} from '@userActions/IOU'; import { createDistanceRequest as createDistanceRequestIOUActions, getIOURequestPolicyID, - requestMoney as requestMoneyIOUActions, setMoneyRequestBillable, - setMoneyRequestCategory, setMoneyRequestParticipantsFromReport, - setMoneyRequestReceipt, setMoneyRequestReimbursable, - startMoneyRequest, - trackExpense as trackExpenseIOUActions, updateLastLocationPermissionPrompt, } from '@userActions/IOU'; import {submitPerDiemExpenseForSelfDM, submitPerDiemExpense as submitPerDiemExpenseIOUActions} from '@userActions/IOU/PerDiem'; +import {setMoneyRequestReceipt} from '@userActions/IOU/Receipt'; import {getReceiverType, sendInvoice} from '@userActions/IOU/SendInvoice'; import {sendMoneyElsewhere, sendMoneyWithWallet} from '@userActions/IOU/SendMoney'; import {splitBill, splitBillAndOpenReport, startSplitBill} from '@userActions/IOU/Split'; -import {openDraftWorkspaceRequest} from '@userActions/Policy/Policy'; -import {removeDraftTransaction, removeDraftTransactionsByIDs, replaceDefaultDraftTransaction} from '@userActions/TransactionEdit'; +import {requestMoney as requestMoneyIOUActions, trackExpense as trackExpenseIOUActions} from '@userActions/IOU/TrackExpense'; +import {removeDraftTransaction, replaceDefaultDraftTransaction} from '@userActions/TransactionEdit'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -120,6 +120,13 @@ import type {Receipt} from '@src/types/onyx/Transaction'; import type {FileObject} from '@src/types/utils/Attachment'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; +import CategoryDefaultsSetter from './confirmation/CategoryDefaultsSetter'; +import DraftWorkspaceOpener from './confirmation/DraftWorkspaceOpener'; +import ExpenseDefaultsSetter from './confirmation/ExpenseDefaultsSetter'; +import MoneyRequestInitializer from './confirmation/MoneyRequestInitializer'; +import OdometerReceiptStitcher from './confirmation/OdometerReceiptStitcher'; +import ReceiptFileValidator from './confirmation/ReceiptFileValidator'; +import TelemetrySpanManager from './confirmation/TelemetrySpanManager'; import type {WithFullTransactionOrNotFoundProps} from './withFullTransactionOrNotFound'; import withFullTransactionOrNotFound from './withFullTransactionOrNotFound'; import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; @@ -195,10 +202,10 @@ function IOURequestStepConfirmation({ const draftPolicyID = getIOURequestPolicyID(initialTransaction, reportDraft); const [policyDraft] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${draftPolicyID}`); const [policyReal] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${realPolicyID}`); - const [reportDrafts] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT); const [gpsDraftDetails] = useOnyx(ONYXKEYS.GPS_DRAFT_DETAILS); const [betas] = useOnyx(ONYXKEYS.BETAS); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const [delegateEmail] = useOnyx(ONYXKEYS.ACCOUNT, {selector: delegateEmailSelector}); const expensifyIcons = useMemoizedLazyExpensifyIcons(['ReplaceReceipt', 'SmartScan']); @@ -255,8 +262,11 @@ function IOURequestStepConfirmation({ const [draftTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector}); const reportAttributesDerived = useReportAttributes(); + const reportTransactions = useReportTransactions(report?.reportID); const [recentlyUsedDestinations] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_DESTINATIONS}${policyID}`); const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); + const transactionViolationsRef = useRef(transactionViolations); + transactionViolationsRef.current = transactionViolations; const hasViolations = hasViolationsReportUtils(report?.reportID, transactionViolations, currentUserPersonalDetails.accountID, currentUserPersonalDetails.login ?? ''); const policyCategories = useMemo(() => { @@ -275,6 +285,7 @@ function IOURequestStepConfirmation({ const receiverAccountID = receiverParticipant && 'accountID' in receiverParticipant && receiverParticipant.accountID ? receiverParticipant.accountID : CONST.DEFAULT_NUMBER_ID; const receiverType = getReceiverType(receiverParticipant); const senderWorkspaceID = transaction?.participants?.find((participant) => participant?.isSender)?.policyID; + const [senderWorkspacePolicyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${senderWorkspaceID}`); const existingInvoiceReport = useParticipantsInvoiceReport(receiverAccountID, receiverType, senderWorkspaceID); @@ -291,7 +302,6 @@ function IOURequestStepConfirmation({ const isDistanceRequest = isDistanceRequestTransactionUtils(transaction); const isManualDistanceRequest = isManualDistanceRequestTransactionUtils(transaction); const isOdometerDistanceRequest = isOdometerDistanceRequestTransactionUtils(transaction); - const isFocused = useIsFocused(); const isGPSDistanceRequest = isGPSDistanceRequestTransactionUtils(transaction); const transactionDistance = isManualDistanceRequest || isOdometerDistanceRequest || isGPSDistanceRequest ? (transaction?.comment?.customUnit?.quantity ?? undefined) : undefined; const isTimeRequest = requestType === CONST.IOU.REQUEST_TYPE.TIME; @@ -323,11 +333,6 @@ function IOURequestStepConfirmation({ const [isConfirming, setIsConfirming] = useState(false); const [isStitchingReceipt, setIsStitchingReceipt] = useState(false); const [stitchError, setStitchError] = useState(''); - const lastStitchedImages = useRef<{ - startImage: FileObject | string | undefined; - endImage: FileObject | string | undefined; - } | null>(null); - const headerTitle = useMemo(() => { if (isCategorizingTrackExpense) { return translate('iou.categorize'); @@ -357,217 +362,75 @@ function IOURequestStepConfirmation({ const privateIsArchived = privateIsArchivedMap[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${participant.reportID}`]; return participant.accountID ? getParticipantsOption(participant, personalDetails) - : getReportOption(participant, privateIsArchived, policy, personalDetails, conciergeReportID, reportAttributesDerived, reportDrafts); + : getReportOption(participant, privateIsArchived, policy, personalDetails, conciergeReportID, reportAttributesDerived); }) ?? [], - [transaction?.participants, iouType, personalDetails, reportAttributesDerived, reportDrafts, privateIsArchivedMap, policy, conciergeReportID], + // getReportOrDraftReport (called inside getReportOption) falls back to its module-level allReportsDraft + // connection, so we don't need to subscribe to COLLECTION.REPORT_DRAFT here. + [transaction?.participants, iouType, personalDetails, reportAttributesDerived, privateIsArchivedMap, policy, conciergeReportID], ); const participantsPolicyTags = useParticipantsPolicyTags(participants ?? []); const isPolicyExpenseChat = useMemo(() => participants?.some((participant) => participant.isPolicyExpenseChat), [participants]); const shouldGenerateTransactionThreadReport = !isBetaEnabled(CONST.BETAS.NO_OPTIMISTIC_TRANSACTION_THREADS); const formHasBeenSubmitted = useRef(false); + const isFromGlobalCreate = !!(transaction?.isFromGlobalCreate ?? transaction?.isFromFloatingActionButton); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); useFetchRoute(transaction, transaction?.comment?.waypoints, action, shouldUseTransactionDraft(action, iouType) ? CONST.TRANSACTION.STATE.DRAFT : CONST.TRANSACTION.STATE.CURRENT); - useEffect(() => { - endSpan(CONST.TELEMETRY.SPAN_OPEN_CREATE_EXPENSE); - endSpan(CONST.TELEMETRY.SPAN_CONFIRMATION_MOUNT); + const policyExpenseChatPolicyID = participants?.find((participant) => participant.isPolicyExpenseChat)?.policyID; - // Grab parent ref before ending it — children need it for parent_span_id linking - const parentSpan = getSpan(CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION); + const senderPolicyID = participants?.find((participant) => !!participant && 'isSender' in participant && participant.isSender)?.policyID; - startSpan(CONST.TELEMETRY.SPAN_CONFIRMATION_LIST_READY, { - name: CONST.TELEMETRY.SPAN_CONFIRMATION_LIST_READY, - op: CONST.TELEMETRY.SPAN_CONFIRMATION_LIST_READY, - parentSpan, - attributes: {[CONST.TELEMETRY.ATTRIBUTE_IOU_TYPE]: iouType}, - }); - startSpan(CONST.TELEMETRY.SPAN_CONFIRMATION_RECEIPT_LOAD, { - name: CONST.TELEMETRY.SPAN_CONFIRMATION_RECEIPT_LOAD, - op: CONST.TELEMETRY.SPAN_CONFIRMATION_RECEIPT_LOAD, - parentSpan, - }); + const odometerStartImage = transaction?.comment?.odometerStartImage; + const odometerEndImage = transaction?.comment?.odometerEndImage; - // End parent AFTER children are created — Sentry preserves parent_span_id regardless - endSpan(CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION); + // Pre-insert is only useful for flows whose submit ends in handleNavigateAfterExpenseCreate + // (which navigates to Search). Flows that use dismissModalAndOpenReportInInboxTab (PAY, + // SPLIT-from-global-create, per-diem self-DM track) navigate to a specific report instead, + // so pre-inserting Search would leave a stale route in the stack. + const canPreInsertSearch = iouType !== CONST.IOU.TYPE.PAY && iouType !== CONST.IOU.TYPE.SPLIT && !(isPerDiemRequest && iouType === CONST.IOU.TYPE.TRACK); - return () => { - cancelSpan(CONST.TELEMETRY.SPAN_CONFIRMATION_LIST_READY); - cancelSpan(CONST.TELEMETRY.SPAN_CONFIRMATION_RECEIPT_LOAD); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- we only want this to run on mount/unmount - }, []); + const hasPreInsertFired = useRef(false); + const isTransactionReady = !!transaction; useEffect(() => { - if (!isCreatingTrackExpense || policyID === undefined) { - return; - } - - openDraftWorkspaceRequest(policyID); - }, [isCreatingTrackExpense, policy?.pendingAction, policyID]); - - const policyExpenseChatPolicyID = useMemo(() => { - return participants?.find((participant) => participant.isPolicyExpenseChat)?.policyID; - }, [participants]); - - const senderPolicyID = useMemo(() => { - return participants?.find((participant) => !!participant && 'isSender' in participant && participant.isSender)?.policyID; - }, [participants]); - - useEffect(() => { - if (policyExpenseChatPolicyID && policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) { - openDraftWorkspaceRequest(policyExpenseChatPolicyID); - return; - } - if (senderPolicyID && policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) { - openDraftWorkspaceRequest(senderPolicyID); - } - }, [isOffline, policy?.pendingAction, policyExpenseChatPolicyID, senderPolicyID]); - - useEffect(() => { - if (!isOdometerDistanceRequest || !isFocused) { - return; - } - - const odometerStartImage = transaction?.comment?.odometerStartImage; - const odometerEndImage = transaction?.comment?.odometerEndImage; - - // Skip stitching when source images haven't changed (compare by URI not reference - // because Onyx may create new object instances when restoring a backup transaction) - const startUri = getOdometerImageUri(odometerStartImage); - const endUri = getOdometerImageUri(odometerEndImage); if ( - lastStitchedImages.current !== null && - getOdometerImageUri(lastStitchedImages.current.startImage) === startUri && - getOdometerImageUri(lastStitchedImages.current.endImage) === endUri + hasPreInsertFired.current || + !isTransactionReady || + !getIsNarrowLayout() || + !isFromGlobalCreate || + !canPreInsertSearch || + isReportTopmostSplitNavigator() || + isSearchTopmostFullScreenRoute() ) { return; } - if (!odometerStartImage || !odometerEndImage) { - const singleImage = odometerStartImage ?? odometerEndImage; - - if (!singleImage) { - return; - } + hasPreInsertFired.current = true; - setMoneyRequestReceipt( - currentTransactionID, - getOdometerImageUri(singleImage), - getOdometerImageName(singleImage), - shouldUseTransactionDraft(action, iouType), - getOdometerImageType(singleImage), - ); - lastStitchedImages.current = {startImage: odometerStartImage, endImage: odometerEndImage}; - return; - } + const type = iouType === CONST.IOU.TYPE.INVOICE ? CONST.SEARCH.DATA_TYPES.INVOICE : CONST.SEARCH.DATA_TYPES.EXPENSE; + const queryString = buildCannedSearchQuery({type}); + const searchRoute = ROUTES.SEARCH_ROOT.getRoute({query: queryString}); - let ignore = false; - setIsStitchingReceipt(true); - setStitchError(''); - - stitchOdometerImages(odometerStartImage, odometerEndImage) - .then((stitchedImage) => { - if (ignore || !stitchedImage) { - return; - } - setMoneyRequestReceipt( - currentTransactionID, - getOdometerImageUri(stitchedImage), - getOdometerImageName(stitchedImage), - shouldUseTransactionDraft(action, iouType), - getOdometerImageType(stitchedImage), - ); - lastStitchedImages.current = {startImage: odometerStartImage, endImage: odometerEndImage}; - }) - .catch((error: unknown) => { - if (ignore) { - return; - } - Log.warn('stitchOdometerImages failed', {error}); - setStitchError(translate('iou.error.stitchOdometerImagesFailed')); - }) - .finally(() => { - if (ignore) { - return; - } - setIsStitchingReceipt(false); - }); + const timer = setTimeout(() => { + Navigation.preInsertFullscreenUnderRHP(searchRoute); + }, CONST.PRE_INSERT_FULLSCREEN_DELAY); return () => { - ignore = true; - }; - }, [isOdometerDistanceRequest, isFocused, currentTransactionID, transaction?.comment?.odometerStartImage, transaction?.comment?.odometerEndImage, action, translate, iouType]); - - const defaultBillable = !!policy?.defaultBillable; - useEffect(() => { - if (isMovingTransactionFromTrackExpense) { - return; - } - for (const transactionID of transactionIDs) { - setMoneyRequestBillable(transactionID, defaultBillable); - } - }, [transactionIDs, defaultBillable, isMovingTransactionFromTrackExpense]); - - useEffect(() => { - if (isMovingTransactionFromTrackExpense) { - return; - } - const defaultReimbursable = (isPolicyExpenseChat && isPaidGroupPolicy(policy)) || isCreatingTrackExpense ? (policy?.defaultReimbursable ?? true) : true; - for (const transactionID of transactionIDs) { - setMoneyRequestReimbursable(transactionID, defaultReimbursable); - } - }, [transactionIDs, policy, isPolicyExpenseChat, isMovingTransactionFromTrackExpense, isCreatingTrackExpense]); - - useEffect(() => { - // Exit early if the transaction is still loading - if (!!isLoadingTransaction || (transaction?.transactionID && (!transaction?.isFromGlobalCreate || !isEmptyObject(transaction?.participants)))) { - return; - } - - startMoneyRequest( - iouType ?? CONST.IOU.TYPE.CREATE, - // When starting to create an expense from the global FAB, If there is not an existing report yet, a random optimistic reportID is generated and used - // for all of the routes in the creation flow. - reportID ?? generateReportID(), - draftTransactionIDs, - ); - // eslint-disable-next-line react-hooks/exhaustive-deps -- we don't want this effect to run again - }, [isLoadingTransaction]); + clearTimeout(timer); - useEffect(() => { - for (const item of transactions) { - if (!item.category) { - // If the expense had his category cleared due to unsaved changes (i.e. changing to recipient to one that does not have category) - // then we should reset the category to it's last saved value - const existingCategory = existingTransaction?.category; - if (existingCategory) { - const isExistingCategoryEnabled = policyCategories?.[existingCategory]?.enabled; - if (isExistingCategoryEnabled) { - setMoneyRequestCategory(item.transactionID, existingCategory, policy); - } - } - continue; + if (!Navigation.getIsFullscreenPreInsertedUnderRHP() || formHasBeenSubmitted.current) { + return; } - } - // We don't want to clear out category every time the transactions change - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [policy?.id, policyCategories, transactions.length]); - const policyDistance = Object.values(policy?.customUnits ?? {}).find((customUnit) => customUnit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); - const defaultCategory = policyDistance?.defaultCategory ?? ''; - - useEffect(() => { - for (const item of transactions) { - if (!isDistanceRequest || !!item?.category) { - continue; - } - setMoneyRequestCategory(item.transactionID, defaultCategory, policy, isMovingTransactionFromTrackExpense); - } - // Prevent resetting to default when unselect category + Navigation.removePreInsertedFullscreenIfNeeded(); + }; + // isFromGlobalCreate, iouType, and canPreInsertSearch are stable for the lifetime of + // this screen instance. isTransactionReady may flip from false to true once, which + // re-triggers the effect so the pre-insert fires even when the transaction loads late. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [transactionIDs, requestType, defaultCategory, policy?.id]); + }, [isTransactionReady]); const navigateBack = useCallback(() => { if (backTo) { @@ -633,66 +496,6 @@ function IOURequestStepConfirmation({ backToReport, ]); - // When the component mounts, if there is a receipt, see if the image can be read from the disk. If not, redirect the user to the starting step of the flow. - // This is because until the request is saved, the receipt file is only stored in the browsers memory as a blob:// and if the browser is refreshed, then - // the image ceases to exist. The best way for the user to recover from this is to start over from the start of the request process. - // skip this in case user is moving the transaction as the receipt path will be valid in that case - useEffect(() => { - let newReceiptFiles: Record = {}; - let isScanFilesCanBeRead = true; - - Promise.all( - transactions.map((item) => { - const itemReceiptFilename = item.receipt?.filename; - const itemReceiptPath = item.receipt?.source; - const itemReceiptType = item.receipt?.type; - const isLocalFile = isLocalFileFileUtils(itemReceiptPath); - - if (!isLocalFile) { - if (item.receipt) { - newReceiptFiles = {...newReceiptFiles, [item.transactionID]: item.receipt}; - } - return Promise.resolve(); - } - - const onSuccess = (file: File) => { - const receipt: Receipt = file; - if (item?.receipt?.isTestReceipt) { - receipt.isTestReceipt = true; - receipt.state = CONST.IOU.RECEIPT_STATE.SCAN_COMPLETE; - } else if (item?.receipt?.isTestDriveReceipt) { - receipt.isTestDriveReceipt = true; - receipt.state = CONST.IOU.RECEIPT_STATE.SCAN_COMPLETE; - } else { - receipt.state = file && requestType === CONST.IOU.REQUEST_TYPE.MANUAL ? CONST.IOU.RECEIPT_STATE.OPEN : CONST.IOU.RECEIPT_STATE.SCAN_READY; - } - - newReceiptFiles = {...newReceiptFiles, [item.transactionID]: receipt}; - }; - - const onFailure = () => { - isScanFilesCanBeRead = false; - if (initialTransactionID === item.transactionID) { - setMoneyRequestReceipt(item.transactionID, '', '', true, ''); - } - }; - - return validateReceiptFile(itemReceiptFilename, itemReceiptPath, itemReceiptType, onSuccess, onFailure) ?? Promise.resolve(); - }), - ).then(() => { - if (isScanFilesCanBeRead) { - setReceiptFiles(newReceiptFiles); - return; - } - if (requestType === CONST.IOU.REQUEST_TYPE.MANUAL) { - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, initialTransactionID, reportID, Navigation.getActiveRouteWithoutParams())); - return; - } - removeDraftTransactionsByIDs(draftTransactionIDs, true); - navigateToStartMoneyRequestStep(requestType, iouType, initialTransactionID, reportID); - }); - }, [requestType, iouType, initialTransactionID, reportID, action, report, transactions, participants]); - const requestMoney = useCallback( (selectedParticipants: Participant[], gpsPoint?: GpsPoint) => { if (!transactions.length) { @@ -714,7 +517,7 @@ function IOURequestStepConfirmation({ const isTestReceipt = receipt?.isTestReceipt ?? false; const isTestDriveReceipt = receipt?.isTestDriveReceipt ?? false; const isLinkedTrackedExpenseReportArchived = - !!item.linkedTrackedExpenseReportID && !!privateIsArchivedMap[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${item.linkedTrackedExpenseReportID}`]; + !!item.linkedTrackedExpenseReportID && privateIsArchivedMap[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${item.linkedTrackedExpenseReportID}`]; const itemAmount = isTestReceipt ? CONST.TEST_RECEIPT.AMOUNT : item.amount; const itemCurrency = isTestReceipt ? CONST.TEST_RECEIPT.CURRENCY : item.currency; @@ -727,6 +530,7 @@ function IOURequestStepConfirmation({ currentUserPersonalDetails.accountID, hasOutstandingChildTask, parentReportAction, + delegateEmail, false, ); } @@ -810,7 +614,7 @@ function IOURequestStepConfirmation({ isASAPSubmitBetaEnabled, currentUserAccountIDParam: currentUserPersonalDetails.accountID, currentUserEmailParam: currentUserPersonalDetails.email ?? '', - transactionViolations, + transactionViolations: transactionViolationsRef.current, policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], quickAction, existingTransactionDraft, @@ -845,7 +649,6 @@ function IOURequestStepConfirmation({ shouldGenerateTransactionThreadReport, backToReport, isASAPSubmitBetaEnabled, - transactionViolations, policyRecentlyUsedCurrencies, quickAction, isSelfTourViewed, @@ -854,6 +657,7 @@ function IOURequestStepConfirmation({ isViewTourTaskParentReportArchived, hasOutstandingChildTask, parentReportAction, + delegateEmail, translate, toLocaleDigit, betas, @@ -894,7 +698,20 @@ function IOURequestStepConfirmation({ quickAction, }); } else { - submitPerDiemExpenseIOUActions({ + const isExpenseReport = isMoneyRequestReport(report); + let existingChatReport = report; + if (isExpenseReport) { + existingChatReport = getReportOrDraftReport(report?.chatReportID); + } else if (!report?.reportID && participant.isPolicyExpenseChat && participant.reportID) { + existingChatReport = getReportOrDraftReport(participant.reportID); + } + const {optimisticChatReportID, chatReportID} = resolveOptimisticChatReportID( + [participant.accountID ?? CONST.DEFAULT_NUMBER_ID, currentUserPersonalDetails.accountID], + existingChatReport, + ); + const activeReportID = isExpenseReport && Navigation.getTopmostReportId() === report?.reportID ? report?.reportID : chatReportID; + + const result = submitPerDiemExpenseIOUActions({ report, participantParams: { payeeEmail: currentUserPersonalDetails.login, @@ -931,7 +748,16 @@ function IOURequestStepConfirmation({ quickAction, betas, personalDetails, + optimisticChatReportID, }); + if (result && activeReportID) { + navigateAfterExpenseCreate({ + activeReportID, + transactionID: transaction.transactionID, + isFromGlobalCreate: transaction.isFromFloatingActionButton ?? transaction.isFromGlobalCreate, + hasMultipleTransactions: reportTransactions.length > 0, + }); + } } }, [ @@ -952,6 +778,7 @@ function IOURequestStepConfirmation({ quickAction, betas, personalDetails, + reportTransactions.length, ], ); @@ -968,7 +795,7 @@ function IOURequestStepConfirmation({ } for (const [index, item] of transactions.entries()) { const isLinkedTrackedExpenseReportArchived = - !!item.linkedTrackedExpenseReportID && !!privateIsArchivedMap[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${item.linkedTrackedExpenseReportID}`]; + !!item.linkedTrackedExpenseReportID && privateIsArchivedMap[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${item.linkedTrackedExpenseReportID}`]; const itemDistance = isManualDistanceRequest || isOdometerDistanceRequest || isGPSDistanceRequest ? (item.comment?.customUnit?.quantity ?? undefined) : undefined; trackExpenseIOUActions({ @@ -1107,7 +934,7 @@ function IOURequestStepConfirmation({ }, backToReport, isASAPSubmitBetaEnabled, - transactionViolations, + transactionViolations: transactionViolationsRef.current, quickAction, policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], personalDetails, @@ -1138,7 +965,6 @@ function IOURequestStepConfirmation({ gpsDraftDetails, backToReport, isASAPSubmitBetaEnabled, - transactionViolations, quickAction, policyRecentlyUsedCurrencies, personalDetails, @@ -1174,45 +1000,6 @@ function IOURequestStepConfirmation({ formHasBeenSubmitted.current = true; - const hasReceiptFiles = Object.values(receiptFiles).some((receipt) => !!receipt); - const isFromGlobalCreate = transaction?.isFromGlobalCreate ?? transaction?.isFromFloatingActionButton ?? false; - - const scenario = getSubmitExpenseScenario({ - iouType, - isDistanceRequest, - isMovingTransactionFromTrackExpense, - isUnreported, - isCategorizingTrackExpense, - isSharingTrackExpense, - isPerDiemRequest, - isFromGlobalCreate, - hasReceiptFiles, - }); - - const submitSpanAttributes = { - [CONST.TELEMETRY.ATTRIBUTE_SCENARIO]: scenario, - [CONST.TELEMETRY.ATTRIBUTE_HAS_RECEIPT]: hasReceiptFiles, - [CONST.TELEMETRY.ATTRIBUTE_IS_FROM_GLOBAL_CREATE]: isFromGlobalCreate, - [CONST.TELEMETRY.ATTRIBUTE_IOU_TYPE]: iouType, - [CONST.TELEMETRY.ATTRIBUTE_IOU_REQUEST_TYPE]: requestType ?? 'unknown', - }; - - startSpan(CONST.TELEMETRY.SPAN_SUBMIT_EXPENSE, { - name: 'submit-expense', - op: CONST.TELEMETRY.SPAN_SUBMIT_EXPENSE, - attributes: submitSpanAttributes, - }); - - startSpan(CONST.TELEMETRY.SPAN_SUBMIT_TO_DESTINATION_VISIBLE, { - name: 'submit-to-destination-visible', - op: CONST.TELEMETRY.SPAN_SUBMIT_TO_DESTINATION_VISIBLE, - attributes: submitSpanAttributes, - }); - - // IMPORTANT: Every branch below must call markSubmitExpenseEnd() after dispatching the expense action. - // The submit follow-up action span above is ended by the target screen (ReportScreen, Search, etc.) or by runAfterInteractions for dismiss_modal_only. - // This ensures the telemetry span started above is always closed, including inside async getCurrentPosition callbacks. - // If missed, the impact is benign (an orphaned Sentry span), but it pollutes telemetry data. if (iouType !== CONST.IOU.TYPE.TRACK && isDistanceRequest && !isMovingTransactionFromTrackExpense && !isUnreported) { createDistanceRequest(iouType === CONST.IOU.TYPE.SPLIT ? splitParticipants : selectedParticipants, trimmedComment); markSubmitExpenseEnd(); @@ -1286,7 +1073,7 @@ function IOURequestStepConfirmation({ policyRecentlyUsedCategories, policyRecentlyUsedTags, isASAPSubmitBetaEnabled, - transactionViolations, + transactionViolations: transactionViolationsRef.current, quickAction, policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], betas, @@ -1321,7 +1108,7 @@ function IOURequestStepConfirmation({ policyRecentlyUsedCategories, policyRecentlyUsedTags, isASAPSubmitBetaEnabled, - transactionViolations, + transactionViolations: transactionViolationsRef.current, quickAction, policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], betas, @@ -1349,6 +1136,7 @@ function IOURequestStepConfirmation({ policyRecentlyUsedCategories, isFromGlobalCreate: transaction?.isFromFloatingActionButton ?? transaction?.isFromGlobalCreate, policyRecentlyUsedTags, + senderPolicyTags: senderWorkspacePolicyTags ?? {}, }); markSubmitExpenseEnd(); return; @@ -1441,17 +1229,16 @@ function IOURequestStepConfirmation({ policyRecentlyUsedTags, quickAction, isASAPSubmitBetaEnabled, - transactionViolations, existingInvoiceReport, policy, policyTags, + senderWorkspacePolicyTags, policyCategories, trackExpense, userLocation, submitPerDiemExpense, policyRecentlyUsedCurrencies, reportID, - requestType, betas, participantsPolicyTags, personalDetails, @@ -1471,38 +1258,31 @@ function IOURequestStepConfirmation({ return; } + const {optimisticChatReportID, chatReportID} = resolveOptimisticChatReportID([participant.accountID ?? CONST.DEFAULT_NUMBER_ID, currentUserPersonalDetails.accountID], report); + const sendMoneyParams = { + report, + quickAction, + amount: transaction.amount, + currency, + comment: trimmedComment, + currentUserAccountID: currentUserPersonalDetails.accountID, + recipient: participant, + created: transaction.created, + merchant: transaction.merchant, + receipt: receiptFiles[transaction.transactionID], + optimisticChatReportID, + }; + if (paymentMethod === CONST.IOU.PAYMENT_TYPE.ELSEWHERE) { setIsConfirmed(true); - sendMoneyElsewhere( - report, - quickAction, - transaction.amount, - currency, - trimmedComment, - currentUserPersonalDetails.accountID, - participant, - transaction.created, - transaction.merchant, - receiptFiles[transaction.transactionID], - ); - return; - } - - if (paymentMethod === CONST.IOU.PAYMENT_TYPE.EXPENSIFY) { + sendMoneyElsewhere(sendMoneyParams); + } else if (paymentMethod === CONST.IOU.PAYMENT_TYPE.EXPENSIFY) { setIsConfirmed(true); - sendMoneyWithWallet( - report, - quickAction, - transaction.amount, - currency, - trimmedComment, - currentUserPersonalDetails.accountID, - participant, - transaction.created, - transaction.merchant, - receiptFiles[transaction.transactionID], - ); + sendMoneyWithWallet(sendMoneyParams); + } else { + return; } + dismissModalAndOpenReportInInboxTabHelper(chatReportID, undefined, reportTransactions.length > 0); }, [ transaction?.currency, @@ -1516,6 +1296,7 @@ function IOURequestStepConfirmation({ currentUserPersonalDetails.accountID, receiptFiles, quickAction, + reportTransactions.length, ], ); @@ -1537,6 +1318,40 @@ function IOURequestStepConfirmation({ // To prevent the component from rendering with the wrong currency, we show a loading indicator until the correct currency is set. const isLoading = !!transaction?.originalCurrency; + const startSubmitSpans = () => { + const hasReceiptFiles = Object.values(receiptFiles).some((receipt) => !!receipt); + // Re-derive from transaction inside the callback so telemetry captures the value + // at submission time, not at render time (transaction is mutable Onyx state). + const isFromGlobalCreateForTelemetry = !!(transaction?.isFromGlobalCreate ?? transaction?.isFromFloatingActionButton); + const scenario = getSubmitExpenseScenario({ + iouType, + isDistanceRequest, + isMovingTransactionFromTrackExpense, + isUnreported, + isCategorizingTrackExpense, + isSharingTrackExpense, + isPerDiemRequest, + isFromGlobalCreate: isFromGlobalCreateForTelemetry, + hasReceiptFiles, + }); + const submitSpanAttributes = { + [CONST.TELEMETRY.ATTRIBUTE_SCENARIO]: scenario, + [CONST.TELEMETRY.ATTRIBUTE_HAS_RECEIPT]: hasReceiptFiles, + [CONST.TELEMETRY.ATTRIBUTE_IS_FROM_GLOBAL_CREATE]: isFromGlobalCreateForTelemetry, + [CONST.TELEMETRY.ATTRIBUTE_IOU_TYPE]: iouType, + [CONST.TELEMETRY.ATTRIBUTE_IOU_REQUEST_TYPE]: requestType ?? 'unknown', + }; + + startSpan(CONST.TELEMETRY.SPAN_SUBMIT_EXPENSE, { + name: 'submit-expense', + op: CONST.TELEMETRY.SPAN_SUBMIT_EXPENSE, + })?.setAttributes(submitSpanAttributes); + startSpan(CONST.TELEMETRY.SPAN_SUBMIT_TO_DESTINATION_VISIBLE, { + name: 'submit-to-destination-visible', + op: CONST.TELEMETRY.SPAN_SUBMIT_TO_DESTINATION_VISIBLE, + })?.setAttributes(submitSpanAttributes); + }; + const onConfirm = (listOfParticipants: Participant[]) => { setIsConfirming(true); setSelectedParticipantList(listOfParticipants); @@ -1553,13 +1368,31 @@ function IOURequestStepConfirmation({ } } - requestAnimationFrame(() => { - createTransaction(listOfParticipants); - // Keep the pre-submit loading state visible for one more paint so the spinner appears before navigation work starts. + startSubmitSpans(); + + // Fast path: the Search page was pre-inserted under the RHP (see useEffect above). + // Dismiss the RHP immediately so the user sees the Search page, then run the + // heavy createTransaction work in the next frame - "dismiss first, compute later". + // Reserve the deferred write channel synchronously so that the Search component + // always sees hasDeferredWrite=true on mount (on iOS, rAF fires after + // startTransition resolves, so without the reservation Search would mount first). + if (Navigation.getIsFullscreenPreInsertedUnderRHP()) { + setPendingSubmitFollowUpAction(CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.NAVIGATE_TO_SEARCH); + Navigation.clearFullscreenPreInsertedFlag(); + reserveDeferredWriteChannel(CONST.DEFERRED_LAYOUT_WRITE_KEYS.SEARCH); + Navigation.dismissModal(); requestAnimationFrame(() => { + createTransaction(listOfParticipants); setIsConfirming(false); }); - }); + } else { + requestAnimationFrame(() => { + createTransaction(listOfParticipants); + requestAnimationFrame(() => { + setIsConfirming(false); + }); + }); + } }; /** @@ -1640,6 +1473,61 @@ function IOURequestStepConfirmation({ shouldAvoidScrollOnVirtualViewport={!isMobileSafari()} testID="IOURequestStepConfirmation" > + + + + + + + setStartLocationPermissionFlow(false)} onGrant={() => { + startSubmitSpans(); navigateAfterInteraction(() => { createTransaction(selectedParticipantList, true); }); }} onDeny={() => { + startSubmitSpans(); updateLastLocationPermissionPrompt(); navigateAfterInteraction(() => { createTransaction(selectedParticipantList, false); @@ -1700,13 +1590,7 @@ function IOURequestStepConfirmation({ { @@ -1719,25 +1603,17 @@ function IOURequestStepConfirmation({ shouldDisplayReceipt={!isMovingTransactionFromTrackExpense && (!isDistanceRequest || isManualDistanceRequest || isOdometerDistanceRequest) && !isPerDiemRequest} isPolicyExpenseChat={isPolicyExpenseChat} policyID={policyID} - iouMerchant={transaction?.merchant} - iouCreated={transaction?.created} - isDistanceRequest={isDistanceRequest} - isManualDistanceRequest={isManualDistanceRequest} isOdometerDistanceRequest={isOdometerDistanceRequest} isLoadingReceipt={isStitchingReceipt} - isGPSDistanceRequest={isGPSDistanceRequest} isPerDiemRequest={isPerDiemRequest} shouldShowSmartScanFields={shouldShowSmartScanFields} action={action} isConfirmed={isConfirmed} isConfirming={isConfirming} - iouIsReimbursable={transaction?.reimbursable} onToggleReimbursable={setReimbursable} expensesNumber={transactions.length} isReceiptEditable isTimeRequest={isTimeRequest} - iouTimeCount={transaction?.comment?.units?.count} - iouTimeRate={transaction?.comment?.units?.rate} shouldHideToSection={shouldHideToSection} /> diff --git a/src/pages/iou/request/step/IOURequestStepDate.tsx b/src/pages/iou/request/step/IOURequestStepDate.tsx index 651abdb747b7..2fef1e3e15dd 100644 --- a/src/pages/iou/request/step/IOURequestStepDate.tsx +++ b/src/pages/iou/request/step/IOURequestStepDate.tsx @@ -18,8 +18,9 @@ import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {shouldUseTransactionDraft} from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getFormattedCreated, hasReceipt} from '@libs/TransactionUtils'; -import {setMoneyRequestCreated, updateMoneyRequestDate} from '@userActions/IOU'; +import {setMoneyRequestCreated} from '@userActions/IOU'; import {setDraftSplitTransaction} from '@userActions/IOU/Split'; +import {updateMoneyRequestDate} from '@userActions/IOU/UpdateMoneyRequest'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; diff --git a/src/pages/iou/request/step/IOURequestStepDescription.tsx b/src/pages/iou/request/step/IOURequestStepDescription.tsx index c3b8a1dea85f..11a9a4d4e718 100644 --- a/src/pages/iou/request/step/IOURequestStepDescription.tsx +++ b/src/pages/iou/request/step/IOURequestStepDescription.tsx @@ -25,8 +25,9 @@ import Navigation from '@libs/Navigation/Navigation'; import Parser from '@libs/Parser'; import {hasReceipt} from '@libs/TransactionUtils'; import variables from '@styles/variables'; -import {setMoneyRequestDescription, updateMoneyRequestDescription} from '@userActions/IOU'; +import {setMoneyRequestDescription} from '@userActions/IOU'; import {setDraftSplitTransaction} from '@userActions/IOU/Split'; +import {updateMoneyRequestDescription} from '@userActions/IOU/UpdateMoneyRequest'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; diff --git a/src/pages/iou/request/step/IOURequestStepDestination.tsx b/src/pages/iou/request/step/IOURequestStepDestination.tsx index 9de68627f749..025f2e0224ae 100644 --- a/src/pages/iou/request/step/IOURequestStepDestination.tsx +++ b/src/pages/iou/request/step/IOURequestStepDestination.tsx @@ -67,6 +67,7 @@ function IOURequestStepDestination({ const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [userBillingGracePeriodEnds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); + const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); const reportPolicyID = getIOURequestPolicyID(transaction, report); const policyID = reportPolicyID === CONST.POLICY.ID_FAKE ? getPolicyByCustomUnitID(transaction, allPolicies)?.id : reportPolicyID; const [policy, policyMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${explicitPolicyID ?? policyID}`); @@ -108,7 +109,7 @@ function IOURequestStepDestination({ }; const updateDestination = (destination: ListItem & {currency: string}) => { - if (openedFromStartPage && policy?.id && shouldRestrictUserBillableActions(policy.id, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds)) { + if (openedFromStartPage && policy?.id && shouldRestrictUserBillableActions(policy.id, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed)) { Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); return; } diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx index 29a7df183a2e..aa54fddfa958 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx @@ -15,6 +15,7 @@ import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalD import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; import useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy'; import useFetchRoute from '@hooks/useFetchRoute'; +import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; @@ -24,13 +25,15 @@ import usePolicy from '@hooks/usePolicy'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import usePrevious from '@hooks/usePrevious'; import useReportAttributes from '@hooks/useReportAttributes'; +import useReportIsArchived from '@hooks/useReportIsArchived'; import useSelfDMReport from '@hooks/useSelfDMReport'; import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep'; import useThemeStyles from '@hooks/useThemeStyles'; import useWaypointItems from '@hooks/useWaypointItems'; -import {setMoneyRequestAmount, updateMoneyRequestDistance} from '@libs/actions/IOU'; +import {setMoneyRequestAmount} from '@libs/actions/IOU'; import {handleMoneyRequestStepDistanceNavigation} from '@libs/actions/IOU/MoneyRequest'; import {setDraftSplitTransaction, setSplitShares} from '@libs/actions/IOU/Split'; +import {updateMoneyRequestDistance} from '@libs/actions/IOU/UpdateMoneyRequest'; import {init, stop} from '@libs/actions/MapboxToken'; import {openReport} from '@libs/actions/Report'; import {openDraftDistanceExpense, removeWaypoint, updateWaypoints as updateWaypointsUtil} from '@libs/actions/Transaction'; @@ -41,7 +44,7 @@ import {getLatestErrorField} from '@libs/ErrorUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {shouldUseTransactionDraft} from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; -import {isArchivedReport, isPolicyExpenseChat as isPolicyExpenseChatUtil} from '@libs/ReportUtils'; +import {isPolicyExpenseChat as isPolicyExpenseChatUtil} from '@libs/ReportUtils'; import {getDistanceInMeters, getRateID, getRequestType, hasRoute, isCustomUnitRateIDForP2P, isWaypointNullIsland} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import type {IOUType} from '@src/CONST'; @@ -76,11 +79,11 @@ function IOURequestStepDistance({ currentUserPersonalDetails, }: IOURequestStepDistanceProps) { const styles = useThemeStyles(); + const isInLandscapeMode = useIsInLandscapeMode(); const {isOffline} = useNetwork(); const {translate} = useLocalize(); const {isBetaEnabled} = usePermissions(); - const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`); - const isArchived = isArchivedReport(reportNameValuePairs); + const isArchived = useReportIsArchived(report?.reportID); const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.parentReportID)}`); const [parentReportNextStep] = useOnyx(`${ONYXKEYS.COLLECTION.NEXT_STEP}${getNonEmptyStringOnyxID(report?.parentReportID)}`); @@ -108,6 +111,7 @@ function IOURequestStepDistance({ const [betas] = useOnyx(ONYXKEYS.BETAS); const [draftTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector}); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const isEditing = action === CONST.IOU.ACTION.EDIT; const isEditingSplit = (iouType === CONST.IOU.TYPE.SPLIT || iouType === CONST.IOU.TYPE.SPLIT_EXPENSE) && isEditing; @@ -315,7 +319,6 @@ function IOURequestStepDistance({ reportAttributesDerived, personalDetails, waypoints, - customUnitRateID, currentUserLogin: currentUserEmailParam, currentUserAccountID: currentUserAccountIDParam, backTo, @@ -333,7 +336,7 @@ function IOURequestStepDistance({ policyRecentlyUsedCurrencies, introSelected, activePolicyID, - privateIsArchived: reportNameValuePairs?.private_isArchived, + privateIsArchived: isArchived, selfDMReport, policyForMovingExpenses, betas, @@ -343,6 +346,7 @@ function IOURequestStepDistance({ amountOwed, userBillingGracePeriodEnds, ownerBillingGracePeriodEnd, + conciergeReportID, }); }, [ iouType, @@ -354,7 +358,6 @@ function IOURequestStepDistance({ reportAttributesDerived, personalDetails, waypoints, - customUnitRateID, currentUserEmailParam, currentUserAccountIDParam, backTo, @@ -372,7 +375,6 @@ function IOURequestStepDistance({ policyRecentlyUsedCurrencies, introSelected, activePolicyID, - reportNameValuePairs?.private_isArchived, policyForMovingExpenses, selfDMReport, betas, @@ -382,6 +384,7 @@ function IOURequestStepDistance({ amountOwed, userBillingGracePeriodEnds, ownerBillingGracePeriodEnd, + conciergeReportID, ]); const getError = () => { @@ -544,8 +547,19 @@ function IOURequestStepDistance({ shouldShowNotFoundPage={(isEditing && !currentTransaction?.comment?.waypoints) || shouldShowNotFoundPage} shouldShowWrapper={!isCreatingNewRequest} > - <> - + + {isInLandscapeMode && ( + + + + )} + + !isInLandscapeMode ? ( + + ) : undefined } /> - - - {/* Show error message if there is route error or there are less than 2 routes and user has tried submitting, */} - {((shouldShowAtLeastTwoDifferentWaypointsError && atLeastTwoDifferentWaypointsError) || duplicateWaypointsError || hasRouteError) && ( - + {/* Show error message if there is route error or there are less than 2 routes and user has tried submitting, */} + {((shouldShowAtLeastTwoDifferentWaypointsError && atLeastTwoDifferentWaypointsError) || duplicateWaypointsError || hasRouteError) && ( + + )} +