diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index c1238d6805aa..459a780ca8b4 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -51,7 +51,10 @@ For example: 1. Click on the text input to bring it into focus 2. Upload an image via copy paste 3. Verify a modal appears displaying a preview of that image + +It's acceptable to write "Same as tests" if the QA team is able to run the tests in the above "Tests" section. ---> +// TODO: These must be filled out, or the issue title must include "[No QA]." - [ ] Verify that no errors appear in the JS console diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 415d7b36c4cb..d578621930a7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -825,7 +825,7 @@ jobs: "./ios-build-artifact/New Expensify.ipa#ios.ipa" "./ios-hybrid-build-artifact/Expensify.ipa#ios-hybrid.ipa" "./ios-hybrid-sourcemap-artifact/main.jsbundle.map#ios-hybrid-sourcemap.js.map" - "./web-staging-sourcemaps-artifact/web-staging-sourcemap.js.map#web-staging-sourcemap.js.map" + "./web-staging-sourcemaps-artifact/web-staging-merged-source-map.js.map#web-staging-sourcemap.js.map" "./web-staging-build-tar-gz-artifact/webBuild.tar.gz#web-staging.tar.gz" "./web-staging-build-zip-artifact/webBuild.zip#web-staging.zip" ) diff --git a/.well-known/assetlinks.json b/.well-known/assetlinks.json index a28004b45b08..803946f438d6 100644 --- a/.well-known/assetlinks.json +++ b/.well-known/assetlinks.json @@ -5,4 +5,12 @@ "package_name": "com.expensify.chat", "sha256_cert_fingerprints": ["2E:65:6F:1C:34:F5:7E:BF:FC:C0:2D:A3:14:0E:83:FE:61:51:F2:9B:5D:59:58:61:C4:4D:A9:99:0C:CA:F4:8E"] } -}] \ No newline at end of file + }, + { + "relation": ["delegate_permission/common.handle_all_urls"], + "target": { + "namespace": "android_app", + "package_name": "org.me.mobiexpensifyg", + "sha256_cert_fingerprints": ["87:03:DC:2B:20:99:CB:F7:AF:39:0C:8F:F2:E4:78:F2:61:E9:D1:7E:F4:AF:E5:02:D9:72:F2:4D:1F:29:FF:65"] + } +}] diff --git a/README.md b/README.md index 730e745e368a..6b75fbed1b2c 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ * [Expensify Code of Conduct](CODE_OF_CONDUCT.md) * [Contributor License Agreement](contributingGuides/CLA.md) * [React StrictMode](contributingGuides/STRICT_MODE.md) +* [Left Hand Navigation(LHN)](contributingGuides/LEFT_HAND_NAVIGATION.md) ---- diff --git a/android/app/build.gradle b/android/app/build.gradle index 4de38c169882..64e6ec12309b 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -110,8 +110,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009005703 - versionName "9.0.57-3" + versionCode 1009005900 + versionName "9.0.59-0" // 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/assets/images/simple-illustrations/simple-illustration__perdiem.svg b/assets/images/simple-illustrations/simple-illustration__perdiem.svg new file mode 100644 index 000000000000..ea5a865a2694 --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__perdiem.svg @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/contributingGuides/LEFT_HAND_NAVIGATION.md b/contributingGuides/LEFT_HAND_NAVIGATION.md new file mode 100644 index 000000000000..344b0c4f8ecf --- /dev/null +++ b/contributingGuides/LEFT_HAND_NAVIGATION.md @@ -0,0 +1,44 @@ +## OVERVIEW + +The Left Hand Navigation (LHN) is designed to show different types of reports based on their status, user settings, and specific conditions. Each report type has unique visual indicators and sorting rules to help users quickly identify and prioritize their tasks. + +### Types of report displayed in the LHN + +The following outlines the expected behavior regarding which reports are displayed in the LHN: + +- The report currently being viewed by the user is highlighted as the active report in the LHN, making it easy for users to locate their focus point within the navigation. +If a report has unresolved issues, like an unapproved expense or outstanding violations, it will display a red dot next to it, indicating urgent action is required. These reports are displayed at the top of the LHN list (under pinned chats) and sorted alphabetically by report name for easy access. +- Reports that need user action, such as responding to a message that mentions them, completing an assigned task, or addressing an expense, will display a green dot next to them. Additionally, if a system or concierge message indicates a trial period has expired and a payment method is missing, it will prompt the user with a similar green dot. This visual indicator helps users quickly identify where their attention is required. +- If a user has started drafting a comment in a report, a pencil icon as indicator appears next to it in the LHN, letting users know there is an incomplete draft. These reports are sorted alphabetically by report name. +- Pinned reports are always displayed at the top of the LHN list and are sorted alphabetically by name, giving quick access to reports the user wants to keep top-of-mind. +- When the user has focus mode enabled, unread chat messages will display in bold in the LHN. This also applies to reports where notifications are hidden. Unread chats in focus mode are sorted alphabetically by report name to help users locate them more easily. +- Archived reports are displayed in the LHN when the user is in default mode. These reports are shown with an indication that they are archived and are sorted by the date of the last visible action, with the most recent appearing first. +- Self-DM messages will now be displayed in LHN. This allows users who want to track their own notes or messages in the LHN to do so without needing to look elsewhere. + +### Types of report excluded from the LHN + +Certain reports are excluded from the LHN to avoid clutter and to focus on relevant content for the user: + +- Reports that are explicitly marked as hidden. +- Reports with no participants are not displayed, as they lack meaningful content. +- If the user does not have permission to access a report (due to policy restrictions), it will not be shown. +- Transaction threads that contain only one transaction are excluded. +- If a report is an empty chat, unless it's a report user is actively looking at. +- For users with domain-based email addresses, reports are hidden if the includeDomainEmail setting is disabled. +- Reports with a parent message pending deletion. +- When focus mode is enabled and there are no unread messages. + +### Sorting priorities for displayed report groups + +1. Pinned, RBR and attention-required (GBR) reports: + - Always sorted alphabetically by report name. +2. Error reports: + - Sorted alphabetically by report name. +3. Draft reports: + - Sorted alphabetically by report name. +4. Non-Archived reports: + - In default mode, these are sorted by the lastVisibleActionCreated date, so the most recently updated reports appear first. + - In focus mode, these reports are sorted alphabetically by name for quicker navigation. +5. Archived eports: + - In default mode, these are sorted by lastVisibleActionCreated, with recent reports displayed first. + - In focus mode, archived reports are sorted alphabetically by name. diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 75cb080f1349..926fb1e24d22 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -9,7 +9,7 @@ "dependencies": { "electron-context-menu": "^2.3.0", "electron-log": "^4.4.8", - "electron-updater": "^6.3.8", + "electron-updater": "^6.3.9", "mime-types": "^2.1.35", "node-machine-id": "^1.1.12" }, @@ -59,9 +59,9 @@ } }, "node_modules/builder-util-runtime": { - "version": "9.2.9", - "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.9.tgz", - "integrity": "sha512-DWeHdrRFVvNnVyD4+vMztRpXegOGaQHodsAjyhstTbUNBIjebxM1ahxokQL+T1v8vpW8SY7aJ5is/zILH82lAw==", + "version": "9.2.10", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.10.tgz", + "integrity": "sha512-6p/gfG1RJSQeIbz8TK5aPNkoztgY1q5TgmGFMAXcY8itsGW6Y2ld1ALsZ5UJn8rog7hKF3zHx5iQbNQ8uLcRlw==", "license": "MIT", "dependencies": { "debug": "^4.3.4", @@ -156,12 +156,12 @@ "integrity": "sha512-QQ4GvrXO+HkgqqEOYbi+DHL7hj5JM+nHi/j+qrN9zeeXVKy8ZABgbu4CnG+BBqDZ2+tbeq9tUC4DZfIWFU5AZA==" }, "node_modules/electron-updater": { - "version": "6.3.8", - "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.3.8.tgz", - "integrity": "sha512-OFsA2vyuOZgsqq4EW6tgW8X8e521ybDmQyIYLqss7HdXev+Ak90YatzpIECOBJXpmro5YDq4yZ2xFsKXqPt1DQ==", + "version": "6.3.9", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.3.9.tgz", + "integrity": "sha512-2PJNONi+iBidkoC5D1nzT9XqsE8Q1X28Fn6xRQhO3YX8qRRyJ3mkV4F1aQsuRnYPqq6Hw+E51y27W75WgDoofw==", "license": "MIT", "dependencies": { - "builder-util-runtime": "9.2.9", + "builder-util-runtime": "9.2.10", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", @@ -469,9 +469,9 @@ "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==" }, "builder-util-runtime": { - "version": "9.2.9", - "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.9.tgz", - "integrity": "sha512-DWeHdrRFVvNnVyD4+vMztRpXegOGaQHodsAjyhstTbUNBIjebxM1ahxokQL+T1v8vpW8SY7aJ5is/zILH82lAw==", + "version": "9.2.10", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.10.tgz", + "integrity": "sha512-6p/gfG1RJSQeIbz8TK5aPNkoztgY1q5TgmGFMAXcY8itsGW6Y2ld1ALsZ5UJn8rog7hKF3zHx5iQbNQ8uLcRlw==", "requires": { "debug": "^4.3.4", "sax": "^1.2.4" @@ -538,11 +538,11 @@ "integrity": "sha512-QQ4GvrXO+HkgqqEOYbi+DHL7hj5JM+nHi/j+qrN9zeeXVKy8ZABgbu4CnG+BBqDZ2+tbeq9tUC4DZfIWFU5AZA==" }, "electron-updater": { - "version": "6.3.8", - "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.3.8.tgz", - "integrity": "sha512-OFsA2vyuOZgsqq4EW6tgW8X8e521ybDmQyIYLqss7HdXev+Ak90YatzpIECOBJXpmro5YDq4yZ2xFsKXqPt1DQ==", + "version": "6.3.9", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.3.9.tgz", + "integrity": "sha512-2PJNONi+iBidkoC5D1nzT9XqsE8Q1X28Fn6xRQhO3YX8qRRyJ3mkV4F1aQsuRnYPqq6Hw+E51y27W75WgDoofw==", "requires": { - "builder-util-runtime": "9.2.9", + "builder-util-runtime": "9.2.10", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", diff --git a/desktop/package.json b/desktop/package.json index 326d6f24f740..ac66df7e9aed 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -6,7 +6,7 @@ "dependencies": { "electron-context-menu": "^2.3.0", "electron-log": "^4.4.8", - "electron-updater": "^6.3.8", + "electron-updater": "^6.3.9", "mime-types": "^2.1.35", "node-machine-id": "^1.1.12" }, diff --git a/docs/Gemfile.lock b/docs/Gemfile.lock index 4c492ae1e251..9932c5f85699 100644 --- a/docs/Gemfile.lock +++ b/docs/Gemfile.lock @@ -1,70 +1,85 @@ GEM remote: https://rubygems.org/ specs: - addressable (2.8.0) - public_suffix (>= 2.0.2, < 5.0) + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) + bigdecimal (3.1.8) colorator (1.1.0) - concurrent-ruby (1.1.10) + concurrent-ruby (1.3.4) em-websocket (0.5.3) eventmachine (>= 0.12.9) http_parser.rb (~> 0) eventmachine (1.2.7) - ffi (1.15.5) + ffi (1.17.0-arm64-darwin) + ffi (1.17.0-x86_64-darwin) forwardable-extended (2.6.0) + google-protobuf (4.28.3-arm64-darwin) + bigdecimal + rake (>= 13) + google-protobuf (4.28.3-x86_64-darwin) + bigdecimal + rake (>= 13) http_parser.rb (0.8.0) - i18n (0.9.5) + i18n (1.14.6) concurrent-ruby (~> 1.0) - jekyll (3.9.3) + jekyll (4.3.4) addressable (~> 2.4) colorator (~> 1.0) em-websocket (~> 0.5) - i18n (>= 0.7, < 2) - jekyll-sass-converter (~> 1.0) + i18n (~> 1.0) + jekyll-sass-converter (>= 2.0, < 4.0) jekyll-watch (~> 2.0) - kramdown (>= 1.17, < 3) + kramdown (~> 2.3, >= 2.3.1) + kramdown-parser-gfm (~> 1.0) liquid (~> 4.0) - mercenary (~> 0.3.3) + mercenary (>= 0.3.6, < 0.5) pathutil (~> 0.9) - rouge (>= 1.7, < 4) + rouge (>= 3.0, < 5.0) safe_yaml (~> 1.0) - jekyll-feed (0.15.1) + terminal-table (>= 1.8, < 4.0) + webrick (~> 1.7) + jekyll-feed (0.17.0) jekyll (>= 3.7, < 5.0) jekyll-redirect-from (0.16.0) jekyll (>= 3.3, < 5.0) - jekyll-sass-converter (1.5.2) - sass (~> 3.4) + jekyll-sass-converter (3.0.0) + sass-embedded (~> 1.54) jekyll-seo-tag (2.8.0) jekyll (>= 3.8, < 5.0) jekyll-watch (2.2.1) listen (~> 3.0) - kramdown (2.3.2) + kramdown (2.4.0) rexml kramdown-parser-gfm (1.1.0) kramdown (~> 2.0) liquid (4.0.4) - listen (3.7.1) + listen (3.9.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) - mercenary (0.3.6) + mercenary (0.4.0) pathutil (0.16.2) forwardable-extended (~> 2.6) - public_suffix (4.0.7) - rb-fsevent (0.11.1) - rb-inotify (0.10.1) + public_suffix (6.0.1) + rake (13.2.1) + rb-fsevent (0.11.2) + rb-inotify (0.11.1) ffi (~> 1.0) - rexml (3.2.5) - rouge (3.26.0) + rexml (3.3.9) + rouge (4.4.0) safe_yaml (1.0.5) - sass (3.7.4) - sass-listen (~> 4.0.0) - sass-listen (4.0.0) - rb-fsevent (~> 0.9, >= 0.9.4) - rb-inotify (~> 0.9, >= 0.9.7) - webrick (1.7.0) + sass-embedded (1.80.6-arm64-darwin) + google-protobuf (~> 4.28) + sass-embedded (1.80.6-x86_64-darwin) + google-protobuf (~> 4.28) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + unicode-display_width (2.6.0) + webrick (1.9.0) PLATFORMS arm64-darwin-22 arm64-darwin-23 + arm64-darwin-24 x86_64-darwin-20 x86_64-darwin-21 diff --git a/docs/Hidden/Expensify-Lounge.md b/docs/Hidden/Expensify-Lounge.md new file mode 100644 index 000000000000..716040ba2078 --- /dev/null +++ b/docs/Hidden/Expensify-Lounge.md @@ -0,0 +1,66 @@ +--- +title: Expensify Lounge +description: Explore the Expensify Lounge - A stylish space to work, relax, and connect. +--- + +The Expensify Lounge is a place where people come to Get Shit Done. With beautiful surroundings, great coffee, and a collaborative community, it's the perfect environment to fuel productivity. Check out this guide on how to make the most of the Expensify Lounge! + +# The Two Rules + +## Rule #1 - Get Shit Done +The Lounge is designed to help you focus, collaborate, and bring your boldest ideas to life. To keep this environment productive, we ask our members to remember: + +- **#focus** - Use the space as it’s intended, without disrupting others. The Lounge is social and collaborative but ultimately meant to support productive work. +- **#urgency** - Remote work is fantastic, but face-to-face collaboration is unmatched. Use the Lounge to meet co-workers in person and drive your projects forward. +- **#results** - Don’t confuse time spent with effort or effort with results. Visualize what you want to accomplish and don’t leave until it’s done. + +## Rule #2 - Don’t Ruin It for Everyone Else +We want the Lounge to be an incredible, ever-evolving space. To achieve this, please follow these guidelines: + +- **#writeitdown** - If you can share knowledge, do it! Write a blog post, document, or post in Expensify Chat to help others learn from your experience. Suggestions to improve the Lounge are always welcome. +- **#showup** - Be fully present when you’re here. Engage with others and collaborate in social spaces. This is a community built to get shit done; the more you contribute, the more you gain. +- **#oneteam** - Inclusivity is a priority. We do not tolerate any form of discrimination. Make an effort to include those who want to join. +- **#nocreeps** - Don’t make others feel uncomfortable with your words or actions. If you feel uncomfortable or notice it happening to someone else, use the escalation process in the FAQ. + +--- + +# How to Use the Expensify Lounge +With these two rules in mind, here’s how to get the most from the Lounge: + +## Rule #1 - Getting Shit Done +- **Order drinks from Concierge** - Contact Concierge here to ask questions or order beverages, and they’ll deliver your order to you. +- **Using an office** - Offices are first-come, first-serve, and ideal for brief calls or meetings. Please keep usage to under an hour. Offices cannot be reserved. +- **Lounge hours** - The Lounge is open from 8am-6pm PT, Monday through Friday, and closed on some major holidays. Check our Google Maps profile for holiday hours. +- **Suggest improvements** - Post any ideas to enhance the Lounge experience in #announce - Expensify Lounge. + +## Rule #2 - Not Ruining It for Everyone Else +- **Offices are for calls** - Only use an office if you have a call or meeting, and try to keep it under an hour. +- **Respect others** - Avoid being too loud or distracting while others work. When collaborating in Expensify Chat, be respectful and maintain a positive environment. +- **Stay home if you’re sick** - If you’re feeling unwell, please skip the Lounge or wear a mask in public areas. +- **If you see something, say something** - If you feel uncomfortable or notice others in discomfort, notify Concierge. In Expensify Chat, you can also use our moderation tools (outlined in the FAQ). + +We’re thrilled to have you here to live richly, have fun, and help save the world with us. Now, go enjoy the Expensify Lounge, and let’s Get Shit Done! + +--- + +{% include faq-begin.md %} + +## What is Concierge? +Concierge is our automated system that answers member questions in real-time. Local lounge questions are routed to the Lounge’s Concierge. Message Concierge for drink requests or general inquiries—they’ll handle it for you! + +## Who is invited to the Expensify Lounge? +Everyone is invited! Whether you’re a current customer or just need a productive space, we’d love to have you. + +## How do I escalate something that’s making me or someone else uncomfortable? +In Expensify Chat, use the escalation feature to flag messages as: + +- **Spam or Inconsiderate**: This sends a whisper to the sender and flags the message. These flags are visible to all users but not reviewed by Concierge. +- **Intimidating or Bullying**: The message is hidden and reviewed. If confirmed, it will remain hidden, and we’ll communicate the violation to the sender. +- **Harassment or Assault**: The message is hidden immediately, and our team reviews it. The sender receives a warning, and Concierge may block the user if needed. + +In person, please notify Concierge with your lounge location, and they’ll escalate the issue accordingly. + +## Where are other Expensify Lounge locations? +Currently, we only have the San Francisco Lounge, but stay tuned for more locations coming soon! +{% include faq-end.md %} + diff --git a/docs/_includes/footer.html b/docs/_includes/footer.html index 798fb2cf7e96..c7b55b28cfd5 100644 --- a/docs/_includes/footer.html +++ b/docs/_includes/footer.html @@ -50,10 +50,10 @@

Resources

ExpensifyHelp
  • - Community + Terms of Service
  • - Privacy + Privacy
  • diff --git a/docs/articles/expensify-classic/connections/quickbooks-online/Quickbooks-Online-Troubleshooting.md b/docs/articles/expensify-classic/connections/quickbooks-online/Quickbooks-Online-Troubleshooting.md index 6a43141f1ab9..a397e34accb0 100644 --- a/docs/articles/expensify-classic/connections/quickbooks-online/Quickbooks-Online-Troubleshooting.md +++ b/docs/articles/expensify-classic/connections/quickbooks-online/Quickbooks-Online-Troubleshooting.md @@ -18,7 +18,7 @@ This error occurs when the account applied as a category to the expense in Expen 5. Click on the pencil icon on the right to check if you have "In multiple accounts" selected: 6. If "In multiple accounts" is selected, go to Chart of Accounts and click Edit for the account in question. 7. Check the billable option and select an income account within your chart of accounts -8. Sync your QuickBooks Online connection in Settings > Workspaces > [workspace name] > Connections. +8. Sync your QuickBooks Online connection in Settings > Workspaces > Workspace Name > Connections. 9. Open the report and click the Export to button and then the QuickBooks Online option. # ExpensiError QBO046: Feature Not Included in Subscription @@ -44,11 +44,11 @@ QuickBooks Online requires all expenses exported from Expensify to use a categor ## How to fix it -1. Sync your QuickBooks Online connection in Expensify from Settings > Workspaces > [workspace name] > Connections, and click the **Sync Now** button. +1. Sync your QuickBooks Online connection in Expensify from Settings > Workspaces > Workspace Name > Connections, and click the **Sync Now** button. 2. Review the expenses on the report. If any appear with a red _Category no longer valid_ violation, recategorize the expense until all expenses are violation-free. 3. Click the **Export t**o button and then the **QuickBooks Online** option. - If you receive the same error, continue. -4. Note the categories used on the expenses and check the Settings > Workspaces > [workspace name] > Categories page to confirm the exact categories used on the report are enabled and connected to QuickBooks Online (you'll see a green QB icon next to all connected categories). +4. Note the categories used on the expenses and check the Settings > Workspaces > Workspace Name > Categories page to confirm the exact categories used on the report are enabled and connected to QuickBooks Online (you'll see a green QB icon next to all connected categories). 5. Confirm the categories used on the expenses in the report match exactly the accounts in your QuickBooks Online chart of accounts. 6. If you make any changes in QuickBooks Online or in Expensify, always sync the connection and then try to export again. @@ -67,13 +67,13 @@ There are two different ways you can resolve this error. 1. Log into QuickBooks Online. 2. Access the Employee Records for your submitters. 3. Edit the name to differentiate them from the name they have on their account in Expensify. -4. Sync your QuickBooks Online connection in Settings > Workspaces > [workspace name] > Connections. +4. Sync your QuickBooks Online connection in Settings > Workspaces > Workspace Name > Connections. 5. Open the report and click the Export to button and then the QuickBooks Online option. **Option 2**: 1. Log into QuickBooks Online. 2. Manually create all of your Vendor Records, making sure that the email matches the email address associated with the user in Expensify. - - In this case, we recommend disabling _Automatically Create Entities_ under Settings > Workspaces > [workspace name] > Connections > Configure > Advanced, so that you will receive the correct error messages when a vendor record doesn't exist. + - In this case, we recommend disabling _Automatically Create Entities_ under Settings > Workspaces > Workspace Name > Connections > Configure > Advanced, so that you will receive the correct error messages when a vendor record doesn't exist. # ExpensiError QBO097: When You Use Accounts Payable, You Must Choose a Vendor in the Name Field @@ -84,8 +84,8 @@ This error occurs when you are exporting reimbursable expenses as Journal Entrie ## How to fix it There are three different ways you can resolve this error. -- Select a different type of export for reimbursable expenses under Settings > Workspaces > [worksapce name] > Connections > Configure > Export tab. -- Enable _Automatically Create Entities_ under Settings > Workspaces > [workspace name] > Connections > Configure > Advanced to create vendor records automatically. +- Select a different type of export for reimbursable expenses under Settings > Workspaces > Worksapce Name > Connections > Configure > Export tab. +- Enable _Automatically Create Entities_ under Settings > Workspaces > Workspace Name > Connections > Configure > Advanced to create vendor records automatically. - Manually create vendor records in QuickBooks Online for each employee. # ExpensiError QBO099: Items marked as billable must have sales information checked @@ -102,7 +102,7 @@ This error occurs when an Item category on an expense does not have sales inform 4. Check the option for **Sales**. 5. Select an income account. 6. Save your changes. -7. Sync your QuickBooks Online connection in Settings > Workspaces > [workspace name] > Connections. +7. Sync your QuickBooks Online connection in Settings > Workspaces > Workspace Name > Connections. 8. Open the report and click the **Export to** button and then the **QuickBooks Online** option. @@ -116,7 +116,7 @@ _Note: This error message can also show up as, "QuickBooks Reconnect error: OAut ## How to fix it -1. Navigate to Settings > Workspaces > Groups > [workspace name] > Connections. +1. Navigate to Settings > Workspaces > Groups > Workspace Name > Connections. 2. Click the **Sync Now** button. 3. In the pop-up window, click **Reconnect** and enter your current QuickBooks Online credentials. @@ -133,7 +133,7 @@ This error occurs when settings in QuickBooks Online are enabled to warn of dupl 1. Log into QuickBooks Online. 2. Navigate to Settings > Advanced. 3. Under the Other Preferences section, make sure "Warn if duplicate bill number is used" is set to "Off" -4. Sync your QuickBooks Online connection in Settings > Workspaces > [workspace name] > Connections. +4. Sync your QuickBooks Online connection in Settings > Workspaces > Workspace Name > Connections. 5. Open the report and click the **Export to** button and then the **QuickBooks Online** option. # Export error: QuickBooks Online: The transaction needs to be in the same currency as the A/R and A/P accounts @@ -152,12 +152,12 @@ You can find the correct Vendor record by exporting your QuickBooks Online vendo If you have multiple Vendors with different currencies with the same email, Expensify is likely trying to export to the wrong one. 1. Try removing the email address from the vendor in QuickBooks Online that you aren't trying to export to. -2. Sync your QuickBooks Online connection in Settings > Workspaces > [workspace name] > Connections. +2. Sync your QuickBooks Online connection in Settings > Workspaces > Workspace Name > Connections. 3. Open the report and click the **Export to** button and then the **QuickBooks Online** option. If this still fails, you'll need to confirm that the A/P account selected in Expensify is set to the correct currency for the export. -1. Navigate to Settings > Workspaces > [workspace name] > Connections. +1. Navigate to Settings > Workspaces > Workspace Name > Connections. 2. Under the Exports tab check that both A/P accounts are the correct currency. # Why are company card expenses exported to the wrong account in QuickBooks Online? @@ -174,9 +174,9 @@ It’s important to note that expenses imported from a card linked at the indivi The user exporting the report must be a domain admin. You can check the history and comment section at the bottom of the report to see who exported the report. -If your reports are being exported automatically by Concierge, the user listed as the Preferred Exporter under Settings > Workspaces > [workspace name] > Connections > click **Configure** must also be a domain admin. +If your reports are being exported automatically by Concierge, the user listed as the Preferred Exporter under Settings > Workspaces > Workspace Name > Connections > click **Configure** must also be a domain admin. -If the report exporter is not a domain admin, all company card expenses will export to the bank account set in Settings > Workspaces > [workspace name] > Connections > click **Configure** for non-reimbursable expenses. +If the report exporter is not a domain admin, all company card expenses will export to the bank account set in Settings > Workspaces > Workspace Name > Connections > click **Configure** for non-reimbursable expenses. **Has the company card been mapped under the correct workspace?** diff --git a/docs/articles/expensify-classic/expenses/Add-an-expense.md b/docs/articles/expensify-classic/expenses/Add-an-expense.md index 92a96e989013..5f40ff377be6 100644 --- a/docs/articles/expensify-classic/expenses/Add-an-expense.md +++ b/docs/articles/expensify-classic/expenses/Add-an-expense.md @@ -2,7 +2,6 @@ title: Add an expense description: Create a new expense in Expensify --- -
    You can add an expense automatically with SmartScan or enter the expense details manually. @@ -41,63 +40,189 @@ You can open any receipt and click **Fill out details myself** to add or edit th {% include end-selector.html %} -# Email a receipt - You can also email receipts to SmartScan by sending them to receipts@expensify.com from an email address tied to your Expensify account (either a primary or secondary email). SmartScan will automatically pull all of the details from the receipt, fill them in for you, and add the receipt to the Expenses tab on your account. {% include info.html %} **For copilots**: To ensure a receipt is routed to the Expensify account you are copiloting instead of your own account, email the receipt to receipts@expensify.com with the email address of the account you are copiloting as the subject line of the email. {% include end-info.html %} -# Add an expense manually +# Add a per diem expense + +A per diem (also called “per diem allowance” or “daily allowance”) is a fixed daily payment provided by an employer to cover expenses during business or work-related travel. These allowances simplify travel expense tracking and reimbursement for meals, lodging, and incidental expenses. + +{% include info.html %} +Before you can add a per diem expense, a Workspace Admin must [enable per diem expenses](https://help.expensify.com/articles/expensify-classic/workspaces/Enable-per-diem-expenses) for the workspace and add the per diem rates. If you do not see an option for per diem rates, it is currently unavailable for your workspace, and you’ll need to reach out to one of your Workspace Admins for guidance. +{% include end-info.html %} + +To add a per diem expense, + +1. Click the **Expenses** tab. +2. Click **New Expense** and choose **Per Diem**. +3. Select your travel destination. + - If your trip involves multiple stops, create a separate per diem expense for each destination. +4. Select the start date, end date, start time, and end time for the trip. +5. Select a sub-rate. The available sub-rates are dependent on the trip duration. + - You can include meal deductions or overnight lodging costs if allowed by your workspace. +6. Enter any other required coding information, such as the category, description, or report, and click **Save**. + +# Add a mileage expense + +You can track your mileage-related expenses by logging your trips in Expensify. You have a couple of different options for logging distance: + +- Web app: + - **Manually create**: Manually enter the number of miles for the trip + - **Create from map**: Automatically determine the trip distance based on the start and end location. +- Mobile app: + - **Manually create**: Manually enter the miles for the trip and your mileage rate + - **Odometer**: Enter your odometer reading before and after the trip + - **Start GPS**: Currently under development and unavailable for use. + +{% include info.html %} +When adding a distance expense, the rates available are determined by the rates set in your [workspace rate settings](https://help.expensify.com/articles/expensify-classic/workspaces/Set-time-and-distance-rates). To update these rates or add a new rate, you must be a Workspace Admin. +{% include end-info.html %} {% include selector.html values="desktop, mobile" %} {% include option.html value="desktop" %} 1. Click the **Expenses** tab. -2. Click the + icon in the top right. -3. Select the type of expense. - - **Manually create**: Manually enter receipt details. - - **Scan receipt**: Upload a saved image of a receipt. - - **Create multiple**: Manually enter multiple expenses at once. - - **Time**: Create an expense based on hours. - - **Distance**: Create an expense based on distance. - - Manually Create: Manually enter the distance details for the expense. - - Create from Map: Enter the start and end destination and Expensify will help you create a receipt for the trip. -4. Click **Save**. +2. Click **New Expense**. +3. Select the expense type. + - **Manually create**: + - Enter the number of miles for the trip. + - Select your rate. + - If desired, select the category, add a description, or select a report to add the expense to. + - Click **Save**. + - **Create from map**: + - Add your start location as point A. + - Add your end location as point B. + - If applicable, click **Add Destination** to add additional stops. + - To generate a map receipt, leave the Create Receipt checkbox selected. + - Click **Save**. + - Select your rate. + - If desired, select the category, add a description, or select a report to add the expense to. + - Click **Save**. + {% include end-option.html %} {% include option.html value="mobile" %} -1. Tap the ☰ menu icon in the top left. -2. Tap **Expenses**. -3. Tap the + icon in the top right. -4. Tap the correct expense type and enter the expense details. - - **Manually create**: Manually enter receipt details. - - **Time**: Enter work time and rate. - - **Manually create (Distance)**: Manually enter trip details by total distance. - - **Odometer**: Manually enter trip details by start and end odometer readings. - - **Start GPS**: Track distance while using the Expensify app to automatically calculate the distance in real time during the trip. -5. Tap **Save**. +1. Click the + icon in the top right corner. +2. Under the Distance section, select the expense type. + - **Manually create**: + - Enter your mileage. + - Select your rate. + - If desired, click **More Options** to select the category, add a description, or select a report to add the expense to. + - Click **Save**. + - **Odometer**: + - Enter your vehicle’s odometer reading before the trip. + - Enter your vehicle’s odometer reading after the trip. + - Select your rate. + - If desired, click **More Options** to select the category, add a description, or select a report to add the expense to. + - Click **Save**. {% include end-option.html %} {% include end-selector.html %} +# Add a group expense + +Capture group and event expenses with Attendee Tracking by documenting who attended and the cost per attendee. The amount is always divided evenly between all attendees—different amounts cannot be allocated to specific attendees. To divide the amounts differently, you’ll first have to split the expense. + {% include info.html %} -If you are an employee under a company workspace, you may not see all of the different expense type options depending on your company’s workspace settings. +Attendees added to an expense will not be notified that they were added to an expense, nor will they share in the expense or be requested to pay for any portion of the expense. {% include end-info.html %} +{% include selector.html values="desktop, mobile" %} + +{% include option.html value="desktop" %} +1. Click the **Expenses** tab. +2. Click the expense you want to add attendees to. +3. Click the attendees field and enter the name or email address of the attendee. + - If the attendee is a member of your workspace, you can select their name from the list. + - If the attendee is not a member of your workspace, enter their full name or email address and press Enter on your keyboard to add them as a new attendee. +4. Click **Save**. + +Once added, you’ll also see the list of attendees in the expense overview on the Expenses tab. To see the cost per employee, hover over the receipt total. These details are also available on any report that you add the expense to. +{% include end-option.html %} + +{% include option.html value="mobile" %} +1. Tap the **Expenses** tab. +2. Tap the expense you want to add attendees to. +3. Scroll down to the bottom and tap **More Options**. +4. Tap the attendees field and enter the name or email address of the attendee. + - If the attendee is a member of your workspace, you can select their name from the list. + - If the attendee is not a member of your workspace, enter their full name or email address and press Enter on your keyboard to add them as a new attendee. +5. Tap **Save**. + +Attendees will also be listed on any report that you add the expense to. + +{% include end-option.html %} + +{% include end-selector.html %} + +# Add expenses in bulk + +You can upload bulk receipt images or add receipt details in bulk. + +## SmartScan receipt images in bulk + +{% include selector.html values="desktop, mobile" %} + +{% include option.html value="desktop" %} +1. Click the **Expenses** tab. +2. Drag and drop up to 10 images or PDF receipts at once from your computer’s files. You can drop them anywhere on the Expense page where you see a green plus icon next to your mouse cursor. +{% include end-option.html %} + +{% include option.html value="mobile" %} +1. Open the mobile app and tap the camera icon in the bottom right corner. +2. Tap the camera icon in the right corner to select the Rapid Fire mode. +3. Take a clear photo of each receipt. +4. When all receipts are captured, tap the X in the left corner to close the camera. +{% include end-option.html %} + +{% include end-selector.html %} + +## Manually add receipt details in bulk + +*Note: This process is currently not available from the mobile app and must be completed from the Expensify website.* + +1. Click the **Expenses** tab. +2. Click **New Expense** and select **Create Multiple**. +3. Enter the expense details for up to 10 expenses and click **Save**. + +## Upload personal expenses via CSV, XLS, etc. + +*Note: This process is currently not available from the mobile app and must be completed from the Expensify website.* + +1. Hover over Settings, then click **Account**. +2. Click the **Credit Card Import** tab. +3. Under Personal Cards, click **Import Transactions from File**. +4. Click **Upload** and select a .csv, .xls, .ofx, or a .qfx file. + {% include faq-begin.md %} **What’s the difference between a reimbursable and non-reimbursable expense?** -- Reimbursable expenses are things that you pay for with your own money that the company has agreed to pay you back for (like business travel paid for with personal funds). -- Non-reimbursable expenses are things you pay for with company money that need to be documented for accounting purposes (like a lunch paid for with a company card). +- **Reimbursable expenses**: Expenses that the company has agreed to pay you back for. This may include: + - Cash & personal card: Expenses paid for by the employee on behalf of the business. + - Per diem: Expenses for a daily or partial daily rate [configured in your Workspace](https://help.expensify.com/articles/expensify-classic/workspaces/Enable-per-diem-expenses). + - Time: An hourly rate for your employees or jobs as [set for your workspace](https://help.expensify.com/articles/expensify-classic/workspaces/Set-time-and-distance-rates). This expense type is usually used by contractors or small businesses billing the customer via [Expensify Invoicing](https://help.expensify.com/articles/expensify-classic/workspaces/Set-Up-Invoicing). + - Distance: Expenses related to business travel. +- **Non-reimbursable expenses**: Expenses are things you pay for with company money that need to be documented for accounting purposes (like a lunch paid for with a company card). +- **Billable expenses**: Business or employee expenses that must be billed to a specific client or vendor. This option is for tracking expenses for invoicing to customers, clients, or other departments. Any kind of expense can be billable, in _addition_ to being either reimbursable or non-reimbursable. + +You can also see a breakdown of these expense types on your report and can even organize the report by them. {% include info.html %} If you are an employee under a company workspace, your expenses may automatically be configured as reimbursable or non-reimbursable depending on the details that are entered. If an expense is incorrectly labeled, you must reach out to an admin to have it corrected. {% include end-info.html %} +**Why don't I see the option for one of these types of expenses?** + +If you are an employee under a company workspace, you may not see all of the different expense type options depending on your company’s workspace settings. + +**How do I edit my per diem expenses?** + +Per diem expenses cannot be amended. To make changes, you must delete the expense and recreate it. + {% include faq-end.md %} -
    diff --git a/docs/articles/expensify-classic/expenses/Add-expenses-in-bulk.md b/docs/articles/expensify-classic/expenses/Add-expenses-in-bulk.md deleted file mode 100644 index 6ee84e1ead15..000000000000 --- a/docs/articles/expensify-classic/expenses/Add-expenses-in-bulk.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -title: Add expenses in bulk -description: Add multiple expenses at one time ---- -
    - -You can upload bulk receipt images or add receipt details in bulk. - -# SmartScan receipt images in bulk - -{% include selector.html values="desktop, mobile" %} - -{% include option.html value="desktop" %} -1. Click the **Expenses** tab. -2. Drag and drop up to 10 images or PDF receipts at once from your computer’s files. You can drop them anywhere on the Expense page where you see a green plus icon next to your mouse cursor. -{% include end-option.html %} - -{% include option.html value="mobile" %} -1. Open the mobile app and tap the camera icon in the bottom right corner. -2. Tap the camera icon in the right corner to select the Rapid Fire mode. -3. Take a clear photo of each receipt. -4. When all receipts are captured, tap the X in the left corner to close the camera. -{% include end-option.html %} - -{% include end-selector.html %} - -# Manually add receipt details in bulk - -*Note: This process is currently not available from the mobile app and must be completed from the Expensify website.* - -1. Click the **Expenses** tab. -2. Click **New Expense** and select **Create Multiple**. -3. Enter the expense details for up to 10 expenses and click **Save**. - -# Upload personal expenses via CSV, XLS, etc. - -*Note: This process is currently not available from the mobile app and must be completed from the Expensify website.* - -1. Hover over Settings, then click **Account**. -2. Click the **Credit Card Import** tab. -3. Under Personal Cards, click **Import Transactions from File**. -4. Click **Upload** and select a .csv, .xls, .ofx, or a .qfx file. - -
    diff --git a/docs/articles/expensify-classic/expenses/Track-group-expenses.md b/docs/articles/expensify-classic/expenses/Track-group-expenses.md deleted file mode 100644 index 82921b0e8cd3..000000000000 --- a/docs/articles/expensify-classic/expenses/Track-group-expenses.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -title: Track group expenses -description: Use Attendee Tracking to track group expenses ---- -
    - -Capture group and event expenses with Attendee Tracking by documenting who attended and the cost per attendee. The amount is always divided evenly between all attendees—different amounts cannot be allocated to specific attendees. To divide the amounts differently, you’ll first have to split the expense. - -{% include info.html %} -Attendees added to an expense will not be notified that they were added to an expense, nor will they share in the expense or be requested to pay for any portion of the expense. -{% include end-info.html %} - -{% include selector.html values="desktop, mobile" %} - -{% include option.html value="desktop" %} -1. Click the **Expenses** tab. -2. Click the expense you want to add attendees to. -3. Click the attendees field and enter the name or email address of the attendee. - - If the attendee is a member of your workspace, you can select their name from the list. - - If the attendee is not a member of your workspace, enter their full name or email address and press Enter on your keyboard to add them as a new attendee. -4. Click **Save**. - -Once added, you’ll also see the list of attendees in the expense overview on the Expenses tab. To see the cost per employee, hover over the receipt total. These details are also available on any report that you add the expense to. -{% include end-option.html %} - -{% include option.html value="mobile" %} -1. Tap the **Expenses** tab. -2. Tap the expense you want to add attendees to. -3. Scroll down to the bottom and tap **More Options**. -4. Tap the attendees field and enter the name or email address of the attendee. - - If the attendee is a member of your workspace, you can select their name from the list. - - If the attendee is not a member of your workspace, enter their full name or email address and press Enter on your keyboard to add them as a new attendee. -5. Tap **Save**. - -Attendees will also be listed on any report that you add the expense to. - -{% include end-option.html %} - -{% include end-selector.html %} - -
    diff --git a/docs/articles/expensify-classic/expenses/Track-mileage-expenses.md b/docs/articles/expensify-classic/expenses/Track-mileage-expenses.md deleted file mode 100644 index e8b9ab0eac75..000000000000 --- a/docs/articles/expensify-classic/expenses/Track-mileage-expenses.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -title: Track mileage expenses -description: Add mileage-related expenses ---- - -
    - -You can track your mileage-related expenses by logging your trips in Expensify. You have a couple of different options for logging distance: - -- Web app: - - **Manually create**: Manually enter the number of miles for the trip - - **Create from map**: Automatically determine the trip distance based on the start and end location. -- Mobile app: - - **Manually create**: Manually enter the miles for the trip and your mileage rate - - **Odometer**: Enter your odometer reading before and after the trip - - **Start GPS**: Currently under development and unavailable for use. - -{% include info.html %} -When adding a distance expense, the rates available are determined by the rates set in your workspace rate settings. To update these rates or add a new rate, you must be a Workspace Admin. -{% include end-info.html %} - -{% include selector.html values="desktop, mobile" %} - -{% include option.html value="desktop" %} - -1. Click the **Expenses** tab. -2. Click **New Expense**. -3. Select the expense type. - - **Manually create**: - - Enter the number of miles for the trip. - - Select your rate. - - If desired, select the category, add a description, or select a report to add the expense to. - - Click **Save**. - - **Create from map**: - - Add your start location as point A. - - Add your end location as point B. - - If applicable, click **Add Destination** to add additional stops. - - To generate a map receipt, leave the Create Receipt checkbox selected. - - Click **Save**. - - Select your rate. - - If desired, select the category, add a description, or select a report to add the expense to. - - Click **Save**. - -{% include end-option.html %} - -{% include option.html value="mobile" %} - -1. Click the + icon in the top right corner. -2. Under the Distance section, select the expense type. - - **Manually create**: - - Enter your mileage. - - Select your rate. - - If desired, click **More Options** to select the category, add a description, or select a report to add the expense to. - - Click **Save**. - - **Odometer**: - - Enter your vehicle’s odometer reading before the trip. - - Enter your vehicle’s odometer reading after the trip. - - Select your rate. - - If desired, click **More Options** to select the category, add a description, or select a report to add the expense to. - - Click **Save**. -{% include end-option.html %} - -{% include end-selector.html %} - -
    - diff --git a/docs/articles/expensify-classic/expenses/Track-per-diem-expenses.md b/docs/articles/expensify-classic/expenses/Track-per-diem-expenses.md deleted file mode 100644 index 88dd91997592..000000000000 --- a/docs/articles/expensify-classic/expenses/Track-per-diem-expenses.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -title: Track per diem expenses -description: Add daily allowance expenses for business travel ---- -
    - -A per diem (also called “per diem allowance” or “daily allowance”) is a fixed daily payment provided by an employer to cover expenses during business or work-related travel. These allowances simplify travel expense tracking and reimbursement for meals, lodging, and incidental expenses. - -{% include info.html %} -Before you can add a per diem expense, a Workspace Admin must enable per diem expenses for the workspace and add the per diem rates. If you do not see an option for per diem rates, it is currently unavailable for your workspace, and you’ll need to reach out to one of your Workspace Admins for guidance. -{% include end-info.html %} - -To add a per diem expense, - -1. Click the **Expenses** tab. -2. Click **New Expense** and choose **Per Diem**. -3. Select your travel destination. - - If your trip involves multiple stops, create a separate per diem expense for each destination. -4. Select the start date, end date, start time, and end time for the trip. -5. Select a sub-rate. The available sub-rates are dependent on the trip duration. - - You can include meal deductions or overnight lodging costs if allowed by your workspace. -6. Enter any other required coding information, such as the category, description, or report, and click **Save**. - -# FAQs - -**How do I edit my per diem expenses?** - -Per diem expenses cannot be amended. To make changes, you must delete the expense and recreate it. - -**What if my admin requires daily per diem submissions?** - -No problem! Create a separate per diem expense for each day of your trip. - -
    diff --git a/docs/articles/expensify-classic/expensify-billing/Change-Plan-Or-Subscription.md b/docs/articles/expensify-classic/expensify-billing/Change-Plan-Or-Subscription.md index 1e631a53b0b3..b245a26d10a0 100644 --- a/docs/articles/expensify-classic/expensify-billing/Change-Plan-Or-Subscription.md +++ b/docs/articles/expensify-classic/expensify-billing/Change-Plan-Or-Subscription.md @@ -50,7 +50,7 @@ If Auto Renew is disabled then the last bill at the annual rate will be issued o # How to downgrade to a free account from an Individual Plan ## Web 1. Log in to your account through a web browser. -1. Go to **Settings > Policies > Individual > Subscription**. +1. Go to **Settings > Workspaces > Individual > Subscription**. 1. Click "Cancel Subscription" to end your Monthly Subscription. Note: Your subscription is a pre-purchase for 30 days of unlimited SmartScanning. This means that when you cancel, you do not get a refund and instead get to use the remainder of the month of unlimited SmartScanning you purchased. diff --git a/docs/articles/expensify-classic/expensify-card/Request-the-Card.md b/docs/articles/expensify-classic/expensify-card/Request-the-Card.md index 1f412665fc2f..1272cbd1f117 100644 --- a/docs/articles/expensify-classic/expensify-card/Request-the-Card.md +++ b/docs/articles/expensify-classic/expensify-card/Request-the-Card.md @@ -2,6 +2,11 @@ title: Request the Card description: Details on requesting the Expensify Card as an employee --- +_Note: The Expensify Card is currently only available to companies that have:_ +_- A US Bank Account_ +_- US documentation_ +_- A private email domain i.e. we cannot provision Expensify cards for users with gmail.com, hotmail.com, yahoo.com etc_ + To start using the Expensify Card, do the following: 1. **Enable Expensify Cards:** An admin must first enable the cards. Then, an admin can assign you a card by setting a limit, which allows access to the card. 2. **Request the Card:** diff --git a/docs/articles/expensify-classic/settings/Set-Notifications.md b/docs/articles/expensify-classic/settings/Set-Notifications.md index 0e18d6f22cf5..da55dafb833c 100644 --- a/docs/articles/expensify-classic/settings/Set-Notifications.md +++ b/docs/articles/expensify-classic/settings/Set-Notifications.md @@ -4,72 +4,66 @@ description: This article is about how to troubleshoot notifications from Expens --- # Overview -Sometimes, members may have trouble receiving important email notifications from Expensify, such as Expensify Magic Code emails, account validation emails, secondary login validations, integration emails, or report action notifications (rejections, approvals, etc.). - -# Here's how to troubleshoot missing Expensify notifications: - -1. **No error message, but the email is never received** -The email might be delayed; give it 30-60 minutes to arrive in your inbox. -Check **Email Preferences** on the web via **Settings > Your Account > Preferences**In the **Contact Preferences** section. Ensure that the relevant boxes are checked for the email type you're missing. Check your email spam and trash folders, as Expensify messages might end up there inadvertently. -Check to make sure you haven't unintentionally blocked Expensify emails and whitelist [expensify.com](https://community.expensify.com/home/leaving?allowTrusted=1&target=http%3A%2F%2Fexpensify.com%2F), mg.expensify.com, and [amazonSES.com](https://community.expensify.com/home/leaving?allowTrusted=1&target=http%3A%2F%2Famazonses.com%2F) with your email provider. - -2. **A "We're having trouble emailing you" banner at the top of your screen** -Verify that your email address in your account settings is correct and is a real deliverable email address. -Re-send Verification Email: Look for an option to re-send a verification email, usually provided when this banner appears. - -![ExpensifyHelp_EmailError]({{site.url}}/assets/images/ExpensifyHelp_EmailError.png){:width="100%"} - -# Deep Dive +Sometimes members may have trouble receiving important email notifications from Expensify, such as Expensify Magic Code emails, account validation emails, secondary login validations, integration emails, or report action notifications (rejections, approvals, etc.). + +# Troubleshooting missing Expensify notifications + +## Issue: The email or notification is never received, and no message, banner, or additional context is provided +Emails can sometimes be delayed and could take up to 30-60 minutes to arrive in your inbox. If you're expecting a notification that still hasn't arrived after waiting: + - Check your **Email Preferences** on the web via **Settings > Account > Preferences**. In the **Contact Preferences** section, ensure that the relevant boxes are checked for the email type you're missing. + - Check your email spam and trash folders, as Expensify messages might end up there inadvertently. + - Check to make sure you haven't unintentionally blocked Expensify emails. Allowlist the domain expensify.com with your email provider. + +## Issue: A banner that says “We’re having trouble emailing you” shows the top of your screen. +Confirm the email address on your Expensify account is a deliverable email address, and then click the link in the banner that says "here". If successful, you will see a confirmation that your email was unblocked. + + ![ExpensifyHelp_EmailError]({{site.url}}/assets/images/ExpensifyHelp_EmailError.png){:width="100%"} + + **If unsuccessful, you will see another error:** + - If the new error or SMTP message includes a URL, navigate to that URL for further instructions. + - If the new error or SMTP message includes "mimecast.com", consult with your company's IT team. + - If the new error or SMTP message includes "blacklist", it means your company has configured their email servers to use a third-party email reputation or blocklisting service. Consult with your company's IT team. + +![ExpensifyHelp_SMTPError]({{site.url}}/assets/images/ExpensifyHelp_SMTPError.png){:width="100%"} -**For Private Domains**: +# Further troubleshooting for public domains -If your organization uses a private domain, consult your IT department or IT person to ensure that the following domains are whitelisted to receive our emails: expensify.com, mg.expensify.com, and amazonSES.com. These domains are the sources of various notification emails, so make sure they aren't being blocked. +If you are still not receiving Expensify notifications and have an email address on a public domain such as gmail.com or yahoo.com, you may need to add Expensify's domain expensify.com to your email's allowlist by taking the following steps: -**For Public Domains (e.g., Gmail, Yahoo, Hotmail)**: + - Search for messages from expensify.com in your spam folder, open them, and click “Not Spam” at the top of each message. + - Configure an email filter that identifies Expensify's email domain expensify.com and directs all incoming messages to your inbox, to avoid messages going to spam. + - Add specific known Expensify email addresses such as concierge@expensify.com to your email contacts list. -To whitelist our emails on public email services: +# Further troubleshooting for private domains -1. Check your Spam Folder: Search for messages from expensify.com in your Spam folder, open them, and click "Not Spam" at the top of the message. -2. Create a Filter: Set up a filter that identifies the entire expensify.com domain and directs all incoming messages to your inbox, preventing them from going to Spam. -3. Add Specific Contacts: While optional, adding specific email addresses from Expensify as contacts can further prevent emails from going to Spam. +If your organization uses a private domain, Expensify emails may be blocked at the server level. This can sometimes happen unexpectedly due to broader changes in email provider's handling or filtering of incoming messages. Consult your internal IT team to assist with the following: -Please note that even if you receive emails from our Concierge support communication, ensure that both expensify.com and mg.expensify.com are whitelisted as they use different servers. + - Ensure that the domain expensify.com is allowlisted on domain email servers. This domains is the sources of various notification emails, so it's important it is allowlisted. + - Confirm there is no server-level email blocking and that spam filters are not blocking Expensify emails. Even if you have received messages from our Concierge support in the past, ensure that expensify.com is allowlisted. -**Email Server Blocking**: -Your email server may be blocking our emails due to spam filters or other services. Check with your IT department to investigate and resolve any server-level email blocking issues. +## Companies using Outlook -**Mimecast**: -If your company uses Mimecast, a service that can affect email deliverability, check with your IT department. If Mimecast is in use, reach out to us at concierge@expensify.com through a new email, as this should ensure delivery to your inbox. Mimecast should eventually recognize the Expensify domain, preventing future filtering. +- Add Expensify to your personal Safe Senders list by following these steps: [Outlook email client](https://support.microsoft.com/en-us/office/add-recipients-of-my-email-messages-to-the-safe-senders-list-be1baea0-beab-4a30-b968-9004332336ce) / [Outlook.com](https://support.microsoft.com/en-us/office/safe-senders-in-outlook-com-470d4ee6-e3b6-402b-8cd9-a6f00eda7339) +- **Company IT administrators:** Add Expensify to your domain's Safe Sender list by following the steps here: [Create safe sender lists in EOP](https://learn.microsoft.com/en-us/defender-office-365/create-safe-sender-lists-in-office-365) +- **Company IT administrators:** Add expensify.com to the domain's explicit allowlist. You may need to contact Outlook support for specific instructions, as each company's setup varies. +- **Company administrators:** Contact Outlook support to see if there are additional steps to take based on your domain's email configuration. -**For Outlook Users**: -For Outlook users specifically: +## Companies using Google Workspaces: -1. Click the gear icon in Outlook and select "View all Outlook settings." -2. Choose "Mail" from the settings menu. -3. Under the "Junk email" submenu, click "Add" under "Safe senders and domains." -4. Enter the email address you want to whitelist. -5. Click "Save." +- **Company IT administrators:** Adjust your domain's email allowlist and safe senders lists to include expensify.com by following these steps: [Allowlists, denylists, and approved senders](https://support.google.com/a/answer/60752) -When you click the "Settings" link in the banner in Expensify, you'll be directed to your account settings page, where you may encounter a few different scenarios: +{% include faq-begin.md %} -- "Temporarily Suspended Emails": If the message mentions "temporarily suspended emails to," follow the steps provided in the yellow box. This situation typically occurs when we can't find a valid inbox to send our emails to. Possible reasons include: - - A misspelled email address during account creation. - - Use of a distribution list email (acting as an "alias" email) without a linked inbox. - - An auto-responder that has been responding to our emails for an extended period. -- To resolve this issue, confirm that the email address is indeed associated with an active inbox. Then, click the link that says "here," and your email should be unblocked shortly. -- SMTP Error (Gray Box): In some cases, you might encounter a gray box with an SMTP error message. This error can vary, but it typically looks something like this: +## How can I be sure that emails from Expensify are legitimate and not spam? -![ExpensifyHelp_SMTPError]({{site.url}}/assets/images/ExpensifyHelp_SMTPError.png){:width="100%"} +Expensify's emails are SPF and DKIM-signed, meaning they are cryptographically signed and encrypted to prevent spoofing. -**These look a bit cryptic, yes, but hang in there!** +## Why do legitimate emails from Expensify sometimes end up marked as spam? -The error messages you see are the raw message text received from your email provider's server to Amazon. These messages can vary in text, but the best course of action is to follow the link provided (by copying and pasting) in the text for the next steps. +The problem typically arises when our domain or one of our sending IP addresses gets erroneously flagged by a 3rd party domain or IP reputation services. Many IT departments use lists published by such services to filter email for the entire company. -**Scenario 1**: If the message in the gray box includes "mimecast.com": It means that our emails are being blocked by the server. In this case, you should contact your IT person or team to address the issue. +## What is the best way to ensure emails are not accidentally marked as Spam? -**Scenario 2**: If the message in the gray box mentions "blacklist at org/.com/.net," or resembles the screenshot provided, it indicates that your IT team has configured your email to use a third-party email reputation or blacklisting service. Here's what you need to know: -- All our emails are SPF and DKIM-signed, meaning they are cryptographically signed as coming from us and are not spam. -- The problem arises because we send mail from a cloud-based service. This means that the sender's IP serves multiple vendors, including Expensify. If one of those vendors is marked as spam, it can block all messages from that IP, even if they're from different vendors (including us). -- The better approach is for the server to flag spam via DKIM and SPF (rather than solely relying on the sender's IP address), as our messages are correctly signed and encrypted to prevent spoofing. +For server-level spam detection, the safest approach to allowlisting email from Expensify is to verify DKIM and SPF, rather than solely relying on the third-party reputation of the sending IP address. -To resolve these issues, consider discussing them with your IT team, as they can help implement the necessary changes to ensure you receive our emails without interruption. +{% include faq-end.md %} diff --git a/docs/articles/new-expensify/expenses-&-payments/Approve-and-pay-expenses.md b/docs/articles/new-expensify/expenses-&-payments/Approve-and-pay-expenses.md deleted file mode 100644 index 26634d9a33df..000000000000 --- a/docs/articles/new-expensify/expenses-&-payments/Approve-and-pay-expenses.md +++ /dev/null @@ -1,109 +0,0 @@ ---- -title: Approve and Pay Expenses -description: Approve, hold, or pay expenses submitted to you ---- -
    - -As a workspace admin, you can set an approval workflow for the expenses submitted to you. Expenses can be, - -- Instantly submitted without needing approval. -- Submitted at a desired frequency (daily, weekly, monthly) and follow an approval workflow. - -**Setting approval workflow and submission frequencies** - -Approval workflow settings and submission frequencies can be set in the Workflow settings of your workspace. - -# Manually approve expense - -When someone sends an expense or a group of expenses to you for approval, you’ll receive the expense in Expensify Chat for the related workspace. Chats with new updates appear with a green dot to the right of the chat message. Concierge also sends you an email notification for the new expense. - -{% include info.html %} -If an expense is sent to you by a friend, you will not need to approve the expense. Instead, you can immediately pay the expense when you are ready. -{% include end-info.html %} - -# Approve expenses - -To approve an expense, - -1. Open the Expensify Chat thread for the expense. -2. Click the expense or group of expenses. -3. Review the expense details to ensure they are correct. Look at each receipt, the amount, the description, and any additional details. -4. Determine the next steps. - - **Approve**: When you’re satisfied with the expense, click **Approve**. - - **Handle holds**: If any of the expenses are on hold, you can choose to either approve only the expenses that are not on hold or approve the full amount, including any held expenses. - - **Request changes**: You can add a comment to the expense’s chat thread in your Expensify Chat inbox to request changes to the expense details. - -{% include info.html %} -If the transaction is pending (a common occurrence with recent company card expenses or SmartScan expenses), you’ll need to wait until the transaction posts before approving it. -{% include end-info.html %} - -![The approve button in an expense]({{site.url}}/assets/images/ExpensifyHelp_ApproveExpense_1.png){:width="100%"} - -![The approve button when you click into the expense]({{site.url}}/assets/images/ExpensifyHelp_ApproveExpense_2.png){:width="100%"} - -You’re now ready to pay the expense. - -# Hold an expense - -If you need to delay a payment or if you need more information on the expense before it can be approved, you can hold the expense. - -To hold an expense, - -1. Open the Expensify Chat thread for the expense. -2. Click the expense or group of expenses. -3. Click the three dot menu at the top right of the expense and select **Hold**. -4. Enter a reason for the delay. -5. Review the Hold Overview page and click **Got It**. - -When you’re ready, you can choose to: -- **Remove the hold**: Complete the steps above and select **Unhold**. -- **Approve the expense**: Complete the steps above for “Approve expenses.” -Once the expense has been approved, you can now pay the expense. - -{% include info.html %} -Held expenses will not be available for payment until they have been approved. -{% include end-info.html %} - -# Unapprove an expense - -Some details of approved expenses and reports cannot be edited. If you need to edit an expense that has been approved, admins and the last approver have the option to unapprove reports. - -1. Click the workspace logo in the top left corner. -2. Select the workspace associated with the expense report. -3. Find the approved report by searching for the submitter. -4. Click the dropdown arrow at the top of the report to view the report actions. -5. Click **Unapprove**. - -The unapproved report will return to an editable state, and the submitter will receive an email and chat notification that the expense has been unapproved. - -{% include info.html %} -Reports that have been paid cannot be unapproved. If the approved expense has already been exported to an accounting package, you’ll see a warning that unapproving an expense can cause data discrepancies and Expensify Card reconciliation issues. Ideally, you’ll want to delete the data that has already been exported to the accounting package before approving the expense again. -{% include end-info.html %} - -# Pay expenses - -Once you’ve approved an expense—or if the expense does not require approval—you’ll be able to pay it. - -{% include info.html %} -To pay expenses within Expensify, you’ll need to [set up your Expensify Wallet](https://help.expensify.com/articles/new-expensify/expenses-&-payments/Set-up-your-wallet). -{% include end-info.html %} - -To pay an expense, - -1. Open the Expensify Chat thread for the expense. -2. Click the expense or group of expenses. -3. Select a payment option. - - Click **Pay** to pay the full expense within Expensify. If the expenses contain one that has been held, the pay amount will only include the expenses that have not been held. Then you’ll select your payment method. - - Click **Pay Elsewhere** to indicate that a payment has been sent using a method outside of Expensify, such as cash or a check. This will label the expense as Paid. - -# FAQ - -**Why was an expense automatically approved?** - -We refer to this as **Instant Submit**. If a workspace doesn’t have Delayed Submission enabled, an expense report will automatically be submitted. - -**Why is an employee expense showing as ‘pending?’** - -An Expensify Card expense will show as pending if the merchant hasn’t posted it. This is usually the case with hotel holds, or card rental holds. A hold will normally last no more than 7-10 business days unless it’s a hotel hold, which can last 31 days. - -
    diff --git a/docs/articles/new-expensify/expenses-&-payments/Approve-expenses.md b/docs/articles/new-expensify/expenses-&-payments/Approve-expenses.md new file mode 100644 index 000000000000..77587cc124f0 --- /dev/null +++ b/docs/articles/new-expensify/expenses-&-payments/Approve-expenses.md @@ -0,0 +1,139 @@ +--- +title: Approve Expenses +description: Approve, hold, and unapprove submitted expenses +--- +
    + +Expenses can be created through manual entry, tracking distance, or scanning a receipt. They can be submitted to an individual or a workspace. + +This help article has more details about creating and submitting an expense to an individual or a workspace. + +# Receiving an expense from an Individual + +When an expense is submitted to an individual, it doesn’t need approval. It only needs to be paid. + +This help article has the steps to pay the expense. + +# Receiving a workspace expense + +When an expense is submitted to a workspace with an “approval workflow”, it must be approved before it can be paid. + +As a workspace admin, you can set an [approval workflow](https://help.expensify.com/articles/new-expensify/workspaces/Add-approvals) in the workspace settings. For each expense report, you’ll have the option to: + +- **Approve:** Click Approve if you’re satisfied with the expense details. +- **Hold the expense:** If you need to delay a payment or provide more information before approval, you can hold an expense. +- **Unapprove the expense:** You can return the expense to the submitter for revisions. + +# Approve workspace expenses + +{% include selector.html values="desktop, mobile" %} + +{% include option.html value="desktop or WebApp" %} +1. When an expense is submitted, you will receive an email and in-app notification with the details of the expense. +2. Click the expense in the email to be directed to New Expensify, where you can review it. +3. Click on the expense to view the receipt, amount, description, and additional details the submitter provides. +4. Click **Approve**. +5. When you are ready to pay the expense, follow the steps in this help article. +{% include end-option.html %} + +{% include option.html value="mobile" %} +1. When an expense is submitted, you will receive a text message and in-app notification with its details. +2. Tap on the expense in the text or notification to be directed to New Expensify, where you can review it. +3. Tap on the expense to view the receipt, amount, description, and any additional details the submitter provides. +4. Tap **Approve**. +{% include end-option.html %} + +{% include end-selector.html %} + +{% include info.html %} +If the transaction is pending (a common occurrence with recent company cards or SmartScan expenses), you’ll need to wait until the transaction posts before approving it. +{% include end-info.html %} + + +# Hold a workspace expense + +{% include selector.html values="desktop, mobile" %} + +{% include option.html value="desktop" %} +1. Locate the expense on the **Search** page. +2. Click **View**. +3. Click the drop-down arrow at the top of the expense. +4. Click the **Hold** button. +5. Enter a reason for the delay. The reason for the hold will be added to the expense report. + +

     

    + +When you’re ready to remove the hold, + +1. Locate the expense on the Search page. +2. Click **View**. +3. Click the drop-down arrow at the top of the expense. +4. Select **UnHold**. +5. Complete the steps above to “Approve expenses.” Once the expense has been approved, you can pay it. +{% include end-option.html %} + +{% include option.html value="mobile" %} +1. Locate the expense on the **Search** page. +2. Tap **View**. +3. Tap the drop-down arrow at the top of the expense. +4. Select the **Hold** button. +5. Enter a reason for the delay. The reason for the hold will be added to the expense report. + +

     

    + +When you’re ready to remove the hold, + +1. Tap **Search** and select the expense. +2. Tap the drop-down arrow at the top of the expense. +3. Select **UnHold**. +4. Complete the steps above to “Approve expenses.” Once the expense has been approved, you can pay it. +{% include end-option.html %} + +{% include end-selector.html %} + +{% include info.html %} +Held expenses will not be available for payment until they have been approved. +{% include end-info.html %} + +# Unapprove a workspace expense + +{% include selector.html values="desktop, mobile" %} + +{% include option.html value="desktop" %} +1. Locate the expense on the **Search** page. +2. Click **View**. +3. Click the drop-down arrow at the top of the report +4. Click **Unapprove**. +5. The submitter will receive an email and in-app notification that the expense has been unapproved. +6. An unapproved expense can be deleted by clicking the drop-down arrow at the top of the expense. +{% include end-option.html %} + +{% include option.html value="mobile" %} +1. Locate the expense on the **Search** page. +2. Tap **View**. +3. Tap the drop-down arrow at the top of the expense. +4. Tap **Unapprove**. +5. The submitter will receive a text and in-app notification that the expense has been unapproved. +6. An unapproved expense can be deleted by clicking the drop-down arrow at the top of the expense. +{% include end-option.html %} + +{% include end-selector.html %} + +Reports that have been paid cannot be unapproved. + +If the approved expense has already been exported to an accounting package, you’ll see a warning that unapproving an expense can cause data discrepancies and Expensify Card reconciliation issues. Ideally, you’ll want to delete the data already exported to the accounting package before approving the expense again. + +{% include faq-begin.md %} + +**Why is an employee expense showing as ‘pending?’** + +An Expensify Card expense will show as pending if the merchant hasn’t posted it. This is usually the case with hotel holds, or card rental holds. A hold will normally last no more than 7-10 business days unless it’s a hotel hold, which can last 31 days. + +**What are expense reports?** + +In Expensify, expense reports group expenses in a batch to be paid or reconciled. When a draft report is open, all new expenses are added to it. + +Once a report is submitted, you can track the status from the **Search** section. Click the **View** button for a specific expense or expense report. The status is displayed at the top of the expense or report. +{% include faq-end.md %} + +
    diff --git a/docs/articles/new-expensify/expenses-&-payments/Create-an-expense.md b/docs/articles/new-expensify/expenses-&-payments/Create-an-expense.md index cf6a13f9d5ac..38f1e0fdd466 100644 --- a/docs/articles/new-expensify/expenses-&-payments/Create-an-expense.md +++ b/docs/articles/new-expensify/expenses-&-payments/Create-an-expense.md @@ -1,68 +1,83 @@ --- title: Create an expense -description: Request payment from an employer or a friend +description: How to create an expense as an individual or workspace member redirect_from: articles/request-money/Request-and-Split-Bills/ ---
    -You can create an expense to request payment from an employer’s workspace or from a friend using any of the following options: -- **SmartScan**: Take a picture of a receipt to capture the expense details automatically. -- **Add manually**: Manually enter the expense details. -- **Create a distance expense**: Capture mileage expenses by entering the addresses you traveled to. Expensify automatically calculates the distance, the rate per mile, and the total cost. +Expenses can be created through SmartScanning a receipt, emailing a receipt, tracking distance, and manually creating an expense. + +They can be submitted to an individual or a workspace. Before we outline the steps to create an expense, let’s go over the reasons to send an expense to an individual or a workspace. + +# Sending an expense to an Individual + +If you use Expensify for personal use, submitting to an individual is likely best. + +Once the expense is created, you will see the option to send it to an email or phone number. Alternatively, add an expense to a chat, which will go straight to the person you are chatting with. + +When an expense is submitted to an individual’s email or phone number, the payor will receive an email or text notification with the amount that needs to be paid. They can click on the amount in the email or text to pay the expense. + +# Submit an expense to a workspace or employer + +If you are an employee or a workspace member, you should submit the expense to the workspace instead of an individual. A workspace is designed to code expenses to the company's requirements. + +When an expense is submitted to a workspace, your approver will receive an email or text notification prompting them to approve and pay it. + +# How to Create an Expense # SmartScan a receipt {% include selector.html values="desktop, mobile" %} -{% include option.html value="desktop" %} -1. Click the + icon in the bottom left menu and select **Submit Expense**. +{% include option.html value="desktop or WebApp" %} +1. Click the **Global Create** button and select **Submit Expense**. 2. Click **Scan**. -3. Drag and drop the receipt into Expensify, or click **Choose File** to select it from your saved files. *Note: The SmartScan process will auto-populate the merchant, date, and amount.* -4. Use the search field to find the desired workspace or an individual’s name, email, or phone number. -5. Add a description, category, tags, or tax as desired, or as required by your workspace. +3. You can drag and drop the receipt into Expensify or click **Choose File** to select it from your saved files. _The SmartScan process will auto-populate the merchant, date, and amount._ +4. Enter the desired workspace or an individual’s email or phone number to receive the expense report. +5. Add a description, category, tags, or tax as desired or as required by your workspace. 6. (Optional) Enable the expense as billable if it should be billed to a client. -7. Click **Submit Expense**. +7. Click **Submit expense**. {% include end-option.html %} {% include option.html value="mobile" %} -1. Tap the + icon at the bottom of the screen and select **Submit Expense**. +1. ​​Tap the **Global Create** button and select **Submit Expense**. 2. Tap **Scan**. -3. Tap the green button to take a photo of a receipt, or tap the Image icon to the left of it to upload a receipt from your phone. *Note: The SmartScan process will auto-populate the merchant, date, and amount.* -4. Use the search field to find the desired workspace or an individual’s name, email, or phone number. -5. Add a description, category, tags, or tax as desired, or as required by your workspace. +3. Tap the green button to take a photo of a receipt, or tap the Image icon to upload a receipt from your phone. _The SmartScan process will auto-populate the merchant, date, and amount._ +4. Enter the desired workspace or an individual’s email or phone number to receive the expense report. +5. Add a description, category, tags, or tax as desired or as required by your workspace. 6. (Optional) Enable the expense as billable if it should be billed to a client. -7. Tap **Submit**. +7. Tap **Submit expense**. {% include end-option.html %} {% include end-selector.html %} {% include info.html %} -You can also forward receipts to receipts@expensify.com using an email address that is your primary or secondary email address. SmartScan will automatically pull all of the details from the receipt and add it to your expenses. +You can also forward receipts to receipts@expensify.com using your primary or secondary email address. SmartScan will automatically extract all the details from the receipt and add them to your expenses. {% include end-info.html %} # Manually add an expense {% include selector.html values="desktop, mobile" %} -{% include option.html value="desktop" %} -1. Click the + icon in the bottom left menu and select **Submit Expense**. +{% include option.html value="desktop or WebApp" %} +1. Click the **Global Create** button and select **Submit Expense**. 2. Click **Manual**. -3. Enter the amount on the receipt and click **Next**. *Note: Click the currency symbol to select a different currency.* -4. Use the search field to find the desired workspace or an individual’s name, email, or phone number. -5. (Optional) Add a description. -6. Add a merchant. -7. Click **Show more** to add additional fields (like a category) as desired, or as required by your workspace. +3. Enter the currency and amount. +4. Click **Next**. +5. Enter the desired workspace or an individual’s email or phone number to receive the expense report. +6. Add a description, category, tags, or tax as desired or as required by your workspace. Click **Show More** to see all coding options. +7. (Optional) Enable the expense as billable if it should be billed to a client. 8. Click **Submit**. {% include end-option.html %} {% include option.html value="mobile" %} -1. Tap the + icon at the bottom of the screen and select **Submit Expense**. +1. Tap the **Global Create** button and select **Submit Expense**. 2. Tap **Manual**. -3. Enter the amount on the receipt and tap **Next**. *Note: Click the currency symbol to select a different currency.* -4. Use the search field to find the desired workspace or an individual’s name, email, or phone number. -5. (Optional) Add a description. -6. Add a merchant. -7. Tap **Show more** to add additional fields (like a category) as desired, or as required by your workspace. +3. Enter the currency and amount. +4. Tap **Next**. +5. Enter the desired workspace or an individual’s email or phone number to receive the expense report. +6. Add a description, category, tags, or tax as desired or as required by your workspace. Tap **Show More** to see all coding options. +7. (Optional) Enable the expense as billable if it should be billed to a client. 8. Tap **Submit**. {% include end-option.html %} @@ -72,54 +87,65 @@ You can also forward receipts to receipts@expensify.com using an email address t {% include selector.html values="desktop, mobile" %} -{% include option.html value="desktop" %} -1. Click the + icon in the bottom left menu and select **Submit Expense**. +{% include option.html value="desktop or WebApp" %} +1. Click the **Global Create** button and select **Submit Expense**. 2. Click **Distance**. 3. Click **Start** and enter the starting location of your trip. -4. Click **Stop** and enter the ending location of your trip. -5. (Optional) Click **Add stop** to add additional stops, if applicable. +4. Click **Stop** and enter the ending location of your trip. +5. (Optional) Click **Add Stop** to add additional stops, if applicable. Drag and drop on the parallel lines (=) to reorder the stops if needed. 6. Tap **Next**. -7. Use the search field to find the desired workspace or an individual’s name, email, or phone number. -8. (Optional) Add a description. -9. Click **Submit**. +7. Enter the desired workspace or an individual’s email or phone number to receive the expense report. +8. Add a description, category, tags, or tax as desired or as required by your workspace. Click **Show More** to see all coding options. +9. (Optional) Enable the expense as billable if it should be billed to a client. +10. Click **Submit**. {% include end-option.html %} {% include option.html value="mobile" %} -1. Tap the + icon at the bottom of the screen and select **Submit Expense**. +1. Tap the **Global Create** button and select **Submit Expense**. 2. Tap **Distance**. 3. Tap **Start** and enter the starting location of your trip. -4. Tap **Stop** and enter the ending location of your trip. -5. (Optional) Tap **Add stop** to add additional stops, if applicable. -6. Tap **Next**. -7. Use the search field to find the desired workspace or an individual’s name, email, or phone number. -8.(Optional) Add a description. -9. Tap **Submit**. +4. Tap **Stop** and enter the ending location of your trip. +5. (Optional) Tap **Add Stop** to add additional stops, if applicable. Drag and drop on the parallel lines (=) to reorder the stops if needed. +6. Tap Next. +7. Enter the desired workspace or an individual’s email or phone number to receive the expense report. +8. Add a description, category, tags, or tax as desired or as required by your workspace. Tap **Show More** to see all coding options. +9. (Optional) Enable the expense as billable if it should be billed to a client. +10. Click **Submit**. {% include end-option.html %} {% include end-selector.html %} -# Next Steps +# Next Steps for expenses sent to an Individual -The next steps for the expense depend on whether it was submitted to a workspace or to an individual: -- **Expenses submitted to a workspace** are automatically added to a report and checked for any violations or inconsistencies. A chat thread for the expense is also added to your chat inbox. When you open the chat, the top banner will show the expense status and any next steps. By default, reports are automatically submitted for approval every Sunday. However, if it is ready for early submission, you can manually submit a report for approval. Once a report is submitted, your approver will be prompted to review your expense report. If changes are required, you will receive a notification to resolve any violations and resubmit. You will also be notified once your approver approves or denies your expenses. -- **Expenses submitted to a friend** are sent right to that individual via email or text. You can chat with them about the expense in Expensify Chat, and you can receive payments through your Expensify Wallet or outside of Expensify. +- Expenses submitted to an individual are instantly sent. +- The payer will receive an email or text prompting them to review and pay the expense. +- You can chat with the paying individual in Expensify. +- Make sure to [connect your personal bank account](https://help.expensify.com/articles/new-expensify/expenses-&-payments/Connect-a-Personal-Bank-Account) to receive payment. -{% include faq-begin.md %} -**Can I divide a payment between multiple people?** +# Next Steps for expense sent to a workspace -Yes, you can split an expense to share the cost between multiple people. +- Expenses submitted to a workspace are automatically added to a report and checked for violations or inconsistencies. +- You can view the details and status of the expense on the **Search** tab. +- Workspace settings determine the frequency of report submission. However, if the report is ready for early submission, you can manually submit a report for approval. +- Once a report is submitted, your approver will get an email or text to review and pay the expense. +- If changes are required, you will receive a notification to fix the expense and resubmit. +- You will also be notified once your approver approves or denies your expenses. +- Make sure to [connect your personal bank account](https://help.expensify.com/articles/new-expensify/expenses-&-payments/Connect-a-Personal-Bank-Account) to receive payment. -**Can I pay someone in another currency?** +{% include faq-begin.md %} +**Can I divide a payment between multiple people?** -While you can record your expenses in different currencies, Expensify wallets are only available for members who can add a U.S. personal bank account. +Yes, you can [split an expense](https://help.expensify.com/articles/new-expensify/expenses-&-payments/Split-an-expense) in a group chat. **Can I change an expense once I’ve submitted it?** -Yes, you can edit an expense until it is paid. When an expense is submitted to a workspace, you, your approvers, and admins can edit the details on an expense except for the amount and date. +Yes, you can edit an expense until it is paid. When an expense is submitted, the details can be edited except for the amount and date. **What are expense reports?** -In Expensify, expenses are submitted on an expense report. When a draft report is open, all new expenses are added to the draft report. Once a report is submitted, it shows what stage of the approval process the expenses are in and any required next steps. +In Expensify, expense reports group expenses in a batch to be paid or reconciled. When a draft report is open, all new expenses are added to it. + +Once a report is submitted, you can track the status from the **Search** section. Click the **View** button for a specific expense or expense report. The status is displayed at the top of the expense or report. {% include faq-end.md %}
    diff --git a/docs/articles/new-expensify/expensify-card/Upgrade-to-the-new-Expensify-Card-from-Visa.md b/docs/articles/new-expensify/expensify-card/Upgrade-to-the-new-Expensify-Card-from-Visa.md index 8fffec75e744..782e939e991e 100644 --- a/docs/articles/new-expensify/expensify-card/Upgrade-to-the-new-Expensify-Card-from-Visa.md +++ b/docs/articles/new-expensify/expensify-card/Upgrade-to-the-new-Expensify-Card-from-Visa.md @@ -12,7 +12,7 @@ When you upgrade the Expensify Cards to the new program, you'll have access to e - Unique naming for each virtual card for simplified expense categorization {% include info.html %} -The Expensify Card upgrade must be completed by November 1, 2024. +The Expensify Card upgrade must be completed by December 1, 2024. {% include end-info.html %} # Upgrade your company’s Expensify Card program diff --git a/docs/articles/new-expensify/expensify-card/Use-your-Expensify-Card.md b/docs/articles/new-expensify/expensify-card/Use-your-Expensify-Card.md index 6c7457641ce6..8915778962a0 100644 --- a/docs/articles/new-expensify/expensify-card/Use-your-Expensify-Card.md +++ b/docs/articles/new-expensify/expensify-card/Use-your-Expensify-Card.md @@ -4,13 +4,13 @@ description: Use your physical or virtual Expensify Card ---
    -As soon as you receive your physical Expensify Visa® Commercial Card, you can start using it right away by swiping it like you would with any other card, or you can link your card to your Apple or Google Pay mobile wallet to make in-person, contactless payments. You can also use your virtual Expensify Card for online and in-app purchases. +As soon as you receive your physical Expensify Visa® Commercial Card, you can start using it right away by swiping it like you would with any other card. You can also link your card to your Apple or Google Pay mobile wallet to make in-person, contactless payments. You can also use your virtual Expensify Card for online and in-app purchases. A virtual card is a digital card that can be used for online transactions. Virtual cards have the same details as physical cards, but they offer several additional benefits: -- **Flexibility**: Virtual cards can be created or deleted instantly. You can use them for individual transactions with predetermined amounts or recurring payments and subscriptions. +- **Flexibility:** Virtual cards can be created or deleted instantly. They can be used for individual transactions with predetermined amounts or recurring payments and subscriptions. - **Customizable limits**: You can set spending limits for each virtual card. -- **Security**: Admins have the option to issue virtual cards for a single-use (e.g. for one of expenses) or fixed-use (e.g. for recurring expenses). Since you have placed a limit on their usage, it makes them less susceptible to unauthorized transactions. -- **Insights**: You can easily track recurring spend for specific vendors when assigning a virtual card to a team, department, or vendor. +- **Security**: Admins have the option to issue virtual cards for a single-use (e.g., for one of the expenses) or fixed-use (e.g., for recurring expenses). Since you have placed a limit on their usage, it makes them less susceptible to unauthorized transactions. +- **Insights**: When assigning a virtual card to a team, department, or vendor, you can easily track recurring spending for specific vendors. # View your virtual card details @@ -34,7 +34,7 @@ A virtual card is a digital card that can be used for online transactions. Virtu {% include faq-begin.md %} -**Why did my transaction get declined?** +## Why did my transaction get declined? Here are some reasons why an Expensify Card transaction might be declined: @@ -43,7 +43,13 @@ Here are some reasons why an Expensify Card transaction might be declined: - **Incorrect card details**: Your card information was entered incorrectly with the merchant. Entering incorrect card information, such as the CVC, ZIP, or expiration date, will also lead to declines. There was suspicious activity - **Fraudulent or risky activity**: If Expensify detects unusual or suspicious activity, we may block transactions as a security measure. This could happen due to irregular spending patterns, attempted purchases from risky vendors, or multiple rapid transactions. Check your Expensify Home page to approve unusual merchants and try again. If the spending looks suspicious, we may complete a manual due diligence check, and our team will do this as quickly as possible - your cards will all be locked while this happens. The merchant is located in a restricted country -**How do I report my Expensify Card expenses?** +## Where can I use my Expensify Card? + +Generally, the Expensify Card can be used anywhere Visa is accepted. However, the Expensify Card program is based in the US, so we are bound by US sanctions and other international limitations. + +Expensify Card purchases will be declined if a merchant is physically located in, or has its headquarters or billing address, in the following countries -- Belarus, Burundi, Cambodia, Central African Republic, Democratic Republic of the Congo, Cuba, Iran, Iraq, North Korea, Lebanon, Libya, Russia, Somalia, South Sudan, Syrian Arab Republic, Tanzania, Ukraine, Venezuela, Yemen, Zimbabwe + +## How do I report my Expensify Card expenses? You can report and submit Expensify Card expenses just like any other expenses, and you’ll want to submit them regularly to ensure you have a sufficient spending amount available on the card. As your expenses are approved, your Smart Limit updates accordingly. diff --git a/docs/assets/images/SageConfigureIntegrationConfigureButton.png b/docs/assets/images/SageConfigureIntegrationConfigureButton.png new file mode 100644 index 000000000000..e3ec52bacbb0 Binary files /dev/null and b/docs/assets/images/SageConfigureIntegrationConfigureButton.png differ diff --git a/docs/assets/images/SageConfigureUserDefinedDimensionsFilter.png b/docs/assets/images/SageConfigureUserDefinedDimensionsFilter.png new file mode 100644 index 000000000000..f126bb10dc51 Binary files /dev/null and b/docs/assets/images/SageConfigureUserDefinedDimensionsFilter.png differ diff --git a/docs/assets/images/SageConnectCreatingWorkspace.png b/docs/assets/images/SageConnectCreatingWorkspace.png new file mode 100644 index 000000000000..6084d0a8c7fb Binary files /dev/null and b/docs/assets/images/SageConnectCreatingWorkspace.png differ diff --git a/docs/assets/images/SageConnectEnableSage.png b/docs/assets/images/SageConnectEnableSage.png new file mode 100644 index 000000000000..25b43a510c15 Binary files /dev/null and b/docs/assets/images/SageConnectEnableSage.png differ diff --git a/docs/assets/images/SageConnectEnterCredentials.png b/docs/assets/images/SageConnectEnterCredentials.png new file mode 100644 index 000000000000..63772972290d Binary files /dev/null and b/docs/assets/images/SageConnectEnterCredentials.png differ diff --git a/docs/assets/images/SageConnectSettingUpWebServicesUser.png b/docs/assets/images/SageConnectSettingUpWebServicesUser.png new file mode 100644 index 000000000000..0fd3bb68c3d2 Binary files /dev/null and b/docs/assets/images/SageConnectSettingUpWebServicesUser.png differ diff --git a/docs/assets/images/SageConnectSubscriptionSettings.png b/docs/assets/images/SageConnectSubscriptionSettings.png new file mode 100644 index 000000000000..2e74d27c71e6 Binary files /dev/null and b/docs/assets/images/SageConnectSubscriptionSettings.png differ diff --git a/docs/assets/images/SageConnectTimeandExpenseSequenceNumbers.png b/docs/assets/images/SageConnectTimeandExpenseSequenceNumbers.png new file mode 100644 index 000000000000..8750c1ed596b Binary files /dev/null and b/docs/assets/images/SageConnectTimeandExpenseSequenceNumbers.png differ diff --git a/docs/assets/images/SageConnectWebServicesAuthorizations.png b/docs/assets/images/SageConnectWebServicesAuthorizations.png new file mode 100644 index 000000000000..d0b9a786d1cc Binary files /dev/null and b/docs/assets/images/SageConnectWebServicesAuthorizations.png differ diff --git a/docs/redirects.csv b/docs/redirects.csv index 06fd7c1ef502..d9f18ebb0227 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -591,3 +591,10 @@ https://help.expensify.com/articles/expensify-classic/articles/expensify-classic https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Pay-Bills,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Create-and-Pay-Bills https://help.expensify.com/articles/new-expensify/billing-and-subscriptions/add-a-payment-card-and-view-your-subscription,https://help.expensify.com/articles/new-expensify/billing-and-subscriptions/Add-a-payment-card-and-view-your-subscription https://help.expensify.com/articles/new-expensify/billing-and-subscriptions/Billing-page-coming-soon,https://help.expensify.com/articles/new-expensify/billing-and-subscriptions/Billing-page +https://help.expensify.com/articles/expensify-classic/expenses/Add-expenses-in-bulk,https://help.expensify.com/articles/expensify-classic/expenses/Add-an-expense +https://help.expensify.com/articles/expensify-classic/expenses/Track-group-expenses,https://help.expensify.com/articles/expensify-classic/expenses/Add-an-expense +https://help.expensify.com/articles/expensify-classic/expenses/Track-mileage-expenses,https://help.expensify.com/articles/expensify-classic/expenses/Add-an-expense +https://help.expensify.com/articles/expensify-classic/expenses/Track-per-diem-expenses,https://help.expensify.com/articles/expensify-classic/expenses/Add-an-expense +https://community.expensify.com/discussion/5116/faq-where-can-i-use-the-expensify-card,https://help.expensify.com/articles/new-expensify/expensify-card/Use-your-Expensify-Card#where-can-i-use-my-expensify-card +https://help.expensify.com/articles/other/Expensify-Lounge,https://help.expensify.com/Hidden/Expensify-Lounge +https://help.expensify.com/articles/new-expensify/expenses-&-payments/Approve-and-pay-expenses,https://help.expensify.com/articles/new-expensify/expenses-&-payments/Approve-expenses diff --git a/help/GUIDELINES.md b/help/GUIDELINES.md new file mode 100644 index 000000000000..7fbf693e6830 --- /dev/null +++ b/help/GUIDELINES.md @@ -0,0 +1,150 @@ +# New Help Guidelines +This file outlines a series of specific rules. Whenever editing any file on this site, please verify your changes comply with these rules. + +## General Philosophy +In general, this help site is built around a few common principles: + +* **Consistency** - Every page of the site should follow a common pattern, as should every chapter on the page, and every section in the chapter +* **Focus** - Every section should focus as much as possible on a single self-contained subset of the page, with complex subsets being broken into section groups rather than large singular sections +* **Plain language** - All writing should target a high-school reading level, with very common language and simple phrasings. + + +## Structure Rules +To avoid ambiguity, let's establish the following terms: + +* **Site** - All of the pages combine to create a single help "site" providing comprehensive details on the Expensify Superapp, which is a collection of multiple products combined into a single app. + +* **Page** - Each help "page" is devoted to a single product within a tightly integrated suite. Accordingly, while each product page can refer to other products, each product page should only provide detailed definitions on a single product to avoid redundancy between product pages. Each product is split into multiple + +* **Chapter** - Each page is split into a standard set of "chapters", each of which contains multiple sections. + +* **Section** - Each chapter has three or more "sections", consisting of a header and body. +[Fr +* **Header** - Each section has a "header", which describes the contents of that section. + +* **Body** - Each section has a "body", which contains the contents of that section. + + +## Chapter Rules +Every page has exactly four "top level" chapters, which are given `##` (H2) headers: + +* **Introduction** - This chapter is devoted to very high level, jargon-free marketing language explaining the benefits of the product in clear and simple prose. The Introduction chapter has exactly three sections: + + * *Main uses* - This section has a definition list summarizing the key scenarios in which this product would be used. + + * *Core users* - This section has a definition list summarizing the key audiences that use this product. + + * *Key advantages* - This section has a definition list summarizing the major benefits of this product over the competition. + +* **Concepts** - This chapter is devoted to establishing a clear, unambiguous lexicon for discussing this product. It contains three or more definition list sections or section groups. It does not contain any how-to or FAQ sections, the Concepts section is entirely focused on establishing the concepts themselves, not explaining how to use them. + +* **Tutorials** - This chapter is devoted to providing detailed step-by-step instructions on how to accomplish certain goals. This chapter contains three or more how-to sections or section groups. Everything in the Tutorial should be consistent with the language established in the Concepts. + +* **FAQ** - This chapter provides focused answers to very specific questions that are easily misunderstood or otherwise don't fit perfectly in the above chapters. This chapter contains three or more FAQ-style sections or section groups. The FAQ does not define any new terms (only the Concepts section does that), and does not give any step-by-step instructions (only the Tutorials section does that). + +Anything outside of these four chapters should be moved within the relevant chapter, following the section guidelines for that chapter. + + +## Header Rules +There are two kinds of headers: + +* **Short headers** - These are titles that are limited to 1-3 short words, such that it will fit into the "left hand nav" containing the table of contents, without "wrapping" around. Short titles capitalize major words. For example, this would be a short title: + + ``` + # Platforms + ``` + +* **Long headers** - These are longer titles (4+ words), prefixed with a short title in square brackets. This allows for longer and more descriptive titles, while still providing a short title that fits into the left-hand nav comfortably. Long titles ask a complete question, and are capitalized and punctuated like a normal sentence. For example, this would be a long title: + + ``` + # [Platforms] Where can I use the Expensify App? + ``` + +* To avoid confusion, no two sections in the same chapter or section group should have the same short or long title. + +* Headers that contain questions should be asked from the customer's perspective (ie, "How do I X?" not "How do you do X?") + + +## Section Rules +There are three kinds of sections: + +### Definition List Sections +A "definition list" type section break a high level concept into smaller pieces, and consists of: + +* A "long header" describing the topic being deconstructed and defined, generally starting with "What", but never "How" or "Why". +* 1-2 introductory sentences, explaining the theme of the list +* An unnumbered bullet list, where each bullet consists of: + * A bolded term of 1-3 words + * A clear definition or description of the term, in 1-3 complete sentences. +* Nothing should exist in the section after the bullet list + +An example of a definition list section follows: + + ``` + # [Fruit] What are the best fruits? + It's well known that these are the best fruits: + + * **Apples** - The king of fruit. So crispy. + * **Oranges** - Often seen as diametrically opposed. But still delish. + * **Tomato** - Some people don't know this is a fruit. But it is. + ``` + +### How-to List Sections +A "how-to list" type section gives sequential steps to accomplish a goal, and consists of: + +* A "long header" describing the goal of the tutorial, starting with "How". +* 1-2 introductory sentences, explaining the goal of the tutorial +* A numbered list, where each step consists of a single sentence covering: + * A specific UI element to press or type into, if any, in bold + * An explanation of the benefit of doing this + * Each step describes exactly one user action; do not combine multiple actions into a single step +* Confirm the sum of the steps accomplishes the clearly stated goal +* Confirm every concept mentioned in the tutorial has a corresponding definition in the Concepts section +* Nothing should exist in the section after the numbered list + +An example of a how-to section follows: + + ``` + # [Email] How do I send an email? + Email is the easiest way to write someone. To send an email: + + 1. Press the **Email** app icon, to open the app. + 2. Press the **Compose** button, to start writing the email. + 3. Enter the address you want to send to into the **To** field, so it gets to the right person. + 4. Provide a subject of the email in the **Subject** field, to entice them to open the email. + 5. Write the email into the large blank body, to detail the message. + 6. Press the **Send** button, to deliver it to its addressed recipient. + ``` + +### Frequently Asked Question (FAQ) Sections +A "FAQ" type section gives a detailed answer to a single question, often to explain the non-obvious reasoning behind something, and consists of: + +* A "long header", asking a specific question, generally starting with "Why" + * Note: A FAQ cannot ask a "How do I...?" question -- move this to the Tutorials chapter and use a HowTo section +* 1 paragraph answering the question, in 2-4 comprehensive sentences. + * Note: A FAQ cannot have a bullet list -- move this to the Concepts chapter and use a definition list section + * Note: A FAQ cannot have a numbered list -- move this to the Tutorials chapter and use a HowTo section + + +## Section Group Rules +When the Concepts, Tutorials, or FAQ chapters have 6 or more sections, those sections can optionally be split into two or more "section groups". Each section group is given a "H3" header (`###`), and consists of: + +* A short header, named after the common theme of the sections of the section group +* 3-6 sections, of any type + + +## Cross Platform Rules +All instructions should be written in a fashion to work across all platforms (web, mobile, desktop, native, etc). Accordingly, the language should to the greatest degree possible be written in such a fashion that works across all platforms. Specifically: + +* Where possible, use a cross-platform verb. For example, do not say "click" or "tap", say "press" +* If there is no suitable cross-platform term, briefly explain how to do the equivalent action on both platforms. For example, "right-click or long-tap to open the context menu..." +* For anything that has no equivalent, clarify which platform the instruction refers to. For example: "If you have a mouse, hover over the chat to see the hover menu..." + +## General Language Rules +To ensure that the content always sounds consistent: + +* "You" always refers to the reader, who is a user and customer of Expensify +* "We" refers to the company Expensify, who is the author of the superapp this is documenting. +* Any use of "we" could be replaced with "Expensify" and would still work. +* The help documentation is in effect the product/company talking directly to the user, in the first person. + diff --git a/help/_config.yml b/help/_config.yml index 11091b1a8b7c..407dfe9fea91 100644 --- a/help/_config.yml +++ b/help/_config.yml @@ -8,3 +8,4 @@ github_username: expensify # Ignore what's only used for the Github repo exclude: - README.md + - GUIDELINES.md diff --git a/help/_layouts/default.html b/help/_layouts/default.html index cf8c7feeaea0..7fcd95c1b325 100644 --- a/help/_layouts/default.html +++ b/help/_layouts/default.html @@ -85,13 +85,27 @@ .toc-sidebar li { margin-left: 0; - padding-left: 10px; + padding-left: 0; + } + + .js-toc > ul > li > a { + font-weight: bold; + font-size: 18px; + } .js-toc > ul > li > ul > li { margin-top: 25px; } + .js-toc > ul > li > ul > li > a { + font-weight: bold; + } + + .js-toc > ul > li > ul > li > ul > li > ul > li { + padding-left: 10px; + } + .toc-sidebar a { word-wrap: break-word; display: block; @@ -110,20 +124,9 @@ .toc-sidebar .is-active-link { background-color: #eaf5ff; color: #0366d6; - font-weight: bold; border-radius: 6px; } - a:has(+ ul.is-collapsible)::after { - content: '∧'; /* Use the logical AND symbol */ - display: inline; /* Ensure the caret appears directly after the content */ - margin-left: 5px; /* Add some space between the text and the caret */ - transform: rotate(180deg); /* Rotate the caret 180 degrees */ - display: inline-block; /* Required to apply transform */ - position: relative; /* Enables positioning adjustments */ - top: 3px; /* Moves the caret down 3 pixels */ - } - /* Main content area */ main { margin-left: 300px; @@ -161,7 +164,7 @@ } .is-active-link { - font-weight: bold; + font-weight: normal; } .scroll-spacer { @@ -211,7 +214,7 @@ .footer-column { flex: 1; max-width: 300px; /* Set a max-width for each column */ - padding: 0 20px; /* Add padding for some space between the columns */ + padding: 0 20px; /* Add padding for some space between the columns */ } @@ -270,8 +273,8 @@

    Resources

  • Press Kit
  • Support
  • ExpensifyHelp
  • -
  • Community
  • -
  • Privacy
  • +
  • Terms of Service
  • +
  • Privacy
  • Expensify App
  • diff --git a/help/card.md b/help/card.md index c6a457629643..1ed51daf7713 100644 --- a/help/card.md +++ b/help/card.md @@ -5,14 +5,14 @@ title: Expensify Cards ## Introduction The Expensify Card is a corporate payment card that integrates seamlessly with Expensify Expense, allowing you to manage company spending in real-time. By enforcing your company’s expense policy at the point of sale, the Expensify Card eliminates the need for manual receipt tracking, reduces fraud, and ensures compliance with expense policies. Whether you’re looking for simplified expense management, real-time control, or cashback rewards, the Expensify Card is designed to meet your needs. -### [Main uses] When should I use the Expensify Card? +### Main uses The Expensify Card is ideal for any business looking to streamline its expense management and control employee spending. Key use cases include: * **Enforcing company policy** - Ensure that purchases are compliant with your company’s expense policy automatically at the point of sale. * **Automating expense tracking** - Eliminate manual receipt entry by capturing expenses automatically with every card transaction. * **Real-time spending control** - Gain immediate insight into employee spending, and control purchases with dynamic card limits. * **Earning cashback** - Get rewarded for company spending with up to 2% cashback on all purchases. -### [Core users] Who uses the Expensify Card? +### Core users The Expensify Card is a valuable tool for companies of all sizes, from startups to large enterprises. Some common users include: * **Small businesses** - Manage corporate spending efficiently without the complexity of traditional corporate cards. * **Enterprises** - Gain full visibility into employee spending and ensure compliance across all transactions. @@ -20,7 +20,7 @@ The Expensify Card is a valuable tool for companies of all sizes, from startups * **Nonprofits** - Track and control organizational spending while ensuring that all expenses align with donor guidelines. * **Accountants** - Streamline reimbursement and auditing processes by eliminating manual entry and simplifying receipt management. -### [Key advantages] Why should I use the Expensify Card? +### Key advantages The Expensify Card offers unique advantages for companies looking to optimize their expense management: * **Policy enforcement at the point of sale** - Automatically enforce your company’s expense policy when employees use the card, ensuring that only approved purchases go through. * **Real-time visibility** - See employee spending as it happens, with every transaction instantly visible in Expensify Expense. @@ -31,11 +31,10 @@ The Expensify Card offers unique advantages for companies looking to optimize th * **Fraud reduction** - Reduce fraud by limiting card use to specific categories or vendors, and by gaining full visibility into all transactions. ## Concepts -Expensify Cards introduce several concepts that redefine corporate spending management. ### [Policy enforcement] How does the Expensify Card enforce company policy? The Expensify Card is designed to automatically enforce your company’s expense policy: -* **Policy-based approvals** - Transactions are approved or denied based on predefined expense categories, amounts, or vendor types. For example, purchases outside of approved categories (e.g., entertainment, personal items) can be blocked in real-time. +* **Policy-based approvals** - Transactions are approved or denied based on predefined expense categories, amounts, or vendor types. * **Spending limits** - Set individual or department-level spending limits that the card will automatically enforce. * **Real-time monitoring** - Managers and admins can view all transactions as they happen, allowing them to flag or approve expenses in real-time. @@ -62,61 +61,232 @@ The Expensify Card includes several features that reduce the risk of fraud: * **Real-time visibility** - Track all card transactions as they happen, making it easy to identify and address suspicious activity. * **Dynamic limits** - Adjust spending limits in real-time, so if an employee is in a situation where they need additional funds, it can be handled securely. -## Platforms -The Expensify Card works seamlessly across all platforms, ensuring that your company’s expense management is fully integrated: -* **Web app** - Manage Expensify Cards and monitor transactions from the Expensify web app. -* **Mobile app** - Employees can use the Expensify mobile app to track expenses, view transactions, and manage their cards on the go. -* **Desktop app** - Full control of Expensify Cards is available through the Expensify desktop app for Mac and Windows, making it easy for admins to manage policies and review transactions. +### [Virtual Cards] What are the benefits of using virtual Expensify Cards? +Virtual cards are digital cards designed for online transactions with several benefits: +* **Flexibility** - Create or delete virtual cards instantly for transactions with predetermined amounts or recurring payments. +* **Customizable limits** - Set spending limits for each virtual card. +* **Security** - Issue virtual cards for single-use or recurring expenses to reduce the risk of unauthorized transactions. +* **Insights** - Track recurring spend for specific vendors by assigning a virtual card to a team, department, or vendor. ## Tutorials -### [Issue a card] How do I issue an Expensify Card to an employee? -1. Go to **Settings** > **Cards** in the Expensify app. -2. Press **Issue Card** and select the employee from the list. -3. Set an initial spending limit, and assign the card to the employee’s workspace. -4. The employee will receive an email with instructions to activate their card. +### Getting Started +#### [Enable Expensify Card] How do I enable the Expensify Card for my workspace? +To enable the Expensify Card for your workspace, you must be a Workspace Admin. Follow these steps: + +1. Press your profile image or icon in the menu. +2. Scroll and press **Workspaces** in the menu. +3. Select the workspace you want to enable Expensify Cards for. +4. Press **More features** in the menu. +5. Under the **Spend** section, enable the Expensify Card toggle. + +#### [Select a bank account] How do I select a bank account for the Expensify Card? +Before issuing Expensify Cards, connect them with a bank account. Here's how: + +1. Press **Expensify Card** in the menu. +2. Press **Issue new card**. +3. Select an existing bank account or follow the steps to add a new one. + +### Card Management +#### [Issue a card] How do I issue an Expensify Card to an employee? +To issue an Expensify Card to an employee, follow these steps: + +1. Press **Issue card**. +2. Select the employee you want to issue the card to. +3. Choose to issue a physical or virtual card. +4. Pick a smart, monthly, or fixed limit. +5. Enter the limit amount and add a card name. +6. Press **Issue card** to confirm and issue the card. + +#### [Adjust limits] How do I adjust spending limits on an Expensify Card? +To adjust spending limits on an Expensify Card, follow these steps: -### [Adjust limits] How do I adjust spending limits on an Expensify Card? 1. Go to **Settings** > **Cards**. 2. Select the employee’s card from the list. 3. Press **Edit Limits** and adjust the spending limit for the card. 4. Press **Save** to apply the new limit. -### [View transactions] How do I track Expensify Card transactions? +#### [Manage Expensify Cards] How do I manage my issued Expensify Cards? +To manage your issued Expensify Cards, you must be a Workspace Admin. Follow these steps: + +1. Press your profile image or icon in the bottom left menu. +2. Scroll down and press **Workspaces** in the left menu. +3. Select the workspace containing the desired Expensify Cards. +4. Press **Expensify Card** in the left menu to see a list of all issued cards. +5. Press a card row to view details or adjust the card limit, limit type, name, or deactivate it. +6. Press **Settings** in the top right to adjust the settlement account or change the settlement frequency. + +### Transactions and Tracking +#### [View transactions] How do I track Expensify Card transactions? +To track Expensify Card transactions, follow these steps: + 1. Navigate to the **Expenses** section in the Expensify app. 2. Filter by **Expensify Card** to view all transactions made using the card. 3. Select any transaction to view the details, including receipts and categorization. -### [Manage policies] How do I enforce a company policy using Expensify Cards? -1. Go to **Settings** > **Policies**. -2. Select the policy to apply to your Expensify Cards. -3. Under **Spending Rules**, set category and spending restrictions. -4. Press **Save** to ensure all Expensify Card transactions follow these rules. +#### [Dispute a transaction] How do I dispute an Expensify Card transaction? +If you encounter a transaction error, you can dispute it by following these steps: -## FAQ +1. Contact the merchant to try and resolve the issue directly. +2. If unresolved, contact Expensify by opening a chat with Expensify Concierge or emailing concierge@expensify.com with details of the disputed charge and supporting documentation. +3. If you suspect fraud, immediately deactivate your card by pressing your profile image, selecting **Wallet**, pressing your Expensify Card, and then **Report card fraud**. Follow the prompts to deactivate and request a new card. +4. Enable [Two-Factor Authentication (2FA)](https://help.expensify.com/articles/new-expensify/settings/Enable-Two-Factor-Authentication) for added security. -### How do I set up the Expensify Card for my company? -To set up the Expensify Card: -1. Go to **Settings** > **Cards**. -2. Follow the prompts to enable the Expensify Card for your company. -3. Issue cards to employees, set spending limits, and define company policies for card usage. +### Digital Wallet and Notifications +#### [Add to Wallet] How do I add the Expensify Card to my digital wallet? +To use your Expensify Card for contactless payments, add it to your Apple or Google Pay digital wallet: + +**Apple Pay** + +1. Open the **Wallet** app on your device. +2. Press the **+** button to add a new card. +3. Select **Debit or Credit Card**. +4. Press **Continue** and follow the instructions to add your virtual Expensify Card. + +**Google Pay** + +1. Open the **Google Pay** app on your device. +2. Press **Add to Wallet**. +3. Select **Payment Card** and then **Add new debit or credit card**. +4. Enter your virtual Expensify Card details to complete the process. + +#### [Enable Notifications] How do I enable notifications for my Expensify Card? +To receive real-time notifications for spending activity on your Expensify Card, follow these steps: + +1. From your Expensify Chat inbox, press the dropdown on the logo or avatar in the top left corner. +2. Select the workspace you want to update the notification settings for. +3. Press the workspace chat in your inbox (the chat with your workspace’s name as the title). +4. Press the header at the top of the chat. +5. Press **Settings**. +6. Press **Notify me about new messages** and select **Immediately**. + +Then, enable notifications on your device: + +**iPhone** + +1. Go to your device settings. +2. Find and tap **New Expensify**. +3. Tap **Notifications** and enable notifications. +4. Customize your alerts. Depending on your phone model, you may have extra options to customize the types of notifications you receive. + +**Android** + +1. Go to your device settings. +2. Tap **Notifications** and select **Apps notifications**. +3. Find and tap **New Expensify**. +4. Enable notifications. +5. Customize your alerts. Depending on your phone model, you may have extra options to customize the types of notifications you receive. + +You will now receive real-time spend notifications to your mobile device. -### How does the Expensify Card enforce my company’s expense policy? -The Expensify Card automatically enforces your company’s expense policy by: -* Blocking purchases outside of approved categories. -* Enforcing spending limits in real-time. -* Providing real-time visibility into employee spending for managers. +### Card Details and Limits +#### [Update Mailing Address] How do I update my Expensify Card mailing address? +To update your mailing address for your Expensify Card, follow these steps: -### How do employees submit expenses with the Expensify Card? +1. Hover over **Settings** and press **Account**. +2. Press the **Credit Card Import** tab. +3. Press **Request a New Card** on your physical card pending activation. +4. Select **I lost my card**. If you’re updating your address to receive your new Expensify Visa® Commercial Card, select this option even though you have not lost a card. +5. Confirm your details and press **Continue**. +6. Update your address and press **Continue**. If the new card has already been shipped to an incorrect address, proceed to the next step to resend the card to the newly updated address. +7. Proceed with the card replacement. Your new card will arrive in 2-3 business days. + +#### [Check Card Limit] How do I check my Expensify Card limit? +The Smart Limit of your Expensify Card updates automatically after each purchase. To check your available Smart Limit, follow these steps: + +1. Press your profile image or icon in the menu. +2. Press **Wallet**. +3. Press your Expensify Card to see the available Smart Limit. + +### Upgrading and Virtual Card Details +#### [Upgrade Cards] How do I upgrade to the new Expensify Visa® Commercial Card? +To upgrade your company’s Expensify Cards to the new Expensify Visa® Commercial Card, follow these steps: + +1. On the **Home** page, press the task titled "Upgrade to the new and improved Expensify Card." +2. Review and agree to the **Terms of Service**. +3. Press **Get the new card** to automatically mail new physical cards to existing cardholders with limits greater than $0 and issue virtual cards for immediate use. +4. If Positive Pay is enabled, contact your bank to whitelist the new ACH ID: 2270239450. +5. Remind employees to update payment information for recurring charges to their virtual card information. + +Existing cards remain active until deactivated by a Domain Admin or the cardholder. Cards won't be issued to employees who don't currently have them; you'll need to [issue a new card](https://help.expensify.com/articles/expensify-classic/expensify-card/Set-Up-the-Expensify-Visa%C2%AE-Commercial-Card-for-your-Company) for them. + +#### [View Virtual Card Details] How do I view my virtual Expensify Card details? +To view your virtual card details in Expensify, follow these steps: + +1. Press your profile image or icon in the menu. +2. Press **Wallet**. +3. Press your Expensify Card. +4. Press **Reveal Details** to view your virtual Expensify Card number, expiration date, CVV, and address. + +## FAQs + +### Usage and Setup +#### Why should I use Expensify Expense for my business? +The Expensify Card is a corporate payment card that integrates seamlessly with Expensify Expense, providing simplified expense management, real-time control, and cashback rewards. + +#### How do I set up the Expensify Card for my company? +To set up the Expensify Card, go to **Settings** > **Cards**, follow the prompts to enable the Expensify Card for your company, issue cards to employees, set spending limits, and define company policies for card usage. + +#### How does the Expensify Card enforce my company’s expense policy? +The Expensify Card automatically enforces your company’s expense policy by blocking purchases outside of approved categories, enforcing spending limits in real-time, and providing real-time visibility into employee spending for managers. + +### Expense Submission and Tracking +#### How do employees submit expenses with the Expensify Card? Employees don’t need to manually submit expenses with the Expensify Card. Each transaction is automatically recorded, categorized, and attached to an expense report. Receipts are automatically captured and matched with transactions, eliminating the need for manual entry. -### Can I track transactions in real-time? +#### Can I track transactions in real-time? Yes, the Expensify Card provides real-time visibility into all transactions. Admins and managers can monitor employee spending as it happens, ensuring full control and oversight. -### What rewards do I earn with the Expensify Card? +### Rewards and Benefits +#### What rewards do I earn with the Expensify Card? The Expensify Card offers up to 2% cashback on all purchases. Cashback can be applied directly to reduce your monthly Expensify bill, or used to offset other company expenses. -### How do I control where employees can use their Expensify Cards? +#### How do I control where employees can use their Expensify Cards? You can control employee card usage by setting vendor and category restrictions. For example, you can restrict cards to be used only for travel-related purchases, or limit spending to certain vendors. These restrictions are enforced at the point of sale. +### Transaction Issues +#### Why did my transaction get declined? +Here are some reasons why an Expensify Card transaction might be declined: + - **Insufficient card limit**: If a transaction exceeds your Expensify Card’s available limit, the transaction will be declined. Submitting expenses and getting them approved will free up your limit for more spending. + - **Inactive card**: Your card isn’t active yet or it was disabled by your Domain Admin. + - **Incorrect card details**: Your card information was entered incorrectly with the merchant. Entering incorrect card information, such as the CVC, ZIP, or expiration date, will also lead to declines. + - **Fraudulent or risky activity**: If Expensify detects unusual or suspicious activity, we may block transactions as a security measure. + +### Expense Reporting +#### How do I report my Expensify Card expenses? +You can report and submit Expensify Card expenses just like any other expenses, and you’ll want to submit them regularly to ensure you have a sufficient spending amount available on the card. As your expenses are approved, your Smart Limit updates accordingly. + +SmartScanned receipts should automatically attach to the related Expensify Card expense. Expensify also automatically generates an IRS-compliant eReceipt for every transaction as long as the expense isn’t lodging-related. If your organization doesn’t require itemized receipts, you can rely on eReceipts instead. + +### Fraud Protection +#### How am I protected from fraud using the Expensify Card? +Expensify uses sophisticated algorithms to detect and block unusual card activity. You can also enable real-time notifications to receive alerts each time your card is charged. + +#### How long does the dispute process take? +The dispute process can take up to 90 days. + +#### Can I cancel a dispute? +You can cancel a filed dispute by using your Expensify Chat thread with Concierge or by emailing concierge@expensify.com. + +### Account and Usage Requirements +#### Do I need a specific type of bank account to use the Expensify Card? +The Expensify Card requires a US business bank account opened in the name of a business incorporated in the US. + +#### Can I use the Expensify Card across multiple workspaces? +You can use the Expensify Card on every workspace you create. However, a settlement account can only be used with the Expensify Card on one workspace. + +#### Can I issue multiple cards to the same employee? +You can issue an unlimited number of both physical and virtual cards to employees, supporting a variety of use cases. + +### Upgrading and Reconciliation +#### Why don’t I see the task to agree to new terms on my Home page? +There are several reasons why the task to accept new terms might not appear: + - You may not be a Domain Admin. + - Another domain admin has already accepted the terms. + - The task might be hidden. Scroll to the bottom of the Home page and press **Show Hidden Tasks** to view all tasks. + +#### Will upgrading affect the continuous reconciliation process? +The upgrade process won't affect continuous reconciliation. During the transition, you may have employees with both old and new cards, resulting in two separate debits for your settlement account per period. Once all spending transitions to the new cards, you'll only see one settlement. +#### Do I have to upgrade to the new Expensify Visa® Commercial Card? +Yes, an upgrade to the new Expensify Visa® Commercial Card is necessary. A deadline will be provided soon, but you'll have ample time to complete the upgrade. \ No newline at end of file diff --git a/help/chat.md b/help/chat.md index c3b684874974..b46d1bec1066 100644 --- a/help/chat.md +++ b/help/chat.md @@ -2,136 +2,246 @@ layout: product title: Expensify Chat --- + ## Introduction -Expensify Chat is a full-featured business chat tool, seamlessly integrated into the Expensify Superapp. It enables real-time collaboration with your team, clients, vendors, and friends, offering a powerful, Slack-style chat experience. Expensify Chat provides all the features you expect from a modern chat tool, including chat rooms, direct messages, file sharing, image attachments, emoji reactions, and threaded conversations. - -### [Main uses] When should I use Expensify Chat? -Expensify Chat is designed for teams and businesses of all sizes to facilitate communication and collaboration. Use Expensify Chat to: -* **Collaborate with teammates** - Create chat rooms and direct messages to discuss projects, share updates, and work together in real-time. -* **Support clients** - Manage client conversations with ease, keeping all discussions, invoices, and approvals in one place. -* **Engage with vendors** - Communicate with your vendors to negotiate, place orders, and track payments. -* **Coordinate with friends** - Keep in touch with friends and colleagues using direct messages or group chats. - -### [Core users] Who uses Expensify Chat? -Expensify Chat is for everyone who needs to stay connected and collaborate, including: -* **Teams** - Coordinate work, share updates, and resolve issues quickly with real-time chat rooms and message threads. -* **Remote workers** - Stay connected with your team from anywhere, with desktop and mobile chat apps that support real-time communication. -* **Clients** - Provide seamless client communication, allowing you to manage projects and billing through the same platform. -* **Vendors** - Manage vendor communication, ensuring that orders, invoices, and payments are all handled in one platform. -* **Friends and family** - Expensify Chat is also great for personal conversations, making it easy to chat and share files with anyone who has an email address. - -### [Key advantages] Why should I use Expensify Chat? -Expensify Chat offers unique benefits that set it apart from other business chat tools: -* **Integrated with Expensify** - Unlike standalone chat apps, Expensify Chat is fully integrated with the Expensify Superapp, giving you access to all your expenses, invoices, payments, and chats in one platform. -* **Real-time communication** - Instantly message anyone with an email address or phone number, whether they are part of your organization or an external client or vendor. -* **Threads and reactions** - Organize conversations with threaded replies and react to messages with emojis to keep discussions focused and fun. -* **File sharing and attachments** - Share files, images, and links directly within your chats for easy collaboration. -* **Searchable history** - Expensify Chat allows you to search through all conversations, so you never lose track of important discussions or files. -* **Cross-device functionality** - Stay connected with your team from anywhere, with support for both desktop and mobile apps. +Expensify Chat is a tool for real-time collaboration with a Slack-style experience. + +### Main Uses +Key scenarios for using Expensify Chat: + +* **Team collaboration** - Discuss projects and share updates in real-time. +* **Client support** - Keep all client communications and approvals in one place. +* **Vendor engagement** - Communicate with vendors for negotiations and orders. +* **Friend coordination** - Stay in touch with friends through direct messages or group chats. + +### Core Users +The main audiences for Expensify Chat: + +* **Teams** - Coordinate work and resolve issues quickly. +* **Remote workers** - Stay connected via desktop and mobile apps. +* **Clients** - Manage projects and billing seamlessly. +* **Vendors** - Handle orders and payments efficiently. +* **Friends and family** - Easy personal conversations with file sharing. + +### Key Advantages +Benefits of using Expensify Chat: + +* **Integration with Expensify** - Access expenses, invoices, and chats in one place. +* **Instant communication** - Message anyone with an email or phone number. +* **Organized discussions** - Threaded replies and emoji reactions. +* **Easy file sharing** - Directly share files and images within chats. +* **Searchable history** - Find past conversations and files effortlessly. +* **Cross-device support** - Stay connected on desktop and mobile. ## Concepts -Expensify Chat introduces several key features that make it a powerful communication tool. - -### [Chat rooms] How do chat rooms work in Expensify Chat? -Chat rooms are the core feature of Expensify Chat, allowing groups of people to collaborate in real-time: -* **Create rooms** - You can create public or private rooms for your team, clients, or vendors. Public rooms are open to anyone in your workspace, while private rooms require an invitation. -* **Invite members** - Invite anyone with an email address or SMS number to join a chat room, even if they aren’t on Expensify yet. -* **Threads** - Keep conversations organized by replying to specific messages in a thread. This is useful for discussing multiple topics in a single room. - -### [Direct messages] What are direct messages? -Direct messages are private, one-on-one conversations between two users: -* **One-to-one messaging** - Use direct messages for private conversations with teammates, clients, or friends. -* **Send files** - Attach images, documents, and links directly in your one-on-one conversations. -* **Searchable** - All direct messages are fully searchable, so you can easily find past conversations or files. - -### [File sharing] How do I share files in Expensify Chat? -Expensify Chat makes it easy to share files and attachments: -* **Upload files** - You can upload images, documents, PDFs, and other files directly into any chat room or direct message. -* **Preview files** - View shared files directly in the chat without having to download them. -* **Download files** - All shared files can be downloaded for offline use or further collaboration. - -### [Emoji reactions] How do emoji reactions work in Expensify Chat? -Emoji reactions add a fun and efficient way to respond to messages: -* **React to messages** - Simply click the emoji icon under any message to react with an emoji. Reactions are visible to everyone in the conversation. -* **Multiple reactions** - You can add multiple reactions to the same message, and others can join in by adding their own reactions. - -### [Threads] How do threaded conversations work? -Threads allow you to keep conversations organized within chat rooms: -* **Reply to a specific message** - Instead of creating a new message, you can reply directly to a previous message to start a thread. -* **View threaded replies** - Threads are nested under the original message, making it easy to follow the conversation. -* **Keep discussions organized** - Threads prevent clutter in busy chat rooms by grouping related messages together. - -### [Search] How does search work in Expensify Chat? -Expensify Chat includes a powerful search feature to help you find messages, files, and conversations: -* **Search messages** - Search across all your chat rooms and direct messages to find specific keywords, phrases, or conversations. -* **Search files** - Quickly locate any files shared in chat rooms or direct messages by searching for file names or types. -* **Filter by chat room** - Narrow your search results by limiting them to a specific chat room or direct message. - -## Platforms -Expensify Chat works across multiple platforms, ensuring you can stay connected with your team wherever you are: -* **Web app** - Access Expensify Chat through your browser, with full support for chat rooms, file sharing, and emoji reactions. -* **Mobile app** - Stay connected on the go with the Expensify mobile app, which supports all chat features, including image attachments and notifications. -* **Desktop app** - Use the Expensify desktop app for a more immersive experience, with full support for notifications, file sharing, and threaded conversations. + +### Chat Types +Expensify Chat supports several types of communication: + +* **Private chats** - One-on-one communication. +* **Group chats** - Private conversations with multiple participants. +* **Chat rooms** - Public or private discussions available to workspace members. + +### Special Chat Rooms +Expensify Chat includes special chat rooms for specific purposes: + +#### Admin and Announce +Special rooms in a workspace: + +* **#admins** - Only accessible to Workspace Admins to manage settings and collaborate with other admins. This room includes your Expensify Setup Specialist and, if applicable, your Account Manager. You can also: + - Chat with your dedicated Expensify Setup Specialist. + - Chat with your Account Manager (if you have a subscription with 10 or more members). + - Review changes made to your Workspace settings. + +* **#announce** - For company-wide announcements. By default, all Workspace Members can send messages, but permissions can be updated to allow only admins to post. + +### Update Messaging Permissions in #announce +To allow only admins to post in an #announce room: + +1. Open the #announce room chat in your inbox. +2. Press the room header. +3. Select **Settings**. +4. Choose **Who can post** and select **Admins only**. + +### Reorder Chat Inbox + +Customize the order of chat messages in your inbox by pinning them or changing your message priority: + +* **Pin**: Moves a specific chat to the top of your inbox list. +* **Message priority**: Determines the order of message display: + - **Most Recent**: Shows all chats by the most recent, with pinned chats at the top. + - **#focus**: Displays only unread and pinned chats, sorted alphabetically. + +#### Pin a Message + +To pin a message: + +1. Press and hold (or right-click) a chat in your inbox. +2. Select **Pin**. The chat will be pinned to the top of your inbox. +3. To unpin, repeat this process and select the pin icon again. + +#### Change Message Priority + +To change message priority: + +1. Press your profile image or icon. +2. Select the **Preferences** tab. +3. Choose **Priority Mode** and select either #focus or Most Recent. + +### Leave a Chat Room + +To leave a chat room: + +1. Open the chat room. +2. Press the header or the 3 dot menu icon in the top right. +3. Select **Leave**. After leaving, the chat room will no longer appear in your inbox, and you won't receive notifications from it. + +### Flag Chat Messages + +Flagging a message as offensive (including unwanted behavior or offensive messages or attachments) escalates it to Expensify’s internal moderation team for review. The person who sent the message will be notified of the flag anonymously, and the moderation team will decide what further action is needed. + +Depending on the severity of the offense, messages can be hidden (with an option to reveal) or fully removed. In extreme cases, the sender of the message may be temporarily or permanently blocked from posting. + +Messages sent in public chat rooms are automatically reviewed for offensive content by an automated system. If offensive content is found, the message is sent to Expensify’s internal moderation team for further review. + +To flag a message: + +1. Open the chat in your inbox. +2. Press and hold (or hover over on desktop) the message and select **Flag as offensive**. +3. Select a category: spam, inconsiderate, intimidation, bullying, harassment, or assault. ## Tutorials -### [Create a chat room] How do I create a chat room in Expensify Chat? -1. Navigate to the **Chat** section of the Expensify app. -2. Press **Create Room**. -3. Enter a name for the room and choose whether to make it public or private. -4. Invite members by entering their email addresses or phone numbers. -5. Press **Create** to finalize the room. - -### [Send a direct message] How do I send a direct message? -1. Press **New Message** from the chat screen. -2. Enter the email address or phone number of the person you want to message. -3. Type your message and press **Send**. -4. Optionally, attach files or images by pressing the attachment icon. - -### [React to a message] How do I react to a message with an emoji? -1. Hover over the message you want to react to. -2. Press the **emoji** icon that appears below the message. -3. Choose an emoji from the list, and it will be added to the message. -4. To add more reactions, simply repeat the process. - -### [Start a thread] How do I reply to a message in a thread? -1. Hover over the message you want to reply to. -2. Press the **Reply in thread** button. -3. Type your reply and press **Send**. Your reply will appear nested under the original message. - -### [Search for a message] How do I search for messages or files? -1. Press the **Search** bar at the top of the chat screen. -2. Enter the keyword, phrase, or file name you are looking for. -3. Filter results by chat room or direct message (optional). -4. Press **Search** to view the results. +### [Create Room] How do I create a chat room? +To create a chat room: + +1. Press the **+** button and select **Start Chat**. +2. Choose the **#Room** tab. +3. Enter a name for the room (ensure it's unique within the workspace). +4. Optionally, add a description. +5. Select **Workspace** to assign the room to a workspace. +6. Choose **Who can post** to set posting permissions (all members or only admins). +7. Set **Visibility** to determine room accessibility: + - **Public**: Viewable by anyone (ideal for conferences). + - **Private**: Only invited individuals can find it. + - **Workspace**: Accessible by all workspace members. +8. Press **Create room** to finalize the setup. + +*Note: Anyone, including those outside the workspace, can be invited to private or restricted rooms.* + +### [Invite Members] How do I invite members to a chat group or room? +Invite members using one of the following methods: + +- **Mentioning**: + 1. Open the chat group or room. + 2. In the message field, type @ and the person’s name or email address. Repeat for all participants. + 3. Enter a message if desired and press Send. + +- **Members Pane**: + 1. Open the chat group or room. + 2. Press the room or group header, then **Members**. + 3. Press **Invite member**, select contacts, and press **Invite**. + +- **Sharing Link or QR Code**: + 1. Open the chat group or room. + 2. Press the room or group header, then **Share**. + 3. Copy the link or present the QR code for others to scan. + +*Note*: These options are only for groups or rooms, not for private 1-on-1 chats. + +### [Start a Private Chat] How do I start a private 1-on-1 chat? +To start a private 1-on-1 chat: + +1. Press the **+** button and select **Start Chat**. +2. Enter the name, email, or phone number of the person you want to chat with. +3. Select their name to start a new chat with them. + +*Note: You cannot add more people to a private chat. To include additional participants, create a group chat.* + +### [Start a Group Chat] How do I start a group chat? +To start a group chat: + +1. Press the **+** button and select **Start Chat**. +2. Enter the names, emails, or phone numbers of the participants and select **Add to group** for each. +3. Press **Next** and update the group image or name if desired. + - **Name**: Select **Group Name**, enter the new name, and save. + - **Image**: Select the profile image, upload a new image, and adjust as needed. +4. Press **Start group** to create the chat. + +### [Direct Message] How do I send a direct message? +To send a direct message: + +1. Press **New Message**. +2. Enter the recipient's email or phone. +3. Type and send your message. +4. Attach files using the attachment icon. + +### [Send and Format Messages] How do I send and format chat messages? +To send and format chat messages: + +1. Open any chat in your inbox. +2. Use the message bar at the bottom to enter your message, add attachments, and insert emojis. + - **To add a message**: Press the field labeled "Write something" and type your message. + - **To add an attachment**: Press the plus icon and select **Add attachment**. Choose the attachment from your files. + - **To add an emoji**: Press the emoji icon to the right of the message field. +3. Press the Send icon to send the message. + +You can format the text using markdown: + +- _Italicize_: Add underscores _ on both sides of the text. +- **Bold**: Add two asterisks ** on both sides of the text. +- ~~Strikethrough~~: Add two tildes ~~ on both sides of the text. +- Heading: Add a number sign # in front of the text. +- Inline image: Add `![Alt text](image URL)` with the URL and alt text. +- Tag another member: Add an @ symbol followed by the member's name, username, or email. +- Mention a room: Add a # followed by the room name. +- > Blockquote: Add an angled bracket > in front of the text. +- `Code block for a small amount of text`: Add a backtick ` on both sides of the text. +- Code block for the entire message: Add three backticks ``` at the beginning and end of the message. + +### [Start a Conversation Thread] How do I start a conversation thread? +To start a conversation thread within a chat: + +1. Open the chat in your inbox. +2. Press on the message you want to reply to and select **Reply in thread**. +3. Enter and submit your reply in the new chat thread. + +To return to the main conversation, use the link at the top of the thread. + +### [React Message] How do I react to a message? +To react to a message: + +1. Hover over the message (desktop only). +2. Press the **emoji** icon. +3. Select an emoji to add to the message. + +### [Edit or Delete Messages] How do I edit or delete messages? +To edit or delete your own messages: + +1. Open a chat in your inbox. +2. Press on the message you want to edit or delete. +3. Select **Edit comment** to modify the message. Once edited, an "edited" label will appear next to it. +4. Select **Delete comment** to remove the message or image for all viewers. Note that deleting a message cannot be undone. ## FAQ ### How do I get started with Expensify Chat? -To start using Expensify Chat: -1. Log in to your Expensify account and navigate to the **Chat** section. -2. Create new chat rooms or direct messages and start chatting with your team, clients, or vendors. -3. You can also join existing chat rooms if you've been invited. - -### Can I invite external users to Expensify Chat? -Yes, you can invite anyone to Expensify Chat by entering their email address or phone number. They will receive an invitation to join and can participate in chat rooms or direct messages. - -### Can I search through past conversations in Expensify Chat? -Yes, Expensify Chat allows you to search through all your past conversations, including chat rooms and direct messages. Simply use the search bar at the top of the screen to find specific messages or files. +Log in to your account and go to the **Chat** section to create or join chat rooms and start messaging. -### How do I send files and attachments in Expensify Chat? -To send files: -1. Open a chat room or direct message. -2. Press the **attachment** icon. -3. Select the file from your device and press **Send**. +### Can I invite external users? +Yes, invite anyone via email or phone to join chat rooms or direct messages. -### What types of files can I share in Expensify Chat? -You can share images, documents, PDFs, and other common file types in Expensify Chat. +### Can I search past conversations? +Yes, use the search bar to find specific messages or files in past conversations. -### Can I create private chat rooms? -Yes, when creating a new chat room, you can choose to make it private. Private rooms require an invitation to join, and only invited members can see the room or participate in the conversation. +### What's the difference between a private 1-on-1 chat and a group chat with only 2 people? +With a group chat, you can add additional people to the chat at any time. However, you cannot add more participants to a private 1-on-1 chat. -### How do I manage notifications in Expensify Chat? -You can manage your notifications from the **Settings** section of the Expensify app. Here, you can customize notification preferences for chat messages, mentions, and other activity. +### How do I remove someone from a chat group or room? +Currently, members have to remove themselves from a chat. +### Why is someone I don't recognize in my #admins room? +Your #admins room includes your dedicated Expensify Setup Specialist who assists with onboarding and answers your questions. If you have a subscription with 10 or more members, your dedicated Account Manager is also part of the #admins room for ongoing support. +### Additional Permissions +Some chat rooms may have permissions that restrict who can send messages. If you do not have the required permission level, you will not be able to send messages in those rooms. \ No newline at end of file diff --git a/help/expense.md b/help/expense.md index 0d0012c95fbb..a6335b8e3549 100644 --- a/help/expense.md +++ b/help/expense.md @@ -2,110 +2,881 @@ layout: product title: Expensify Expense --- + ## Introduction Expensify Expense is the core of the Expensify Superapp, offering world-class expense management capabilities for individuals and businesses alike. Whether you're tracking personal expenses for budgeting, submitting receipts for reimbursement, or overseeing company-wide spending, Expensify Expense simplifies the process with its user-friendly design and powerful automation features. -### [Main uses] When should I use Expensify Expense? -Expensify Expense is designed for a wide range of expense management needs, including: -* **Reimburse employee receipts** - Manage business expenses by capturing and submitting receipts for approval. -* **Track personal expenses** - Keep tabs on your own expenses for tax deductions, budgeting, or general financial tracking. -* **Split bills** - Easily divide the cost of shared expenses like meals or group activities and send or receive payments. -* **Automate receipt capture** - Use SmartScan to automatically capture receipt details and categorize them instantly. -* **Submit and approve expense reports** - Create detailed reports for approval, with multi-level workflows if needed. -* **Stay on top of company spending** - With corporate cards and real-time tracking, managers can ensure compliance and stay within budget. - -### [Core users] Who uses Expensify Expense? -Expensify Expense is versatile enough for personal, business, and enterprise use. Some key user groups include: +### [Main uses] What are the main uses of Expensify Expense? +Expensify Expense is designed for a wide range of expense management needs: +* **Reimburse Employee Receipts** - Manage business expenses by capturing and submitting receipts for approval. +* **Track Personal Expenses** - Keep tabs on your expenses for tax deductions, budgeting, or general financial tracking. +* **Split Bills** - Easily divide the cost of shared expenses like meals or group activities and send or receive payments. +* **Automate Receipt Capture** - Use SmartScan to automatically capture receipt details and categorize them instantly. +* **Submit and Approve Expense Reports** - Create detailed reports for approval, with multi-level workflows if needed. +* **Stay on Top of Company Spending** - With corporate cards and real-time tracking, managers can ensure compliance and stay within budget. + +### [Core users] Who are the core users of Expensify Expense? +Expensify Expense is versatile enough for personal, business, and enterprise use. Key user groups include: * **Individuals** - Track personal spending and maximize tax deductions with easy categorization of expenses. * **Freelancers** - Manage client billable expenses and reimbursements. * **Employees** - Submit expense reports with attached receipts, whether you're in the office or traveling. * **Managers** - Approve expenses, oversee spending, and ensure compliance with company policies. * **Accountants** - Streamline financial reporting by integrating with accounting platforms and processing reimbursements. -* **Corporate teams** - Manage large-scale company expenses with corporate cards and centralized approval workflows. +* **Corporate Teams** - Manage large-scale company expenses with corporate cards and centralized approval workflows. -### [Key advantages] Why should I use Expensify Expense? -Expensify Expense offers a variety of advantages for both personal and corporate users: -* **Automated receipt capture** - Eliminate manual data entry with SmartScan, which reads and categorizes receipts automatically. -* **Integrated corporate cards** - Link company cards to track purchases in real-time and avoid the need for reimbursements. -* **Real-time expense tracking** - Keep an eye on budgets and expenses as they happen, reducing the risk of overspending. -* **Custom approval workflows** - Create multi-level approval processes to streamline and secure the expense submission process. -* **Seamless integration** - Sync your expenses with accounting systems like QuickBooks, Xero, NetSuite, and others. -* **Worldwide compatibility** - Expensify supports every currency, making it ideal for international travel and business. +### [Key advantages] What are the key advantages of using Expensify Expense? +Expensify Expense offers a variety of advantages: +* **Automated Receipt Capture** - Eliminate manual data entry with SmartScan. +* **Integrated Corporate Cards** - Link company cards to track purchases in real-time. +* **Real-Time Expense Tracking** - Monitor budgets and expenses as they happen. +* **Custom Approval Workflows** - Create multi-level approval processes. +* **Seamless Integration** - Sync your expenses with accounting systems like QuickBooks, Xero, NetSuite, and others. +* **Worldwide Compatibility** - Expensify supports every currency, ideal for international business. ## Concepts -Expensify Expense is built on a set of core concepts that make expense tracking easy and efficient: -### [Receipt capture] How does Expensify Expense capture receipts? -Expensify simplifies receipt management with SmartScan: -* **SmartScan** - Automatically scans and extracts important details from your receipts (date, amount, merchant, etc.) and categorizes the expense. +### [Receipt capture] What is receipt capture in Expensify Expense? +Receipt capture simplifies receipt management with SmartScan: +* **SmartScan** - Automatically scans and extracts details from your receipts and categorizes the expense. * **E-receipts** - Automatically generate IRS-compliant electronic receipts for purchases made with the Expensify Card. -* **Manual upload** - Take a photo of your receipt or upload it manually from your phone or desktop. - -### [Expense reports] How do I create and submit an expense report? -Expensify streamlines expense report creation: -1. **Add expenses** - Attach receipts or manually enter expenses into a report. -2. **Categorize expenses** - Use custom categories and tags to organize your expenses. -3. **Submit for approval** - Send your report to the relevant approver(s) with just one click. -4. **Track status** - Get notified when your report is approved and reimbursed. - -### [Approvals] What is the approval process? -Managers can review and approve expenses through a customizable workflow: -* **Single or multi-level approvals** - Set up multiple approvers based on the amount or department. -* **Automatic reminders** - Send automatic reminders to approvers to ensure timely processing. -* **Real-time visibility** - Approvers can see the full expense report with attached receipts and can approve or reject it with a single click. - -### [Corporate cards] How do Expensify Cards work? -Expensify Cards integrate directly with Expensify Expense to automate expense tracking: -* **Automatic receipt capture** - Transactions made with Expensify Cards automatically generate e-receipts. -* **Spend limits and controls** - Managers can set individual spending limits, track real-time spend, and lock cards if needed. -* **Rewards** - Earn up to 2% cashback on Expensify Card purchases. - -### [Integrations] Which accounting systems does Expensify Expense support? -Expensify integrates with all major accounting systems: -* **QuickBooks** - Sync expenses and receipts with your QuickBooks account for easy reconciliation. -* **Xero** - Automate the transfer of expense data to your Xero account. -* **NetSuite** - Link expenses to your NetSuite ERP system for complete financial management. -* **More integrations** - Expensify also integrates with Sage Intacct, Oracle, and others. - -### [Reimbursement] How do I get reimbursed for my expenses? -Expensify makes reimbursement quick and easy: -* **Direct deposit** - Get reimbursed directly to your bank account after your report is approved. -* **International payments** - Expensify supports reimbursement in multiple currencies, perfect for global teams. - -## Platforms -Expensify Expense is available on all platforms, ensuring you can track expenses wherever you are: -* **Web app** - Access Expensify Expense from your browser at any time. -* **Mobile app** - Track expenses on the go using the Expensify mobile app for iOS and Android. -* **Desktop app** - Use the Expensify desktop app for Windows or Mac to manage expenses and reports. +* **Manual Upload** - Take a photo or upload your receipt manually from your device. + +### [Expense categories] What are expense categories? +Expense categories help code expenses for accounting and financial reporting. Categories can be manually created or imported from connected platforms like QuickBooks, Xero, and NetSuite. Over time, Expensify learns how you categorize specific merchants and applies them automatically. + +### [Track taxes] What is tax management in Expensify? +Expensify allows you to configure and manage tax rates within your workspace, applicable on Collect and Control plans. This applies the correct tax rates to expenses based on currency and workspace settings. + +#### Enabling and Managing Taxes +Expensify allows you to enable and manage tax rates in your workspace: + +* **Enable Taxes** - Taxes can be enabled on any workspace where the default currency is not USD. If there's a direct accounting integration, tax rates will be managed through the integration. + +* **Managing Tax Rates** - You can manually add, edit, or delete tax rates. Additionally, you can set default tax rates for both workspace currency and foreign currencies. + +### [Corporate cards] What is the role of corporate cards in Expensify Expense? +Corporate cards integrate with Expensify Expense for automated expense tracking: +* **Automatic Receipt Capture** - Transactions automatically generate e-receipts. +* **Spend Limits and Controls** - Managers can set limits, track spending, and lock cards. +* **Rewards** - Earn cashback on Expensify Card purchases. + +### [Integrations] What accounting systems does Expensify support? +Expensify integrates with all major accounting systems, including QuickBooks Online, Xero, NetSuite, and Sage Intacct. + +### [Distance Rates] What are distance rates in Expensify? +Distance rates are configured for mileage expenses, allowing employees to select predefined rates when logging distance-based expenses. + +### [NetSuite Integration] What is the NetSuite integration in Expensify? +NetSuite integration allows for seamless data transfer between Expensify and NetSuite: +* **Expense Categories** - Automatically imported from NetSuite into Expensify for consistency. +* **Tags and Report Fields** - Import departments, classes, and locations as tags or report fields for detailed categorization. +* **Custom Segments/Records** - Import custom segments and records for more specific data mapping. +* **Auto-Sync** - Synchronize data changes between Expensify and NetSuite daily. + +### [Duplicate Detection] What is duplicate detection in Expensify? +Duplicate Detection helps prevent duplicate expense requests by flagging expenses with the same date and amount in the same member's account: +* **Flagging** - A red dot appears in the left menu or the expense’s chat room, putting the expense on “hold.” +* **Eligibility** - Available exclusively for Collect & Control plans. + +### [Bank Account Connection] What does connecting a personal bank account to Expensify mean? +Connecting a personal bank account allows direct receipt of payments and reimbursements: +* **Secure Verification** - We use Plaid, an encrypted third-party platform, to verify your banking information securely. +* **Direct Deposits** - Once connected, all payments and reimbursements go directly into your designated bank account. + +### [Expensify Wallet] What is the Expensify Wallet? +The Expensify Wallet enables peer-to-peer payments by connecting a personal bank account: +* **Setup** - Connect your bank account via Plaid to enable the wallet. +* **Verification** - Verify your identity through Onfido by uploading identification. +* **Payments** - Once the wallet is enabled, you can send and receive payments seamlessly. + +### [Business Bank Account Validation] What is validating a business bank account in Expensify? +Validating a business bank account is essential to ensure that your account is ready for use in Expensify: +* **Test Deposits** - After the bank account connection is approved, Expensify sends three test transactions to your account for validation. +* **Verification Status** - Check your bank account status in the **Bank accounts** section under workspace settings. The status will either be **Verifying** or **Pending**. +* **Input Transaction Amounts** - Once you receive the test deposits, input the transaction amounts as prompted in Expensify to complete the validation process. + +### [Expense Tags] What are tags in Expensify Expense? +Tags in Expensify refer to line-item details like classes, projects, locations, and customers that help code expenses for accounting and reporting. Tags can be manually created or imported from a connected accounting system. Expensify learns and applies tags automatically over time. + +### [Workflows] What are workflows in Expensify Expense? +Workflows in Expensify Expense allow you to manage expense approvals and submissions: +* **Add Approvals** - Requires additional approval for an expense before payment can be authorized. The default approver is the workspace owner, but it can be changed to another workspace admin. +* **Delay Submissions** - Determines when expenses without issues are automatically submitted. You can set a delay frequency for automatic submissions. + +### [Report Fields] What are report fields in Expensify Expense? +Report fields allow you to add additional details to your reports: +* **Enable Report Fields** - Report fields can be enabled in Workspaces on the Control plan. They provide a way to specify header-level details like project names or locations. +* **Create Report Fields** - Once enabled, report fields can be created for free-text input, date selection, or a list of options. +* **Edit/Delete Report Fields** - Existing report fields can be modified or removed as needed to keep your workspace organized. ## Tutorials -### [Create report] How do I create an expense report? -1. Navigate to **Create** > **Expense Report**. -2. Add your receipts and manually log expenses. -3. Categorize your expenses. -4. Submit the report for approval. +### Expense Reports +#### [Create report] How do I create an expense report? +To create an expense report: +1. Press **Create** > **Expense Report** to start a new report. +2. Add your receipts and manually log expenses for tracking. +3. Categorize your expenses for organization. +4. Submit the report for approval to the relevant supervisor. + +#### [Submit Expenses] What happens after I submit an expense? +After submitting an expense, the next steps depend on whether it was sent to a workspace or an individual: +- **Workspace submissions**: Automatically added to a report, checked for violations, and a chat is created. Reports are submitted for approval every Sunday, but can be manually submitted if ready. +- **Individual submissions**: Sent via email or text, with chat option in Expensify Chat for discussions. + +#### [Approve expenses] How do I approve expense reports? +To approve expense reports: +1. Go to your Inbox and select the report needing approval. +2. Review the receipts and expense details for accuracy. +3. Press **Approve** or **Reject** based on your assessment. + +### Manage Workflows +#### [Enable workflows] How do I enable workflows in Expensify? +To enable workflows: +1. Press your profile image or icon in the bottom left menu. +2. Press **Workspaces** in the left menu. +3. Select the desired workspace. +4. Press **More features** in the left menu. +5. Under the Spend section, toggle **Workflows** to enable approval settings. + +#### [Select workflows] How do I select workflow settings? +To select workflow settings: +1. Press **Workflows** in the left menu. +2. Toggle the desired settings: + - **Add Approvals**: Select an approver for expenses requiring additional approval. + - **Delay Submissions**: Choose a frequency for automatic submission of expenses. + +#### [Add approvals] How do I add approvals to a workspace? +To enable Add approvals on a workspace: +1. Press your profile image or icon in the bottom left menu. +2. Press **Workspaces** in the left menu. +3. Select the workspace where you want to add approvals. +4. Press **Workflows** in the left menu. +5. Toggle **Add approvals**. + +Enabling **Add approvals** reveals the option to set a default approval workflow. + +#### [Configure approval workflows] How do I configure approval workflows in a workspace? +To configure the default approval workflow: +1. Press your profile image or icon in the bottom left menu. +2. Press **Workspaces** in the left menu. +3. Select the workspace where you want to set the approval workflow. +4. Press **Workflows** in the left menu. +5. Under **Expenses from Everyone**, press **First approver**. +6. Select the workspace member as the first approver. +7. Under **Additional approver**, continue selecting members. +8. Press **Save**. + +To set a custom approval workflow for specific members: +1. Press your profile image or icon in the bottom left menu. +2. Press **Workspaces** in the left menu. +3. Select the workspace where you want to add approvals. +4. Press **Workflows** in the left menu. +5. Under **Add approvals**, press **Add approval workflow**. +6. Choose the specific member for the custom workflow. +7. Press **Next**. +8. Select the first approver. +9. Press **Next**. +10. Press **Additional approver** to select more members. +11. Press **Add workflow** to save. + +#### [Edit or delete approval workflows] How do I manage approval workflows in Expensify? +To edit an approval workflow: +1. On the **Workflows** page, press the workflow to edit. +2. Press the Approver field for the desired level. +3. Select or deselect members as approvers. +4. Press **Save**. + +To delete an approval workflow: +1. On the **Workflows** page, press the workflow to delete. +2. Press **Delete**. +3. In the confirmation window, press **Delete** again. + +### Set Up Payment Account +#### [Set up payment account] How do I set up a business bank account for workspace payments? +To set up a business bank account for payments: +1. Press **Workflows**. +2. Enable the **Payments** toggle. +3. Press **Connect Bank Account** and follow the prompts to connect your company bank account. +4. Select an authorized expense payer, who is a workspace admin with access to the business bank account. + +### Expense Capture +#### [SmartScan] How do I use SmartScan to capture receipts? +To use SmartScan, follow these steps: +1. Press the **+** icon and select **Submit Expense**. +2. Press **Scan**. +3. Take a photo of a receipt or upload it from your device. SmartScan will auto-populate details like merchant, date, and amount. +4. Use the search field to find the desired workspace or person's name, email, or phone number. +5. Add a description, category, tags, or tax as needed. +6. (Optional) Enable the expense as billable if it should be billed to a client. +7. Press **Submit**. + +#### [Manually add expense] How do I manually add an expense? +To add an expense manually, follow these steps: +1. Press the **+** icon and select **Submit Expense**. +2. Press **Manual**. +3. Enter the amount and press **Next**. Choose a currency if necessary. +4. Use the search field to find the desired workspace or person's name, email, or phone number. +5. (Optional) Add a description. +6. Add a merchant. +7. Press **Show more** to add additional fields like category if needed. +8. Press **Submit**. + +### Manage Expense Tags +#### [Create and manage tags] How do I create and manage expense tags? +To create and manage expense tags in your workspace: +1. Press your profile image or icon in the bottom left menu. +2. Press **Workspaces** in the left menu. +3. Select the desired workspace. +4. Press **More features** and enable the **Tags** toggle in the Organize section. +5. Press **Tags**. +6. To add a tag, press **Add Tag**, enter a name, and press **Save**. +7. To delete a tag, press the tag, press the three-dot menu, and select **Delete tag**. + +#### [Enable or disable tags] How do I enable or disable tags for expenses? +To enable or disable tags: +1. Press your profile image or icon in the bottom left menu. +2. Press **Workspaces** in the left menu. +3. Select the desired workspace. +4. Press **Tags**. +5. Press the tag and use the toggle to enable or disable it. + +#### [Add or edit a GL code] How do I add or edit a GL code for a tag? +If your workspace is on the Control plan, you can add or edit a GL code for a tag: +1. Press your profile image or icon in the bottom left menu. +2. Press **Workspaces** in the left menu. +3. Select the workspace. +4. Press **Tags**. +5. Press the tag to open tag-settings. +6. Click the GL code field, make changes, and press **Save**. + +### Manage Expense Categories +#### [Create categories] How do I create expense categories? +To create expense categories: +1. Press your profile image or icon in the bottom menu. +2. Press **Workspaces**. +3. Select the workspace you want to add categories to. +4. Press **Categories**. +5. Press **Add Category** and enter a name. +6. Press **Save**. + +#### [Delete categories] How do I delete expense categories? +To delete an expense category: +1. Press the category in the **Categories** page. +2. Press the three-dot menu in the top right. +3. Press **Delete category** to permanently delete it. + +#### [Enable or disable categories] How do I enable or disable expense categories? +To enable or disable categories: +1. Press your profile image or icon in the bottom menu. +2. Press **Workspaces**. +3. Select a workspace. +4. Press **Categories**. +5. Press a category and use the toggle to enable or disable it. + +### Require Tags and Categories +#### [Require tags and categories] How do I require tags and categories for expenses? +To require workspace members to add tags and/or categories to their expenses: +1. Press your profile image or icon in the bottom left menu. +2. Press **Workspaces** in the left menu. +3. Select a workspace. +4. Press **Tags** or **Categories** in the left menu. +5. Press **Settings** at the top right of the page. +6. Enable the “Members must tag/categorize all expenses" toggle. +7. If desired, repeat steps 4-6 for tags or categories (whichever you haven’t done yet). + +This will highlight the tag and/or category field as required on all expenses. Note that expenses can still be submitted without a tag and/or category, but the submitter and approver will see an orange dot on the expense details to alert them that the tag/category is missing. + +### Distance Expenses +#### [Create distance expense] How do I create a distance expense? +To create a distance expense: +1. Press the **+** icon and select **Submit Expense**. +2. Press **Distance**. +3. Enter starting and ending locations. +4. (Optional) Add stops by pressing **Add stop**. +5. Press **Next**. +6. Use the search field to find the desired workspace or person's name, email, or phone number. +7. (Optional) Add a description. +8. Press **Submit**. + +#### [Create and send a distance request] How do I create and send a distance request for reimbursement? +To create and send a distance request for mileage reimbursement: +1. Press the green **+** button and select **Request Money**. +2. Press **Distance** on the Request Money screen. +3. Enter the **Start** and **Finish** addresses and press **Next**. If you have multiple stops, add them before proceeding. +4. Choose the recipient by selecting your organization's workspace from the recent workspaces list. +5. On the confirmation page, review the amount, date, and distance. Optionally, add a description or category. Press **Request**. +6. Your request will be sent to a workspace admin for approval and reimbursement through Expensify or other means. + +### Manage Distance Rates +#### [Enable distance rates] How do I enable distance rates in a workspace? +To enable distance rates in a workspace you manage: +1. Press your profile image or icon in the bottom left menu. +2. Press **Workspaces** in the left menu. +3. Select the workspace where you want to enable distance rates. +4. Press **More features** in the left menu. +5. Toggle **Distance rates** to enable the feature. + +Once enabled, a new **Distance rates** option will appear in the left menu. + +#### [Add or manage distance rates] How do I add, edit, or delete distance rates? +To manage distance rates: +1. Press your profile image or icon in the bottom left menu. +2. Press **Workspaces** in the left menu. +3. Select the workspace where you want to manage distance rates. +4. Press **Distance rates** in the left menu. + +To add a rate: +1. Press **Add rate** in the top right. +2. Enter a value and press **Save**. + +To edit or delete a rate: +1. Press the desired distance rate. +2. To enable or disable, use the toggle next to **Enable rate** and press **Save**. +3. To edit, enter the new value and press **Save**. +4. To delete, press **Delete**. + +For bulk actions: +1. Use the checkboxes next to distance rates. +2. Press "x selected" at the top right. +3. Choose **Enable rates**, **Disable rates**, or **Delete rates** as needed. + +### Manage Tax Rates +#### [Enable Taxes] How do I enable taxes on a workspace? +To enable taxes on your workspace: +1. Press your profile image or icon in the bottom left menu. +2. Press **Workspaces** in the left menu. +3. Select the workspace where you want to enable tax codes. +4. Press **More features** in the left menu. +5. Toggle **Taxes** to enable the feature. + +After enabling taxes, a new **Taxes** option will appear in the left menu. + +#### [Add or manage tax rates] How do I add, edit, or delete tax rates? +To manage tax rates: +1. Press your profile image or icon in the bottom left menu. +2. Press **Workspaces** in the left menu. +3. Select the workspace where you want to manage tax rates. +4. Press **Taxes** in the left menu. + +To add a rate: +1. Press **Add rate** in the top right. +2. Enter a name, value, and tax code, then press **Save**. + +To edit or delete a rate: +1. Press the desired tax rate. +2. To enable or disable, use the toggle next to **Enable rate** and press **Save**. +3. To edit, enter the new value and press **Save**. +4. To delete, press **Delete**. + +For bulk actions: +1. Use the checkboxes next to tax rates. +2. Press "x selected" at the top right. +3. Choose **Enable rates**, **Disable rates**, or **Delete rates** as needed. + +#### [Change Default Tax Rates] How do I change the default tax rates in a workspace? +To change the default tax rates: +1. On the **Taxes** settings page, press **Settings** in the top right. +2. Press **Workspace currency default** or **Foreign currency default** and select the desired tax rate. + +### Bank Account Management +#### [Connect Business Bank Account] How do I connect a business bank account in Expensify? +To connect a business bank account: +1. Enable the Make or Track Payments Workflow by navigating to **Workspaces** > **More Features** > **Enable Workflows**, then press **Workflows** and enable **Make or Track Payments**. +2. Press **Connect Bank Account** and select either **Connect Online with Plaid** or **Connect Manually**. +3. Enter your bank details. +4. Upload a photo of your ID and take a selfie video for verification. +5. Enter your company information, including business name, address, tax ID, and website. +6. Provide additional information on beneficial owners if applicable. +7. Verify all details are accurate and accept the agreement terms. + +#### [Validate Business Bank Account] How do I validate a business bank account in Expensify? +To validate your business bank account: +1. Navigate to **Settings > Workspaces > _Workspace Name_ > Bank account** to check the status. +2. If the status is **Verifying**, check your email for further instructions. If **Pending**, proceed to the next step. +3. Wait 1-2 business days for Expensify to send three test transactions to your bank account. +4. In the **Bank accounts** section of your workspace settings, input the transaction amounts as prompted. + +Once completed, your business bank account is validated and ready for use in Expensify. + +#### [Unlock Business Bank Account] How do I unlock a business bank account? +If your business bank account is locked due to a rejected withdrawal request, follow these steps to unlock it: +1. Go to **Settings > Workspaces > _Workspace Name_ > Bank account** and press **Fix**. This sends a request to our support team to review the reason for the lock. They will provide you with the necessary next steps. +2. Be patient, as unlocking the account can take several business days due to ACH processing times and clawback periods. + +If you need to enable direct debits from your verified bank account, provide your bank with the following details: +- **For Expensify**: + - ACH CompanyIDs: 1270239450, 4270239450, 2270239450 + - ACH Originator Name: Expensify +- **For Bill Payments with Stripe**: + - ACH CompanyIDs: 1800948598, 4270465600 + - ACH Originator Name: expensify.com +- **For International Reimbursements with CorPay**: + - ACH CompanyIDs: 1522304924, 2522304924 + - ACH Originator Name: Cambridge Global Payments + +#### [Connect Personal Bank Account] How do I connect a personal bank account to Expensify? +To connect a personal bank account for receiving payments and reimbursements: +1. Press your profile image or icon in the bottom left menu. +2. Press **Wallet**. +3. Press **Add Bank Account** to initiate the process. +4. Press **Continue** to redirect to Plaid for secure bank account verification. +5. Follow the prompts to enter your bank account details via Plaid. +6. Once done, return to Expensify to complete the linking process. +7. Choose the account you wish to connect and press **Save & continue**. + +Once connected, payments and reimbursements will be automatically deposited into the linked bank account. + +### Invoice Management +#### [Enable Invoicing] How do I enable invoicing on a workspace? +To enable invoicing: +1. Press your profile image or icon in the bottom left menu. +2. Press **Workspaces** and select the workspace for which you want to enable invoicing. +3. Press **More features** in the left menu. +4. Under the Earn section, enable the **Invoice** toggle. + +#### [Send Invoice] How do I send an invoice using Expensify? +To send an invoice: +1. Press the **+** icon in the bottom left menu and select **Send Invoice**. +2. Enter the amount due and press **Next**. +3. Enter the email or phone number of the person who should receive the invoice. +4. (Optional) Add additional invoice details, including description, date, category, tag, and/or tax. +5. Press **Send**. + +#### [Receive Invoice Payment] How do I receive invoice payments? +If you have not connected a business bank account to receive invoice payments, you will see an **Invoice balance** in your Wallet. Expensify will automatically transfer these invoice payments once a business bank account is connected. + +#### [Pay an Invoice] How do I pay an invoice in Expensify? +To pay an invoice in Expensify, follow these steps: + +1. Press the link in the email or text notification you receive from Expensify. +2. Press **Pay**. +3. Choose to **Pay as an individual** or **Pay as a business**. +4. Press **Add Bank Account** or **Add debit or credit card** to issue payment. + +You can also view all unpaid invoices by searching for the sender’s email or phone number on the left-hand side of the app. The invoices waiting for your payment will have a green dot. + +### Expense Management +#### [Approve and Pay Expenses] How do I approve and pay expenses in Expensify? +To manage expenses effectively, follow these steps: + +1. **Manually Approve an Expense**: + - Open the Expensify Chat thread for the expense. + - Press the expense or group of expenses. + - Review the details, ensuring receipt, amount, and description accuracy. + - Determine the next step: Approve, hold, or request changes. + +2. **Approve Expenses**: + - Open the Expensify Chat thread for the expense. + - Press the expense or group of expenses. + - Review the expense details for correctness. + - Decide the next steps: + - **Approve**: When satisfied, press **Approve**. + - **Handle Holds**: Choose to approve non-held expenses or the full amount, including held ones. + - **Request Changes**: Add a comment in the chat thread to request any changes. + +3. **Hold an Expense**: + - Open the Expensify Chat thread for the expense. + - Press the expense or group of expenses. + - Press the three-dot menu and select **Hold**. + - Enter a reason for the hold. + - Review the hold overview and press **Got It**. + - When ready, remove the hold or approve the expense. + +4. **Unapprove an Expense**: + - Press the workspace logo in the top left. + - Select the workspace with the expense report. + - Search for the approved report. + - Press the dropdown arrow for report actions. + - Press **Unapprove**. + +5. **Pay Expenses**: + - Open the Expensify Chat thread for the expense. + - Press the expense or group of expenses. + - Select a payment option: + - Press **Pay** to pay the full amount within Expensify. + - Press **Pay Elsewhere** if payment is made outside Expensify. + +#### [Review & Resolve Duplicates] How do I handle duplicate expense requests? +To review and resolve duplicate expenses: +1. Press the red dot in the left menu or open the expense’s chat room to view the flagged request. +2. Press the green **Review duplicates** button at the top of the request. +3. Review the list of potential duplicates. +4. To resolve a duplicate, press either **Keep all** or **Keep this one**. + - **Keep all**: Retains all expenses as separate charges and removes the hold. + - **Keep this one**: Retains this expense and discards its other related duplicates. +5. If discrepancies exist between the duplicates (e.g., category, tags), choose which details to keep. +6. Confirm your selection to merge the requests or keep all. + +The expenses are removed from the duplicates list and the hold is removed. -### [SmartScan] How do I use SmartScan to capture receipts? -1. Snap a photo of your receipt or upload it to Expensify. -2. Let SmartScan automatically detect and categorize the expense. +#### [Track Expenses] How do I track expenses in Expensify? +To create, store, or share non-reimbursable expenses using the Track Expenses feature: +1. Press the **+** icon in the bottom menu and select **Track Expense**. +2. Create the expense manually, scan the receipt, or add a distance expense. +3. Choose the next steps for the expense: + - **Submit it to someone**: Select this option to request payment from a contact or other members of your Expensify workspace. + - **Categorize it**: Select this option to choose a category and additional details to code the expense for a specific workspace. The expense will then be placed on a report and can be submitted to the workspace for approval. + - **Share it with my accountant**: Select this option to share the expense with your accountant. The expense will then be placed on a report under the workspace for your accountant to review. + - **Nothing for now**: Select this option to store the expense. Expensify will keep the expense until you are ready to take action on it—it won’t expire. When you’re ready, you can then select one of the above options for the expense at a later time. -### [Approve expenses] How do I approve expense reports? -1. Go to your Inbox and select the report requiring approval. -2. Review the receipts and expense details. -3. Click **Approve** or **Reject**. +#### [Split an Expense] How do I split an expense with others? +Splitting an expense allows the person who paid the bill to request money from multiple people who will split the cost with them. To split an expense: +1. Press the **+** icon and select **Split Expense**. +2. Upload a photo of your receipt or manually enter the total bill amount. +3. Press **Next**. +4. Enter the names, email addresses, or phone numbers for the people you want to request money from. Note: You can select multiple people. +5. Press **Next**. +6. (Optional) Enter a reason for the request in the Description field. +7. (Optional) If you manually entered the bill amount, add the merchant and date of purchase. +8. Press **Split**. + +Each person will receive an email or text with the details of the request. You can also chat with them about the expense in Expensify Chat, and you can receive payments through your Expensify Wallet or outside of Expensify. + +### Manage Report Fields +#### [Enable Report Fields] How do I enable report fields on a workspace? +To enable report fields on a workspace: +1. Press your profile image or icon in the bottom left menu. +2. Press **Workspaces** in the left menu. +3. Select the workspace you want to enable report fields for. +4. Press **More features** and toggle **Report Fields** to enable them. + +#### [Create Report Fields] How do I create new report fields? +To create new report fields: +1. Press your profile image or icon in the bottom left menu. +2. Press **Workspaces** in the left menu. +3. Select the workspace you want to create report fields on. +4. Press **Report Fields** in the left menu. +5. Press **Add Field** in the top right corner to create a new field. +6. Enter a name for your report field and select the field type (Text, Date, or List). +7. Press **Save** to finalize the new field. + +#### [Edit or Delete Report Fields] How do I edit or delete existing report fields? +To edit or delete existing report fields: +1. Press your profile image or icon in the bottom left menu. +2. Press **Workspaces** in the left menu. +3. Select the workspace with the report fields you want to edit or delete. +4. Press **Report Fields** in the left menu. +5. Select the report field you wish to edit or delete. +6. Make the required edits in the right-hand panel, or press **Delete**. + +### Accounting Integrations +#### [Connect to QuickBooks Online] How do I connect Expensify to QuickBooks Online? +To integrate with QuickBooks Online: +1. Press your profile image or icon in the bottom left menu to access settings. +2. Press **Workspaces** and select the workspace you want to connect to QuickBooks Online. +3. Press **More features** and enable the Accounting toggle. +4. Press **Accounting** and then **Set up** next to QuickBooks Online. +5. Enter your Intuit login details to import your settings. + +#### [Configure QuickBooks Online] How do I configure QuickBooks Online settings in Expensify? +Configuring QuickBooks Online involves setting import, export, and advanced settings for seamless integration with Expensify. + +1. **Import Settings**: + - Under Accounting, select **Import** under QuickBooks Online. + - Review settings for Chart of Accounts, Classes, Customers/Projects, Locations, and Taxes. + +2. **Export Settings**: + - Under Accounting, select **Export** for QuickBooks Online. + - Review settings for Preferred Exporter, Export Out-of-Pocket Expenses, and Invoices. + +3. **Advanced Settings**: + - Select **Advanced** under QuickBooks Online. + - Set options for Auto-sync, Invite Employees, Automatically Create Entities, and Sync Reimbursed Reports. + +#### [Connect to Xero] How do I connect Expensify to Xero? +To integrate with Xero: +1. Press your profile image or icon in the bottom left menu to access settings. +2. Press **Workspaces** and select your desired workspace. +3. Press **More features** and enable the Accounting toggle. +4. Press **Accounting** and then **Set up** next to Xero. +5. Enter your Xero login details to import your settings. + +#### [Configure Xero] How do I configure Xero settings in Expensify? +To configure Xero settings: +1. Under the Accounting settings for your workspace, press **Import** under the Xero connection. +2. Select options for settings like Xero organization, Chart of Accounts, Tracking Categories, Re-bill Customers, and Taxes. +3. Under the Accounting settings, press **Export** for Xero connection configuration. +4. Review export settings like Preferred Exporter, Export Out-of-Pocket Expenses, and Xero Bank Account. +5. Press **Advanced** under Xero connection to set Auto-sync, Set Purchase Bill Status, Sync Reimbursed Reports, and other advanced settings. + +#### [Connect to Sage Intacct] How do I connect Expensify to Sage Intacct? +To integrate with Sage Intacct: +1. In Expensify, go to **Settings > Workspaces > [Workspace Name] > Accounting**. +2. Press **Set up** next to Sage Intacct and enter your credentials. +3. Press **Confirm** to finalize the setup. + +#### [Configure Sage Intacct] How do I configure Sage Intacct settings in Expensify? +To configure Sage Intacct: +1. Navigate to **Accounting settings** and select **Entity** under Sage Intacct to choose the entity. +2. Press **Import** to set preferences for categories, expenses, and dimensions. +3. Press **Export** to choose exporter and methods for expenses. +4. Press **Advanced** to enable features like auto-sync. + +#### [Connect to NetSuite] How do I connect Expensify to NetSuite? +To integrate with NetSuite: +1. Log into Expensify as a workspace admin and press your profile image or icon in the bottom left menu. +2. Scroll down and press **Workspaces** and select the workspace you want to connect to NetSuite. +3. Press **More features** and enable the Accounting toggle. +4. Press **Accounting** and then **Set up** next to NetSuite. +5. Enter your NetSuite Account ID, Token ID, and Token Secret. These can be found in NetSuite under **Setup > Integration > Web Services Preferences**. +6. Press **Confirm** to complete the setup. + +#### [Configure NetSuite] How do I configure NetSuite settings in Expensify? +To configure NetSuite settings: +1. Ensure the Expensify Bundle is installed in NetSuite by going to **Customization > SuiteBundler > Search & Install Bundles**. +2. Enable Token-Based Authentication in NetSuite under **Setup > Company > Enable Features > SuiteCloud > Manage Authentication**. +3. Add the Expensify Integration Role to a user in NetSuite under **Lists > Employees** and manage access. +4. Create Access Tokens in NetSuite by entering "page: tokens" in the Global Search and selecting **New Access Token**. +5. Confirm Expense Categories and Reports are enabled in NetSuite under **Setup > Accounting** and **Employees**. +6. Follow the detailed steps for ensuring transaction forms are properly configured in NetSuite for Expense Reports, Journal Entries, Vendor Bills, and Credits. + +### Exporting Data +#### [Export Expenses] How do I export expenses to a CSV file? +To export your expense data to a CSV file: +1. Press the **Search** tab in the bottom left menu to view your expenses. +2. Select the checkbox next to the expenses or reports you wish to export. +3. Press **# selected** at the top-right and select **Download** to export. + + The CSV download will save locally to your device with the file naming prefix "Expensify." This file includes data such as Date, Merchant, Description, From, To, Category, Tag, Tax, Amount, Currency, Type, and Receipt URL. + +#### [Exporting Reports to Xero] How do I export reports to Xero manually? +If an error occurs during an automatic export to Xero: +1. Check your email or the related Workspace Chat for error notifications. +2. Resolve the issue by opening the expense and making necessary changes. +3. Ensure the report is in the Approved, Closed, or Reimbursed state. +4. An admin must press the heading at the top of the expense, select **Export**, and then choose **Xero**. + +#### [Exporting Reports to QuickBooks Online] How do I manually export reports to QuickBooks Online? +If an error occurs during an automatic export to QuickBooks Online: +1. Check your email or the related Workspace Chat for error notifications. +2. Open the expense and make necessary changes. +3. Ensure the report is in the Approved, Closed, or Reimbursed state. +4. An admin must press the heading at the top of the expense, select **Export**, and then choose **QuickBooks Online**. + +#### [QuickBooks Online Manual Export Troubleshooting] Why can't I manually export a report to QuickBooks Online? +To export a report to QuickBooks Online, the report must be in the Approved, Closed, or Reimbursed state. If the report is in the Open state, pressing **Export** will lead to an empty page. Ensure the report is submitted or approved if it's in the Processing state. Once these changes are made, an admin can manually export the report to QuickBooks Online. ## FAQ -### Why should I use Expensify Expense for my business? -Expensify Expense automates time-consuming processes like receipt capture, approval workflows, and reimbursement, saving you time and improving accuracy. +### General Inquiries +#### Why should I use Expensify Expense for my business? +Expensify Expense automates processes like receipt capture, workflows, and reimbursement, saving time and improving accuracy. + +#### How do SmartScan limits work? +SmartScan allows you to scan a set number of receipts each month for free, with more available under paid plans. + +#### Can I use Expensify Expense for free? +Yes, Expensify Expense offers a free plan with basic features, with advanced plans for larger business needs. + +#### How does Expensify support multi-currency expenses? +Expensify converts expenses to your preferred currency and supports global reimbursement. + +### Workflow Management +#### [Delayed Submission and Approvals] If I have delayed submission and an approver, what should I expect with a report? +When Add Approver is enabled with Delay Submission, expense reports go from Open > Processing > Approved. If delayed submission is disabled, expense reports go from Processing > Approved. + +#### Can an employee have more than one approval workflow? +No, each employee can have only one approval workflow. + +### Integration and Export +#### [Disconnect from Xero] How do I disconnect Xero from Expensify? +To disconnect Xero: +1. Press your profile image or icon in the bottom left menu. +2. Press **Workspaces** and select your workspace. +3. Press **Accounting**. +4. Press the three-dot menu next to Xero and select **Disconnect**. +5. Press **Disconnect** again to confirm. + +You will no longer see the imported options from Xero. + +#### [Xero Export Confirmation] How do I know if a report successfully exported to Xero? +When a report exports successfully, a message is posted in the related Expensify Chat room. + +#### [Duplicate Report Handling] What happens if I manually export a report that has already been exported? +When an admin manually exports a report, Expensify will warn them if the report has already been exported. If the admin chooses to export it again, it will create a duplicate report in Xero. You will need to delete the duplicate entries from within Xero. + +#### [Auto Sync Impact] What happens to existing reports that have already been approved and reimbursed if I enable Auto Sync? +- If Auto Sync was disabled when your Workspace was linked to Xero, enabling it won’t impact existing reports that haven’t been exported. +- If a report has been exported and reimbursed via ACH, it will be automatically marked as paid in Xero during the next sync. +- If a report has been exported and marked as paid in Xero, it will be automatically marked as reimbursed in Expensify during the next sync. +- If a report has not yet been exported to Xero, it won’t be automatically exported. + +#### [Report Export to Sage Intacct] Why wasn't my report automatically exported to Sage Intacct? +There are a number of factors that can cause auto-export to fail. If this happens, you will find the specific export error in the report comments for the report that failed to export. Once you’ve resolved any errors, you can manually export the report to Sage Intacct. + +#### [Negative Expenses to Sage Intacct] Can I export negative expenses to Sage Intacct? +Yes, you can export negative expenses to Sage Intacct. If you are exporting out-of-pocket expenses as expense reports, then the total of each exported report cannot be negative. + +#### [NetSuite Plan Requirement] What type of Expensify plan is required to connect to NetSuite? +You need a Control workspace to integrate with NetSuite. If you have a Collect workspace, you will need to upgrade to Control. + +#### [NetSuite Page Size] What should I set my page size to in NetSuite for importing customers and vendors? +Make sure your page size is set to 1000 in NetSuite for importing your customers and vendors. Go to **Setup > Integration > Web Services Preferences** and search **Page Size** to determine your page size. + +#### [NetSuite Export Options] What are the export options for NetSuite? +You can export out-of-pocket expenses and company card expenses as Expense Reports, Vendor Bills, or Journal Entries in NetSuite. For invoices, select an Accounts Receivable account. Export settings can be configured to choose the date for records, export foreign currency amounts, and export to the next open period if a period is closed. + +#### [QuickBooks Online Error Resolution] Why do I see a red dot next to my QuickBooks Online connection? +If there is an error with your connection, you’ll see a red dot next to Accounting in the left menu. When you press Accounting, you’ll also see a red dot displayed next to the QuickBooks Online connection card. This may occur if you incorrectly enter your QuickBooks Online login information when trying to establish the connection. To resubmit your login details: +1. Press the three-dot menu to the right of the QuickBooks Online connection. +2. Press **Enter credentials**. +3. Enter your Intuit login details to establish the connection. + +#### [QuickBooks Online Export Confirmation] How do I know if a report is successfully exported to QuickBooks Online? +When a report exports successfully, a message is posted in the expense’s related chat room. + +#### [Duplicate Report Handling in QuickBooks Online] What happens if I manually export a report that has already been exported? +When an admin manually exports a report, Expensify will notify them if the report has already been exported. Exporting the data again will create a duplicate report in QuickBooks Online. + +#### [Auto Sync Impact for QuickBooks Online] What happens to existing approved and reimbursed reports if I enable Auto Sync? +- If Auto Sync was disabled when your Workspace was linked to QuickBooks Online, enabling it won’t impact existing reports that haven’t been exported. +- If a report has been exported and reimbursed via ACH, it will be automatically marked as paid in QuickBooks Online during the next sync. +- If a report has been exported and marked as paid in QuickBooks Online, it will be automatically marked as reimbursed in Expensify during the next sync. + +Reports that have yet to be exported to QuickBooks Online won’t be automatically exported. + +#### [Report Exporting to Xero Troubleshooting] Why can't I manually export a report to Xero? +To export a report to Xero, it must be in the Approved, Closed, or Reimbursed state. If it is in the Open state, pressing **Export** will lead to a notification that the data is not yet available for export. Make sure the report is submitted or approved if it's in the Processing state. Once these changes are made, an admin can manually export the report to Xero. + +### Exporting and Downloading Options +#### [CSV Export Options] Can I export in a different format, like PDF or XLS? +No, currently Expensify supports CSV export only. + +#### [CSV Customization] Can I add columns to the CSV download to capture additional data points? +No, the CSV template cannot be customized. + +#### [Bulk Selection] Can I select expenses or reports in bulk for exporting? +Yes, you can select expenses or reports in bulk by using the **Select multiple** or **Select all** option. To display these options on the mobile app, simply long press an item. + +### Invoicing and Payment +#### [Workspace Requirement] Why do I need to create a workspace to send an invoice? +A workspace is a configuration of settings related to your business. Since invoicing is considered a business feature, you must have a workspace to configure and use invoicing. + +#### [Invoice Communication] How do I communicate with the sender/recipient about the invoice? +Expensify will automatically notify the invoice recipient about the new invoice via email, SMS, and a mobile app notification, along with instructions on how to pay it. Daily reminders will be sent until the invoice is paid. Additionally, an invoice chat room will be automatically created in Expensify between the invoice sender, their workspace admins, and the payer. You can use this chat to discuss anything related to the invoice. + +#### [Invoice Export] Can you export invoices between an accounting integration? +Yes, you can export invoices between Expensify and your connected accounting integration. + +#### [Invoice Permissions] Who can send and pay an invoice? +All workspace admins will be able to send and pay invoices. Invoices can also be paid by anyone, including recipients without an Expensify account. + +#### [Disable Invoicing] What happens if I disable invoicing in the future? +When invoicing is disabled, all previously created invoice rooms and historical invoices will remain unaffected and continue to exist. However, all workspace admins will no longer have the option to send an invoice. + +#### [Business Bank Account Error] Why am I getting an error after I enter my website when connecting a business bank account? +We can only accept a private domain website to ensure the security of your business. If you receive an error when entering your website, it is likely because the domain is not recognized as private. Make sure you are using a business email with a private domain. If you continue to experience issues, contact our support team at concierge@expensify.com for further assistance. + +### Duplicate Handling +#### [Duplicate Expense Handling] What should I do if an expense is flagged as a duplicate? +If an expense is flagged as a duplicate, you can review and resolve it by selecting to keep all duplicates or only one. Adjust and confirm any discrepancies before finalizing your choice. + +#### [Duplicate Detection Criteria] When are expenses flagged as duplicates? +Expenses are flagged as duplicates if they have the same date and amount unless: +- They were split from a single expense. +- They were imported from a credit card. +- Matching email receipts were received with different timestamps. + +#### [Concierge Duplicate Alert] What should I do if Concierge flags a receipt as a duplicate? +If Concierge flags a receipt as a duplicate, scanning the receipt again will trigger the same alert. You can review these in the deleted filter on Expensify Classic. + +#### [Edit Duplicate Requests] Can I edit a duplicate request once resolved? +Yes, you can edit a duplicate request after it has been resolved, but ensure the hold is first removed. + +#### [Review Discarded Duplicates] Can I review a discarded duplicate later? +Yes, approvers can review discarded duplicates to ensure accuracy and prevent fraud. + +### Invoice Payment Options +#### [Invoice Payment Options] What are the payment options for invoices in Expensify? +When paying an invoice, you can choose to pay as an individual or as a business. You can add a bank account or use a debit or credit card to issue payment. Only the person who received the invoice will see the option to pay it. If you want to pay an invoice outside of Expensify, you will need to coordinate with the vendor to discuss alternative payment options. + +#### [Adding Payment Methods] Can I add additional payment methods for paying invoices? +Yes, you can add additional payment methods to your Expensify Wallet. To do this, go to Account Settings > Wallet, then press Add Bank Account. This allows you to choose a payment method when paying future invoices. + +#### [Invoice Sending Limitations] Can anyone send an invoice through Expensify? +Only Expensify customers can send an invoice. This feature is designed to ensure that businesses using Expensify can manage their invoice billing processes efficiently while providing flexibility for their customers to make payments. + +#### [Invoice Visibility] Can someone other than the recipient pay an invoice? +No, only the person who received the invoice will see the option to pay it. This ensures secure and accurate processing of invoice payments. + +#### [Unpaid Invoices] How can I view unpaid invoices? +To view all unpaid invoices, search for the sender’s email or phone number on the left-hand side of the app. Invoices awaiting your payment will have a green dot. + +#### [Invoice Chat Communication] How can I communicate about an invoice? +You can chat directly with your vendor at expensify.com in the designated invoice room to discuss invoice-related matters. + +### Bank Account Requirements +#### [Business Bank Account Requirements] What are the general requirements for adding a business bank account? +To add a business bank account to issue reimbursements via ACH (US) or to issue Expensify Cards: +- Enter a physical address for yourself, any Beneficial Owner (if one exists), and the business associated with the bank account. We cannot accept a PO Box or MailDrop location. +- We are required by law to verify your identity. Part of this process requires you to verify a US-issued photo ID. Your ID must be issued by the United States to use features related to US ACH. You and any Beneficial Owner (if one exists) must also have a US address. + +#### [Beneficial Owner Definition] What is a Beneficial Owner? +A Beneficial Owner refers to an **individual** who owns 25% or more of the business. If no individual owns 25% or more of the business, the company does not have a Beneficial Owner. + +#### [Beneficial Owner Details] What do I do if the Beneficial Owner section only asks for personal details, but my organization is owned by another company? +Please indicate you have a Beneficial Owner only if it is an individual who owns 25% or more of the business. + +#### [Address and ID Verification] Why can’t I input my address or upload my ID? +When adding a verified business bank account in Expensify, the individual adding the account and any beneficial owner (if one exists) are required to have a US address, US photo ID, and a US SSN. If you do not meet these requirements, you’ll need to have another admin add the bank account and then share access with you once it is verified. + +#### [Document Requests] Why am I asked for documents when adding my bank account? +When a bank account is added to Expensify, we complete a series of checks to verify the information provided to us. We conduct these checks to comply with both our sponsor bank's requirements and federal government regulations, specifically the Bank Secrecy Act / Anti-Money Laundering (BSA / AML) laws. Expensify also has anti-fraud measures in place. + +If automatic verification fails, we may request manual verification, which could involve documents such as address verification for your business, a letter from your bank confirming bank account ownership, etc. + +If you have any questions regarding the documentation request you received, please contact Concierge and they will be happy to assist. + +### Bank Account Validation +#### [Microtransaction Validation] I don’t see all three microtransactions I need to validate my bank account. What should I do? +Wait until the end of the second business day. If you still don’t see them, please contact your bank and ask them to whitelist our ACH IDs **1270239450**, **4270239450**, and **2270239450**. Expensify’s ACH Originator Name is "Expensify." + +Once that's all set, make sure to contact your account manager or concierge, and our team will be able to re-trigger those three test transactions! + +#### [Test Deposits for Validation] How many test deposits will I receive when validating my business bank account? +You will receive two withdrawals and one deposit in your business bank account to complete the validation process. + +#### [Missing Test Deposits] What should I do if I don't see the test deposits in my business bank account after two business days? +If the test deposits are not visible after two business days, it may be due to direct debits not being enabled on your bank account. In such cases, provide your bank with the following details: +- ACH CompanyIDs: 1270239450, 4270239450, 2270239450 +- ACH Originator Name: Expensify + +If the issue persists, please contact Expensify Support for further assistance. + +### Sage Intacct Integration +#### [Configure Sage Intacct] How do I set up and configure Sage Intacct in Expensify? +To connect and configure Sage Intacct: +1. **Connect to Sage Intacct**: Go to **Settings > Workspaces > [Workspace Name] > Accounting** and press **Set up** next to Sage Intacct, then enter your credentials to complete the setup. +2. **Select Entity**: Choose the Sage Intacct entity to connect each Expensify workspace to, especially for multi-entity setups. +3. **Import Settings**: Navigate to Accounting settings, press **Import** under Sage Intacct, and set preferences for expense types, categories, dimensions, customers/projects, and tax. +4. **Export Settings**: Access export options under **Export**, choosing your preferred exporter and export methods for out-of-pocket and company card expenses. +5. **Advanced Settings**: Enable auto-sync, invite employees, and configure reimbursement sync under **Advanced** settings to ensure seamless integration. -### How do SmartScan limits work? -SmartScan allows you to scan a set number of receipts each month for free, with additional scans available under paid plans. +#### [Frequently Asked Questions] What are common questions about using Sage Intacct with Expensify? +Some common concerns include: +- **Auto-sync**: Only newly approved reports will be auto-exported to Sage Intacct. Existing approved reports must be manually exported. +- **Negative Expenses**: Negative expenses can be exported, but out-of-pocket expense reports cannot be entirely negative. +- **Export Errors**: If auto-export fails, check report comments for specific errors and resolve them before attempting manual export. -### Can I use Expensify Expense for free? -Yes, Expensify Expense offers a free plan with basic features, and advanced plans are available for businesses with larger needs. +### Sage Intacct Tutorials +#### [Configure Import Settings] How do I configure import settings for Sage Intacct? +To configure import settings: +1. Under Accounting settings, press **Import** for Sage Intacct. +2. Choose how to import categories, dimensions, customers/projects, and tax. +3. Configure expense types and chart of accounts based on how you plan to export expenses. +4. Set up mapping for billable expenses by enabling necessary permissions in Sage Intacct. -### How does Expensify support multi-currency expenses? -Expensify automatically converts expenses to your preferred currency and supports global reimbursement. +#### [Configure Export Settings] How do I configure export settings for Sage Intacct? +To configure export settings: +1. In Accounting settings, press **Export** under Sage Intacct. +2. Choose the preferred exporter and export date options. +3. Decide whether to export out-of-pocket expenses as expense reports or vendor bills. +4. Set export preferences for company card expenses, selecting between credit card charges or vendor bills. +#### [Manage Advanced Settings] How do I manage advanced settings for Sage Intacct? +To manage advanced settings: +1. Navigate to **Settings > Workspaces > [Workspace name] > Accounting** and press **Advanced** under Sage Intacct. +2. Enable auto-sync for daily updates and automatic export of expenses. +3. Use the invite employees feature to add Sage Intacct users to Expensify. +4. Configure reimbursement sync to reflect accurate status between Expensify and Sage Intacct. \ No newline at end of file diff --git a/help/index.md b/help/index.md index dde0e97da851..45366fecae11 100644 --- a/help/index.md +++ b/help/index.md @@ -3,558 +3,503 @@ layout: product title: Expensify --- ## Introduction -The Expensify Superapp packs the full power of 6 world-class business, finance, and collaboration products into a single app that works identically on desktop and mobile, efficiently with your colleagues, and seamlessly with your customers, vendors, family, and friends. +The Expensify Superapp combines 6 world-class business, finance, and collaboration products into one app. It works identically on desktop and mobile, with colleagues and customers, and for personal use. -### [Main uses] When should I use Expensify? +### Main uses Expensify can do a lot. You should check us out whenever you need to: -* **Track and manage expenses** - Whether you are reimbursing employee receipts, deducting personal expenses, or just splitting the bill, Expensify Expense is for you. -* **Issue corporate cards** - Skip the reimbursement and capture receipts electronically in real-time by issuing the Expensify Card to yourself and your employees. -* **Book and manage travel** - If you are booking your own business trip, arranging a trip for a colleague, or managing the travel of your whole company, Expensify Travel has got you covered. -* **Chat with friends and coworkers** - Whether it's collaborating with your team, supporting your client, negotiating with your vendor, or just saying Hi to a friend, Expensify Chat connects you with anyone with an email address or SMS number. -* **Collect invoice payments online** - Expensify Invoice allows you to collect online payments from consumers and businesses alike – anyone with an email address or SMS number. -* **Approve and pay bills online** - Scan, process, and approve bills online using Expensify Billpay, then we'll pay them electronically or via check, whatever they prefer. - -If you send, receive, or spend money – or even just talk to literally anyone, about literally anything – Expensify is the tool for you. - -### [Core users] Who uses Expensify? -Expensify offers something for everyone. Some people who commonly use us include: -* **Individuals** - Millions of individuals use Expensify to track personal expenses to maximize their tax deductions, stay within personal budgets, or just see where their money is going. -* **Friends** - Expensify is a great way to split bills with friends, whether it's monthly rent and household expenses, a big-ticket bachelorette party, or just grabbing drinks with friends. -* **Employees** - Road warriors and desk jockeys alike count on Expensify to reimburse expense reports they create in international airports, swanky hotels, imposing conference centers, quaint coffee shops, and boring office supply stores around the world. -* **Managers** - Bosses manage corporate spend with Expensify to empower their best (and keep tabs on their… not-so-best), staying ahead of schedule and under budget. -* **Accountants** - Internal accountants, fractional CFOs, CAS practices – you name it, they use Expensify to Invoice customers, process vendor bills, capture eReceipts, manage corporate spend: the whole shebang. If you're an accountant, we're already best friends. -* **Travel managers** - Anyone looking to manage employee travel has come to the right place. - -If you are a person online who does basically anything, you can probably do it with Expensify. - -### [Key advantages] Why should I use Expensify? -Though we do a lot, you've got a lot of options for everything we do. But you should use us because we are: -* **Simple enough for individuals** - We've worked extremely hard to make a product that strips out all the complex jargon and enterprise baggage, and gives you a simple tool that doesn't overwhelm you with functionality and language you don't understand. -* **Powerful enough for enterprises** - We've worked extremely hard to make a product that "scales up" to reveal increasingly sophisticated features, but only to those who need it, and only when they need it. Expensify is used by public companies, multinational companies, companies with tens of thousands of employees, non-profits, investment firms, accounting firms, manufacturers, and basically every industry in every currency and in every country around the world. If you are a company, we can support your needs, no matter how big or small. -* **6 products for the price of 1** - Do you pay for an expense management system? A corporate card? A travel management platform? An enterprise chat tool? An invoicing tool? A billpay tool? Now you don't need to. Expensify's superapp design allows us to offer ALL these features on a single platform, at probably less than what you pay for any of them individually. -* **Supports everyone everywhere** - Expensify works on iPhones and Androids, desktops and browsers. We support every currency and can reimburse to almost any country. You don't need to be an IT wizard – if you can type in their email address or SMS number, you can do basically everything with them. -* **You get paid to use it** - Do you spend money? Spend it on the Expensify Card and we pay you up to 2% cashback. It's your money after all. -* **Revenue share for accountants** - Do you manage the books for a bunch of clients? Become an Expensify Approved Accountant and take home 0.5% revenue share. Or share it with your clients as a discount, up to you! - -You are in the driver's seat; we're here to earn your business. But we're going to work harder for you than the other guys, and you won't be disappointed. +* **Track and manage expenses** - Whether reimbursing employee receipts, deducting personal expenses, or splitting a bill, Expensify Expense is for you. +* **Issue corporate cards** - Skip reimbursement and capture receipts in real-time by issuing the Expensify Card to yourself and employees. +* **Book and manage travel** - If booking your own trip, arranging for a colleague, or managing company travel, Expensify Travel has you covered. +* **Chat with friends and coworkers** - Collaborate with your team, support clients, negotiate with vendors, or just say Hi with Expensify Chat. +* **Collect invoice payments online** - Expensify Invoice lets you collect online payments from anyone with an email or SMS number. +* **Approve and pay bills online** - Scan, process, and approve bills online with Expensify Billpay, and we'll pay them electronically or via check. + +If you send, receive, or spend money – or talk to anyone about anything – Expensify is for you. + +### Core users +Expensify offers something for everyone. Common users include: +* **Individuals** - Millions track personal expenses to maximize deductions, stay within budgets, or see where money goes. +* **Friends** - Split bills with friends for rent, parties, or drinks. +* **Employees** - Reimburse expense reports from airports, hotels, conference centers, or coffee shops. +* **Managers** - Manage corporate spend, empowering the best and keeping tabs on the rest, staying on schedule and budget. +* **Accountants** - Internal accountants, CFOs, CAS practices use Expensify to invoice customers, process vendor bills, and manage spend. +* **Travel managers** - Manage employee travel easily with Expensify. + +If you're online doing anything, Expensify can probably help. + +### Key advantages +You've got options, but use Expensify because it is: +* **Simple enough for individuals** - A simple tool without overwhelming functionality or language. +* **Powerful enough for enterprises** - It scales up to reveal sophisticated features only when needed. Used by public companies, multinationals, and more. +* **6 products for the price of 1** - Offers expense management, corporate cards, travel management, chat, invoicing, and billpay in one platform. +* **Supports everyone everywhere** - Works on iPhones, Androids, desktops, and browsers, supporting every currency and reimbursing to almost any country. +* **You get paid to use it** - Spend on the Expensify Card and earn up to 2% cashback. +* **Revenue share for accountants** - Manage client books and earn or share a 0.5% revenue share. + +You are in the driver's seat, and we're here to earn your business. ## Concepts -The Expensify Superapp has a lot of moving pieces, so let's break them down one by one. - -### [Superapp] What makes Expensify a superapp? -A "superapp" is a single app that combines multiple products into one seamlessly interconnected experience. Expensify isn't a "suite" of separate products linked through a single account – Expensify is a single app with a single core design that can perform multiple product functions. The secret to making such a seamless experience is that we build all product functions atop the same common core: -* **App** - The basis of the superapp experience is the actual app itself, which runs on your mobile phone or desktop computer. -* **Chats** - Even if you don't plan on using Expensify Chat for enterprise-grade workspace collaboration, chat is infused through the entire product. -* **Expense** - Even if you aren't actively managing your expenses, you've still got them. Every product that deals with money is ultimately dealing with expenses of some kind. -* **Workspace** - Though Expensify works great for our millions of individual members, every product really shines when used between groups of members sharing a "workspace." -* **Domain** - To support more advanced security features, many products provide extra functionality to members who are on the same email "domain." - -These are the foundational concepts you'll see again and again that underpin the superapp as a whole. - -### [App screens] What is the Expensify app? -Just like your eyes are a window to your soul, the Expensify App is the doorway through which you experience the entire global world of interconnected chat-centric collaborative data that comprises the Expensify network. The main tools of this app consist of: -* **Inbox** - The main screen of the app is the Inbox, which highlights exactly what you should do next, consolidated across all products. -* **Search** - The next major screen is Search, which as you'd expect, lets you search everything across all products, from one convenient and powerful place. -* **Settings** - Settings wraps up all your personal, workspace, and domain configuration options, all in one helpful space. -* **Create** - Finally, the big green plus button is the Create button, which lets you create pretty much anything, across all the products. - -It's a deceptively simple app, with a few very familiar-looking screens and buttons that unlock an incredible range of sophisticated multi-product power. - -### [Platforms] Where can I use the Expensify app? -The Expensify app comes in three flavors: -* **Expensify web app** - The Expensify web app is what you would access at new.expensify.com. You can access the web app via a mobile web browser or a desktop web browser – it's optimized to work on both. -* **Expensify mobile app** - The Expensify mobile app works more or less identically to the Expensify web app (when opened in a mobile browser), but is more reliable, higher performance, has better support for notifications. -* **Expensify desktop app** - The Expensify desktop app works more or less identically to the Expensify web app (when opened in a desktop browser), but is more reliable, higher performance, and has better support for notifications. - -Whatever computer or phone you use, Expensify will work on it. - -### [Workspace] What is a workspace? -A workspace groups members together to enable secure sharing and real-time collaboration. Every product adds features to the workspace, but all share the same common baseline: -* **Name** - You can name your workspace anything. Names are not globally unique, but even if every other Alice has their own "Alice's Apples" workspace, yours is definitely the most special. -* **Profile photo** - Give your workspace a great headshot (or logo), or just stick with the beautiful one it is randomly assigned. -* **Description** - Help your members out by giving a good description to your workspace containing copious links and details. -* **Currency** - Though every workspace can support expenses in every currency, for convenience they are all converted into a single currency of your choosing. -* **Headquarters** - Workspaces work great for virtual teams, but some products deal with the physical world and need to know where you are headquartered. -* **Members** - Though there are many situations in which you might want a workspace just for personal use, in general, workspaces work best when they have many members. -* **Admins** - All members have some common elements of access, but "admin" members have enhanced privileges to manage the workspace overall. -* **Rooms** - Every workspace has a series of chat rooms, some of which are built in automatically and some of which are created manually. -* **Plan** - Workspaces come in two flavors, depending on the functionality you need: - * **Collect** - The Collect workspace is optimized for businesses with simpler requirements looking for basic expense management, Expensify Card, invoice collections, and bill pay functionality. - * **Control** - The Control workspace is built for more advanced companies with more powerful needs, such as multi-level approval, advanced domain control, enterprise accounting integrations, and so on. - -Workspaces make up the backbone of Expensify's collaboration features. - -### [Domain] What is a domain? -A domain is a secondary way of grouping users, generally for more advanced security purposes. Unlike a workspace, which can contain anybody with any email address or SMS number, you join a domain by validating your email address and then optionally "claiming" it as your own. -* **Name** - Each domain corresponds to the "domain name" of your email address (eg, cathy@croissants.com would have the domain of `croissants.com`). Unlike a workspace, you can't rename your domain. -* **Members** - A domain is similar to a workspace in that it represents a group of users. Unlike a workspace, however, domain members are generally limited to those who have validated email contact methods on this domain. -* **Group** - Every member of the domain is a member of exactly one group on the domain. This domain group sets various security rules for that member, such as setting their "preferred workspace." - -Domains allow for more advanced management and top-down control of Expensify members. - -### [Inbox] What does the Inbox do? -Given Expensify's chat-centric design, that makes Expensify in effect a superpowered chat app – and in any chat app, the most important page is the Inbox. The Inbox does a real-time search across all products to highlight exactly what you should do *right now*. A few key features of the Inbox include: -* **Green dot** - Whenever someone is waiting on you to do something – such as an expense you need to reimburse or a booking you need to approve – that thing's chat will be put to the top of the list with a little green dot next to it. -* **Red dot** - Anything you need to finish to accomplish something you started – such as fixing a violation before an expense can be submitted – will also be put to the top of the list with a little red dot next to it. -* **Pinned** - Anything you want to pay special attention to can be manually "pinned" to the top of the Inbox so it stays top of mind. -* **Priority mode** - Though everyone's work style is unique to them, the Expensify app is organized around two modes of prioritization: - * **Most recent mode** - The default mode for new users is to sort the Inbox to put whatever chat was most recently modified at the top. This works particularly well for those engaged in rapid-fire collaboration who want to "go where the action's at." - * **Focus mode** - When the Inbox gets over 30 rows, it automatically switches to "focus mode," which alphabetically organizes the chats and only shows those that are "unread" (ie, have comments you haven't read yet), have a green or red dot, or are pinned. This works well for those engaged in many large group conversations that you might want to monitor, but not necessarily engage with immediately. - -The Inbox is the most powerful page in the app, and where you will spend the bulk of your time. - -### [Search] What does Search do? -By and large, pretty much anything. Expensify has a "universal search" design that brings all data objects into a single place, and then lets you search all those objects using an incredibly flexible and powerful search engine. Search consists of the following main pieces: -* **Query** - At the top of the search page is the "query," which formally describes what you are searching for. -* **Datatype selector** - By default, we will search all datatypes simultaneously, but you can narrow the results to a single type. -* **Filters** - Similarly, each datatype has its own properties (eg, an expense has an amount, a trip has a destination), and you can filter on each. -* **Saved searches** - If you dial in a search you intend to do again and again, you can save it for future reuse. - -The Inbox's job is to push information in your direction, but the Search page exists to help you find anything you're looking for. - -### Settings -Every product will generally have its own distinct settings, but all settings are conveniently grouped into three main categories: -* **Account** - Every user has an "account" that stores all data owned by that user. Each individual person has a single user account, though that account can be associated with many contact methods (ie, email addresses and SMS numbers). -* **Workspace** - Group functionality across all products is organized into "workspaces," which allow secure sharing of data and settings between multiple members. -* **Domain** - Many users sign up with an email address, and the end of that address (ie, @company.com) corresponds to the "domain" that user is a member of. Domains are another way to group accounts and securely share data between the domain members. - -Every product adds its own layers of sophistication and power onto the common foundation of this shared superapp core. - -#### [Account] What are my account settings? -Your account contains the sum total of all data you own or shared with you, across all products. But all products rely upon the same common set of account properties: -* **Profile** - Your profile allows you to introduce and uniquely identify yourself to everyone else. -* **Wallet** - Your wallet organizes the various financial payment tools (such as the Expensify Card) and bank accounts associated with your account. -* **Preferences** - Your preferences configure high-level settings on how you are notified and how data is presented to you. - -Your personal account contains all the details that make you, you. - -#### [Profile] What are my profile settings? -Your "profile" is how you identify yourself, both publicly and privately: -* **Your public details** - As the name implies, your public details can be seen by other users. These include: - * **Profile photo** - Your profile photo is the image that is shown next to your name wherever you appear. You can customize this however you please, or a random "avatar" image will be picked for you. - * **Display name** - Your display name is the name that is generally shown next to your photo. If you don't have a display name, then your primary contact method will be shown instead. - * **Contact methods** - Your contact methods are all the email addresses and SMS numbers associated with your account. All contact methods allow you to sign in and associate any email receipts with your account. - * **Primary contact method** - This is the contact method that is highlighted on your profile, and to which all communications are sent. If you are an employee of a business, your primary contact method will typically be your company email address. - * **Secondary contact method** - You can add any number of "secondary" contact methods. These are not shown on your profile, but do allow you to sign into your account. It's helpful to have multiple secondary contact methods (such as a personal email address and personal phone number) to ensure you can access your account if you lose access to your primary contact method (such as your work address). - * **Status** - Your status is an expiring optional icon and message you can set that appears next to your name, such as to hint that you are on vacation or in a meeting, etc. - * **Pronouns** - Your pronouns are an optional tool for allowing you to indicate how you would like to be addressed by others. - * **Timezone** - Your timezone reflects the timezone in which you are currently located. This will generally be set automatically as you travel around the world, but can be manually set as well. - -* **Your private details** - Also as the name implies, your private details are not shown to others but might be required to enable certain functionality: - * **Legal name** - Your legal name is what appears on your government ID, which might differ from how you like to be addressed on a daily basis (ie, your display name). By default, your legal name is assumed to be your display name, but if that is not the case, you can easily correct this. - * **Date of birth** - Your date of birth is the birthday listed on your government ID. - * **Address** - Your address reflects where you would like us to contact you via mail, in the event we ever need to do so (such as to ship you an Expensify Card). - -The combination of your public and private profile gives you the tools to introduce yourself to the world and to us. - -#### [Wallet] What are my wallet settings? -Your wallet is your one-stop shop for all things banking and payment card-related. The major items in your wallet include: -* **Cash** - Just like a regular wallet that has a mix of cash and cards, your Expensify wallet is also able to hold electronic cash you receive from others. -* **Cards** - This contains a central list of every card associated with your Expensify account: - * **Expensify Cards** - Your employer can assign you an Expensify Card that gives you access to company credit for business purchases. - * **Imported cards** - You can import the transactions from your personal or corporate card into Expensify to submit to your company for approval or reimbursement or just to manage for your own needs. - * **Payment** - You can link a credit card to your account for paying your Expensify subscription or to fund your wallet's cash balance. -* **Bank accounts** - This contains a link of bank accounts associated with your Expensify account: - * **Personal bank account** - You can like a personal bank account to either receive company reimbursements or fund your wallet's cash balance. - * **Business bank account** - You can connect your business's bank account to reimburse expenses, issue Expensify Cards, collect online invoice payments, pay bills, and more! - -Just like your normal wallet, a lot can be stuffed into your Expensify wallet, and all of it is priceless. - -#### [Preferences] What are my preferences? -Your preferences are personal settings that affect how we display information to you: -* **Training and marketing** - We, in general, like to occasionally reach out with new information about features, changes, or offers to help – but only if you like. -* **App sounds** - We've worked hard to come up with some subtle audio cues that hint when certain actions happen in the app, but they are entirely optional. -* **Priority mode** - This is how you specify which Inbox priority mode you prefer. -* **Language** - Everybody in the world can use Expensify, and we are supporting an increasing number of languages natively. -* **Theme** - Give into the dark side or stay in the light, we won't judge! -* **Two-factor authentication** - We strongly recommend everyone enable two-factor authentication to secure access to your account. - -Everybody likes things their own way, and preferences are how you make the Expensify app your own. - -#### [Subscription] What are my subscription settings? -Most of Expensify is completely free to use, and millions of members use Expensify without paying anything at all. To unlock our more powerful functionality, create a workspace and pick which products you need – each can be adopted independently, but all are included in the base price (though some products have slightly different nuances: Expensify Card cashback deducts from the bill, Expensify Travel booking fees add to the bill, etc). Regardless of which products you enable, all are billed together via the same subscription. Your subscription consists of the following: -* **Billing card** - Pick a credit or debit card from your wallet to pay your subscription. -* **Subscription length** - Expensify has options for everyone depending on your specific needs, allowing you to balance cost versus commitment: - * **Pay-per-use** - By default, your Expensify account starts with zero-risk, zero-commitment: just use Expensify to your heart's content, and you will be billed for as much or as little as you use the next month. - * **Annual plan** - Once you know how much Expensify you need, lock in a 50% annual plan discount by committing to a certain number of seats for 12 months. The annual plan is configured as follows: - * **Subscription size** - This is the number of seats you commit to purchasing for the next 12 months (billed monthly), at a 50% discounted rate. Any active seats billed at the end of the month in excess of the subscription size are billed at the pay-per-use rate (ie, without the 50% discount). - * **Auto-renew** - Whether to automatically renew this subscription at the end of 12 months, or revert back to pay-per-use (giving up the 50% discount). - * **Auto-increase annual seats** - Whether to automatically increase the number of annual seats you commit to based on the number of seats used. This avoids being accidentally billed for any pay-per-use seats. - -Pick the plan that works for you, and feel free to change as you need. - -#### [Price] What is the price of Expensify? -For most users, Expensify is completely free. For business users, the price of Expensify depends on which features are enabled – and with Expensify Card cashback, you can actually be paid to use Expensify! The major variables going into the price of Expensify for your specific needs include the following: -* **Personal use** - Most users enjoy Expensify free of charge, as there is a huge range of free features designed for use by yourself and with your friends. -* **Active seats** - Our paid functionality is largely contained within workspaces and billed on an "active seat" basis. This means at the end of the month, we look over the activity of each workspace member to determine if they used any paid functionality or merely free features: - * **Paid seat** - A workspace member who uses any paid functionality (ie, submitting, approving, or paying expenses) requires a "paid seat." - * **Free seat** - A workspace member who only used free functionality (ie, viewing expenses, chatting outside of an expense report) only requires a "free seat." -* **Paid seat price** - Once we determine how many paid seats you require in a given month, we initially set the price per paid seat at $20/seat/mo for Collect workspaces and $36/seat/mo for Control. -* **Expensify Card discount** - The first modification to the base seat price is to assess how much total spend was approved on the workspace, versus how much of it was spent on the Expensify Card. This will generate a sliding discount ranging from 0% (if you aren't using the card at all) to 50% (if you have used the Expensify Card for at least 50% of your company's spend). The Expensify Card discount is applied to the seat price, which can reduce it down to $10/seat/mo for Collect workspaces or $18/seat/mo for Control. For example: - * If your company spends 0% of the total approved spend on the Expensify Card, you receive no discount. - * If your company spends 25% of the total approved spend on the Expensify Card, you receive a 25% discount off each seat. - * If your company spends 75% of the total approved spend on the Expensify card, you receive a 50% discount off each seat. -* **Annual plan discount** - Next, we determine how many seats you have committed to in your annual plan subscription size and apply an additional 50% discount to those seats – bringing the price down to $5/seat/mo for Collect workspaces, or $9/seat/mo for Control. -* **Expensify Card cashback** - Finally, we calculate how much cashback you earned from spending on the Expensify Card and apply that to the bill, reducing the price further. In many cases, the cashback is larger than the Expensify bill itself, meaning our so-called "paid" features could not only be free, *you can actually be paid to use them.* - -Long story short, depending upon which features you use, you might pay us, it might be free, or we might even pay you. There are a lot of variables involved, so please check out our savings calculator to understand how this will shake out for you. - -#### [Save the world] What the heck is "Save the world"? -Expensify.org’s mission is to empower individuals and communities to eliminate injustice around the world by making giving and volunteering more convenient, meaningful, and collaborative. We simplify full transparency for all, allowing our donors and volunteers to connect and make positive permanent changes. The foundation of Expensify.org was built on applying our expertise in expense management to increase the transparency of how funds are used, the convenience of how donations are gathered, and — most importantly — the human connection between donors, volunteers, and recipients. - -Please note that our funding model is not in the form of a grant given to a nonprofit organization. Instead, we're looking to help amplify the work of individuals who are directly absorbing the costs. - -### [Global create] What does the big green Create button do? -Saving the best for last is the big green "global create" button. As the name suggests, this allows you to create basically anything your account is allowed to create. The exact options will depend on which products are configured in your workspace, but it can be any of the following: -* **Start chat** - Begins a new chat with one or more users. -* **Track expense** - Tracks an expense for personal use. -* **Submit expense** - Submits an expense to another user for payment. -* **Split expense** - Splits an expense with one or more other users for shared payments. -* **Pay someone** - Sends money to another user from your Expensify wallet balance. -* **Send invoice** - Sends an invoice from a workspace to a customer for online payment. -* **Assign task** - Creates a new task and assigns it to yourself or another user for completion. -* **Book travel** - Books a flight, hotel reservation, or car rental. -* **Quick action** - Repeats the last action you took, most commonly to scan a receipt and submit it via a particular workspace all in a single button. - -As you can see, there's a lot packed into that big button – press it and see what happens! -## Tutorials -The Expensify superapp has a lot of moving parts, what specifically are you trying to do? Let's point you in the right direction with some step-by-step guides. - -### Role - -#### [Individual] How do I use Expensify as an individual? -Expensify is designed to be flexible for a wide range of individual use cases. As an individual, you can: -1. Track personal expenses -2. Split bills with friends -3. Collect receipts and categorize them -4. Use Expensify Card for cashback and simplified reimbursement - -Simply log in, navigate to the expense section, and use the Create button to start organizing your expenses. - -#### [Friends] How do I use Expensify with my friends? -You can use Expensify to settle shared expenses between friends, such as splitting the bill at a restaurant. Here's how: -1. Create an expense and enter the total amount. -2. Choose **Split Expense** and add your friends by entering their email addresses. -3. Expensify will calculate each person's share, and you can easily send a request to them to settle the balance. - -#### [Employee] How do I use Expensify as an employee? -As an employee, Expensify can help you: -1. Submit expense reports for approval. -2. Use the Expensify Card for company expenses. -3. Book travel and manage expenses during work trips. -4. Communicate with colleagues through integrated chat features. - -After logging in, create an expense report, attach receipts, and submit it for approval through your workspace. - -#### [Manager] How do I use Expensify as a manager? -Managers can use Expensify to: -1. Approve or reject expense reports from their team. -2. Monitor corporate spending in real-time. -3. Issue Expensify Cards to employees. -4. Set up advanced approval workflows for multi-level reviews. - -Use the Inbox and Workspace features to manage team expenses and approvals efficiently. - -#### [Accountant] How do I use Expensify as an accountant? -Accountants can: -1. Manage multiple clients’ expense workflows through different workspaces. -2. Create invoices and collect payments. -3. Export data directly to accounting software for tax purposes. -4. Benefit from revenue-sharing programs by becoming an Expensify Approved Accountant. - -You can use the Invoice and Bill Pay tools to manage clients' billing, and track expenses for tax reporting. - -#### [Travel manager] How do I use Expensify as a travel manager? -Travel managers can: -1. Book and manage employee travel. -2. Track expenses related to flights, hotels, and car rentals. -3. Issue Expensify Cards for travel-related spending. -4. Approve travel expenses before they are reimbursed. - -Simply navigate to the Travel section, where you can manage travel bookings and expense submissions in one place. - -### Platforms - -#### [Web] How do I access Expensify on the web? -To visit the Expensify website: -1. Go to www.expensify.com, either on a desktop or mobile browser. - -#### [Mobile] How do I install the Expensify mobile app? -To install the Expensify mobile app: -1. Visit the Expensify for iOS or Expensify for Android app stores. -2. Press **Install**. -3. Follow the prompts to install. -4. Press the Expensify icon in your phone's app list to start. - -#### [Desktop] How do I install the Expensify desktop app? -To install the Expensify desktop app on MacOS: -1. Download the Expensify for MacOS or Expensify for Windows installer. -2. Double-click on the installer to open it. -3. Click the Expensify icon on the taskbar to start. - -#### [Sign in] How do I sign up or sign in to my Expensify account? -Signing up for a new account works the same as signing into an existing account, as follows: -1. Install or access Expensify on any platform: - * Access Expensify on the web. - * Install the Expensify mobile app. - * Install the Expensify desktop app. -2. Choose how you want to connect and press Next: - * Press **Email** and enter your email address, or - * Press **Phone Number** and enter your SMS-compatible phone number. - * Press **Google** and sign in to your Google account, or - * Press **Apple** and sign into your Apple account. -3. If asked to validate your email address, check your email inbox for a magic link and press it. -4. If asked to join, this means that this is the first time you are signing in with this email address or phone number; press **Join** to confirm you entered it correctly. - -#### [Magic link] How do I use a magic link? -Magic links are used for secure login without passwords. When prompted: -1. Check your email for the Expensify Magic Link. -2. Click the link in your email, and it will log you in to Expensify without needing to enter a password. - -#### [Sign out] How do I sign out? -To sign out of Expensify: -1. Press **Settings** in the Expensify app. -2. Scroll to the bottom and press **Sign Out**. - -#### [Two factor] How do I secure my account with two-factor authentication? -To enable two-factor authentication: -1. Press **Settings**. -2. Press **Security**. -3. Press **Two-factor authentication**. -4. Follow the steps to link your mobile phone for 2FA. - -#### [Close account] How do I close my account? -To close your account: -1. Press **Settings**. -2. Press **Security**. -3. Press **Close account**. -4. Confirm by following the prompts to complete the process. +### Superapp Fundamentals +#### [Superapp Introduction] What is a superapp? +A superapp is a single app combining multiple products into one interconnected experience. Expensify isn't a "suite" of separate products but a single app performing multiple functions. Built on a common core: +* **App** - The superapp experience runs on your mobile phone or desktop computer. +* **Chats** - Chat is infused through the entire product, even if not used for enterprise-grade collaboration. +* **Expense** - All products dealing with money ultimately deal with expenses. + +#### [Domain Introduction] What is a domain? +A domain groups users for advanced security. Join by validating your email: +* **Name** - Corresponds to the "domain name" of your email address. +* **Members** - Represents users with validated email contact methods. +* **Group** - Each member belongs to one group, setting security rules. + +### Expensify Tools +#### [Tools Introduction] What are the main tools in the Expensify App? +The Expensify App is your window to the connected world of Expensify: +* **Inbox** - Highlights what you should do next, across all products. +* **Search** - Lets you search everything across all products from one place. +* **Settings** - Wraps up personal, workspace, and domain configuration options. +* **Create** - The big green plus button to create anything across all products. + +#### [Workspace Introduction] What is a workspace? +A workspace groups members for secure sharing and collaboration. Features include: +* **Name** - Name your workspace anything, it's not globally unique. +* **Profile photo** - Use a headshot or logo, or the assigned one. +* **Description** - Provide details for members with links and information. +* **Currency** - Supports expenses in every currency, converted to one of your choice. +* **Headquarters** - Some products need to know your physical location. +* **Members** - Workspaces work best with many members. +* **Admins** - Admins have enhanced privileges to manage the workspace. +* **Rooms** - Contains chat rooms built in automatically or created manually. +* **Plan** - Workspaces come in two flavors: + * **Collect** - Optimized for businesses with simpler requirements. + * **Control** - Built for companies with more powerful needs. + +### App Platforms and Search +#### [Platforms Introduction] Where can I use the Expensify App? +The Expensify app is available in three forms: +* **Expensify web app** - Accessed at new.expensify.com via mobile or desktop web browser. +* **Expensify mobile app** - Works like the web app but is more reliable and supports notifications. +* **Expensify desktop app** - Similar to the web app but optimized for desktops and supports notifications. + +Expensify works on any computer or phone. + +#### [Search Introduction] What does Expensify's "universal search" do? +Expensify's "universal search" brings all data into one place. Search components include: +* **Query** - Describes what you are searching for. +* **Datatype selector** - Narrow results to a single type. +* **Filters** - Filter by datatype properties. +* **Saved searches** - Save searches for future use. + +The Search page helps you find anything you're looking for. + +### Inbox and Communication +#### [Inbox Introduction] What makes Expensify's Inbox powerful? +Expensify's chat-centric design makes it a superpowered chat app. The Inbox highlights what you should do now: +* **Green dot** - Indicates someone is waiting on you. +* **Red dot** - Shows what you need to finish. +* **Pinned** - Manually pin important items. +* **Priority mode** - Organized by two modes: + * **Most recent mode** - Sorts Inbox by recent activity. + * **Focus mode** - Shows unread, green/red dot, or pinned chats. + +The Inbox is the most powerful page, where you'll spend most of your time. + +### Security and Data Protection +#### [Security Overview] What security measures does Expensify implement? +Expensify takes security seriously, aligning its measures with those used by banks to protect sensitive financial data. Regular testing and updates ensure security stays ahead of potential threats. Expensify also undergoes daily checks by McAfee for added protection against hackers. Users can verify Expensify's security at the McAfee SECURE site. + +#### [Security Standards] What are Expensify's security standards? +Expensify adheres to the Payment Card Industry Data Security Standard (PCI-DSS), a high security standard used by major companies like PayPal and Visa to protect online credit card information. Additionally, Expensify is compliant with SSAE 16 and undergoes an annual SSAE-18 SOC 1 Type 2 audit by independent third-party auditors. + +#### [Data Encryption] How does Expensify encrypt data and passwords? +Expensify employs data encryption to protect information. Upon submission, data is transformed into a secret code to ensure security during transit between your device and Expensify's servers, as well as within the server network. Expensify uses HTTPS+TLS for all web connections, ensuring data is encrypted at every stage. + +#### [GDPR Compliance] How does Expensify comply with GDPR? +Expensify is committed to the General Data Protection Regulation (GDPR), which strengthens data protection for EU individuals. Key compliance measures include: +- Participation in the EU-US and Swiss-US Privacy Shield Frameworks. +- Annual SSAE-18 SOC 1 Type 2 audits. +- Maintaining PCI-DSS compliance. +- Annual penetration tests by third-party experts. +- Background checks and security training for employees and contractors. +- Appointing a dedicated Data Protection Officer reachable at [privacy@expensify.com](mailto:privacy@expensify.com). +- Signing Data Processing Addendums with vendors. +- Transparency about sub-processors on the website. +- User tools for data export, preference management, and account closure. + +**Disclaimer**: This information is not legal advice. Consult legal counsel for specific GDPR applicability. -### Profile +## Tutorials -#### [Photo] How do I set my profile photo? -To set your profile photo: -1. Press **Settings**. -2. Press the **pencil icon** next to your existing profile photo. -3. Press **Upload** photo. -4. Follow the prompts on your platform to select your photo from local storage. +### Getting Started +#### [Web Access Guide] How do I access Expensify on the web? +Visit the Expensify website: +1. Go to www.expensify.com on a browser. -#### [Display name] How do I change my display name? -To change your display name: -1. Press **Settings**. +#### [Mobile App Installation] How do I install the Expensify mobile app? +Install the Expensify mobile app: +1. Visit iOS or Android app stores. +2. Press **Install**. +3. Follow prompts to install. +4. Press the Expensify icon to start. + +#### [Desktop App Installation] How do I install the Expensify desktop app? +Install the Expensify desktop app: +1. Download the MacOS or Windows installer. +2. Double-click the installer. +3. Click the Expensify icon to start. + +#### [Join a Workspace] How do I join my company's workspace? +Welcome to Expensify! If you received an invitation to join your company's Expensify workspace, follow these steps: + +1. Download the Expensify mobile app to upload expenses and check reports from your phone. +2. Press your profile image or icon in the bottom menu, then press the **pencil icon** next to your photo to upload an image from your saved files. +3. Press **Profile** to edit details like Display Name, Contact Method, Status, Pronouns, and Timezone. +4. Meet **Concierge**, your personal assistant, to get reminders and alerts. +5. Learn to add an expense by SmartScanning a receipt or entering it manually. +6. Secure your account by enabling two-factor authentication through the **Security** settings. + +#### [Create a Company Workspace] How do I create a workspace for my company? +Creating a workspace in Expensify is your first step to organizing your company's expenses. Here's how to do it: + +1. Press your **profile photo** or icon in the bottom menu to open the settings. +2. Scroll and press **Workspaces**. +3. Press **New workspace** to start creating your workspace. +4. Press the **Edit pencil icon** next to your workspace image to upload a custom image. +5. Press **Name** to set the workspace's name. +6. Press **Default Currency** to choose the currency for all expenses. + +Invite team members to collaborate efficiently. + +#### [Manage a Copilot] How do I add, remove, or act as a Copilot? +Manage your Copilot settings: + +1. To add a Copilot: + 1. Press your **profile icon** in the bottom left corner to open **Settings**. + 2. Press **Security**. + 3. Under Copilot: Delegated Access, press **Add Copilot**. + 4. Search for the user you'd like to add using their name or email address. + 5. Select **Full** or **Limited** access and press **Add Copilot**. + +2. To remove a Copilot: + 1. Press your **profile icon** in the bottom left corner to open **Settings**. + 2. Press **Security**. + 3. Under Copilot: Delegated Access, press the three vertical dots next to the Copilot and press **Remove Copilot**. + +3. To act as a Copilot: + 1. Press your **profile icon** in the bottom left corner to open **Settings**. + 2. Press the up-down arrow next to your profile name in the top left corner to access the account switcher. + 3. Select the account and level of access. + +#### [Name Update Process] How do I update my display or legal name? +Update your display or legal name: +1. Press your **profile icon** to open **Settings**. 2. Press **Profile**. -3. Press **Display name**. -4. Enter your first and last name. -5. Press **Save**. +3. Edit your name: + - **Display name**: Press **Display Name**, enter your first name (or nickname) and last name, then press **Save**. + - **Legal name**: Scroll to the Private Details section, press **Legal Name**, enter your legal first and last name, and press **Save**. -#### [Secondary contact] How do I add a secondary contact method? -To add a secondary contact method: -1. Press **Settings**. -2. Press **Profile**. -3. Scroll to **Contact Methods**. -4. Press **Add Secondary Contact** and enter your additional email or phone number. +#### [Update Notification Preferences] How do I update my notification preferences? +Customize how you receive email and in-app notifications from Expensify: -#### [Primary contact] How do I change my primary contact method? -To change your primary contact method: -1. Add a new secondary contact method. -2. Press **Make primary** to make it the new primary contact method. +1. Press your profile image or icon in the bottom menu. +2. Press **Preferences**. +3. Enable or disable the toggles under Notifications: + - **Receive relevant feature updates and Expensify news**: If enabled, you will receive emails and in-app notifications from Expensify about new product and company updates. + - **Mute all sounds from Expensify**: If enabled, all in-app notification sounds will be silenced. -#### [Remove contact] How do I remove a contact method? -To remove a contact method: -1. Press **Settings**. -2. Go to **Profile** and navigate to **Contact Methods**. -3. Select the contact method to remove and press **Remove**. +#### [Email Address Management] How do I change or add an email address on my Expensify account? +To change or add an email address on your Expensify account: -#### [Pronouns] How do I set my pronouns? -To change your pronouns: -1. Press **Settings**. +1. Press your profile image or icon. 2. Press **Profile**. -3. Press **Pronouns**. -4. Start typing your preferred pronouns. -5. Choose your preferred set from the list. - -#### [Timezone] How do I change my timezone? -By default, your timezone will be set automatically to match your system settings. To instead set it manually: -1. Press **Settings**. +3. Press **Contact Method**. +4. Press **New Contact Method**. +5. Enter the email address or phone number you want to use. +6. Press **Add**. +7. A verification code will be sent to your email. Enter it in Expensify and press **Verify**. + +You can press any email address in your list to set it as the default, remove it, or verify it. + +#### [Switch Theme] How do I switch between light and dark mode in Expensify? +Change the appearance of Expensify by selecting a theme: + +1. Press your **profile image or icon** in the bottom menu. +2. Press **Preferences**. +3. Press the **Theme** option and select the desired theme: + - **Dark mode**: The app will appear with a dark background. + - **Light mode**: The app will appear with a light background. + - **Use Device settings**: Expensify will automatically use your device’s default theme. + +#### [Switch Language to Spanish] How do I switch my account language to Spanish? +Change your account language to Spanish: + +1. Press your **profile image or icon** in the bottom menu. +2. Press **Preferences**. +3. Press the **Language** option and select **Spanish**. + +#### [Timezone Adjustment] How do I change my timezone? +Change your timezone: +1. Press your profile image or icon in the bottom menu. 2. Press **Profile**. -3. Press **Timezone**. -4. Disable **Automatically determine your location**. -5. Press **Timezone**. -6. Choose your preferred timezone from the list. +3. Press **Timezone** to select your timezone. -#### [Status] How do I set my status? -To set your status: -1. Press **Settings**. -2. Press **Profile**. -3. Press **Status**. -4. Enter your custom status message and choose an emoji (optional). -5. Press **Save**. +#### [Pronouns Update] How do I update my pronouns? +Update your pronouns to display them on your account: -#### [Legal name] How do I change my legal name? -To change your legal name: -1. Press **Settings**. +1. Press your profile image or icon. 2. Press **Profile**. -3. Press **Legal Name**. -4. Enter your updated legal name. -5. Press **Save**. - -#### [Date of birth] How do I change my date of birth? -To change your date of birth: +3. Press **Pronouns** to select your pronouns. Type any letter into the field to see a list of available options. + +### Troubleshooting +#### [Feature Issues] What should I do if I'm facing issues with a specific feature? +If you're experiencing problems with a specific feature, refer to the respective section of the help docs for common errors and troubleshooting steps. If the issue persists, reach out to Concierge via in-product chat or by emailing concierge@expensify.com. + +#### [Local Issues] How do I troubleshoot local issues with my webpage? +If your webpage isn't loading properly, try these steps: +1. Press [here](https://www.expensify.com/signout.php?clean=true) to force a clean sign-out from the site, which can help remove stale data causing issues. +2. Clear cookies and cache on your browser. +3. Use an Incognito or Private browsing window. +4. Try accessing the site on a different browser. + +#### [JavaScript Console Access] How do I access the JavaScript console on my browser or application? +A developer console logs backend operations of sites and applications, providing information that can help developers solve your issues. To provide a screenshot of your developer console, follow the instructions for your browser or application: + +- **Chrome**: Press Cmd + Option + J on Mac, or Ctrl + Shift + J on Windows; or navigate through View > Developer > JavaScript Console. +- **Firefox**: Press Cmd + Option + K on Mac, or Ctrl + Shift + J on Windows; or go through Menu Bar > More Tools > Web Developer Tools > Console tab. +- **Safari**: Enable the console in Safari by selecting "Show features for web developers" in Safari Menu > Settings > Advanced. Then, press Cmd + Option + C or use the Develop Menu > Show JavaScript Console. +- **Microsoft Edge**: Press Cmd + Option + J on Mac, or Ctrl + Shift + J on Windows; or right-click a webpage and select Inspect > Console. + +### Account Management +#### [Sign In Process] How do I sign up or sign in? +Sign up or sign in: +1. Install or access Expensify on any platform. +2. Choose connection method and press Next. +3. Validate your email address and press **Join** if first time signing in. + +#### [Magic Link Login] How do I use a magic link for secure login? +Use a magic link for secure login: +1. Check email for Expensify Magic Link. +2. Click link to log in without a password. + +#### [Sign Out Process] How do I sign out of Expensify? +Sign out of Expensify: 1. Press **Settings**. -2. Press **Profile**. -3. Press **Date of Birth**. -4. Update your birth date and press **Save**. +2. Scroll and press **Sign Out**. -#### [Address] How do I change my address? -To change your address: +#### [Close Account Process] How do I close my account? +Close your account: +1. Press your profile image or icon in the bottom menu. +2. Press **Security**. +3. Press **Close account**. +4. Provide answers to the questions and confirm closure by pressing **Close Account**. + +### Subscription Management +#### [Manage Subscription] How do I manage my subscription? +To manage your subscription in New Expensify: +1. Open the app on your device. +2. Press your profile icon in the bottom-left corner. +3. Navigate to the **Workspaces** section. +4. Press **Subscription** under Workspaces to view your subscription details. + +#### [Add Payment Card] How do I add a payment card for billing? +To add a payment card for billing: +1. Locate the **Add Payment Card** option within your subscription settings. +2. Enter your payment card details securely to maintain uninterrupted service. + +#### [Understand Subscription Details] What subscription details can I view? +Within your subscription overview, you can view: +- **Plan details**: See the number of seats, billing information, and renewal date. +- **Auto-renew settings**: Check when your subscription will renew automatically. +- **Auto-increase seats**: Discover potential savings by automatically increasing seats for team members exceeding the subscription size. + +#### [Request Early Cancellation] How can I request an early cancellation of my subscription? +To request an early cancellation: +1. Access the **Request Early Cancellation** option in the Subscriptions section. +Note: Early cancellation might not be available for all customers. + +#### [Pricing Information] Where can I find more details on pricing plans? +For detailed pricing plans, visit the billing page [coming soon]. + +### Security and Customization +#### [Enable 2FA Security] How do I secure my account with two-factor authentication? +Secure your account with two-factor authentication: 1. Press **Settings**. -2. Press **Profile**. -3. Press **Address**. -4. Enter your new address and press **Save**. +2. Press **Security**. +3. Press **Two-factor authentication**. +4. Follow steps to link your phone. -### Workspace +#### [Additional Security with 2FA] How do I add an extra layer of security with 2FA? +Adding an extra layer of security can help protect your financial data. To enable two-factor authentication (2FA): -#### [Create] How do I create a workspace? -To create a workspace: +1. Press your profile image or icon in the bottom menu. +2. Press **Security**. +3. Under Security Options, press **Two Factor Authentication**. +4. Save a copy of your backup codes. This is critical to avoid losing access if you cannot use your authenticator app. + - Press **Download** to save the backup codes to your device. + - Press **Copy** to paste the codes into a secure location. +5. Press **Next**. +6. Download or open your preferred authenticator app and connect it to Expensify by scanning the QR code or entering the code manually. +7. Enter the 6-digit code from your authenticator app into Expensify and press **Verify**. + +When you log in to Expensify in the future, you'll need to use a magic code from your email and a 6-digit code from your authenticator app. If you lose access to your authenticator app, use your recovery codes as you would the authenticator code. + +### Profile and Contact Methods +#### [Profile Photo Setup] How do I set my profile photo? +Set your profile photo: +1. Press your profile image or icon in the bottom menu. +2. Press the **pencil icon** next to your photo. +3. Press **Upload Image** to select a photo from your saved files. + +#### [Display Name Modification] How do I change my display name? +Change your display name: 1. Press **Settings**. -2. Press **Workspaces**. -3. Press **Create Workspace**. -4. Follow the steps to name and configure your new workspace. +2. Press **Profile**. +3. Press **Display name**. +4. Enter your name and press **Save**. -#### [Rename] How do I rename my workspace? -To rename your workspace: -1. Press **Settings**. -2. Press **Workspaces**. -3. Select your workspace and press **Edit**. -4. Change the name and press **Save**. +#### [Status Update] How do I set my status? +Set your status: +1. Press your profile image or icon in the bottom menu. +2. Press **Profile**. +3. Press **Status**. +4. (Optional) Press the **emoji icon** to add an emoji. +5. Enter a status message, such as "out of office" or "in a meeting." +6. Press **Clear After** to select when the status should expire. +7. Press **Save**. -#### [Photo] How do I change the profile photo of my workspace? -To change your workspace's profile photo: +#### [Add Secondary Contact] How do I add a secondary contact method? +Add a secondary contact method: 1. Press **Settings**. -2. Press **Workspaces**. -3. Select the workspace and press **Profile Photo**. -4. Upload a new photo and press **Save**. +2. Press **Profile**. +3. Scroll to **Contact Methods**. +4. Press **Add Secondary Contact** and enter details. -#### [Description] How do I change the description of my workspace? -To update your workspace description: -1. Press **Settings**. -2. Press **Workspaces**. -3. Select the workspace and press **Description**. -4. Update the text and press **Save**. +#### [Change Primary Contact] How do I change my primary contact method? +Change your primary contact method: +1. Add a new secondary contact method. +2. Press **Make primary** to set it as primary. -#### [Currency] How do I change the currency of my workspace? -To change your workspace currency: +### Private Details and Regional Settings +#### [Remove Contact Method] How do I remove a contact method? +Remove a contact method: 1. Press **Settings**. -2. Press **Workspaces**. -3. Select the workspace and press **Currency**. -4. Choose a new default currency and press **Save**. +2. Go to **Profile** and **Contact Methods**. +3. Select and press **Remove**. -#### [Headquarters] How do I change the headquarters of my workspace? -To change your workspace's headquarters location: -1. Press **Settings**. -2. Press **Workspaces**. -3. Select the workspace and press **Headquarters**. -4. Update the address and press **Save**. +#### [Set Pronouns] How do I set my pronouns? +Set your pronouns: +1. Press your profile image or icon in the bottom menu. +2. Press **Profile**. +3. Press **Pronouns**. +4. Type any letter to see a list of available pronouns and select your preferred set. -#### [Invite member] How do I add or invite someone to my workspace? -To invite a new member: -1. Press **Settings**. -2. Press **Workspaces**. -3. Select the workspace and press **Members**. -4. Press **Add Member** and enter the email addresses of the new members. -5. Press **Invite**. +#### [Timezone Adjustment] How do I change my timezone? +Change your timezone: +1. Press your profile image or icon in the bottom menu. +2. Press **Profile**. +3. Press **Timezone** to select your timezone. -#### [Remove member] How do I remove someone from my workspace? -To remove a member from your workspace: +#### [Legal Name Update] How do I change my legal name? +Change your legal name: 1. Press **Settings**. -2. Press **Workspaces**. -3. Select the workspace and press **Members**. -4. Choose the member to remove and press **Remove Member**. +2. Press **Profile**. +3. Scroll to the Private Details section and press **Legal Name**. +4. Enter updated name and press **Save**. -#### [Add admin] How do I make someone an admin of my workspace? -To promote a member to an admin: +#### [Date of Birth Adjustment] How do I change my date of birth? +Change your date of birth: 1. Press **Settings**. -2. Press **Workspaces**. -3. Select the workspace and press **Members**. -4. Select the member and press **Make Admin**. +2. Press **Profile**. +3. Scroll to the Private Details section and press **Date of Birth**. +4. Update birth date and press **Save**. -#### [Remove admin] How do I remove an admin from my workspace? -To remove admin privileges: +### Address and Workspace Management +#### [Address Update] How do I change my address? +Change your address: 1. Press **Settings**. -2. Press **Workspaces**. -3. Select the workspace and press **Members**. -4. Choose the admin and press **Remove Admin**. +2. Press **Profile**. +3. Scroll to the Private Details section and press **Address**. +4. Enter new address and press **Save**. -#### [More features] How do I enable features on my workspace? -To enable features: -1. Press **Settings**. -2. Press **Workspaces**. -3. Select the workspace and press **Features**. -4. Toggle on the desired features and press **Save**. +### Theme Preferences +#### [Theme Preference Setup] How do I set my theme preference in New Expensify? +Customize your theme preference in New Expensify to enhance your experience: -#### [Upgrade plan] How do I upgrade my workspace? -To upgrade a workspace to the Control plan: -1. Press **Settings**. -2. Press **Workspaces**. -3. Select your workspace and press **Upgrade to Control**. -4. Follow the steps to finalize the upgrade. +1. Press your **profile image or icon** in the bottom menu. +2. Press **Preferences**. +3. Tap on **Theme**. +4. Choose your preferred theme: + - **Dark mode**: Provides a dark background for a sleek look. + - **Light mode**: Offers a bright background for a classic appearance. + - **Use Device settings**: Aligns with your device's theme settings, adjusting automatically as your device changes. -#### [Delete] How do I delete my workspace? -To delete a workspace: -1. Press **Settings**. -2. Press **Workspaces**. -3. Select the workspace and press **Delete Workspace**. -4. Confirm the deletion. +The default setting is **Use Device Settings**, which matches your device's theme transitions. Your selected theme will sync across all Expensify platforms you use. ## FAQ You've got questions? We've got answers! -### App +### App and Messaging +#### [Passwordless Authentication] Why don't I set a password? +Expensify uses a "passwordless" design, sending a "magic link" to your contact method for secure authentication. Once signed in, you remain signed in until you sign out. + +#### [Infinite Sessions] Why am I never asked to sign in? +Expensify uses "infinite sessions," keeping you signed in indefinitely until you sign out. -#### Why don't I set a password? -Expensify uses a "passwordless" design, where each time you sign in, we send a "magic link" to your contact method. This securely authenticates you based on your ability to receive the magic link to the contact method associated with your account. Once signed into a device, you remain signed into that account until you ask to sign out. +#### [Messaging Closed Accounts] Why can others message me even if my account is closed? +Expensify is a communications platform allowing messaging with valid email or SMS numbers, even if you don't use Expensify. -#### Why am I never asked to sign in? -Expensify uses an "infinite sessions" design, where after you sign in on a particular device, you remain signed in indefinitely, until you explicitly sign out. +#### [Messaging User Blocking] Why can't I block users from messaging me using Expensify? +Like Gmail or iMessage, Expensify allows messaging with valid emails or SMS numbers, without blocking all users. -#### Why can others message me even if my account is closed? -Like Gmail or iMessage, Expensify is a communications platform designed to let you message anyone with a valid email address or SMS number – whether or not they also use Gmail or iMessage. Accordingly, even if you don't use Expensify (or if you did use it but have since closed your account), other users can still message you using Expensify. +### Profile and Legal Information +#### [Display and Legal Names] Why do I have both a display name and legal name? +Your display name shows how you'd like to be identified. Your legal name is used for documentation like billing or tax-related matters. -#### Why can't I block users from messaging me using Expensify? -Similar to how you can't ask Gmail to stop all Gmail users from emailing you, or ask iMessage to stop all iMessage users from texting you, you can't ask Expensify to stop all Expensify users from emailing you. Gmail, iMessage, and Expensify are all tools designed to enable the user to email and SMS other users. +#### [Need for Legal Name] Why do you need my legal name? +Your legal name is for identity verification when issuing payment cards and processing reimbursements. -### Profile +#### [Birth Date Requirement] Why do you need my date of birth? +Your birth date verifies identity for financial products, ensuring compliance with regulations. -#### Why do I have both a display name and legal name? -You have a display name to show how you'd like to be publicly identified. Your legal name is used for documentation purposes, such as for billing or tax-related matters, which require your formal identification. +#### [Home Address Requirement] Why do you need my home address? +We need your address for shipping items and identity verification when processing payments. -#### Why do you need my legal name? -Your legal name is necessary for identity verification when issuing payment cards, processing reimbursements, and fulfilling regulatory requirements. +### Workspace and Copilot +#### [Workspace Address Requirement] Why do you need the address of my workspace's headquarters? +We need the address to process transactions, apply local taxes, and comply with regional laws. -#### Why do you need my date of birth? -Your date of birth is used for verifying your identity when issuing financial products like the Expensify Card. It helps ensure compliance with regulatory requirements. +#### [Copilot Permissions] As a Copilot, can I add or remove other Copilots? +No. Copilots are restricted from adding or removing Copilots from other accounts. Only the account owner can add or remove Copilots from their own account. The only exception is that Copilots can remove themselves from another user's account. -#### Why do you need my home address? -We need your home address for shipping physical items like the Expensify Card and for identity verification when processing reimbursements or payments. +#### [Copilot Action Identification] How can I tell which actions were taken by a Copilot? +Any action taken by a Copilot will be displayed as being taken by the Copilot on behalf of the account owner. -### Workspace +#### [Multiple Copilots] Can I have more than one Copilot? +You can assign as many Copilots as you need—there is no limit. However, you can only add one Copilot per minute. -#### Why do you need the address of my workspace's headquarters? -We need the headquarters' address to correctly process transactions, apply any local taxes, and ensure compliance with regional laws. +### Account Closure and Pricing +#### [Account Closure Issues] Why can't I close my account? +There are several reasons you might be unable to close your account. If your account has an outstanding balance or if you have been assigned a role under a company’s Expensify workspace, you may encounter an error message during the account closure process, or the Close Account button may not be available. Here are the steps to follow for each scenario: +- **Account Under a Validated Domain**: A Domain Admin must remove your account from the domain. Then you will be able to successfully close your account. +- **Sole Domain Admin**: If you are the only Domain Admin for a company’s domain, you must assign a new Domain Admin before you can close your account. +- **Workspace Billing Owner with an annual subscription**: You must downgrade from the annual subscription before closing the account. Alternatively, you can have another user take over billing for your workspaces. +- **Company Workspace Owner**: You must assign a new workspace owner before you can close your account. +- **Account has an outstanding balance**: You must make a payment to resolve the outstanding balance before you can close your account. +- **Preferred Exporter for a workspace integration**: You must assign a new Preferred Exporter before closing your account. +- **Verified Business Account that is locked**: You must unlock the account. +- **Verified Business Account that has an outstanding balance**: You must make a payment to settle any outstanding balances before the account can be closed. +- **Unverified account**: You must first verify your account before it can be closed. -### Pricing +#### [Workspace Member Pricing] Which active workspace members require paid seats? +Workspace members billed for a paid seat if they submit, approve, pay, export, or chat on expenses. -#### Which active workspace members require paid seats? -If a workspace member takes any of the following actions inside of a workspace, you will be billed at the end of the month for a paid seat: -* Submit an expense -* Approve an expense -* Pay an expense -* Export an expense -* Chat on an expense report -* and so on +#### [Member Double Billing] Why do some workspace members using paid features not require paid seats? +Members using paid functionality on multiple workspaces in a month aren't billed twice – no "double dipping." -In general, any action that modifies financial data or participates in a financial workflow is billable activity. +### Billing and Subscriptions +#### [Billing Page] What is the status of the billing page? +The billing page is currently under development and will be available soon. Stay tuned for updates on how to access and use the new billing features. -#### Why do some workspace members using paid features not require paid seats? -In general, any workspace member that uses paid functionality will require a paid seat. However, if you own two or more workspaces with the same member, and the member uses paid functionality on multiple workspaces in a given month (ie, an admin approving expense reports on two different workspaces), you will not be billed twice for the same member – there is no "double dipping." \ No newline at end of file +#### [Recovery Codes Usage] How do I use my recovery codes if I lose access to my authenticator app? +Your recovery codes work the same way as your authenticator codes. Just enter a recovery code as you would the authenticator code. \ No newline at end of file diff --git a/help/map.md b/help/map.md new file mode 100644 index 000000000000..eb218e67dcc0 --- /dev/null +++ b/help/map.md @@ -0,0 +1,371 @@ +--- +layout: product +title: Application Map +--- + +## Application Map +Lost in the app? Let this map guide you! + +* Inbox + * Workspace selector + * Chat selector + * Special chats: + * Concierge + * Workspace chat + * #announce + * #admins + * Personal chat + * Chat modifiers: + * Pin + * Green dot + * Red dot + * Unread + * Current chat + * Chat header + * Profile image + * Name + * Description + * Pin / Unpin + * Share shortcut + * Members + * Invite member + * Bulk actions + * Find a member + * Member list + * Select all + * Member row + * Profile image + * Name + * Remove from chat + * Profile link + * Profile image + * Message + * Email + * Preferred pronouns + * Local time + * Settings + * Notify me about new messages + * Who can post + * Visibility + * Private notes + * Leave + * Message list + * Message actions + * Add reaction + * Reply in thread + * Mark as unread + * Join thread + * Copy link + * Flag as offensive + * Download + * Message composer + * Attach + * Split expense + * Assign task + * Add attachment + * Write something + * Emoji + * Send +* Search + * Type selector + * State selector + * Filters + * Search results table + * Select all + * Search results row +* Settings + * Status shortcut + * Profile + * Public + * Display name + * Contact method + * Status + * Emoji + * Message + * Clear after + * Pronouns + * Timezone + * Automatically determine your location + * Timezone + * Share + * QR Code + * Copy URL + * Get $250 + * Private + * Legal name + * Date of birth + * Phone number + * Address + * Wallet + * Bank accounts + * Assigned cards + * Send and receive money with friends + * Preferences + * Notifications + * Receive relevant feature updates and Expensify news + * Mute all sounds from Expensify + * Priority mode + * Language + * Theme + * Security + * Two-factor authentication + * Close account + * Workspaces + * Profile + * Profile image + * Name + * Description + * Default currency + * Company address + * Share + * Delete + * Members + * Bulk actions + * Remove members + * Make member + * Make admin + * Make auditor + * Profile image + * Name + * Remove from workspace + * Role + * Profile shortcut + * Invoices + * Invoice balance + * Bank accounts + * Bank account + * Make default payment method + * Delete + * Add bank account + * Invoicing details + * Company name + * Company website + * Invoicing details + * Distance rates + * Add rate + * Bulk actions + * Settings + * Rate table + * Rate column + * Status column + * Rate row + * Enable rate + * Rate + * Delete + * Expensify Card + * Issue new card + * Workflows + * Delay submissions + * Submission frequency + * Add approvals + * Approvals + * Expenses from + * Approver + * Add approval workflow + * Make or track payments + * Connect bank account + * Connect online with Plaid + * Connect manually + * Rules + * Expenses + * Receipt required amount + * Max expense amount + * Max expense age + * Billable default + * eReceipts + * Expense reports + * Custom report names + * Prevent self-approvals + * Auto-approve compliant reports + * Auto-pay approved reports + * Categories + * Add category + * Bulk actions + * Delete categories + * Enable categories + * Settings + * Members must categorize all expenses -- Why not in Rules? + * Default spend categories + * Three dots menu + * Import spreadsheet + * Download CSV + * Category table + * Name column + * Status column + * Category row + * Enable category + * Name + * GL code + * Payroll code + * Category rules: + * Require description + * Default tax rate + * Flag amounts over + * Require receipts over + * Delete + * Tags + * Add tag + * Bulk actions + * Delete tag + * Disable tag + * Settings + * Custom tag name + * Members must tag all expenses + * Track billable expenses + * Three dots menu + * Import spreadsheet + * Download CSV + * Tag table + * Name column + * Status column + * Tag row + * Enable tag + * Name + * GL code + * Delete + * Taxes + * Add rate + * Bulk actions + * Delete rate + * Disable rate + * Settings + * Custom tax name + * Workspace currency default + * Foreign currency default + * Tax table + * Name + * Status + * Tax row + * Enable rate + * Name + * Value + * Tax code + * Report fields + * Add field + * Bulk actions + * Delete field + * Field table + * Name column + * Type column + * Field row + * Name + * Type + * Initial value + * Delete + * Accounting + * Connections list + * Quickbooks Online Connect + * Quickbooks Desktop Connect + * Xero + * NetSuite + * Sage Intacct + * More features + * Spend + * Distance rates + * Expensify Card + * Manage + * Workflows + * Rules + * Earn + * Invoices + * Organize + * Categories + * Tags + * Taxes + * Report fields + * Integrate + * Accounting + * Subscription + * Payment + * View payment history + * Request refund + * Your plan + * Subscription details + * Annual subscription + * Pay-per-user + * Three dot menu + * Request tax exempt status + * Domains + * Help + * Switch to Expensify Classic + * About + * App download links + * View keyboard shortcuts + * View the code + * View open jobs + * Report a bug + * Troubleshoot + * Client side logging + * Mask fragile user data wile exporting Onyx state + * Import Onyx state + * Export Onyx state + * Clear cache and restart + * Testing preferences + * Debug mode + * Use Staging server + * Force offline + * Simulate failing network requests + * Authentication status + * Device credentials + * Save the world + * Teachers Unite + * I know a teacher + * I am a teacher + * Sign out +* Search router + * Search for something + * Recent searches + * Recent chats +* Global Create + * Start chat + * Chat + * Name, email, or phone number + * Recents + * Contacts + * Add to group + * Room + * Room name + * Room description + * Workspace + * Visibility + * Track expense + 1. Choose type: + * Manual + * Amount + * Currency + * Scan + * Choose file + * Camera + * Distance + * Start + * Stop + 2. Code the expense + * Amount + * Description + * Show more + * Merchant + * Date + * Submit expense + 1. Choose type: + * Manual + * Amount + * Currency + * Scan + * Choose file + * Camera + * Distance + * Start + * Stop + 2. Choose who to submit to + * Name, email, or phone number + * Recents + * Contacts + 3. Code the expense + * Amount + * Description + * Show more + * Merchant + * Date + * Book travel + * Quick Action Button +* Magic link page diff --git a/help/travel.md b/help/travel.md index 43e082896ce4..351f83f90ba4 100644 --- a/help/travel.md +++ b/help/travel.md @@ -7,7 +7,7 @@ Expensify Travel is a comprehensive travel management platform integrated direct ### [Main uses] When should I use Expensify Travel? Expensify Travel is perfect for any situation involving corporate or personal travel, including: -* **Booking business travel** - Book flights, hotels, and car rentals in a few clicks, all within the Expensify platform. +* **Booking business travel** - Book flights, hotels, car rentals, and train travel in a few presses, all within the Expensify platform. * **Tracking travel expenses** - Automatically capture travel-related expenses such as airfare, lodging, and meals, ensuring everything is logged without manual input. * **Managing employee travel** - Empower managers to oversee travel bookings, set travel policies, and approve expenses in real time. * **Ensuring compliance with travel policies** - Use travel policies to enforce company rules around budgets, preferred vendors, and travel categories. @@ -38,59 +38,104 @@ Expensify Travel integrates a seamless booking experience directly into the app: * **Flights** - Search for and book flights, choosing from corporate-approved airlines or vendors. * **Hotels** - Book hotels using preferred vendor rates or select your own accommodations, with policy checks to ensure compliance. * **Car rentals** - Rent vehicles from top providers, with automatic receipt tracking and expense capture. -* **All in one place** - View and manage your full itinerary (flights, hotels, cars) from a single interface. +* **Trains** - Book train travel alongside other modes of transportation. +* **All in one place** - View and manage your full itinerary (flights, hotels, cars, trains) from a single interface. -### [Travel policies] How do I enforce company travel policies? +### Travel Policies Corporate travel policies can be configured in Expensify Travel to ensure compliance: * **Budgets** - Set maximum budgets for flights, hotels, and other travel-related expenses. * **Preferred vendors** - Require employees to book through specific airlines, hotel chains, or rental agencies to take advantage of corporate rates. * **Approval workflows** - Ensure all travel plans are reviewed and approved by the appropriate managers before booking. * **Expense categories** - Automatically categorize travel expenses in line with company accounting policies. -### [Approvals] How does the travel approval process work? -Travel approvals in Expensify are designed to ensure compliance before any bookings are confirmed: -1. **Request travel** - Employees submit travel requests, including flights, hotels, and car rentals, directly in the app. -2. **Automatic policy checks** - Expensify automatically flags any out-of-policy bookings or expenses for manager review. -3. **Manager approval** - Managers can approve or reject travel requests with one click, ensuring compliance before the trip is booked. -4. **Track approval status** - Both employees and managers can monitor the status of a travel request in real time. +### Approval Methods +Expensify Travel offers three approval methods to accommodate different organizational needs: Soft Approval, Hard Approval, and Passive Approval. -### [Expense integration] How does Expensify Travel integrate with Expensify Expense? -Expensify Travel works seamlessly with Expensify Expense to automate the handling of travel expenses: -* **Automatic expense capture** - Travel-related expenses (flights, hotels, meals) are automatically imported into Expensify Expense for easy tracking and reimbursement. -* **Real-time tracking** - Travel expenses appear in your expense report as soon as they are incurred, providing real-time visibility into costs. -* **One-click submission** - Employees can submit all travel expenses in a single report, and managers can approve them in bulk. +- **Soft Approval**: Bookings are automatically approved unless a manager declines them within 24 hours. If not declined, the arrangements proceed even if they are out of policy. +- **Hard Approval**: Bookings are automatically canceled if not approved within 24 hours. +- **Passive Approval**: Managers are notified of out-of-policy travel, but no action is required. -### [Corporate cards] Can I use Expensify Cards with Expensify Travel? -Yes, Expensify Travel integrates with Expensify Cards: -* **Automatic e-receipts** - Travel purchases made with an Expensify Card automatically generate e-receipts, eliminating the need for paper receipts. -* **Real-time expense tracking** - Expenses made with the Expensify Card are logged in real-time and categorized according to your travel policies. -* **Spend controls** - Set card limits and track spend in real-time to ensure that employees stay within budget. - -## Platforms -Expensify Travel is accessible from all platforms, making it easy to manage travel from anywhere: -* **Web app** - Manage your travel plans from your desktop via the Expensify web app. -* **Mobile app** - Book travel and track expenses on the go using the Expensify mobile app for iOS and Android. -* **Desktop app** - Use the Expensify desktop app for Mac or Windows to access the full range of travel and expense management features. +### Travel Member Roles +Assign roles to manage travel permissions within Expensify Travel: +* **Traveler** - Can only book travel for themselves. +* **Travel Arranger** - Can book travel for themselves and for other workspace members. Arrangers can be set to arrange travel for everyone in the workspace or for specific individuals only. +* **Company Admin** - Can book travel for themselves as well as any other workspace members. They can also access administrative features to define travel policies, add or remove users, configure corporate cards as payment methods, view analytics and metrics, and use the Safety feature. ## Tutorials -### [Book travel] How do I book a flight, hotel, or car rental? -1. Navigate to the **Travel** section in the Expensify app. -2. Select **Book Flight**, **Book Hotel**, or **Book Car Rental**. -3. Enter your travel dates, destination, and any other required details. -4. Select a flight, hotel, or car from the available options. -5. Confirm your booking and add it to your travel itinerary. - -### [Submit travel request] How do I submit a travel request for approval? +### [Book Travel] How do I book a flight, hotel, or car rental? +To book travel from the Expensify app, follow these steps: +1. Press the **Travel** tab. +2. Press **Book or manage travel**. +3. Use the icons at the top to select the type of travel arrangement you want to book: flights, hotels, cars, or trains. +4. Enter the travel information relevant to the selected arrangement (destination, dates of travel, etc.). +5. Select all the details for the arrangement you wish to book. +6. Review the booking details and press **Book Flight / Book Hotel / Book Car / Book Rail** to complete the booking. + +The traveler is emailed an itinerary of the booking. Additionally, +- Their travel details are added to a Trip chat room under their primary workspace. +- An expense report for the trip is created. +- If booked with an Expensify Card, the trip is automatically reconciled. + +### [Submit Travel Request] How do I submit a travel request for approval? +To submit a travel request: 1. Go to **Create** > **Travel Request**. 2. Enter the details of your trip, including flights, hotels, and rental cars. 3. Review your travel options and ensure they are within policy. 4. Submit the request to your manager for approval. -### [Approve travel] How do I approve a travel request? +### [Approve Travel] How do I approve a travel request? +To approve a travel request: 1. Go to your **Inbox** and find the travel request awaiting approval. 2. Review the trip details, including any out-of-policy flags. -3. Click **Approve** or **Reject** as appropriate. +3. Press **Approve** or **Reject** as appropriate. + +### [Edit or Cancel Travel Arrangements] How do I modify or cancel a travel booking? +If you need to edit or cancel your travel arrangements, you can do so through the Expensify app: + +1. Open the Trip chat in your inbox to review your travel arrangements. +2. Press your profile image or icon in the bottom left menu. +3. Scroll down and press **Workspaces** in the left menu. +4. Select the workspace the travel is booked under. +5. Tap into the booking to see more details. +6. Press **Trip Support** for assistance. + +If there are unexpected changes to your itinerary, such as a flight cancellation, Expensify’s travel partner **Spotnana** will reach out to provide updates. Note that any modifications, exchanges, or cancellations made through support will incur a $25 booking change fee. + +### [Configure Travel Policy] How do I set up a travel policy for my workspace? +Workspace admins can create and update travel policies to establish travel rules for different groups of travelers. To configure a travel policy: + +1. Press the **Travel** tab and select **Book or manage travel**. +2. Select the **Program** tab and choose **Policies**. +3. Under Employee or Non-employee, press **Add new** to create a new policy. +4. In the **Edit members** section, select the group of employees for the policy. +5. Choose travel preferences to modify: General, flight, hotel, car, or rail. +6. Press the paperclip icon next to each setting to de-couple it from the default policy. +7. Update the desired settings and save changes. + +### [Demo Video] How can I watch a demo of Expensify Travel? +To see how Expensify Travel works, watch the demo video: +- The video provides a comprehensive overview of using Expensify Travel for booking and managing travel. + +### [Set Approval Method] How do I set the approval method for travel expenses? +To configure the approval method for travel expenses in Expensify: +1. Press the **Travel** tab and choose **Book or manage travel**. +2. Navigate to the **Program** tab and select **Policies**. +3. Under the General section, select the approval methods for Flights, Hotels, Cars, and Rail, choosing between Soft Approval, Hard Approval, or Passive Approval. + +### [Manage Travel Member Roles] How do I assign roles to travel members? +To manage travel member roles within Expensify: +1. Press the **Travel** tab and select **Book or manage travel**. +2. Select the **Program** tab and choose **Users**. +3. Press the name of the member whose role you wish to update. +4. Press the **Roles** tab and select the desired role. +5. Press **Save** to confirm the changes. + +### [Approve Travel Booking] How do I approve or decline a travel booking? +To manage travel booking approvals effectively: +1. Once an employee books a trip, you will receive an email notification with booking details. +2. For **Soft Approval**, no action is required to approve, but to decline, follow the email prompt within 24 hours and press **Decline booking**, then **Deny Booking**. +3. For **Hard Approval**, press **Approve booking** to confirm or **Decline booking** to reject, then follow the respective prompts. ## FAQ @@ -109,3 +154,23 @@ Yes, Expensify Travel supports international bookings and expense tracking in mu ### How do I integrate Expensify Travel with my company’s existing travel policies? You can configure travel policies directly in Expensify by setting budgets, preferred vendors, and approval workflows. These policies will automatically be enforced whenever employees book travel. +### Are extended approval windows given for trips booked over the weekend or during company holidays? +No, the approval window is fixed at 24 hours from when the trip is booked. + +### How does Expensify Travel handle approvals when the assigned approver is out of office? +It is recommended to have multiple approvers set up for travel, as there is no delegated approval for out-of-office scenarios. + +### Can travelers upload a document when submitting a trip for approval? +Travelers cannot upload a document at the time of trip submission, but companies can use a 'reason code' in the Out of Policy rules, which travelers complete at checkout. Documents can then be added to the expense report during submission in Expensify. + +### [Expense Integration] How do I use Expensify Travel with Expensify Expense? +Expensify Travel works seamlessly with Expensify Expense to automate the handling of travel expenses: +* **Automatic expense capture** - Travel-related expenses (flights, hotels, meals) are automatically imported into Expensify Expense for easy tracking and reimbursement. +* **Real-time tracking** - Travel expenses appear in your expense report as soon as they are incurred, providing real-time visibility into costs. +* **One-click submission** - Employees can submit all travel expenses in a single report, and managers can approve them in bulk. + +### [Corporate Cards] Can I use Expensify Cards with Expensify Travel? +Yes, Expensify Travel integrates with Expensify Cards: +* **Automatic e-receipts** - Travel purchases made with an Expensify Card automatically generate e-receipts, eliminating the need for paper receipts. +* **Real-time expense tracking** - Expenses made with the Expensify Card are logged in real time and categorized according to your travel policies. +* **Spend controls** - Set card limits and track spend in real time to ensure that employees stay within budget. \ No newline at end of file diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 3f6ba7f16970..e4f525da1e9f 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.0.57 + 9.0.59 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.57.3 + 9.0.59.0 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 6d7ff5c4ac09..96070daa066c 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 9.0.57 + 9.0.59 CFBundleSignature ???? CFBundleVersion - 9.0.57.3 + 9.0.59.0 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 605eb605529c..e0bef4291004 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.0.57 + 9.0.59 CFBundleVersion - 9.0.57.3 + 9.0.59.0 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile.lock b/ios/Podfile.lock index d1851cbce1af..5268e5e9fb24 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -2391,7 +2391,7 @@ PODS: - RNGoogleSignin (10.0.1): - GoogleSignIn (~> 7.0) - React-Core - - RNLiveMarkdown (0.1.179): + - RNLiveMarkdown (0.1.180): - DoubleConversion - glog - hermes-engine @@ -2411,9 +2411,9 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - RNLiveMarkdown/newarch (= 0.1.179) + - RNLiveMarkdown/newarch (= 0.1.180) - Yoga - - RNLiveMarkdown/newarch (0.1.179): + - RNLiveMarkdown/newarch (0.1.180): - DoubleConversion - glog - hermes-engine @@ -2503,7 +2503,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - RNReanimated (3.15.1): + - RNReanimated (3.15.3): - DoubleConversion - glog - hermes-engine @@ -2523,10 +2523,10 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - RNReanimated/reanimated (= 3.15.1) - - RNReanimated/worklets (= 3.15.1) + - RNReanimated/reanimated (= 3.15.3) + - RNReanimated/worklets (= 3.15.3) - Yoga - - RNReanimated/reanimated (3.15.1): + - RNReanimated/reanimated (3.15.3): - DoubleConversion - glog - hermes-engine @@ -2547,7 +2547,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - RNReanimated/worklets (3.15.1): + - RNReanimated/worklets (3.15.3): - DoubleConversion - glog - hermes-engine @@ -3264,12 +3264,12 @@ SPEC CHECKSUMS: RNFS: 4ac0f0ea233904cb798630b3c077808c06931688 RNGestureHandler: 8781e2529230a1bc3ea8d75e5c3cd071b6c6aed7 RNGoogleSignin: ccaa4a81582cf713eea562c5dd9dc1961a715fd0 - RNLiveMarkdown: 7acba70803223c6fa369c32cd2673c415ae3b5c4 + RNLiveMarkdown: fc07b203a3ed832e2e5d3950e69cd4fc3b0568b6 RNLocalize: d4b8af4e442d4bcca54e68fc687a2129b4d71a81 rnmapbox-maps: 460d6ff97ae49c7d5708c3212c6521697c36a0c4 RNPermissions: 0b1429b55af59d1d08b75a8be2459f65a8ac3f28 RNReactNativeHapticFeedback: 73756a3477a5a622fa16862a3ab0d0fc5e5edff5 - RNReanimated: 76901886830e1032f16bbf820153f7dc3f02d51d + RNReanimated: f46df3b08d5d59cd83c47bb6697ce88e565e0dc7 RNScreens: de6e57426ba0e6cbc3fb5b4f496e7f08cb2773c2 RNShare: bd4fe9b95d1ee89a200778cc0753ebe650154bb0 RNSound: 6c156f925295bdc83e8e422e7d8b38d33bc71852 diff --git a/package-lock.json b/package-lock.json index 7d7c82747222..594888c76972 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "new.expensify", - "version": "9.0.57-3", + "version": "9.0.59-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.57-3", + "version": "9.0.59-0", "hasInstallScript": true, "license": "MIT", "dependencies": { "@dotlottie/react-player": "^1.6.3", - "@expensify/react-native-live-markdown": "0.1.179", + "@expensify/react-native-live-markdown": "0.1.180", "@expo/metro-runtime": "~3.2.3", "@firebase/app": "^0.10.10", "@firebase/performance": "^0.6.8", @@ -51,7 +51,7 @@ "date-fns-tz": "^3.2.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "2.0.101", + "expensify-common": "2.0.106", "expo": "51.0.31", "expo-av": "14.0.7", "expo-image": "1.12.15", @@ -95,7 +95,7 @@ "react-native-launch-arguments": "^4.0.2", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "2.0.73", + "react-native-onyx": "2.0.78", "react-native-pager-view": "6.4.1", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", @@ -104,7 +104,7 @@ "react-native-plaid-link-sdk": "11.11.0", "react-native-qrcode-svg": "6.3.11", "react-native-quick-sqlite": "git+https://github.com/margelo/react-native-quick-sqlite#99f34ebefa91698945f3ed26622e002bd79489e0", - "react-native-reanimated": "3.15.1", + "react-native-reanimated": "3.15.3", "react-native-release-profiler": "^0.2.1", "react-native-render-html": "6.3.1", "react-native-safe-area-context": "4.10.9", @@ -116,7 +116,7 @@ "react-native-url-polyfill": "^2.0.0", "react-native-view-shot": "3.8.0", "react-native-vision-camera": "4.0.0-beta.13", - "react-native-web": "^0.19.12", + "react-native-web": "0.19.13", "react-native-webview": "13.8.6", "react-plaid-link": "3.3.2", "react-web-config": "^1.0.0", @@ -3631,9 +3631,9 @@ } }, "node_modules/@expensify/react-native-live-markdown": { - "version": "0.1.179", - "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.179.tgz", - "integrity": "sha512-TzXEMPZQRBFOFquu0a9sybaDn513JnqxrfkUgqcFJZuJtvOTs6f29Aj2BG/HfDQMSnO/V3elZP1RaodBPlBMmA==", + "version": "0.1.180", + "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.180.tgz", + "integrity": "sha512-toyFMl5nXQiC2lY6x/bGagsXaeCevZjVuebnClwVZskrPMI65o8OH/Y1VvTly9eNWD04Br++ANmOPJZYMisEiQ==", "license": "MIT", "workspaces": [ "parser", @@ -24154,9 +24154,9 @@ } }, "node_modules/expensify-common": { - "version": "2.0.101", - "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.101.tgz", - "integrity": "sha512-5TStDQGsXGJjdk64PBhEdXz/3H6QDlgoanEWI076okL5un4Qd2sSRfxHRiH61foHGsswXJFIZBHK3sysKDOJ4A==", + "version": "2.0.106", + "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.106.tgz", + "integrity": "sha512-KmxKvglbIUJb0sAcmNxb/AXYAqa3GIZfu3MbmtlYDNJx24mjDjtbGkKhm+16TICDoPj2PDRNogIqgUGWmSSZFQ==", "license": "MIT", "dependencies": { "awesome-phonenumber": "^5.4.0", @@ -35504,9 +35504,10 @@ } }, "node_modules/react-native-onyx": { - "version": "2.0.73", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.73.tgz", - "integrity": "sha512-ZgzTS9TV3wIh6cYfBM5sXrYz5A37x47a61n07e24p22gr7DosBX6J8ixaVCkC25G58A+2A+jRfzdtwRC5yW34A==", + "version": "2.0.78", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.78.tgz", + "integrity": "sha512-YL6Zk470TOjhpccf2wqAi4bvvJyDrQccTAYsz+woZu+rKr74UX693U2EhP8ncZ7+dzgfS7zGKep2mwKVesfiWw==", + "license": "MIT", "dependencies": { "ascii-table": "0.0.9", "fast-equals": "^4.0.3", @@ -35626,9 +35627,10 @@ } }, "node_modules/react-native-reanimated": { - "version": "3.15.1", - "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.15.1.tgz", - "integrity": "sha512-DbBeUUExtJ1x1nfE94I8qgDgWjq5ztM3IO/+XFO+agOkPeVpBs5cRnxHfJKrjqJ2MgwhJOUDmtHxo+tDsoeitg==", + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.15.3.tgz", + "integrity": "sha512-5QBk/7PZvZ98Adxm4MRyglwzsRzReTQIe4Hd2wbBBAZ68IC4OYKvsc8cPEjgx3/1mG8HgHFYhbcDe5U2RjeFqw==", + "license": "MIT", "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.0.0-0", "@babel/plugin-transform-class-properties": "^7.0.0-0", @@ -35824,8 +35826,9 @@ } }, "node_modules/react-native-web": { - "version": "0.19.12", - "license": "MIT", + "version": "0.19.13", + "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.19.13.tgz", + "integrity": "sha512-etv3bN8rJglrRCp/uL4p7l8QvUNUC++QwDbdZ8CB7BvZiMvsxfFIRM1j04vxNldG3uo2puRd6OSWR3ibtmc29A==", "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", diff --git a/package.json b/package.json index b3a47c387b6c..a7f306235c0f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.57-3", + "version": "9.0.59-0", "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.", @@ -68,7 +68,7 @@ }, "dependencies": { "@dotlottie/react-player": "^1.6.3", - "@expensify/react-native-live-markdown": "0.1.179", + "@expensify/react-native-live-markdown": "0.1.180", "@expo/metro-runtime": "~3.2.3", "@firebase/app": "^0.10.10", "@firebase/performance": "^0.6.8", @@ -108,7 +108,7 @@ "date-fns-tz": "^3.2.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "2.0.101", + "expensify-common": "2.0.106", "expo": "51.0.31", "expo-av": "14.0.7", "expo-image": "1.12.15", @@ -152,7 +152,7 @@ "react-native-launch-arguments": "^4.0.2", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "2.0.73", + "react-native-onyx": "2.0.78", "react-native-pager-view": "6.4.1", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", @@ -161,7 +161,7 @@ "react-native-plaid-link-sdk": "11.11.0", "react-native-qrcode-svg": "6.3.11", "react-native-quick-sqlite": "git+https://github.com/margelo/react-native-quick-sqlite#99f34ebefa91698945f3ed26622e002bd79489e0", - "react-native-reanimated": "3.15.1", + "react-native-reanimated": "3.15.3", "react-native-release-profiler": "^0.2.1", "react-native-render-html": "6.3.1", "react-native-safe-area-context": "4.10.9", @@ -173,7 +173,7 @@ "react-native-url-polyfill": "^2.0.0", "react-native-view-shot": "3.8.0", "react-native-vision-camera": "4.0.0-beta.13", - "react-native-web": "^0.19.12", + "react-native-web": "0.19.13", "react-native-webview": "13.8.6", "react-plaid-link": "3.3.2", "react-web-config": "^1.0.0", diff --git a/patches/react-native+0.75.2+012+Add-onPaste-to-TextInput.patch b/patches/react-native+0.75.2+012+Add-onPaste-to-TextInput.patch index 55657e61dc09..e5ddeee282fb 100644 --- a/patches/react-native+0.75.2+012+Add-onPaste-to-TextInput.patch +++ b/patches/react-native+0.75.2+012+Add-onPaste-to-TextInput.patch @@ -1,8 +1,8 @@ diff --git a/node_modules/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js b/node_modules/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js -index a77e5b4..5e58ec4 100644 +index 6c4bbb2..770dfee 100644 --- a/node_modules/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js +++ b/node_modules/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js -@@ -455,6 +455,21 @@ export type NativeProps = $ReadOnly<{| +@@ -462,6 +462,21 @@ export type NativeProps = $ReadOnly<{| |}>, >, @@ -24,7 +24,7 @@ index a77e5b4..5e58ec4 100644 /** * The string that will be rendered before text input has been entered. */ -@@ -658,6 +673,9 @@ export const __INTERNAL_VIEW_CONFIG: PartialViewConfig = { +@@ -668,6 +683,9 @@ export const __INTERNAL_VIEW_CONFIG: PartialViewConfig = { topScroll: { registrationName: 'onScroll', }, @@ -34,7 +34,7 @@ index a77e5b4..5e58ec4 100644 }, validAttributes: { maxFontSizeMultiplier: true, -@@ -711,6 +729,7 @@ export const __INTERNAL_VIEW_CONFIG: PartialViewConfig = { +@@ -722,6 +740,7 @@ export const __INTERNAL_VIEW_CONFIG: PartialViewConfig = { secureTextEntry: true, textBreakStrategy: true, onScroll: true, @@ -43,7 +43,7 @@ index a77e5b4..5e58ec4 100644 disableFullscreenUI: true, includeFontPadding: true, diff --git a/node_modules/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js b/node_modules/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js -index 3bfe22c..1cb122f 100644 +index 8326797..dbfe5d5 100644 --- a/node_modules/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js +++ b/node_modules/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js @@ -88,6 +88,9 @@ const RCTTextInputViewConfig = { @@ -56,7 +56,7 @@ index 3bfe22c..1cb122f 100644 }, validAttributes: { fontSize: true, -@@ -153,6 +156,7 @@ const RCTTextInputViewConfig = { +@@ -154,6 +157,7 @@ const RCTTextInputViewConfig = { onSelectionChange: true, onContentSizeChange: true, onScroll: true, @@ -170,7 +170,7 @@ index a94fb19..8cfde15 100644 * The string that will be rendered before text input has been entered. */ diff --git a/node_modules/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm b/node_modules/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm -index d5e2e22..a11679a 100644 +index d5e2e22..065a819 100644 --- a/node_modules/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm +++ b/node_modules/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm @@ -13,6 +13,10 @@ @@ -184,7 +184,7 @@ index d5e2e22..a11679a 100644 @implementation RCTUITextView { UILabel *_placeholderView; UITextView *_detachedTextView; -@@ -172,7 +176,32 @@ - (void)scrollRangeToVisible:(NSRange)range +@@ -172,7 +176,31 @@ - (void)scrollRangeToVisible:(NSRange)range - (void)paste:(id)sender { _textWasPasted = YES; @@ -197,8 +197,7 @@ index d5e2e22..a11679a 100644 + if (UTTypeConformsTo((__bridge CFStringRef)identifier, kUTTypeImage)) { + NSString *MIMEType = (__bridge_transfer NSString *)UTTypeCopyPreferredTagWithClass((__bridge CFStringRef)identifier, kUTTagClassMIMEType); + NSString *fileExtension = (__bridge_transfer NSString *)UTTypeCopyPreferredTagWithClass((__bridge CFStringRef)identifier, kUTTagClassFilenameExtension); -+ NSString *fileName = [NSString stringWithFormat:@"%@.%@", [[NSUUID UUID] UUIDString], fileExtension]; -+ NSString *filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:fileName]; ++ NSString *filePath = RCTTempFilePath(fileExtension, nil); + NSURL *fileURL = [NSURL fileURLWithPath:filePath]; + NSData *fileData = [clipboard dataForPasteboardType:identifier]; + [fileData writeToFile:filePath atomically:YES]; @@ -218,7 +217,7 @@ index d5e2e22..a11679a 100644 } // Turn off scroll animation to fix flaky scrolling. -@@ -264,6 +293,10 @@ - (BOOL)canPerformAction:(SEL)action withSender:(id)sender +@@ -264,6 +292,10 @@ - (BOOL)canPerformAction:(SEL)action withSender:(id)sender return NO; } @@ -346,7 +345,7 @@ index f58f147..e367394 100644 RCT_EXPORT_VIEW_PROPERTY(mostRecentEventCount, NSInteger) diff --git a/node_modules/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm b/node_modules/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm -index 0318671..bb165d7 100644 +index 0318671..667e646 100644 --- a/node_modules/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm +++ b/node_modules/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm @@ -12,6 +12,10 @@ @@ -371,7 +370,7 @@ index 0318671..bb165d7 100644 return [super canPerformAction:action withSender:sender]; } -@@ -222,7 +230,32 @@ - (void)scrollRangeToVisible:(NSRange)range +@@ -222,7 +230,31 @@ - (void)scrollRangeToVisible:(NSRange)range - (void)paste:(id)sender { _textWasPasted = YES; @@ -384,8 +383,7 @@ index 0318671..bb165d7 100644 + if (UTTypeConformsTo((__bridge CFStringRef)identifier, kUTTypeImage)) { + NSString *MIMEType = (__bridge_transfer NSString *)UTTypeCopyPreferredTagWithClass((__bridge CFStringRef)identifier, kUTTagClassMIMEType); + NSString *fileExtension = (__bridge_transfer NSString *)UTTypeCopyPreferredTagWithClass((__bridge CFStringRef)identifier, kUTTagClassFilenameExtension); -+ NSString *fileName = [NSString stringWithFormat:@"%@.%@", [[NSUUID UUID] UUIDString], fileExtension]; -+ NSString *filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:fileName]; ++ NSString *filePath = RCTTempFilePath(fileExtension, nil); + NSURL *fileURL = [NSURL fileURLWithPath:filePath]; + NSData *fileData = [clipboard dataForPasteboardType:identifier]; + [fileData writeToFile:filePath atomically:YES]; diff --git a/patches/react-native-reanimated+3.15.1+001+hybrid-app.patch b/patches/react-native-reanimated+3.15.3+001+hybrid-app.patch similarity index 100% rename from patches/react-native-reanimated+3.15.1+001+hybrid-app.patch rename to patches/react-native-reanimated+3.15.3+001+hybrid-app.patch diff --git a/patches/react-native-reanimated+3.15.1+002+dontWhitelistTextProp.patch b/patches/react-native-reanimated+3.15.3+002+dontWhitelistTextProp.patch similarity index 100% rename from patches/react-native-reanimated+3.15.1+002+dontWhitelistTextProp.patch rename to patches/react-native-reanimated+3.15.3+002+dontWhitelistTextProp.patch diff --git a/patches/react-native-reanimated+3.15.1+003+fixNullViewTag.patch b/patches/react-native-reanimated+3.15.3+003+fixNullViewTag.patch similarity index 100% rename from patches/react-native-reanimated+3.15.1+003+fixNullViewTag.patch rename to patches/react-native-reanimated+3.15.3+003+fixNullViewTag.patch diff --git a/patches/react-native-web+0.19.12+001+initial.patch b/patches/react-native-web+0.19.13+001+initial.patch similarity index 95% rename from patches/react-native-web+0.19.12+001+initial.patch rename to patches/react-native-web+0.19.13+001+initial.patch index c77cfc7829ed..75efdf4da117 100644 --- a/patches/react-native-web+0.19.12+001+initial.patch +++ b/patches/react-native-web+0.19.13+001+initial.patch @@ -1,8 +1,8 @@ diff --git a/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js b/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js -index e137def..c3e5054 100644 +index 1f52b73..53b1a83 100644 --- a/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js +++ b/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js -@@ -285,7 +285,7 @@ class VirtualizedList extends StateSafePureComponent { +@@ -287,7 +287,7 @@ class VirtualizedList extends StateSafePureComponent { // REACT-NATIVE-WEB patch to preserve during future RN merges: Support inverted wheel scroller. constructor(_props) { @@ -11,7 +11,7 @@ index e137def..c3e5054 100644 super(_props); this._getScrollMetrics = () => { return this._scrollMetrics; -@@ -520,6 +520,11 @@ class VirtualizedList extends StateSafePureComponent { +@@ -522,6 +522,11 @@ class VirtualizedList extends StateSafePureComponent { visibleLength, zoomScale }; @@ -23,7 +23,7 @@ index e137def..c3e5054 100644 this._updateViewableItems(this.props, this.state.cellsAroundViewport); if (!this.props) { return; -@@ -569,7 +574,7 @@ class VirtualizedList extends StateSafePureComponent { +@@ -571,7 +576,7 @@ class VirtualizedList extends StateSafePureComponent { this._updateCellsToRender = () => { this._updateViewableItems(this.props, this.state.cellsAroundViewport); this.setState((state, props) => { @@ -32,7 +32,7 @@ index e137def..c3e5054 100644 var renderMask = VirtualizedList._createRenderMask(props, cellsAroundViewport, this._getNonViewportRenderRegions(props)); if (cellsAroundViewport.first === state.cellsAroundViewport.first && cellsAroundViewport.last === state.cellsAroundViewport.last && renderMask.equals(state.renderMask)) { return null; -@@ -589,7 +594,7 @@ class VirtualizedList extends StateSafePureComponent { +@@ -591,7 +596,7 @@ class VirtualizedList extends StateSafePureComponent { return { index, item, @@ -41,7 +41,7 @@ index e137def..c3e5054 100644 isViewable }; }; -@@ -621,12 +626,10 @@ class VirtualizedList extends StateSafePureComponent { +@@ -623,12 +628,10 @@ class VirtualizedList extends StateSafePureComponent { }; this._getFrameMetrics = (index, props) => { var data = props.data, @@ -55,7 +55,7 @@ index e137def..c3e5054 100644 if (!frame || frame.index !== index) { if (getItemLayout) { /* $FlowFixMe[prop-missing] (>=0.63.0 site=react_native_fb) This comment -@@ -650,7 +653,7 @@ class VirtualizedList extends StateSafePureComponent { +@@ -652,7 +655,7 @@ class VirtualizedList extends StateSafePureComponent { // The last cell we rendered may be at a new index. Bail if we don't know // where it is. @@ -64,7 +64,7 @@ index e137def..c3e5054 100644 return []; } var first = focusedCellIndex; -@@ -690,9 +693,15 @@ class VirtualizedList extends StateSafePureComponent { +@@ -692,9 +695,15 @@ class VirtualizedList extends StateSafePureComponent { } } var initialRenderRegion = VirtualizedList._initialRenderRegion(_props); @@ -81,7 +81,7 @@ index e137def..c3e5054 100644 }; // REACT-NATIVE-WEB patch to preserve during future RN merges: Support inverted wheel scroller. -@@ -748,6 +757,26 @@ class VirtualizedList extends StateSafePureComponent { +@@ -750,6 +759,26 @@ class VirtualizedList extends StateSafePureComponent { } } } @@ -108,7 +108,7 @@ index e137def..c3e5054 100644 static _createRenderMask(props, cellsAroundViewport, additionalRegions) { var itemCount = props.getItemCount(props.data); invariant(cellsAroundViewport.first >= 0 && cellsAroundViewport.last >= cellsAroundViewport.first - 1 && cellsAroundViewport.last < itemCount, "Invalid cells around viewport \"[" + cellsAroundViewport.first + ", " + cellsAroundViewport.last + "]\" was passed to VirtualizedList._createRenderMask"); -@@ -796,7 +825,7 @@ class VirtualizedList extends StateSafePureComponent { +@@ -798,7 +827,7 @@ class VirtualizedList extends StateSafePureComponent { } } } @@ -117,7 +117,7 @@ index e137def..c3e5054 100644 var data = props.data, getItemCount = props.getItemCount; var onEndReachedThreshold = onEndReachedThresholdOrDefault(props.onEndReachedThreshold); -@@ -819,17 +848,9 @@ class VirtualizedList extends StateSafePureComponent { +@@ -821,17 +850,9 @@ class VirtualizedList extends StateSafePureComponent { last: Math.min(cellsAroundViewport.last + renderAhead, getItemCount(data) - 1) }; } else { @@ -138,7 +138,7 @@ index e137def..c3e5054 100644 return cellsAroundViewport.last >= getItemCount(data) ? VirtualizedList._constrainToItemCount(cellsAroundViewport, props) : cellsAroundViewport; } newCellsAroundViewport = computeWindowedRenderLimits(props, maxToRenderPerBatchOrDefault(props.maxToRenderPerBatch), windowSizeOrDefault(props.windowSize), cellsAroundViewport, this.__getFrameMetricsApprox, this._scrollMetrics); -@@ -902,16 +923,36 @@ class VirtualizedList extends StateSafePureComponent { +@@ -904,16 +925,36 @@ class VirtualizedList extends StateSafePureComponent { } } static getDerivedStateFromProps(newProps, prevState) { @@ -177,7 +177,7 @@ index e137def..c3e5054 100644 }; } _pushCells(cells, stickyHeaderIndices, stickyIndicesFromProps, first, last, inversionStyle) { -@@ -934,7 +975,7 @@ class VirtualizedList extends StateSafePureComponent { +@@ -936,7 +977,7 @@ class VirtualizedList extends StateSafePureComponent { last = Math.min(end, last); var _loop = function _loop() { var item = getItem(data, ii); @@ -186,7 +186,7 @@ index e137def..c3e5054 100644 _this._indicesToKeys.set(ii, key); if (stickyIndicesFromProps.has(ii + stickyOffset)) { stickyHeaderIndices.push(cells.length); -@@ -969,20 +1010,23 @@ class VirtualizedList extends StateSafePureComponent { +@@ -971,20 +1012,23 @@ class VirtualizedList extends StateSafePureComponent { } static _constrainToItemCount(cells, props) { var itemCount = props.getItemCount(props.data); @@ -216,8 +216,8 @@ index e137def..c3e5054 100644 if (props.keyExtractor != null) { return props.keyExtractor(item, index); } -@@ -1022,7 +1066,12 @@ class VirtualizedList extends StateSafePureComponent { - cells.push( /*#__PURE__*/React.createElement(VirtualizedListCellContextProvider, { +@@ -1024,7 +1068,12 @@ class VirtualizedList extends StateSafePureComponent { + cells.push(/*#__PURE__*/React.createElement(VirtualizedListCellContextProvider, { cellKey: this._getCellKey() + '-header', key: "$header" - }, /*#__PURE__*/React.createElement(View, { @@ -230,7 +230,7 @@ index e137def..c3e5054 100644 onLayout: this._onLayoutHeader, style: [inversionStyle, this.props.ListHeaderComponentStyle] }, -@@ -1124,7 +1173,11 @@ class VirtualizedList extends StateSafePureComponent { +@@ -1126,7 +1175,11 @@ class VirtualizedList extends StateSafePureComponent { // TODO: Android support invertStickyHeaders: this.props.invertStickyHeaders !== undefined ? this.props.invertStickyHeaders : this.props.inverted, stickyHeaderIndices, @@ -243,7 +243,7 @@ index e137def..c3e5054 100644 }); this._hasMore = this.state.cellsAroundViewport.last < itemCount - 1; var innerRet = /*#__PURE__*/React.createElement(VirtualizedListContextProvider, { -@@ -1317,8 +1370,12 @@ class VirtualizedList extends StateSafePureComponent { +@@ -1319,8 +1372,12 @@ class VirtualizedList extends StateSafePureComponent { onStartReached = _this$props8.onStartReached, onStartReachedThreshold = _this$props8.onStartReachedThreshold, onEndReached = _this$props8.onEndReached, @@ -258,7 +258,7 @@ index e137def..c3e5054 100644 var _this$_scrollMetrics2 = this._scrollMetrics, contentLength = _this$_scrollMetrics2.contentLength, visibleLength = _this$_scrollMetrics2.visibleLength, -@@ -1358,16 +1415,10 @@ class VirtualizedList extends StateSafePureComponent { +@@ -1360,16 +1417,10 @@ class VirtualizedList extends StateSafePureComponent { // and call onStartReached only once for a given content length, // and only if onEndReached is not being executed else if (onStartReached != null && this.state.cellsAroundViewport.first === 0 && isWithinStartThreshold && this._scrollMetrics.contentLength !== this._sentStartForContentLength) { @@ -279,7 +279,7 @@ index e137def..c3e5054 100644 } // If the user scrolls away from the start or end and back again, -@@ -1433,6 +1484,11 @@ class VirtualizedList extends StateSafePureComponent { +@@ -1435,6 +1486,11 @@ class VirtualizedList extends StateSafePureComponent { */ _updateViewableItems(props, cellsAroundViewport) { diff --git a/patches/react-native-web+0.19.12+002+fixLastSpacer.patch b/patches/react-native-web+0.19.13+002+fixLastSpacer.patch similarity index 94% rename from patches/react-native-web+0.19.12+002+fixLastSpacer.patch rename to patches/react-native-web+0.19.13+002+fixLastSpacer.patch index 581298613492..c400dcfc8cca 100644 --- a/patches/react-native-web+0.19.12+002+fixLastSpacer.patch +++ b/patches/react-native-web+0.19.13+002+fixLastSpacer.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/react-native-web/dist/modules/AccessibilityUtil/propsToAccessibilityComponent.js b/node_modules/react-native-web/dist/modules/AccessibilityUtil/propsToAccessibilityComponent.js -index 9c9a533..7794181 100644 +index 7d1d587..de51afe 100644 --- a/node_modules/react-native-web/dist/modules/AccessibilityUtil/propsToAccessibilityComponent.js +++ b/node_modules/react-native-web/dist/modules/AccessibilityUtil/propsToAccessibilityComponent.js @@ -27,7 +27,8 @@ var roleComponents = { @@ -13,7 +13,7 @@ index 9c9a533..7794181 100644 var emptyObject = {}; var propsToAccessibilityComponent = function propsToAccessibilityComponent(props) { diff --git a/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js b/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js -index 7f6c880..b05da08 100644 +index 53b1a83..5689220 100644 --- a/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js +++ b/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js @@ -78,14 +78,6 @@ function scrollEventThrottleOrDefault(scrollEventThrottle) { @@ -31,7 +31,7 @@ index 7f6c880..b05da08 100644 /** * Base implementation for the more convenient [``](https://reactnative.dev/docs/flatlist) -@@ -1107,7 +1099,8 @@ class VirtualizedList extends StateSafePureComponent { +@@ -1109,7 +1101,8 @@ class VirtualizedList extends StateSafePureComponent { _keylessItemComponentName = ''; var spacerKey = this._getSpacerKey(!horizontal); var renderRegions = this.state.renderMask.enumerateRegions(); diff --git a/patches/react-native-web+0.19.12+003+image-header-support.patch b/patches/react-native-web+0.19.13+003+image-header-support.patch similarity index 95% rename from patches/react-native-web+0.19.12+003+image-header-support.patch rename to patches/react-native-web+0.19.13+003+image-header-support.patch index d0a490a4ed70..15e83ce31f8a 100644 --- a/patches/react-native-web+0.19.12+003+image-header-support.patch +++ b/patches/react-native-web+0.19.13+003+image-header-support.patch @@ -1,8 +1,8 @@ diff --git a/node_modules/react-native-web/dist/exports/Image/index.js b/node_modules/react-native-web/dist/exports/Image/index.js -index 9649d27..66ef95c 100644 +index 348831d..ca40ee8 100644 --- a/node_modules/react-native-web/dist/exports/Image/index.js +++ b/node_modules/react-native-web/dist/exports/Image/index.js -@@ -135,7 +135,22 @@ function resolveAssetUri(source) { +@@ -137,7 +137,22 @@ function resolveAssetUri(source) { } return uri; } @@ -13,7 +13,7 @@ index 9649d27..66ef95c 100644 + if (onError) { + onError({ + nativeEvent: { -+ error: "Failed to load resource " + uri + " (404)" ++ error: "Failed to load resource " + uri + } + }); + } @@ -26,14 +26,14 @@ index 9649d27..66ef95c 100644 var _ariaLabel = props['aria-label'], accessibilityLabel = props.accessibilityLabel, blurRadius = props.blurRadius, -@@ -238,16 +253,10 @@ var Image = /*#__PURE__*/React.forwardRef((props, ref) => { +@@ -240,16 +255,10 @@ var Image = /*#__PURE__*/React.forwardRef((props, ref) => { } }, function error() { updateState(ERRORED); - if (onError) { - onError({ - nativeEvent: { -- error: "Failed to load resource " + uri + " (404)" +- error: "Failed to load resource " + uri - } - }); - } @@ -47,7 +47,7 @@ index 9649d27..66ef95c 100644 }); } function abortPendingRequest() { -@@ -279,10 +288,79 @@ var Image = /*#__PURE__*/React.forwardRef((props, ref) => { +@@ -281,10 +290,79 @@ var Image = /*#__PURE__*/React.forwardRef((props, ref) => { suppressHydrationWarning: true }), hiddenImage, createTintColorSVG(tintColor, filterRef.current)); }); diff --git a/patches/react-native-web+0.19.12+004+fixPointerEventDown.patch b/patches/react-native-web+0.19.13+004+fixPointerEventDown.patch similarity index 100% rename from patches/react-native-web+0.19.12+004+fixPointerEventDown.patch rename to patches/react-native-web+0.19.13+004+fixPointerEventDown.patch diff --git a/patches/react-native-web+0.19.12+005+osr-improvement.patch b/patches/react-native-web+0.19.13+005+osr-improvement.patch similarity index 94% rename from patches/react-native-web+0.19.12+005+osr-improvement.patch rename to patches/react-native-web+0.19.13+005+osr-improvement.patch index b1afa699e7a2..d0a952172768 100644 --- a/patches/react-native-web+0.19.12+005+osr-improvement.patch +++ b/patches/react-native-web+0.19.13+005+osr-improvement.patch @@ -1,8 +1,8 @@ diff --git a/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js b/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js -index bede95b..2aef4c6 100644 +index 5689220..df40877 100644 --- a/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js +++ b/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js -@@ -332,7 +332,7 @@ class VirtualizedList extends StateSafePureComponent { +@@ -334,7 +334,7 @@ class VirtualizedList extends StateSafePureComponent { zoomScale: 1 }; this._scrollRef = null; @@ -11,7 +11,7 @@ index bede95b..2aef4c6 100644 this._sentEndForContentLength = 0; this._totalCellLength = 0; this._totalCellsMeasured = 0; -@@ -684,16 +684,18 @@ class VirtualizedList extends StateSafePureComponent { +@@ -686,16 +686,18 @@ class VirtualizedList extends StateSafePureComponent { }); } } @@ -32,7 +32,7 @@ index bede95b..2aef4c6 100644 }; // REACT-NATIVE-WEB patch to preserve during future RN merges: Support inverted wheel scroller. -@@ -919,13 +921,13 @@ class VirtualizedList extends StateSafePureComponent { +@@ -921,13 +923,13 @@ class VirtualizedList extends StateSafePureComponent { // first and last could be stale (e.g. if a new, shorter items props is passed in), so we make // sure we're rendering a reasonable range here. var itemCount = newProps.getItemCount(newProps.data); @@ -48,7 +48,7 @@ index bede95b..2aef4c6 100644 if (newProps.maintainVisibleContentPosition != null && prevFirstVisibleItemKey != null && newFirstVisibleItemKey != null) { if (newFirstVisibleItemKey !== prevFirstVisibleItemKey) { // Fast path if items were added at the start of the list. -@@ -944,7 +946,8 @@ class VirtualizedList extends StateSafePureComponent { +@@ -946,7 +948,8 @@ class VirtualizedList extends StateSafePureComponent { cellsAroundViewport: constrainedCells, renderMask: VirtualizedList._createRenderMask(newProps, constrainedCells), firstVisibleItemKey: newFirstVisibleItemKey, @@ -58,7 +58,7 @@ index bede95b..2aef4c6 100644 }; } _pushCells(cells, stickyHeaderIndices, stickyIndicesFromProps, first, last, inversionStyle) { -@@ -1220,7 +1223,7 @@ class VirtualizedList extends StateSafePureComponent { +@@ -1222,7 +1225,7 @@ class VirtualizedList extends StateSafePureComponent { return ret; } } @@ -67,7 +67,7 @@ index bede95b..2aef4c6 100644 var _this$props7 = this.props, data = _this$props7.data, extraData = _this$props7.extraData; -@@ -1244,6 +1247,11 @@ class VirtualizedList extends StateSafePureComponent { +@@ -1246,6 +1249,11 @@ class VirtualizedList extends StateSafePureComponent { if (hiPriInProgress) { this._hiPriInProgress = false; } @@ -79,7 +79,7 @@ index bede95b..2aef4c6 100644 } // Used for preventing scrollToIndex from being called multiple times for initialScrollIndex -@@ -1407,8 +1415,8 @@ class VirtualizedList extends StateSafePureComponent { +@@ -1409,8 +1417,8 @@ class VirtualizedList extends StateSafePureComponent { // Next check if the user just scrolled within the start threshold // and call onStartReached only once for a given content length, // and only if onEndReached is not being executed @@ -90,7 +90,7 @@ index bede95b..2aef4c6 100644 onStartReached({ distanceFromStart }); -@@ -1417,7 +1425,7 @@ class VirtualizedList extends StateSafePureComponent { +@@ -1419,7 +1427,7 @@ class VirtualizedList extends StateSafePureComponent { // If the user scrolls away from the start or end and back again, // cause onStartReached or onEndReached to be triggered again else { @@ -100,7 +100,7 @@ index bede95b..2aef4c6 100644 } } diff --git a/node_modules/react-native-web/src/vendor/react-native/VirtualizedList/index.js b/node_modules/react-native-web/src/vendor/react-native/VirtualizedList/index.js -index 459f017..d20115c 100644 +index 459f017..fb2d269 100644 --- a/node_modules/react-native-web/src/vendor/react-native/VirtualizedList/index.js +++ b/node_modules/react-native-web/src/vendor/react-native/VirtualizedList/index.js @@ -79,6 +79,7 @@ type State = { diff --git a/patches/react-native-web+0.19.12+006+remove focus trap from modal.patch b/patches/react-native-web+0.19.13+006+remove-focus-trap-from-modal.patch similarity index 88% rename from patches/react-native-web+0.19.12+006+remove focus trap from modal.patch rename to patches/react-native-web+0.19.13+006+remove-focus-trap-from-modal.patch index 14dbc88b0b1c..eac73db57e35 100644 --- a/patches/react-native-web+0.19.12+006+remove focus trap from modal.patch +++ b/patches/react-native-web+0.19.13+006+remove-focus-trap-from-modal.patch @@ -1,8 +1,8 @@ diff --git a/node_modules/react-native-web/dist/exports/Modal/index.js b/node_modules/react-native-web/dist/exports/Modal/index.js -index d5df021..e2c46cf 100644 +index a9a7c36..522ef93 100644 --- a/node_modules/react-native-web/dist/exports/Modal/index.js +++ b/node_modules/react-native-web/dist/exports/Modal/index.js -@@ -86,13 +86,11 @@ var Modal = /*#__PURE__*/React.forwardRef((props, forwardedRef) => { +@@ -88,13 +88,11 @@ var Modal = /*#__PURE__*/React.forwardRef((props, forwardedRef) => { onDismiss: onDismissCallback, onShow: onShowCallback, visible: visible diff --git a/patches/react-native-web+0.19.12+007+fix-scrollable-overflown-text.patch b/patches/react-native-web+0.19.13+007+fix-scrollable-overflown-text.patch similarity index 84% rename from patches/react-native-web+0.19.12+007+fix-scrollable-overflown-text.patch rename to patches/react-native-web+0.19.13+007+fix-scrollable-overflown-text.patch index 11b85afcf86c..304a57ad0657 100644 --- a/patches/react-native-web+0.19.12+007+fix-scrollable-overflown-text.patch +++ b/patches/react-native-web+0.19.13+007+fix-scrollable-overflown-text.patch @@ -1,8 +1,8 @@ diff --git a/node_modules/react-native-web/dist/exports/Text/index.js b/node_modules/react-native-web/dist/exports/Text/index.js -index 8c5f79b..4a47f80 100644 +index 4130386..1076f55 100644 --- a/node_modules/react-native-web/dist/exports/Text/index.js +++ b/node_modules/react-native-web/dist/exports/Text/index.js -@@ -166,7 +166,7 @@ var styles = StyleSheet.create({ +@@ -176,7 +176,7 @@ var styles = StyleSheet.create({ textMultiLine: { display: '-webkit-box', maxWidth: '100%', @@ -12,10 +12,10 @@ index 8c5f79b..4a47f80 100644 WebkitBoxOrient: 'vertical' }, diff --git a/node_modules/react-native-web/src/exports/Text/index.js b/node_modules/react-native-web/src/exports/Text/index.js -index 071ae10..e43042c 100644 +index f79e82c..f27ccec 100644 --- a/node_modules/react-native-web/src/exports/Text/index.js +++ b/node_modules/react-native-web/src/exports/Text/index.js -@@ -219,7 +219,7 @@ const styles = StyleSheet.create({ +@@ -223,7 +223,7 @@ const styles = StyleSheet.create({ textMultiLine: { display: '-webkit-box', maxWidth: '100%', diff --git a/src/CONST.ts b/src/CONST.ts index fbe12d1fdfb2..e95e3d4a5603 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -20,6 +20,7 @@ const CLOUDFRONT_DOMAIN = 'cloudfront.net'; const CLOUDFRONT_URL = `https://d2k5nsl2zxldvw.${CLOUDFRONT_DOMAIN}`; const ACTIVE_EXPENSIFY_URL = Url.addTrailingForwardSlash(Config?.NEW_EXPENSIFY_URL ?? 'https://new.expensify.com'); const USE_EXPENSIFY_URL = 'https://use.expensify.com'; +const EXPENSIFY_URL = 'https://www.expensify.com'; const PLATFORM_OS_MACOS = 'Mac OS'; const PLATFORM_IOS = 'iOS'; const ANDROID_PACKAGE_NAME = 'com.expensify.chat'; @@ -89,6 +90,13 @@ const signupQualifiers = { SMB: 'smb', } as const; +const selfGuidedTourTask: OnboardingTaskType = { + type: 'viewTour', + autoCompleted: false, + title: 'Take a 2-minute tour', + description: ({navatticURL}) => `[Take a self-guided product tour](${navatticURL}) and learn about everything Expensify has to offer.`, +}; + const onboardingEmployerOrSubmitMessage: OnboardingMessageType = { message: 'Getting paid back is as easy as sending a message. Let’s go over the basics.', video: { @@ -99,6 +107,7 @@ const onboardingEmployerOrSubmitMessage: OnboardingMessageType = { height: 960, }, tasks: [ + selfGuidedTourTask, { type: 'submitExpense', autoCompleted: false, @@ -264,6 +273,7 @@ type OnboardingTaskType = { workspaceMembersLink: string; integrationName: string; workspaceAccountingLink: string; + navatticURL: string; }>, ) => string); }; @@ -465,6 +475,7 @@ const CONST = { OLD_DOT_ANDROID: 'https://play.google.com/store/apps/details?id=org.me.mobiexpensifyg&hl=en_US&pli=1', OLD_DOT_IOS: 'https://apps.apple.com/us/app/expensify-expense-tracker/id471713959', }, + COMPANY_WEBSITE_DEFAULT_SCHEME: 'http', DATE: { SQL_DATE_TIME: 'YYYY-MM-DD HH:mm:ss', FNS_FORMAT_STRING: 'yyyy-MM-dd', @@ -822,6 +833,7 @@ const CONST = { EMPTY_ARRAY, EMPTY_OBJECT, USE_EXPENSIFY_URL, + EXPENSIFY_URL, GOOGLE_MEET_URL_ANDROID: 'https://meet.google.com', GOOGLE_DOC_IMAGE_LINK_MATCH: 'googleusercontent.com', IMAGE_BASE64_MATCH: 'base64', @@ -834,13 +846,14 @@ const CONST = { UPWORK_URL: 'https://github.com/Expensify/App/issues?q=is%3Aopen+is%3Aissue+label%3A%22Help+Wanted%22', DEEP_DIVE_EXPENSIFY_CARD: 'https://community.expensify.com/discussion/4848/deep-dive-expensify-card-and-quickbooks-online-auto-reconciliation-how-it-works', DEEP_DIVE_ERECEIPTS: 'https://community.expensify.com/discussion/5542/deep-dive-what-are-ereceipts/', + DEEP_DIVE_PER_DIEM: 'https://community.expensify.com/discussion/4772/how-to-add-a-single-rate-per-diem', GITHUB_URL: 'https://github.com/Expensify/App', - TERMS_URL: `${USE_EXPENSIFY_URL}/terms`, - PRIVACY_URL: `${USE_EXPENSIFY_URL}/privacy`, + TERMS_URL: `${EXPENSIFY_URL}/terms`, + PRIVACY_URL: `${EXPENSIFY_URL}/privacy`, LICENSES_URL: `${USE_EXPENSIFY_URL}/licenses`, - ACH_TERMS_URL: `${USE_EXPENSIFY_URL}/achterms`, - WALLET_AGREEMENT_URL: `${USE_EXPENSIFY_URL}/walletagreement`, - BANCORP_WALLET_AGREEMENT_URL: `${USE_EXPENSIFY_URL}/bancorp-bank-wallet-terms-of-service`, + ACH_TERMS_URL: `${EXPENSIFY_URL}/achterms`, + WALLET_AGREEMENT_URL: `${EXPENSIFY_URL}/expensify-payments-wallet-terms-of-service`, + BANCORP_WALLET_AGREEMENT_URL: `${EXPENSIFY_URL}/bancorp-bank-wallet-terms-of-service`, HELP_LINK_URL: `${USE_EXPENSIFY_URL}/usa-patriot-act`, ELECTRONIC_DISCLOSURES_URL: `${USE_EXPENSIFY_URL}/esignagreement`, GITHUB_RELEASE_URL: 'https://api.github.com/repos/expensify/app/releases/latest', @@ -853,7 +866,6 @@ const CONST = { NEWHELP_URL: 'https://help.expensify.com', INTERNAL_DEV_EXPENSIFY_URL: 'https://www.expensify.com.dev', STAGING_EXPENSIFY_URL: 'https://staging.expensify.com', - EXPENSIFY_URL: 'https://www.expensify.com', BANK_ACCOUNT_PERSONAL_DOCUMENTATION_INFO_URL: 'https://community.expensify.com/discussion/6983/faq-why-do-i-need-to-provide-personal-documentation-when-setting-up-updating-my-bank-account', PERSONAL_DATA_PROTECTION_INFO_URL: 'https://community.expensify.com/discussion/5677/deep-dive-security-how-expensify-protects-your-information', @@ -861,7 +873,7 @@ const CONST = { ONFIDO_PRIVACY_POLICY_URL: 'https://onfido.com/privacy/', ONFIDO_TERMS_OF_SERVICE_URL: 'https://onfido.com/terms-of-service/', LIST_OF_RESTRICTED_BUSINESSES: 'https://community.expensify.com/discussion/6191/list-of-restricted-businesses', - TRAVEL_TERMS_URL: `${USE_EXPENSIFY_URL}/travelterms`, + TRAVEL_TERMS_URL: `${EXPENSIFY_URL}/travelterms`, EXPENSIFY_PACKAGE_FOR_SAGE_INTACCT: 'https://www.expensify.com/tools/integrations/downloadPackage', EXPENSIFY_PACKAGE_FOR_SAGE_INTACCT_FILE_NAME: 'ExpensifyPackageForSageIntacct', SAGE_INTACCT_INSTRUCTIONS: 'https://help.expensify.com/articles/expensify-classic/integrations/accounting-integrations/Sage-Intacct', @@ -1265,6 +1277,7 @@ const CONST = { UNREAD_UPDATE_DEBOUNCE_TIME: 300, SEARCH_FILTER_OPTIONS: 'search_filter_options', USE_DEBOUNCED_STATE_DELAY: 300, + LIST_SCROLLING_DEBOUNCE_TIME: 200, }, PRIORITY_MODE: { GSD: 'gsd', @@ -1600,6 +1613,7 @@ const CONST = { CONTRIBUTORS: 'contributors@expensify.com', FIRST_RESPONDER: 'firstresponders@expensify.com', GUIDES_DOMAIN: 'team.expensify.com', + QA_DOMAIN: 'applause.expensifail.com', HELP: 'help@expensify.com', INTEGRATION_TESTING_CREDS: 'integrationtestingcreds@expensify.com', NOTIFICATIONS: 'notifications@expensify.com', @@ -2454,6 +2468,7 @@ const CONST = { ARE_INVOICES_ENABLED: 'areInvoicesEnabled', ARE_TAXES_ENABLED: 'tax', ARE_RULES_ENABLED: 'areRulesEnabled', + ARE_PER_DIEM_RATES_ENABLED: 'arePerDiemRatesEnabled', }, DEFAULT_CATEGORIES: [ 'Advertising', @@ -2620,6 +2635,7 @@ const CONST = { CUSTOM_UNITS: { NAME_DISTANCE: 'Distance', + NAME_PER_DIEM_INTERNATIONAL: 'Per Diem International', DISTANCE_UNIT_MILES: 'mi', DISTANCE_UNIT_KILOMETERS: 'km', MILEAGE_IRS_RATE: 0.67, @@ -2684,6 +2700,7 @@ const CONST = { STEP: { ASSIGNEE: 'Assignee', CARD: 'Card', + CARD_NAME: 'CardName', TRANSACTION_START_DATE: 'TransactionStartDate', CONFIRMATION: 'Confirmation', }, @@ -2789,6 +2806,7 @@ const CONST = { RESTRICT: 'corporate', ALLOW: 'personal', }, + CARD_LIST_THRESHOLD: 8, EXPORT_CARD_TYPES: { /** * Name of Card NVP for QBO custom export accounts @@ -3040,10 +3058,6 @@ const CONST = { get RESTRICTED_ACCOUNT_IDS() { return [this.ACCOUNT_ID.NOTIFICATIONS]; }, - // Account IDs that can't be added as a group member - get NON_ADDABLE_ACCOUNT_IDS() { - return [this.ACCOUNT_ID.NOTIFICATIONS, this.ACCOUNT_ID.CHRONOS]; - }, // Auth limit is 60k for the column but we store edits and other metadata along the html so let's use a lower limit to accommodate for it. MAX_COMMENT_LENGTH: 10000, @@ -3056,6 +3070,7 @@ const CONST = { // Character Limits FORM_CHARACTER_LIMIT: 50, STANDARD_LENGTH_LIMIT: 100, + STANDARD_LIST_ITEM_LIMIT: 8, LEGAL_NAMES_CHARACTER_LIMIT: 150, LOGIN_CHARACTER_LIMIT: 254, CATEGORY_NAME_LIMIT: 256, @@ -3129,8 +3144,8 @@ const CONST = { EXPENSIFY_APPROVED_URL: `${USE_EXPENSIFY_URL}/accountants`, PRESS_KIT_URL: 'https://we.are.expensify.com/press-kit', SUPPORT_URL: `${USE_EXPENSIFY_URL}/support`, - COMMUNITY_URL: 'https://community.expensify.com/', - PRIVACY_URL: `${USE_EXPENSIFY_URL}/privacy`, + TERMS_URL: `${EXPENSIFY_URL}/terms`, + PRIVACY_URL: `${EXPENSIFY_URL}/privacy`, ABOUT_URL: 'https://we.are.expensify.com/how-we-got-here', BLOG_URL: 'https://blog.expensify.com/', JOBS_URL: 'https://we.are.expensify.com/apply', @@ -4882,6 +4897,7 @@ const CONST = { '\n' + '*Your new workspace is ready! It’ll keep all of your spend (and chats) in one place.*', }, + selfGuidedTourTask, { type: 'meetGuide', autoCompleted: false, @@ -4986,7 +5002,10 @@ const CONST = { }, ], }, - [onboardingChoices.PERSONAL_SPEND]: onboardingPersonalSpendMessage, + [onboardingChoices.PERSONAL_SPEND]: { + ...onboardingPersonalSpendMessage, + tasks: [selfGuidedTourTask, ...onboardingPersonalSpendMessage.tasks], + }, [onboardingChoices.CHAT_SPLIT]: { message: 'Splitting bills with friends is as easy as sending a message. Here’s how.', video: { @@ -4997,6 +5016,7 @@ const CONST = { height: 960, }, tasks: [ + selfGuidedTourTask, { type: 'startChat', autoCompleted: false, @@ -5820,6 +5840,7 @@ const CONST = { MAX_TAX_RATE_INTEGER_PLACES: 4, MAX_TAX_RATE_DECIMAL_PLACES: 4, + MIN_TAX_RATE_DECIMAL_PLACES: 2, DOWNLOADS_PATH: '/Downloads', DOWNLOADS_TIMEOUT: 5000, @@ -6080,6 +6101,14 @@ const CONST = { description: 'workspace.upgrade.rules.description' as const, icon: 'Rules', }, + perDiem: { + id: 'perDiem' as const, + alias: 'per-diem', + name: 'Per diem', + title: 'workspace.upgrade.perDiem.title' as const, + description: 'workspace.upgrade.perDiem.description' as const, + icon: 'PerDiem', + }, }; }, REPORT_FIELD_TYPES: { diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 7d3d0edef36e..c5ec21b8b1c2 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -449,9 +449,15 @@ const ONYXKEYS = { /** Stores recently used currencies */ RECENTLY_USED_CURRENCIES: 'nvp_recentlyUsedCurrencies', + /** States whether we transitioned from OldDot to show only certain group of screens. It should be undefined on pure NewDot. */ + IS_SINGLE_NEW_DOT_ENTRY: 'isSingleNewDotEntry', + /** Company cards custom names */ NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES: 'nvp_expensify_ccCustomNames', + /** The user's Concierge reportID */ + CONCIERGE_REPORT_ID: 'conciergeReportID', + /** Collection Keys */ COLLECTION: { DOWNLOAD: 'download_', @@ -1018,9 +1024,11 @@ type OnyxValuesMapping = { [ONYXKEYS.APPROVAL_WORKFLOW]: OnyxTypes.ApprovalWorkflowOnyx; [ONYXKEYS.IMPORTED_SPREADSHEET]: OnyxTypes.ImportedSpreadsheet; [ONYXKEYS.LAST_ROUTE]: string; + [ONYXKEYS.IS_SINGLE_NEW_DOT_ENTRY]: boolean | undefined; [ONYXKEYS.IS_USING_IMPORTED_STATE]: boolean; [ONYXKEYS.SHOULD_SHOW_SAVED_SEARCH_RENAME_TOOLTIP]: boolean; [ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES]: Record; + [ONYXKEYS.CONCIERGE_REPORT_ID]: string; }; type OnyxValues = OnyxValuesMapping & OnyxCollectionValuesMapping & OnyxFormValuesMapping & OnyxFormDraftValuesMapping; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 45501bf46374..cd94035e0fff 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1273,6 +1273,10 @@ const ROUTES = { route: 'settings/workspaces/:policyID/distance-rates/:rateID/tax-rate/edit', getRoute: (policyID: string, rateID: string) => `settings/workspaces/${policyID}/distance-rates/${rateID}/tax-rate/edit` as const, }, + WORKSPACE_PER_DIEM: { + route: 'settings/workspaces/:policyID/per-diem', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/per-diem` as const, + }, RULES_CUSTOM_NAME: { route: 'settings/workspaces/:policyID/rules/name', getRoute: (policyID: string) => `settings/workspaces/${policyID}/rules/name` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index dea0f028e1a0..9b8fe54111cf 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -542,6 +542,7 @@ const SCREENS = { RULES_MAX_EXPENSE_AMOUNT: 'Rules_Max_Expense_Amount', RULES_MAX_EXPENSE_AGE: 'Rules_Max_Expense_Age', RULES_BILLABLE_DEFAULT: 'Rules_Billable_Default', + PER_DIEM: 'Per_Diem', }, EDIT_REQUEST: { diff --git a/src/components/AddPaymentCard/PaymentCardForm.tsx b/src/components/AddPaymentCard/PaymentCardForm.tsx index f38ea60f1aad..5aaa23b238f7 100644 --- a/src/components/AddPaymentCard/PaymentCardForm.tsx +++ b/src/components/AddPaymentCard/PaymentCardForm.tsx @@ -177,18 +177,26 @@ function PaymentCardForm({ }; const onChangeCardNumber = useCallback((newValue: string) => { - // replace all characters that are not spaces or digits + // Replace all characters that are not spaces or digits let validCardNumber = newValue.replace(/[^\d ]/g, ''); - // gets only the first 16 digits if the inputted number have more digits than that + // Gets only the first 16 digits if the inputted number have more digits than that validCardNumber = validCardNumber.match(/(?:\d *){1,16}/)?.[0] ?? ''; - // add the spacing between every 4 digits - validCardNumber = - validCardNumber - .replace(/ /g, '') - .match(/.{1,4}/g) - ?.join(' ') ?? ''; + // Remove all spaces to simplify formatting + const cleanedNumber = validCardNumber.replace(/ /g, ''); + + // Check if the number is a potential Amex card (starts with 34 or 37 and has up to 15 digits) + const isAmex = /^3[47]\d{0,13}$/.test(cleanedNumber); + + // Format based on Amex or standard 4-4-4-4 pattern + if (isAmex) { + // Format as 4-6-5 for Amex + validCardNumber = cleanedNumber.replace(/(\d{1,4})(\d{1,6})?(\d{1,5})?/, (match, p1, p2, p3) => [p1, p2, p3].filter(Boolean).join(' ')); + } else { + // Format as 4-4-4-4 for non-Amex + validCardNumber = cleanedNumber.match(/.{1,4}/g)?.join(' ') ?? ''; + } setCardNumber(validCardNumber); }, []); diff --git a/src/components/AddressSearch/index.tsx b/src/components/AddressSearch/index.tsx index 9c72b371c40f..f4067d357c9d 100644 --- a/src/components/AddressSearch/index.tsx +++ b/src/components/AddressSearch/index.tsx @@ -63,6 +63,7 @@ function AddressSearch( onBlur, onInputChange, onPress, + onCountryChange, predefinedPlaces = [], preferredLocale, renamedInputKeys = { @@ -195,7 +196,7 @@ function AddressSearch( // If the address is not in the US, use the full length state name since we're displaying the address's // state / province in a TextInput instead of in a picker. - if (country !== CONST.COUNTRY.US) { + if (country !== CONST.COUNTRY.US && country !== CONST.COUNTRY.CA) { values.state = longStateName; } @@ -244,6 +245,7 @@ function AddressSearch( onInputChange?.(values); } + onCountryChange?.(values.country); onPress?.(values); }; diff --git a/src/components/AddressSearch/types.ts b/src/components/AddressSearch/types.ts index b654fcad99da..daa28c3d69af 100644 --- a/src/components/AddressSearch/types.ts +++ b/src/components/AddressSearch/types.ts @@ -87,6 +87,9 @@ type AddressSearchProps = { /** The user's preferred locale e.g. 'en', 'es-ES' */ preferredLocale?: Locale; + + /** Callback to be called when the country is changed */ + onCountryChange?: (country: unknown) => void; }; type IsCurrentTargetInsideContainerType = (event: FocusEvent | NativeSyntheticEvent, containerRef: RefObject) => boolean; diff --git a/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx index 5305155ae495..70966a05b918 100644 --- a/src/components/AttachmentPicker/index.native.tsx +++ b/src/components/AttachmentPicker/index.native.tsx @@ -448,7 +448,7 @@ function AttachmentPicker({ title={translate(item.textTranslationKey)} onPress={() => selectItem(item)} focused={focusedIndex === menuIndex} - wrapperStyle={StyleUtils.getItemBackgroundColorStyle(false, focusedIndex === menuIndex, theme.activeComponentBG, theme.hoverComponentBG)} + wrapperStyle={StyleUtils.getItemBackgroundColorStyle(false, focusedIndex === menuIndex, false, theme.activeComponentBG, theme.hoverComponentBG)} /> ))} diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index 1b1f7fbdcf15..07edd148778d 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -290,14 +290,14 @@ function Button( {!!icon && ( - + )} @@ -312,6 +312,7 @@ function Button( small={small} medium={medium} large={large} + isButtonIcon /> ) : ( )} diff --git a/src/components/CountryPicker/CountrySelectorModal.tsx b/src/components/CountryPicker/CountrySelectorModal.tsx index 76fd53138019..2daa74dcb4e8 100644 --- a/src/components/CountryPicker/CountrySelectorModal.tsx +++ b/src/components/CountryPicker/CountrySelectorModal.tsx @@ -7,8 +7,8 @@ import RadioListItem from '@components/SelectionList/RadioListItem'; import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import searchCountryOptions from '@libs/searchCountryOptions'; -import type {CountryData} from '@libs/searchCountryOptions'; +import searchOptions from '@libs/searchOptions'; +import type {Option} from '@libs/searchOptions'; import StringUtils from '@libs/StringUtils'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; @@ -27,7 +27,7 @@ type CountrySelectorModalProps = { currentCountry: string; /** Function to call when the user selects a country */ - onCountrySelected: (value: CountryData) => void; + onCountrySelected: (value: Option) => void; /** Function to call when the user presses on the modal backdrop */ onBackdropPress?: () => void; @@ -52,7 +52,7 @@ function CountrySelectorModal({isVisible, currentCountry, onCountrySelected, onC [translate, currentCountry], ); - const searchResults = searchCountryOptions(debouncedSearchValue, countries); + const searchResults = searchOptions(debouncedSearchValue, countries); const headerMessage = debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : ''; const styles = useThemeStyles(); diff --git a/src/components/CountryPicker/index.tsx b/src/components/CountryPicker/index.tsx index cc51b3c5f537..3f30fcbafb75 100644 --- a/src/components/CountryPicker/index.tsx +++ b/src/components/CountryPicker/index.tsx @@ -2,7 +2,7 @@ import React, {useState} from 'react'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import useLocalize from '@hooks/useLocalize'; import Navigation from '@libs/Navigation/Navigation'; -import type {CountryData} from '@libs/searchCountryOptions'; +import type {Option} from '@libs/searchOptions'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import CountrySelectorModal from './CountrySelectorModal'; @@ -26,7 +26,7 @@ function CountryPicker({value, errorText, onInputChange = () => {}}: CountryPick setIsPickerVisible(false); }; - const updateInput = (item: CountryData) => { + const updateInput = (item: Option) => { onInputChange?.(item.value); hidePickerModal(); }; diff --git a/src/components/DatePicker/CalendarPicker/index.tsx b/src/components/DatePicker/CalendarPicker/index.tsx index 287ec3359175..9906f9b04c3c 100644 --- a/src/components/DatePicker/CalendarPicker/index.tsx +++ b/src/components/DatePicker/CalendarPicker/index.tsx @@ -1,6 +1,6 @@ import {addMonths, endOfDay, endOfMonth, format, getYear, isSameDay, parseISO, setDate, setYear, startOfDay, startOfMonth, subMonths} from 'date-fns'; import {Str} from 'expensify-common'; -import React, {useState} from 'react'; +import React, {useRef, useState} from 'react'; import {View} from 'react-native'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; @@ -51,6 +51,7 @@ function CalendarPicker({ const themeStyles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {preferredLocale, translate} = useLocalize(); + const pressableRef = useRef(null); const [currentDateView, setCurrentDateView] = useState(getInitialCurrentDateView(value, minDate, maxDate)); @@ -148,7 +149,11 @@ function CalendarPicker({ dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}} > setIsYearPickerVisible(true)} + onPress={() => { + pressableRef?.current?.blur(); + setIsYearPickerVisible(true); + }} + ref={pressableRef} style={[themeStyles.alignItemsCenter, themeStyles.flexRow, themeStyles.flex1, themeStyles.justifyContentStart]} wrapperStyle={[themeStyles.alignItemsCenter]} hoverDimmingValue={1} diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx b/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx index ee4858bb0be0..6b1546b87056 100644 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx @@ -2,7 +2,8 @@ import type {ListRenderItem} from '@shopify/flash-list'; import lodashDebounce from 'lodash/debounce'; import React, {useCallback} from 'react'; import type {ForwardedRef} from 'react'; -import {View} from 'react-native'; +import {InteractionManager, View} from 'react-native'; +import type {Emoji} from '@assets/emojis/types'; import EmojiPickerMenuItem from '@components/EmojiPicker/EmojiPickerMenuItem'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; @@ -43,25 +44,28 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji}: EmojiPickerMenuProps, r } = useEmojiPickerMenu(); const StyleUtils = useStyleUtils(); + const updateEmojiList = (emojiData: EmojiUtils.EmojiPickerList | Emoji[], headerData: number[] = []) => { + setFilteredEmojis(emojiData); + setHeaderIndices(headerData); + + InteractionManager.runAfterInteractions(() => { + requestAnimationFrame(() => { + emojiListRef.current?.scrollToOffset({offset: 0, animated: false}); + }); + }); + }; + /** * Filter the entire list of emojis to only emojis that have the search term in their keywords */ const filterEmojis = lodashDebounce((searchTerm: string) => { const [normalizedSearchTerm, newFilteredEmojiList] = suggestEmojis(searchTerm); - if (emojiListRef.current) { - emojiListRef.current.scrollToOffset({offset: 0, animated: false}); - } - if (normalizedSearchTerm === '') { - setFilteredEmojis(allEmojis); - setHeaderIndices(headerRowIndices); - - return; + updateEmojiList(allEmojis, headerRowIndices); + } else { + updateEmojiList(newFilteredEmojiList ?? [], []); } - - setFilteredEmojis(newFilteredEmojiList ?? []); - setHeaderIndices([]); }, 300); const scrollToHeader = (headerIndex: number) => { diff --git a/src/components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS.ts b/src/components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS.ts index 4673b4f269ec..32e063f03109 100644 --- a/src/components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS.ts +++ b/src/components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS.ts @@ -35,6 +35,7 @@ const WIDE_LAYOUT_INACTIVE_SCREENS: string[] = [ SCREENS.SETTINGS.TROUBLESHOOT, SCREENS.SETTINGS.SAVE_THE_WORLD, SCREENS.WORKSPACE.RULES, + SCREENS.WORKSPACE.PER_DIEM, ]; export default WIDE_LAYOUT_INACTIVE_SCREENS; diff --git a/src/components/Form/FormProvider.tsx b/src/components/Form/FormProvider.tsx index 1d66953c1070..5c5c28b82fb9 100644 --- a/src/components/Form/FormProvider.tsx +++ b/src/components/Form/FormProvider.tsx @@ -12,6 +12,7 @@ import CONST from '@src/CONST'; import type {OnyxFormDraftKey, OnyxFormKey} from '@src/ONYXKEYS'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Form} from '@src/types/form'; +import type {Errors} from '@src/types/onyx/OnyxCommon'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {RegisterInput} from './FormContext'; import FormContext from './FormContext'; @@ -244,9 +245,20 @@ function FormProvider( setErrors({}); }, [formID]); + const resetFormFieldError = useCallback( + (inputID: keyof Form) => { + const newErrors = {...errors}; + delete newErrors[inputID]; + FormActions.setErrors(formID, newErrors as Errors); + setErrors(newErrors); + }, + [errors, formID], + ); + useImperativeHandle(forwardedRef, () => ({ resetForm, resetErrors, + resetFormFieldError, })); const registerInput = useCallback( diff --git a/src/components/Form/types.ts b/src/components/Form/types.ts index a77fabc52ce9..ab9260a6b5d9 100644 --- a/src/components/Form/types.ts +++ b/src/components/Form/types.ts @@ -33,6 +33,7 @@ import type NetSuiteCustomListPicker from '@pages/workspace/accounting/netsuite/ import type NetSuiteMenuWithTopDescriptionForm from '@pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteMenuWithTopDescriptionForm'; import type {Country} from '@src/CONST'; import type {OnyxFormKey, OnyxValues} from '@src/ONYXKEYS'; +import type {Form} from '@src/types/form'; import type {BaseForm} from '@src/types/form/Form'; /** @@ -164,6 +165,7 @@ type FormProps = { type FormRef = { resetForm: (optionalValue: FormOnyxValues) => void; resetErrors: () => void; + resetFormFieldError: (fieldID: keyof Form) => void; }; type InputRefs = Record>; diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts index 0efb65ed7a61..991aaea86513 100644 --- a/src/components/Icon/Illustrations.ts +++ b/src/components/Icon/Illustrations.ts @@ -101,6 +101,7 @@ import MoneyWings from '@assets/images/simple-illustrations/simple-illustration_ import OpenSafe from '@assets/images/simple-illustrations/simple-illustration__opensafe.svg'; import PalmTree from '@assets/images/simple-illustrations/simple-illustration__palmtree.svg'; import Pencil from '@assets/images/simple-illustrations/simple-illustration__pencil.svg'; +import PerDiem from '@assets/images/simple-illustrations/simple-illustration__perdiem.svg'; import PiggyBank from '@assets/images/simple-illustrations/simple-illustration__piggybank.svg'; import Profile from '@assets/images/simple-illustrations/simple-illustration__profile.svg'; import QRCode from '@assets/images/simple-illustrations/simple-illustration__qr-code.svg'; @@ -264,4 +265,5 @@ export { OtherCompanyCardDetail, StripeCompanyCardDetail, WellsFargoCompanyCardDetail, + PerDiem, }; diff --git a/src/components/Icon/index.tsx b/src/components/Icon/index.tsx index b4da5c0b0fa2..4ec4556e1c86 100644 --- a/src/components/Icon/index.tsx +++ b/src/components/Icon/index.tsx @@ -40,9 +40,6 @@ type IconProps = { /** Is icon pressed */ pressed?: boolean; - /** Is icon will be used with text */ - hasText?: boolean; - /** Additional styles to add to the Icon */ additionalStyles?: StyleProp; @@ -51,6 +48,9 @@ type IconProps = { /** Determines how the image should be resized to fit its container */ contentFit?: ImageContentFit; + + /** Determines whether the icon is being used within a button. The icon size will remain the same for both icon-only buttons and buttons with text. */ + isButtonIcon?: boolean; }; function Icon({ @@ -59,7 +59,6 @@ function Icon({ height = variables.iconSizeNormal, fill = undefined, small = false, - hasText = false, large = false, medium = false, inline = false, @@ -68,10 +67,11 @@ function Icon({ pressed = false, testID = '', contentFit = 'cover', + isButtonIcon = false, }: IconProps) { const StyleUtils = useStyleUtils(); const styles = useThemeStyles(); - const {width: iconWidth, height: iconHeight} = StyleUtils.getIconWidthAndHeightStyle(small, medium, large, width, height, hasText); + const {width: iconWidth, height: iconHeight} = StyleUtils.getIconWidthAndHeightStyle(small, medium, large, width, height, isButtonIcon); const iconStyles = [StyleUtils.getWidthAndHeightStyle(width ?? 0, height), IconWrapperStyles, styles.pAbsolute, additionalStyles]; if (inline) { diff --git a/src/components/InitialURLContextProvider.tsx b/src/components/InitialURLContextProvider.tsx index 85ad54ca6c94..adf361a2573d 100644 --- a/src/components/InitialURLContextProvider.tsx +++ b/src/components/InitialURLContextProvider.tsx @@ -26,14 +26,15 @@ type InitialURLContextProviderProps = { }; function InitialURLContextProvider({children, url}: InitialURLContextProviderProps) { - const [initialURL, setInitialURL] = useState(url); + const [initialURL, setInitialURL] = useState(); const {setSplashScreenState} = useSplashScreenStateContext(); useEffect(() => { if (url) { - const route = signInAfterTransitionFromOldDot(url); - setInitialURL(route); - setSplashScreenState(CONST.BOOT_SPLASH_STATE.READY_TO_BE_HIDDEN); + signInAfterTransitionFromOldDot(url).then((route) => { + setInitialURL(route); + setSplashScreenState(CONST.BOOT_SPLASH_STATE.READY_TO_BE_HIDDEN); + }); return; } Linking.getInitialURL().then((initURL) => { diff --git a/src/components/MapView/MapView.tsx b/src/components/MapView/MapView.tsx index f1150391dd62..6f1c7aaee458 100644 --- a/src/components/MapView/MapView.tsx +++ b/src/components/MapView/MapView.tsx @@ -3,7 +3,7 @@ import type {MapState} from '@rnmapbox/maps'; import Mapbox, {MarkerView, setAccessToken} from '@rnmapbox/maps'; import {forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; import * as Expensicons from '@components/Icon/Expensicons'; import useTheme from '@hooks/useTheme'; @@ -18,14 +18,14 @@ import useLocalize from '@src/hooks/useLocalize'; import useNetwork from '@src/hooks/useNetwork'; import ONYXKEYS from '@src/ONYXKEYS'; import Direction from './Direction'; -import type {MapViewHandle} from './MapViewTypes'; +import type {MapViewHandle, MapViewProps} from './MapViewTypes'; import PendingMapView from './PendingMapView'; import responder from './responder'; -import type {ComponentProps, MapViewOnyxProps} from './types'; import utils from './utils'; -const MapView = forwardRef( - ({accessToken, style, mapPadding, userLocation, styleURL, pitchEnabled, initialState, waypoints, directionCoordinates, onMapReady, interactive = true}, ref) => { +const MapView = forwardRef( + ({accessToken, style, mapPadding, styleURL, pitchEnabled, initialState, waypoints, directionCoordinates, onMapReady, interactive = true}, ref) => { + const [userLocation] = useOnyx(ONYXKEYS.USER_LOCATION); const navigation = useNavigation(); const {isOffline} = useNetwork(); const {translate} = useLocalize(); @@ -298,8 +298,4 @@ const MapView = forwardRef( }, ); -export default withOnyx({ - userLocation: { - key: ONYXKEYS.USER_LOCATION, - }, -})(memo(MapView)); +export default memo(MapView); diff --git a/src/components/MapView/MapView.website.tsx b/src/components/MapView/MapView.website.tsx index 3a28943b575a..b89bfa19e98e 100644 --- a/src/components/MapView/MapView.website.tsx +++ b/src/components/MapView/MapView.website.tsx @@ -4,11 +4,10 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import usePrevious from '@hooks/usePrevious'; import useThemeStyles from '@hooks/useThemeStyles'; -import type {MapViewHandle} from './MapViewTypes'; +import type {MapViewHandle, MapViewProps} from './MapViewTypes'; import PendingMapView from './PendingMapView'; -import type {ComponentProps} from './types'; -const MapView = forwardRef((props, ref) => { +const MapView = forwardRef((props, ref) => { const {isOffline} = useNetwork(); const {translate} = useLocalize(); const styles = useThemeStyles(); @@ -51,7 +50,6 @@ const MapView = forwardRef((props, ref) => { } > ( +const MapViewImpl = forwardRef( ( { style, @@ -40,13 +39,14 @@ const MapViewImpl = forwardRef( waypoints, mapPadding, accessToken, - userLocation, directionCoordinates, initialState = {location: CONST.MAPBOX.DEFAULT_COORDINATE, zoom: CONST.MAPBOX.DEFAULT_ZOOM}, interactive = true, }, ref, ) => { + const [userLocation] = useOnyx(ONYXKEYS.USER_LOCATION); + const {isOffline} = useNetwork(); const {translate} = useLocalize(); @@ -295,8 +295,4 @@ const MapViewImpl = forwardRef( }, ); -export default withOnyx({ - userLocation: { - key: ONYXKEYS.USER_LOCATION, - }, -})(MapViewImpl); +export default MapViewImpl; diff --git a/src/components/MapView/types.ts b/src/components/MapView/types.ts deleted file mode 100644 index a0494a9ac499..000000000000 --- a/src/components/MapView/types.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type {OnyxEntry} from 'react-native-onyx'; -import type * as OnyxTypes from '@src/types/onyx'; -import type {MapViewProps} from './MapViewTypes'; - -type MapViewOnyxProps = { - userLocation: OnyxEntry; -}; - -type ComponentProps = MapViewProps & MapViewOnyxProps; - -export type {MapViewOnyxProps, ComponentProps}; diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index 85a2298f63d6..39396795c557 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -193,7 +193,7 @@ function BaseModal( safeAreaPaddingRight, shouldAddBottomSafeAreaMargin, shouldAddTopSafeAreaMargin, - shouldAddBottomSafeAreaPadding: !keyboardStateContextValue?.isKeyboardShown && shouldAddBottomSafeAreaPadding, + shouldAddBottomSafeAreaPadding: (!avoidKeyboard || !keyboardStateContextValue?.isKeyboardShown) && shouldAddBottomSafeAreaPadding, shouldAddTopSafeAreaPadding, modalContainerStyleMarginTop: modalContainerStyle.marginTop, modalContainerStyleMarginBottom: modalContainerStyle.marginBottom, diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index c94938c3d103..8bf07e2d3a02 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -134,7 +134,7 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea const shouldShowPayButton = canIOUBePaid || onlyShowPayElsewhere; - const shouldShowApproveButton = useMemo(() => IOU.canApproveIOU(moneyRequestReport, policy), [moneyRequestReport, policy]); + const shouldShowApproveButton = useMemo(() => IOU.canApproveIOU(moneyRequestReport, policy) && !hasOnlyPendingTransactions, [moneyRequestReport, policy, hasOnlyPendingTransactions]); const shouldDisableApproveButton = shouldShowApproveButton && !ReportUtils.isAllowedToApproveExpenseReport(moneyRequestReport); @@ -177,7 +177,7 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea const [isNoDelegateAccessMenuVisible, setIsNoDelegateAccessMenuVisible] = useState(false); const isReportInRHP = isReportOpenInRHP(navigationRef?.getRootState()); - const shouldDisplaySearchRouter = !isReportInRHP; + const shouldDisplaySearchRouter = !isReportInRHP || isSmallScreenWidth; const confirmPayment = useCallback( (type?: PaymentMethodType | undefined, payAsBusiness?: boolean) => { diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index e70a8cec4775..a0143f87e789 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -2,19 +2,17 @@ import {useFocusEffect, useIsFocused} from '@react-navigation/native'; import lodashIsEqual from 'lodash/isEqual'; import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {InteractionManager, View} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import {MouseProvider} from '@hooks/useMouseContext'; -import usePermissions from '@hooks/usePermissions'; import usePrevious from '@hooks/usePrevious'; import useThemeStyles from '@hooks/useThemeStyles'; import blurActiveElement from '@libs/Accessibility/blurActiveElement'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; -import type {MileageRate} from '@libs/DistanceRequestUtils'; import * as IOUUtils from '@libs/IOUUtils'; import Log from '@libs/Log'; import * as MoneyRequestUtils from '@libs/MoneyRequestUtils'; @@ -49,33 +47,7 @@ import UserListItem from './SelectionList/UserListItem'; import SettlementButton from './SettlementButton'; import Text from './Text'; -type MoneyRequestConfirmationListOnyxProps = { - /** Collection of categories attached to a policy */ - policyCategories: OnyxEntry; - - /** Collection of draft categories attached to a policy */ - policyCategoriesDraft: OnyxEntry; - - /** Collection of tags attached to a policy */ - policyTags: OnyxEntry; - - /** The policy of the report */ - policy: OnyxEntry; - - /** The draft policy of the report */ - policyDraft: OnyxEntry; - - /** Mileage rate default for the policy */ - defaultMileageRate: OnyxEntry; - - /** Last selected distance rates */ - lastSelectedDistanceRates: OnyxEntry>; - - /** List of currencies */ - currencyList: OnyxEntry; -}; - -type MoneyRequestConfirmationListProps = MoneyRequestConfirmationListOnyxProps & { +type MoneyRequestConfirmationListProps = { /** Callback to inform parent modal of success */ onConfirm?: (selectedParticipants: Participant[]) => void; @@ -178,23 +150,18 @@ function MoneyRequestConfirmationList({ onConfirm, iouType = CONST.IOU.TYPE.SUBMIT, iouAmount, - policyCategories: policyCategoriesReal, - policyCategoriesDraft, isDistanceRequest = false, - policy: policyReal, - policyDraft, isPolicyExpenseChat = false, iouCategory = '', shouldShowSmartScanFields = true, isEditingSplitBill, - policyTags, iouCurrencyCode, iouMerchant, selectedParticipants: selectedParticipantsProp, payeePersonalDetails: payeePersonalDetailsProp, isReadOnly = false, bankAccountRoute = '', - policyID = '', + policyID, reportID = '', receiptPath = '', iouAttendees, @@ -205,21 +172,28 @@ function MoneyRequestConfirmationList({ onToggleBillable, hasSmartScanFailed, reportActionID, - defaultMileageRate, - lastSelectedDistanceRates, action = CONST.IOU.ACTION.CREATE, - currencyList, shouldDisplayReceipt = false, shouldPlaySound = true, isConfirmed, }: MoneyRequestConfirmationListProps) { + const [policyCategoriesReal] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID ?? '-1'}`); + const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID ?? '-1'}`); + const [policyReal] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID ?? '-1'}`); + const [policyDraft] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${policyID ?? '-1'}`); + const [defaultMileageRate] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${policyID ?? '-1'}`, { + selector: (selectedPolicy) => DistanceRequestUtils.getDefaultMileageRate(selectedPolicy), + }); + const [policyCategoriesDraft] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES_DRAFT}${policyID ?? '-1'}`); + const [lastSelectedDistanceRates] = useOnyx(ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES); + const [currencyList] = useOnyx(ONYXKEYS.CURRENCY_LIST); + const policy = policyReal ?? policyDraft; const policyCategories = policyCategoriesReal ?? policyCategoriesDraft; const styles = useThemeStyles(); const {translate, toLocaleDigit} = useLocalize(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const {canUseP2PDistanceRequests} = usePermissions(iouType); const isTypeRequest = iouType === CONST.IOU.TYPE.SUBMIT; const isTypeSplit = iouType === CONST.IOU.TYPE.SPLIT; @@ -238,9 +212,9 @@ function MoneyRequestConfirmationList({ const defaultRate = defaultMileageRate?.customUnitRateID ?? ''; const lastSelectedRate = lastSelectedDistanceRates?.[policy?.id ?? ''] ?? defaultRate; - const rateID = canUseP2PDistanceRequests ? lastSelectedRate : defaultRate; + const rateID = lastSelectedRate; IOU.setCustomUnitRateID(transactionID, rateID); - }, [defaultMileageRate, customUnitRateID, lastSelectedDistanceRates, policy?.id, canUseP2PDistanceRequests, transactionID, isDistanceRequest]); + }, [defaultMileageRate, customUnitRateID, lastSelectedDistanceRates, policy?.id, transactionID, isDistanceRequest]); const mileageRate = DistanceRequestUtils.getRate({transaction, policy, policyDraft}); const rate = mileageRate.rate; @@ -329,6 +303,8 @@ function MoneyRequestConfirmationList({ return false; }; + const routeError = Object.values(transaction?.errorFields?.route ?? {}).at(0); + useEffect(() => { if (shouldDisplayFieldError && didConfirmSplit) { setFormError('iou.error.genericSmartscanFailureMessage'); @@ -706,9 +682,12 @@ function MoneyRequestConfirmationList({ useEffect(() => { let updatedTagsString = TransactionUtils.getTag(transaction); policyTagLists.forEach((tagList, index) => { - const enabledTags = Object.values(tagList.tags).filter((tag) => tag.enabled); const isTagListRequired = tagList.required ?? false; - if (!isTagListRequired || enabledTags.length !== 1 || TransactionUtils.getTag(transaction, index)) { + if (!isTagListRequired) { + return; + } + const enabledTags = Object.values(tagList.tags).filter((tag) => tag.enabled); + if (enabledTags.length !== 1 || TransactionUtils.getTag(transaction, index)) { return; } updatedTagsString = IOUUtils.insertTagIntoTransactionTagsString(updatedTagsString, enabledTags.at(0)?.name ?? '', index); @@ -743,6 +722,9 @@ function MoneyRequestConfirmationList({ */ const confirm = useCallback( (paymentMethod: PaymentMethodType | undefined) => { + if (routeError) { + return; + } if (iouType === CONST.IOU.TYPE.INVOICE && !hasInvoicingDetails(policy)) { Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_COMPANY_INFO.getRoute(iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams())); return; @@ -815,6 +797,7 @@ function MoneyRequestConfirmationList({ transactionID, reportID, policy, + routeError, ], ); @@ -830,6 +813,16 @@ function MoneyRequestConfirmationList({ }, []), ); + const errorMessage = useMemo(() => { + if (routeError) { + return routeError; + } + if (isTypeSplit && !shouldShowReadOnlySplits) { + return debouncedFormError && translate(debouncedFormError); + } + return formError && translate(formError); + }, [routeError, isTypeSplit, shouldShowReadOnlySplits, debouncedFormError, formError, translate]); + const footerContent = useMemo(() => { if (isReadOnly) { return; @@ -872,38 +865,22 @@ function MoneyRequestConfirmationList({ return ( <> - {!!formError && ( + {!!errorMessage && ( )} {button} ); - }, [ - isReadOnly, - isTypeSplit, - iouType, - confirm, - bankAccountRoute, - iouCurrencyCode, - policyID, - splitOrRequestOptions, - formError, - styles.ph1, - styles.mb2, - shouldShowReadOnlySplits, - debouncedFormError, - translate, - ]); + }, [isReadOnly, iouType, confirm, bankAccountRoute, iouCurrencyCode, policyID, splitOrRequestOptions, styles.ph1, styles.mb2, errorMessage]); const listFooterContent = ( ({ - policyCategories: { - key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, - }, - policyCategoriesDraft: { - key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES_DRAFT}${policyID}`, - }, - policyTags: { - key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, - }, - defaultMileageRate: { - key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - selector: DistanceRequestUtils.getDefaultMileageRate, - }, - policy: { - key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - }, - policyDraft: { - key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${policyID}`, - }, - lastSelectedDistanceRates: { - key: ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES, - }, - currencyList: { - key: ONYXKEYS.CURRENCY_LIST, - }, -})( - memo( - MoneyRequestConfirmationList, - (prevProps, nextProps) => - lodashIsEqual(prevProps.transaction, nextProps.transaction) && - prevProps.onSendMoney === nextProps.onSendMoney && - prevProps.onConfirm === nextProps.onConfirm && - prevProps.iouType === nextProps.iouType && - prevProps.iouAmount === nextProps.iouAmount && - prevProps.isDistanceRequest === nextProps.isDistanceRequest && - prevProps.isPolicyExpenseChat === nextProps.isPolicyExpenseChat && - prevProps.iouCategory === nextProps.iouCategory && - prevProps.shouldShowSmartScanFields === nextProps.shouldShowSmartScanFields && - prevProps.isEditingSplitBill === nextProps.isEditingSplitBill && - prevProps.iouCurrencyCode === nextProps.iouCurrencyCode && - prevProps.iouMerchant === nextProps.iouMerchant && - lodashIsEqual(prevProps.selectedParticipants, nextProps.selectedParticipants) && - lodashIsEqual(prevProps.payeePersonalDetails, nextProps.payeePersonalDetails) && - prevProps.isReadOnly === nextProps.isReadOnly && - prevProps.bankAccountRoute === nextProps.bankAccountRoute && - prevProps.policyID === nextProps.policyID && - prevProps.reportID === nextProps.reportID && - prevProps.receiptPath === nextProps.receiptPath && - prevProps.iouAttendees === nextProps.iouAttendees && - prevProps.iouComment === nextProps.iouComment && - prevProps.receiptFilename === nextProps.receiptFilename && - prevProps.iouCreated === nextProps.iouCreated && - prevProps.iouIsBillable === nextProps.iouIsBillable && - prevProps.onToggleBillable === nextProps.onToggleBillable && - prevProps.hasSmartScanFailed === nextProps.hasSmartScanFailed && - prevProps.reportActionID === nextProps.reportActionID && - lodashIsEqual(prevProps.defaultMileageRate, nextProps.defaultMileageRate) && - lodashIsEqual(prevProps.lastSelectedDistanceRates, nextProps.lastSelectedDistanceRates) && - lodashIsEqual(prevProps.action, nextProps.action) && - lodashIsEqual(prevProps.currencyList, nextProps.currencyList) && - prevProps.shouldDisplayReceipt === nextProps.shouldDisplayReceipt, - ), +export default memo( + MoneyRequestConfirmationList, + (prevProps, nextProps) => + lodashIsEqual(prevProps.transaction, nextProps.transaction) && + prevProps.onSendMoney === nextProps.onSendMoney && + prevProps.onConfirm === nextProps.onConfirm && + prevProps.iouType === nextProps.iouType && + prevProps.iouAmount === nextProps.iouAmount && + prevProps.isDistanceRequest === nextProps.isDistanceRequest && + prevProps.isPolicyExpenseChat === nextProps.isPolicyExpenseChat && + prevProps.iouCategory === nextProps.iouCategory && + prevProps.shouldShowSmartScanFields === nextProps.shouldShowSmartScanFields && + prevProps.isEditingSplitBill === nextProps.isEditingSplitBill && + prevProps.iouCurrencyCode === nextProps.iouCurrencyCode && + prevProps.iouMerchant === nextProps.iouMerchant && + lodashIsEqual(prevProps.selectedParticipants, nextProps.selectedParticipants) && + lodashIsEqual(prevProps.payeePersonalDetails, nextProps.payeePersonalDetails) && + prevProps.isReadOnly === nextProps.isReadOnly && + prevProps.bankAccountRoute === nextProps.bankAccountRoute && + prevProps.policyID === nextProps.policyID && + prevProps.reportID === nextProps.reportID && + prevProps.receiptPath === nextProps.receiptPath && + prevProps.iouAttendees === nextProps.iouAttendees && + prevProps.iouComment === nextProps.iouComment && + prevProps.receiptFilename === nextProps.receiptFilename && + prevProps.iouCreated === nextProps.iouCreated && + prevProps.iouIsBillable === nextProps.iouIsBillable && + prevProps.onToggleBillable === nextProps.onToggleBillable && + prevProps.hasSmartScanFailed === nextProps.hasSmartScanFailed && + prevProps.reportActionID === nextProps.reportActionID && + lodashIsEqual(prevProps.action, nextProps.action) && + prevProps.shouldDisplayReceipt === nextProps.shouldDisplayReceipt, ); diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index dcfe72369651..fe04fea81e71 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -42,9 +42,6 @@ type MoneyRequestConfirmationListFooterProps = { /** The action to perform */ action: IOUAction; - /** Flag indicating if P2P distance requests can be used */ - canUseP2PDistanceRequests: boolean | undefined; - /** The currency of the transaction */ currency: string; @@ -105,9 +102,6 @@ type MoneyRequestConfirmationListFooterProps = { /** Flag indicating if the merchant is required */ isMerchantRequired: boolean | undefined; - /** Flag indicating if the transaction is moved from track expense */ - isMovingTransactionFromTrackExpense: boolean; - /** Flag indicating if it is a policy expense chat */ isPolicyExpenseChat: boolean; @@ -177,7 +171,6 @@ type MoneyRequestConfirmationListFooterProps = { function MoneyRequestConfirmationListFooter({ action, - canUseP2PDistanceRequests, currency, didConfirm, distance, @@ -198,7 +191,6 @@ function MoneyRequestConfirmationListFooter({ isEditingSplitBill, isMerchantEmpty, isMerchantRequired, - isMovingTransactionFromTrackExpense, isPolicyExpenseChat, isReadOnly, isTypeInvoice, @@ -349,24 +341,6 @@ function MoneyRequestConfirmationListFooter({ shouldShow: true, isSupplementary: false, }, - { - item: ( - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams()))} - disabled={didConfirm} - // todo: handle edit for transaction while moving from track expense - interactive={!isReadOnly && !isMovingTransactionFromTrackExpense} - /> - ), - shouldShow: isDistanceRequest && !canUseP2PDistanceRequests, - isSupplementary: false, - }, { item: ( ), - shouldShow: isDistanceRequest && canUseP2PDistanceRequests, + shouldShow: isDistanceRequest, isSupplementary: false, }, { @@ -398,7 +372,7 @@ function MoneyRequestConfirmationListFooter({ interactive={!!rate && !isReadOnly && isPolicyExpenseChat} /> ), - shouldShow: isDistanceRequest && canUseP2PDistanceRequests, + shouldShow: isDistanceRequest, isSupplementary: false, }, { @@ -692,7 +666,6 @@ export default memo( MoneyRequestConfirmationListFooter, (prevProps, nextProps) => lodashIsEqual(prevProps.action, nextProps.action) && - prevProps.canUseP2PDistanceRequests === nextProps.canUseP2PDistanceRequests && prevProps.currency === nextProps.currency && prevProps.didConfirm === nextProps.didConfirm && prevProps.distance === nextProps.distance && @@ -711,7 +684,6 @@ export default memo( prevProps.isEditingSplitBill === nextProps.isEditingSplitBill && prevProps.isMerchantEmpty === nextProps.isMerchantEmpty && prevProps.isMerchantRequired === nextProps.isMerchantRequired && - prevProps.isMovingTransactionFromTrackExpense === nextProps.isMovingTransactionFromTrackExpense && prevProps.isPolicyExpenseChat === nextProps.isPolicyExpenseChat && prevProps.isReadOnly === nextProps.isReadOnly && prevProps.isTypeInvoice === nextProps.isTypeInvoice && diff --git a/src/components/MoneyRequestHeader.tsx b/src/components/MoneyRequestHeader.tsx index 2dde3e9e2aa9..93ac363cff62 100644 --- a/src/components/MoneyRequestHeader.tsx +++ b/src/components/MoneyRequestHeader.tsx @@ -66,7 +66,7 @@ function MoneyRequestHeader({report, parentReportAction, policy, onBackButtonPre const reportID = report?.reportID; const isReportInRHP = isReportOpenInRHP(navigationRef?.getRootState()); - const shouldDisplaySearchRouter = !isReportInRHP; + const shouldDisplaySearchRouter = !isReportInRHP || isSmallScreenWidth; const hasAllPendingRTERViolations = TransactionUtils.allHavePendingRTERViolation([transaction?.transactionID ?? '-1']); diff --git a/src/components/OptionListContextProvider.tsx b/src/components/OptionListContextProvider.tsx index 537919622540..b14a26138b6e 100644 --- a/src/components/OptionListContextProvider.tsx +++ b/src/components/OptionListContextProvider.tsx @@ -1,6 +1,5 @@ import React, {createContext, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; -import {withOnyx} from 'react-native-onyx'; -import type {OnyxCollection} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import usePrevious from '@hooks/usePrevious'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import type {OptionList} from '@libs/OptionsListUtils'; @@ -17,14 +16,11 @@ type OptionsListContextProps = { initializeOptions: () => void; /** Flag to check if the options are initialized */ areOptionsInitialized: boolean; + /** Function to reset the options */ + resetOptions: () => void; }; -type OptionsListProviderOnyxProps = { - /** Collection of reports */ - reports: OnyxCollection; -}; - -type OptionsListProviderProps = OptionsListProviderOnyxProps & { +type OptionsListProviderProps = { /** Actual content wrapped by this component */ children: React.ReactNode; }; @@ -36,6 +32,7 @@ const OptionsListContext = createContext({ }, initializeOptions: () => {}, areOptionsInitialized: false, + resetOptions: () => {}, }); const isEqualPersonalDetail = (prevPersonalDetail: PersonalDetails | null, personalDetail: PersonalDetails | null) => @@ -44,12 +41,13 @@ const isEqualPersonalDetail = (prevPersonalDetail: PersonalDetails | null, perso prevPersonalDetail?.login === personalDetail?.login && prevPersonalDetail?.displayName === personalDetail?.displayName; -function OptionsListContextProvider({reports, children}: OptionsListProviderProps) { +function OptionsListContextProvider({children}: OptionsListProviderProps) { const areOptionsInitialized = useRef(false); const [options, setOptions] = useState({ reports: [], personalDetails: [], }); + const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; const prevPersonalDetails = usePrevious(personalDetails); @@ -144,9 +142,22 @@ function OptionsListContextProvider({reports, children}: OptionsListProviderProp areOptionsInitialized.current = true; }, [loadOptions]); + const resetOptions = useCallback(() => { + if (!areOptionsInitialized.current) { + return; + } + + areOptionsInitialized.current = false; + setOptions({ + reports: [], + personalDetails: [], + }); + }, []); + return ( - // eslint-disable-next-line react-compiler/react-compiler - ({options, initializeOptions, areOptionsInitialized: areOptionsInitialized.current}), [options, initializeOptions])}> + ({options, initializeOptions, areOptionsInitialized: areOptionsInitialized.current, resetOptions}), [options, initializeOptions, resetOptions])} + > {children} ); @@ -157,7 +168,7 @@ const useOptionsListContext = () => useContext(OptionsListContext); // Hook to use the OptionsListContext with an initializer to load the options const useOptionsList = (options?: {shouldInitialize: boolean}) => { const {shouldInitialize = true} = options ?? {}; - const {initializeOptions, options: optionsList, areOptionsInitialized} = useOptionsListContext(); + const {initializeOptions, options: optionsList, areOptionsInitialized, resetOptions} = useOptionsListContext(); useEffect(() => { if (!shouldInitialize || areOptionsInitialized) { @@ -171,13 +182,10 @@ const useOptionsList = (options?: {shouldInitialize: boolean}) => { initializeOptions, options: optionsList, areOptionsInitialized, + resetOptions, }; }; -export default withOnyx({ - reports: { - key: ONYXKEYS.COLLECTION.REPORT, - }, -})(OptionsListContextProvider); +export default OptionsListContextProvider; export {useOptionsListContext, useOptionsList, OptionsListContext}; diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index 2f5537be6145..9b5c0b1b6f56 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -264,7 +264,13 @@ function PopoverMenu({ } setFocusedIndex(menuIndex); }} - wrapperStyle={StyleUtils.getItemBackgroundColorStyle(!!item.isSelected, focusedIndex === menuIndex, theme.activeComponentBG, theme.hoverComponentBG)} + wrapperStyle={StyleUtils.getItemBackgroundColorStyle( + !!item.isSelected, + focusedIndex === menuIndex, + item.disabled ?? false, + theme.activeComponentBG, + theme.hoverComponentBG, + )} shouldRemoveHoverBackground={item.isSelected} titleStyle={StyleSheet.flatten([styles.flex1, item.titleStyle])} // Spread other props dynamically diff --git a/src/components/PushRowWithModal/PushRowModal.tsx b/src/components/PushRowWithModal/PushRowModal.tsx index 79fbc53c1e2c..aa9fa0538dff 100644 --- a/src/components/PushRowWithModal/PushRowModal.tsx +++ b/src/components/PushRowWithModal/PushRowModal.tsx @@ -1,10 +1,13 @@ -import React, {useEffect, useState} from 'react'; +import React, {useMemo} from 'react'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Modal from '@components/Modal'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/RadioListItem'; +import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; +import searchOptions from '@libs/searchOptions'; +import StringUtils from '@libs/StringUtils'; import CONST from '@src/CONST'; type PushRowModalProps = { @@ -40,44 +43,28 @@ type ListItemType = { function PushRowModal({isVisible, selectedOption, onOptionChange, onClose, optionsList, headerTitle, searchInputTitle}: PushRowModalProps) { const {translate} = useLocalize(); - const allOptions = Object.entries(optionsList).map(([key, value]) => ({ - value: key, - text: value, - keyForList: key, - isSelected: key === selectedOption, - })); - const [searchbarInputText, setSearchbarInputText] = useState(''); - const [optionListItems, setOptionListItems] = useState(allOptions); - - useEffect(() => { - setOptionListItems((prevOptionListItems) => - prevOptionListItems.map((option) => ({ - ...option, - isSelected: option.value === selectedOption, + const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); + + const options = useMemo( + () => + Object.entries(optionsList).map(([key, value]) => ({ + value: key, + text: value, + keyForList: key, + isSelected: key === selectedOption, + searchValue: StringUtils.sanitizeString(value), })), - ); - }, [selectedOption]); - - const filterShownOptions = (searchText: string) => { - setSearchbarInputText(searchText); - const searchWords = searchText.toLowerCase().match(/[a-z0-9]+/g) ?? []; - setOptionListItems( - allOptions.filter((option) => - searchWords.every((word) => - option.text - .toLowerCase() - .replace(/[^a-z0-9]/g, ' ') - .includes(word), - ), - ), - ); - }; + [optionsList, selectedOption], + ); const handleSelectRow = (option: ListItemType) => { onOptionChange(option.value); onClose(); }; + const searchResults = searchOptions(debouncedSearchValue, options); + const headerMessage = debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : ''; + return ( option.value === selectedOption)?.keyForList} + sections={[{data: searchResults}]} + initiallyFocusedOptionKey={selectedOption} showScrollIndicator shouldShowTooltips={false} ListItem={RadioListItem} diff --git a/src/components/PushRowWithModal/index.tsx b/src/components/PushRowWithModal/index.tsx index 65c1969fdf8b..83128899b50f 100644 --- a/src/components/PushRowWithModal/index.tsx +++ b/src/components/PushRowWithModal/index.tsx @@ -8,11 +8,11 @@ type PushRowWithModalProps = { /** The list of options that we want to display where key is option code and value is option name */ optionsList: Record; - /** The currently selected option */ - selectedOption: string; + /** Current value of the selected item */ + value?: string; - /** Function to call when the user selects an option */ - onOptionChange: (option: string) => void; + /** Function called whenever list item is selected */ + onInputChange?: (value: string, key?: string) => void; /** Additional styles to apply to container */ wrapperStyles?: StyleProp; @@ -32,13 +32,12 @@ type PushRowWithModalProps = { /** Text to display on error message */ errorText?: string; - /** Function called whenever option changes */ - onInputChange?: (value: string) => void; + /** The ID of the input that should be reset when the value changes */ + stateInputIDToReset?: string; }; function PushRowWithModal({ - selectedOption, - onOptionChange, + value, optionsList, wrapperStyles, description, @@ -47,6 +46,7 @@ function PushRowWithModal({ shouldAllowChange = true, errorText, onInputChange = () => {}, + stateInputIDToReset, }: PushRowWithModalProps) { const [isModalVisible, setIsModalVisible] = useState(false); @@ -58,16 +58,19 @@ function PushRowWithModal({ setIsModalVisible(true); }; - const handleOptionChange = (value: string) => { - onOptionChange(value); - onInputChange(value); + const handleOptionChange = (optionValue: string) => { + onInputChange(optionValue); + + if (stateInputIDToReset) { + onInputChange('', stateInputIDToReset); + } }; return ( <> { const backTo = route.params.backTo; - const comparisonResult = TransactionUtils.compareDuplicateTransactionFields(reviewingTransactionID); - Transaction.setReviewDuplicatesKey({...comparisonResult.keep, duplicates, transactionID: transaction?.transactionID ?? ''}); + + // Clear the draft before selecting a different expense to prevent merging fields from the previous expense + // (e.g., category, tag, tax) that may be not enabled/available in the new expense's policy. + Transaction.abandonReviewDuplicateTransactions(); + const comparisonResult = TransactionUtils.compareDuplicateTransactionFields(reviewingTransactionID, transaction?.reportID ?? ''); + Transaction.setReviewDuplicatesKey({...comparisonResult.keep, duplicates, transactionID: transaction?.transactionID ?? '', reportID: transaction?.reportID}); + if ('merchant' in comparisonResult.change) { Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_MERCHANT_PAGE.getRoute(route.params?.threadReportID, backTo)); } else if ('category' in comparisonResult.change) { diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index b85ecad20f2e..ee87f8f12c7d 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -15,7 +15,6 @@ import ViolationMessages from '@components/ViolationMessages'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; -import usePermissions from '@hooks/usePermissions'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useViolations from '@hooks/useViolations'; @@ -102,7 +101,6 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals const parentReportAction = parentReportActions?.[report?.parentReportActionID ?? '-1']; const isTrackExpense = ReportUtils.isTrackExpenseReport(report); - const {canUseP2PDistanceRequests} = usePermissions(isTrackExpense ? CONST.IOU.TYPE.TRACK : undefined); const moneyRequestReport = parentReport; const linkedTransactionID = useMemo(() => { const originalMessage = parentReportAction && ReportActionsUtils.isMoneyRequestAction(parentReportAction) ? ReportActionsUtils.getOriginalMessage(parentReportAction) : undefined; @@ -307,9 +305,9 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals [transactionAmount, isSettled, isCancelled, isPolicyExpenseChat, isEmptyMerchant, transactionDate, readonly, hasErrors, hasViolations, translate, getViolationsForField], ); - const distanceRequestFields = canUseP2PDistanceRequests ? ( + const distanceRequestFields = ( <> - + - ) : ( - - - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute( - CONST.IOU.ACTION.EDIT, - iouType, - transaction?.transactionID ?? '-1', - report?.reportID ?? '-1', - Navigation.getReportRHPActiveRoute(), - ), - ) - } - /> - ); const isReceiptAllowed = !isPaidReport && !isInvoice; diff --git a/src/components/ScreenWrapper.tsx b/src/components/ScreenWrapper.tsx index 22e54670c264..c74ccf0470d0 100644 --- a/src/components/ScreenWrapper.tsx +++ b/src/components/ScreenWrapper.tsx @@ -1,9 +1,9 @@ -import {useIsFocused, useNavigation} from '@react-navigation/native'; +import {UNSTABLE_usePreventRemove, useIsFocused, useNavigation, useRoute} from '@react-navigation/native'; import type {StackNavigationProp} from '@react-navigation/stack'; import type {ForwardedRef, ReactNode} from 'react'; import React, {createContext, forwardRef, useEffect, useMemo, useRef, useState} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; -import {Keyboard, PanResponder, View} from 'react-native'; +import {Keyboard, NativeModules, PanResponder, View} from 'react-native'; import {PickerAvoidingView} from 'react-native-picker-select'; import type {EdgeInsets} from 'react-native-safe-area-context'; import useEnvironment from '@hooks/useEnvironment'; @@ -164,6 +164,15 @@ function ScreenWrapper( // eslint-disable-next-line react-compiler/react-compiler isKeyboardShownRef.current = keyboardState?.isKeyboardShown ?? false; + const route = useRoute(); + const shouldReturnToOldDot = useMemo(() => { + return !!route?.params && 'singleNewDotEntry' in route.params && route.params.singleNewDotEntry === 'true'; + }, [route?.params]); + + UNSTABLE_usePreventRemove(shouldReturnToOldDot, () => { + NativeModules.HybridAppModule?.closeReactNativeApp(false, false); + }); + const panResponder = useRef( PanResponder.create({ onStartShouldSetPanResponderCapture: (_e, gestureState) => gestureState.numberActiveTouches === CONST.TEST_TOOL.NUMBER_OF_TAPS, diff --git a/src/components/Search/SearchPageHeader.tsx b/src/components/Search/SearchPageHeader.tsx index 5665909185c4..a330be3d5ff6 100644 --- a/src/components/Search/SearchPageHeader.tsx +++ b/src/components/Search/SearchPageHeader.tsx @@ -340,7 +340,10 @@ function SearchPageHeader({queryJSON, hash}: SearchPageHeaderProps) { } const inputQueryJSON = SearchQueryUtils.buildSearchQueryJSON(inputValue); if (inputQueryJSON) { - const standardizedQuery = SearchQueryUtils.standardizeQueryJSON(inputQueryJSON, cardList, taxRates); + // Todo traverse the tree to update all the display values into id values; this is only temporary until autocomplete code from SearchRouter is implement here + // After https://github.com/Expensify/App/pull/51633 is merged, autocomplete functionality will be included into this component, and `getFindIDFromDisplayValue` can be removed + const computeNodeValueFn = SearchQueryUtils.getFindIDFromDisplayValue(cardList, taxRates); + const standardizedQuery = SearchQueryUtils.traverseAndUpdatedQuery(inputQueryJSON, computeNodeValueFn); const query = SearchQueryUtils.buildSearchQueryString(standardizedQuery); SearchActions.clearAllFilters(); Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query})); diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 83d7d5d89b20..e65b12deb64b 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -1,4 +1,5 @@ import {useNavigationState} from '@react-navigation/native'; +import {Str} from 'expensify-common'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; @@ -6,15 +7,16 @@ import type {ValueOf} from 'type-fest'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {usePersonalDetails} from '@components/OnyxProvider'; import {useOptionsList} from '@components/OptionListContextProvider'; -import type {AutocompleteRange, SearchQueryJSON} from '@components/Search/types'; +import type {SearchAutocompleteQueryRange, SearchQueryString} from '@components/Search/types'; import type {SelectionListHandle} from '@components/SelectionList/types'; -import useActiveWorkspaceFromNavigationState from '@hooks/useActiveWorkspaceFromNavigationState'; +import useActiveWorkspace from '@hooks/useActiveWorkspace'; import useDebouncedState from '@hooks/useDebouncedState'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; import useLocalize from '@hooks/useLocalize'; import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; +import * as CardUtils from '@libs/CardUtils'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import {getAllTaxRates} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; @@ -34,9 +36,13 @@ import Timing from '@userActions/Timing'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type PersonalDetails from '@src/types/onyx/PersonalDetails'; +import {getQueryWithSubstitutions} from './getQueryWithSubstitutions'; +import type {SubstitutionMap} from './getQueryWithSubstitutions'; +import {getUpdatedSubstitutionsMap} from './getUpdatedSubstitutionsMap'; import SearchRouterInput from './SearchRouterInput'; import SearchRouterList from './SearchRouterList'; -import type {ItemWithQuery} from './SearchRouterList'; +import type {AutocompleteItemData} from './SearchRouterList'; type SearchRouterProps = { onRouterClose: () => void; @@ -48,7 +54,8 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { const [betas] = useOnyx(ONYXKEYS.BETAS); const [recentSearches] = useOnyx(ONYXKEYS.RECENT_SEARCHES); const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false}); - const [autocompleteSuggestions, setAutocompleteSuggestions] = useState([]); + const [autocompleteSuggestions, setAutocompleteSuggestions] = useState([]); + const [autocompleteSubstitutions, setAutocompleteSubstitutions] = useState({}); const {shouldUseNarrowLayout} = useResponsiveLayout(); const listRef = useRef(null); @@ -58,41 +65,6 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { return state?.routes.at(-1)?.params?.reportID; }); - const activeWorkspaceID = useActiveWorkspaceFromNavigationState(); - const policy = usePolicy(activeWorkspaceID); - const typeAutocompleteList = Object.values(CONST.SEARCH.DATA_TYPES); - const statusAutocompleteList = Object.values({...CONST.SEARCH.STATUS.TRIP, ...CONST.SEARCH.STATUS.INVOICE, ...CONST.SEARCH.STATUS.CHAT, ...CONST.SEARCH.STATUS.TRIP}); - const expenseTypes = Object.values(CONST.SEARCH.TRANSACTION_TYPE); - const allTaxRates = getAllTaxRates(); - const taxAutocompleteList = useMemo(() => getAutocompleteTaxList(allTaxRates, policy), [policy, allTaxRates]); - const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); - const cardAutocompleteList = Object.values(cardList ?? {}).map((card) => card.bank); - const personalDetailsForParticipants = usePersonalDetails(); - const participantsAutocompleteList = Object.values(personalDetailsForParticipants) - .filter((details) => details && details?.login) - // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style - .map((details) => details?.login as string); - - const [allPolicyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES); - const [allRecentCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES); - const categoryAutocompleteList = useMemo(() => { - return getAutocompleteCategories(allPolicyCategories, activeWorkspaceID); - }, [activeWorkspaceID, allPolicyCategories]); - const recentCategoriesAutocompleteList = useMemo(() => { - return getAutocompleteRecentCategories(allRecentCategories, activeWorkspaceID); - }, [activeWorkspaceID, allRecentCategories]); - - const [currencyList] = useOnyx(ONYXKEYS.CURRENCY_LIST); - const currencyAutocompleteList = Object.keys(currencyList ?? {}); - const [recentCurrencyAutocompleteList] = useOnyx(ONYXKEYS.RECENTLY_USED_CURRENCIES); - - const [allPoliciesTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); - const [allRecentTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS); - const tagAutocompleteList = useMemo(() => { - return getAutocompleteTags(allPoliciesTags, activeWorkspaceID); - }, [activeWorkspaceID, allPoliciesTags]); - const recentTagsAutocompleteList = getAutocompleteRecentTags(allRecentTags, activeWorkspaceID); - const sortedRecentSearches = useMemo(() => { return Object.values(recentSearches ?? {}).sort((a, b) => b.timestamp.localeCompare(a.timestamp)); }, [recentSearches]); @@ -137,14 +109,52 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { return reports.slice(0, 10); }, [debouncedInputValue, filteredOptions, searchOptions]); - useEffect(() => { - Report.searchInServer(debouncedInputValue.trim()); - }, [debouncedInputValue]); - const contextualReportData = contextualReportID ? searchOptions.recentReports?.find((option) => option.reportID === contextualReportID) : undefined; + const {activeWorkspaceID} = useActiveWorkspace(); + const policy = usePolicy(activeWorkspaceID); + + const typeAutocompleteList = Object.values(CONST.SEARCH.DATA_TYPES); + const statusAutocompleteList = Object.values({...CONST.SEARCH.STATUS.TRIP, ...CONST.SEARCH.STATUS.INVOICE, ...CONST.SEARCH.STATUS.CHAT, ...CONST.SEARCH.STATUS.TRIP}); + const expenseTypes = Object.values(CONST.SEARCH.TRANSACTION_TYPE); + const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); + const cardAutocompleteList = Object.values(cardList); + const personalDetailsForParticipants = usePersonalDetails(); + + const participantsAutocompleteList = useMemo( + () => + Object.values(personalDetailsForParticipants) + .filter((details): details is NonNullable => !!(details && details?.login)) + .map((details) => ({ + name: details.displayName ?? Str.removeSMSDomain(details.login ?? ''), + accountID: details?.accountID.toString(), + })), + [personalDetailsForParticipants], + ); + const allTaxRates = getAllTaxRates(); + const taxAutocompleteList = useMemo(() => getAutocompleteTaxList(allTaxRates, policy), [policy, allTaxRates]); + const [allPolicyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES); + const [allRecentCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES); + const categoryAutocompleteList = useMemo(() => { + return getAutocompleteCategories(allPolicyCategories, activeWorkspaceID); + }, [activeWorkspaceID, allPolicyCategories]); + const recentCategoriesAutocompleteList = useMemo(() => { + return getAutocompleteRecentCategories(allRecentCategories, activeWorkspaceID); + }, [activeWorkspaceID, allRecentCategories]); + + const [currencyList] = useOnyx(ONYXKEYS.CURRENCY_LIST); + const currencyAutocompleteList = Object.keys(currencyList ?? {}); + const [recentCurrencyAutocompleteList] = useOnyx(ONYXKEYS.RECENTLY_USED_CURRENCIES); + + const [allPoliciesTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); + const [allRecentTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS); + const tagAutocompleteList = useMemo(() => { + return getAutocompleteTags(allPoliciesTags, activeWorkspaceID); + }, [activeWorkspaceID, allPoliciesTags]); + const recentTagsAutocompleteList = getAutocompleteRecentTags(allRecentTags, activeWorkspaceID); + const updateAutocomplete = useCallback( - (autocompleteValue: string, ranges: AutocompleteRange[], autocompleteType?: ValueOf) => { + (autocompleteValue: string, ranges: SearchAutocompleteQueryRange[], autocompleteType?: ValueOf) => { const alreadyAutocompletedKeys: string[] = []; ranges.forEach((range) => { if (!autocompleteType || range.key !== autocompleteType) { @@ -152,6 +162,8 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { } alreadyAutocompletedKeys.push(range.value.toLowerCase()); }); + + let filteredAutocompleteSuggestions: AutocompleteItemData[] | undefined; switch (autocompleteType) { case CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG: { const autocompleteList = autocompleteValue ? tagAutocompleteList : recentTagsAutocompleteList ?? []; @@ -159,13 +171,12 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { .filter((tag) => tag.toLowerCase()?.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(tag)) .sort() .slice(0, 10); - setAutocompleteSuggestions( - filteredTags.map((tagName) => ({ - text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG}:${tagName}`, - query: `${SearchQueryUtils.sanitizeSearchValue(tagName)}`, - })), - ); - return; + + filteredAutocompleteSuggestions = filteredTags.map((tagName) => ({ + filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG, + text: tagName, + })); + break; } case CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY: { const autocompleteList = autocompleteValue ? categoryAutocompleteList : recentCategoriesAutocompleteList; @@ -175,13 +186,12 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { }) .sort() .slice(0, 10); - setAutocompleteSuggestions( - filteredCategories.map((categoryName) => ({ - text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY}:${categoryName}`, - query: `${SearchQueryUtils.sanitizeSearchValue(categoryName)}`, - })), - ); - return; + + filteredAutocompleteSuggestions = filteredCategories.map((categoryName) => ({ + filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY, + text: categoryName, + })); + break; } case CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY: { const autocompleteList = autocompleteValue ? currencyAutocompleteList : recentCurrencyAutocompleteList ?? []; @@ -189,92 +199,110 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { .filter((currency) => currency.toLowerCase()?.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(currency.toLowerCase())) .sort() .slice(0, 10); - setAutocompleteSuggestions( - filteredCurrencies.map((currencyName) => ({ - text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY}:${currencyName}`, - query: `${currencyName}`, - })), - ); - return; + + filteredAutocompleteSuggestions = filteredCurrencies.map((currencyName) => ({ + filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY, + text: currencyName, + })); + break; } case CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE: { const filteredTaxRates = taxAutocompleteList - .filter((tax) => tax.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(tax.toLowerCase())) + .filter((tax) => tax.taxRateName.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(tax.taxRateName.toLowerCase())) .sort() .slice(0, 10); - setAutocompleteSuggestions( - filteredTaxRates.map((tax) => ({text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE}:${tax}`, query: `${SearchQueryUtils.sanitizeSearchValue(tax)}`})), - ); - return; + filteredAutocompleteSuggestions = filteredTaxRates.map((tax) => ({ + filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE, + text: tax.taxRateName, + autocompleteID: tax.taxRateIds.join(','), + })); + + break; } case CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM: { const filteredParticipants = participantsAutocompleteList - .filter((participant) => participant.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(participant.toLowerCase())) + .filter( + (participant) => participant.name.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(participant.name.toLowerCase()), + ) .sort() .slice(0, 10); - setAutocompleteSuggestions(filteredParticipants.map((participant) => ({text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM}:${participant}`, query: `${participant}`}))); - return; + filteredAutocompleteSuggestions = filteredParticipants.map((participant) => ({ + filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, + text: participant.name, + autocompleteID: participant.accountID, + })); + break; } case CONST.SEARCH.SYNTAX_FILTER_KEYS.TO: { const filteredParticipants = participantsAutocompleteList - .filter((participant) => participant.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(participant.toLowerCase())) + .filter( + (participant) => participant.name.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(participant.name.toLowerCase()), + ) .sort() .slice(0, 10); - setAutocompleteSuggestions(filteredParticipants.map((participant) => ({text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.TO}:${participant}`, query: `${participant}`}))); - return; + filteredAutocompleteSuggestions = filteredParticipants.map((participant) => ({ + filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, + text: participant.name, + autocompleteID: participant.accountID, + })); + break; } case CONST.SEARCH.SYNTAX_FILTER_KEYS.IN: { const filteredChats = searchOptions.recentReports .filter((chat) => chat.text?.toLowerCase()?.includes(autocompleteValue.toLowerCase())) .sort((chatA, chatB) => (chatA > chatB ? 1 : -1)) .slice(0, 10); - setAutocompleteSuggestions(filteredChats.map((chat) => ({text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.IN}:${chat.text}`, query: `${chat.reportID}`}))); - return; + filteredAutocompleteSuggestions = filteredChats.map((chat) => ({ + filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.IN, + text: chat.text ?? '', + autocompleteID: chat.reportID, + })); + break; } case CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE: { const filteredTypes = typeAutocompleteList .filter((type) => type.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(type.toLowerCase())) .sort(); - setAutocompleteSuggestions(filteredTypes.map((type) => ({text: `${CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE}:${type}`, query: `${type}`}))); - return; + filteredAutocompleteSuggestions = filteredTypes.map((type) => ({filterKey: CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE, text: type})); + break; } case CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS: { const filteredStatuses = statusAutocompleteList .filter((status) => status.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(status)) .sort() .slice(0, 10); - setAutocompleteSuggestions(filteredStatuses.map((status) => ({text: `${CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS}:${status}`, query: `${status}`}))); - return; + filteredAutocompleteSuggestions = filteredStatuses.map((status) => ({filterKey: CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS, text: status})); + break; } case CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE: { const filteredExpenseTypes = expenseTypes .filter((expenseType) => expenseType.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(expenseType)) .sort(); - setAutocompleteSuggestions( - filteredExpenseTypes.map((expenseType) => ({ - text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE}:${expenseType}`, - query: `${expenseType}`, - })), - ); - return; + + filteredAutocompleteSuggestions = filteredExpenseTypes.map((expenseType) => ({ + filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE, + text: expenseType, + })); + break; } case CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID: { const filteredCards = cardAutocompleteList - .filter((card) => card.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(card.toLowerCase())) + .filter((card) => card.bank.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(card.bank.toLowerCase())) .sort() .slice(0, 10); - setAutocompleteSuggestions( - filteredCards.map((card) => ({ - text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID}:${card}`, - query: `${card}`, - })), - ); - return; + + filteredAutocompleteSuggestions = filteredCards.map((card) => ({ + filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID, + text: CardUtils.getCardDescription(card.cardID), + autocompleteID: card.cardID.toString(), + })); + break; } default: { - setAutocompleteSuggestions(undefined); + filteredAutocompleteSuggestions = undefined; } } + setAutocompleteSuggestions(filteredAutocompleteSuggestions); }, [ tagAutocompleteList, @@ -293,6 +321,10 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { ], ); + useEffect(() => { + Report.searchInServer(debouncedInputValue.trim()); + }, [debouncedInputValue]); + const onSearchChange = useCallback( (userQuery: string) => { let newUserQuery = userQuery; @@ -302,29 +334,44 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { setTextInputValue(newUserQuery); const autocompleteParsedQuery = parseForAutocomplete(newUserQuery); updateAutocomplete(autocompleteParsedQuery?.autocomplete?.value ?? '', autocompleteParsedQuery?.ranges ?? [], autocompleteParsedQuery?.autocomplete?.key); + + const updatedSubstitutionsMap = getUpdatedSubstitutionsMap(userQuery, autocompleteSubstitutions); + setAutocompleteSubstitutions(updatedSubstitutionsMap); + if (newUserQuery) { listRef.current?.updateAndScrollToFocusedIndex(0); } else { listRef.current?.updateAndScrollToFocusedIndex(-1); } }, - [autocompleteSuggestions, setTextInputValue, updateAutocomplete], + [autocompleteSubstitutions, autocompleteSuggestions, setTextInputValue, updateAutocomplete], ); const onSearchSubmit = useCallback( - (query: SearchQueryJSON | undefined) => { - if (!query) { + (queryString: SearchQueryString) => { + const cleanedQueryString = getQueryWithSubstitutions(queryString, autocompleteSubstitutions); + const queryJSON = SearchQueryUtils.buildSearchQueryJSON(cleanedQueryString); + if (!queryJSON) { return; } + onRouterClose(); - const standardizedQuery = SearchQueryUtils.standardizeQueryJSON(query, cardList, allTaxRates); - const queryString = SearchQueryUtils.buildSearchQueryString(standardizedQuery); - Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query: queryString})); + + const standardizedQuery = SearchQueryUtils.traverseAndUpdatedQuery(queryJSON, SearchQueryUtils.getUpdatedAmountValue); + const query = SearchQueryUtils.buildSearchQueryString(standardizedQuery); + Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query})); + setTextInputValue(''); }, - [allTaxRates, cardList, onRouterClose, setTextInputValue], + [autocompleteSubstitutions, onRouterClose, setTextInputValue], ); + const onAutocompleteSuggestionClick = (autocompleteKey: string, autocompleteID: string) => { + const substitutions = {...autocompleteSubstitutions, [autocompleteKey]: autocompleteID}; + + setAutocompleteSubstitutions(substitutions); + }; + useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, () => { onRouterClose(); }); @@ -347,7 +394,7 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { isFullWidth={shouldUseNarrowLayout} updateSearch={onSearchChange} onSubmit={() => { - onSearchSubmit(SearchQueryUtils.buildSearchQueryJSON(textInputValue)); + onSearchSubmit(textInputValue); }} routerListRef={listRef} shouldShowOfflineMessage @@ -363,9 +410,10 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { reportForContextualSearch={contextualReportData} recentSearches={sortedRecentSearches?.slice(0, 5)} recentReports={recentReports} - autocompleteItems={autocompleteSuggestions} + autocompleteSuggestions={autocompleteSuggestions} onSearchSubmit={onSearchSubmit} closeRouter={onRouterClose} + onAutocompleteSuggestionClick={onAutocompleteSuggestionClick} ref={listRef} /> diff --git a/src/components/Search/SearchRouter/SearchRouterList.tsx b/src/components/Search/SearchRouter/SearchRouterList.tsx index c3799ce5579e..cc854ff926c3 100644 --- a/src/components/Search/SearchRouter/SearchRouterList.tsx +++ b/src/components/Search/SearchRouter/SearchRouterList.tsx @@ -3,7 +3,7 @@ import type {ForwardedRef} from 'react'; import {useOnyx} from 'react-native-onyx'; import * as Expensicons from '@components/Icon/Expensicons'; import {usePersonalDetails} from '@components/OnyxProvider'; -import type {SearchQueryJSON} from '@components/Search/types'; +import type {SearchFilterKey, SearchQueryString} from '@components/Search/types'; import SelectionList from '@components/SelectionList'; import SearchQueryListItem from '@components/SelectionList/Search/SearchQueryListItem'; import type {SearchQueryItem, SearchQueryListItemProps} from '@components/SelectionList/Search/SearchQueryListItem'; @@ -16,20 +16,26 @@ import Navigation from '@libs/Navigation/Navigation'; import Performance from '@libs/Performance'; import {getAllTaxRates} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; -import {trimSearchQueryForAutocomplete} from '@libs/SearchAutocompleteUtils'; +import {getQueryWithoutAutocompletedPart} from '@libs/SearchAutocompleteUtils'; import * as SearchQueryUtils from '@libs/SearchQueryUtils'; import * as Report from '@userActions/Report'; import Timing from '@userActions/Timing'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import {getSubstitutionMapKey} from './getQueryWithSubstitutions'; -type ItemWithQuery = { +type SearchQueryItemData = { query: string; - id?: string; text?: string; }; +type AutocompleteItemData = { + filterKey: SearchFilterKey; + text: string; + autocompleteID?: string; +}; + type SearchRouterListProps = { /** value of TextInput */ textInputValue: string; @@ -41,20 +47,23 @@ type SearchRouterListProps = { setTextInputValue: (text: string) => void; /** Recent searches */ - recentSearches: Array | undefined; + recentSearches: Array | undefined; /** Recent reports */ recentReports: OptionData[]; /** Autocomplete items */ - autocompleteItems: ItemWithQuery[] | undefined; + autocompleteSuggestions: AutocompleteItemData[] | undefined; /** Callback to submit query when selecting a list item */ - onSearchSubmit: (query: SearchQueryJSON | undefined) => void; + onSearchSubmit: (query: SearchQueryString) => void; /** Context present when opening SearchRouter from a report, invoice or workspace page */ reportForContextualSearch?: OptionData; + /** Callback to run when user clicks a suggestion item that contains autocomplete data */ + onAutocompleteSuggestionClick: (autocompleteKey: string, autocompleteID: string) => void; + /** Callback to close and clear SearchRouter */ closeRouter: () => void; }; @@ -64,21 +73,25 @@ const setPerformanceTimersEnd = () => { Performance.markEnd(CONST.TIMING.SEARCH_ROUTER_RENDER); }; -function getContextualSearchQuery(reportID: string) { - return `${CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE}:${CONST.SEARCH.DATA_TYPES.CHAT} in:${reportID}`; +function getContextualSearchQuery(reportName: string) { + return `${CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE}:${CONST.SEARCH.DATA_TYPES.CHAT} ${CONST.SEARCH.SYNTAX_FILTER_KEYS.IN}:${SearchQueryUtils.sanitizeSearchValue(reportName)}`; } function isSearchQueryItem(item: OptionData | SearchQueryItem): item is SearchQueryItem { - if ('singleIcon' in item && item.singleIcon && 'query' in item && item.query) { - return true; - } - return false; + return 'searchItemType' in item; } function isSearchQueryListItem(listItem: UserListItemProps | SearchQueryListItemProps): listItem is SearchQueryListItemProps { return isSearchQueryItem(listItem.item); } +function getItemHeight(item: OptionData | SearchQueryItem) { + if (isSearchQueryItem(item)) { + return 44; + } + return 64; +} + function SearchRouterItem(props: UserListItemProps | SearchQueryListItemProps) { const styles = useThemeStyles(); @@ -100,7 +113,18 @@ function SearchRouterItem(props: UserListItemProps | SearchQueryList } function SearchRouterList( - {textInputValue, updateSearchValue, setTextInputValue, reportForContextualSearch, recentSearches, autocompleteItems, recentReports, onSearchSubmit, closeRouter}: SearchRouterListProps, + { + textInputValue, + updateSearchValue, + setTextInputValue, + reportForContextualSearch, + recentSearches, + autocompleteSuggestions, + recentReports, + onSearchSubmit, + onAutocompleteSuggestionClick, + closeRouter, + }: SearchRouterListProps, ref: ForwardedRef, ) { const styles = useThemeStyles(); @@ -119,7 +143,7 @@ function SearchRouterList( { text: textInputValue, singleIcon: Expensicons.MagnifyingGlass, - query: textInputValue, + searchQuery: textInputValue, itemStyle: styles.activeComponentBG, keyForList: 'findItem', searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH, @@ -129,12 +153,14 @@ function SearchRouterList( } if (reportForContextualSearch && !textInputValue) { + const reportQueryValue = reportForContextualSearch.text ?? reportForContextualSearch.alternateText ?? reportForContextualSearch.reportID; sections.push({ data: [ { text: `${translate('search.searchIn')} ${reportForContextualSearch.text ?? reportForContextualSearch.alternateText}`, singleIcon: Expensicons.MagnifyingGlass, - query: getContextualSearchQuery(reportForContextualSearch.reportID), + searchQuery: reportQueryValue, + autocompleteID: reportForContextualSearch.reportID, itemStyle: styles.activeComponentBG, keyForList: 'contextualSearch', searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION, @@ -143,12 +169,13 @@ function SearchRouterList( }); } - const autocompleteData = autocompleteItems?.map(({text, query}) => { + const autocompleteData = autocompleteSuggestions?.map(({filterKey, text, autocompleteID}) => { return { - text, + text: getSubstitutionMapKey(filterKey, text), singleIcon: Expensicons.MagnifyingGlass, - query, - keyForList: query, + searchQuery: text, + autocompleteID, + keyForList: autocompleteID ?? text, // in case we have a unique identifier then use it because text might not be unique searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION, }; }); @@ -162,7 +189,7 @@ function SearchRouterList( return { text: searchQueryJSON ? SearchQueryUtils.buildUserReadableQueryString(searchQueryJSON, personalDetails, cardList, reports, taxRates) : query, singleIcon: Expensicons.History, - query, + searchQuery: query, keyForList: timestamp, searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH, }; @@ -178,20 +205,30 @@ function SearchRouterList( const onSelectRow = useCallback( (item: OptionData | SearchQueryItem) => { if (isSearchQueryItem(item)) { - if (!item?.query) { + if (!item.searchQuery) { return; } - if (item?.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION) { - updateSearchValue(`${item?.query} `); + if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION) { + const searchQuery = getContextualSearchQuery(item.searchQuery); + updateSearchValue(`${searchQuery} `); + + if (item.autocompleteID) { + const autocompleteKey = `${CONST.SEARCH.SYNTAX_FILTER_KEYS.IN}:${item.searchQuery}`; + onAutocompleteSuggestionClick(autocompleteKey, item.autocompleteID); + } return; } - if (item?.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION && textInputValue) { - const trimmedUserSearchQuery = trimSearchQueryForAutocomplete(textInputValue); - updateSearchValue(`${trimmedUserSearchQuery}${item?.query} `); + if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION && textInputValue) { + const trimmedUserSearchQuery = getQueryWithoutAutocompletedPart(textInputValue); + updateSearchValue(`${trimmedUserSearchQuery}${SearchQueryUtils.sanitizeSearchValue(item.searchQuery)} `); + + if (item.autocompleteID && item.text) { + onAutocompleteSuggestionClick(item.text, item.autocompleteID); + } return; } - onSearchSubmit(SearchQueryUtils.buildSearchQueryJSON(item?.query)); + onSearchSubmit(item.searchQuery); } // Handle selection of "Recent chat" @@ -202,27 +239,25 @@ function SearchRouterList( Report.navigateToAndOpenReport(item.login ? [item.login] : [], false); } }, - [closeRouter, textInputValue, onSearchSubmit, updateSearchValue], + [closeRouter, textInputValue, onSearchSubmit, updateSearchValue, onAutocompleteSuggestionClick], ); const onArrowFocus = useCallback( (focusedItem: OptionData | SearchQueryItem) => { - if (!isSearchQueryItem(focusedItem) || focusedItem?.searchItemType !== CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION || !textInputValue) { + if (!isSearchQueryItem(focusedItem) || focusedItem?.searchItemType !== CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION || !focusedItem.searchQuery) { return; } - const trimmedUserSearchQuery = trimSearchQueryForAutocomplete(textInputValue); - setTextInputValue(`${trimmedUserSearchQuery}${focusedItem?.query} `); + + const trimmedUserSearchQuery = getQueryWithoutAutocompletedPart(textInputValue); + setTextInputValue(`${trimmedUserSearchQuery}${SearchQueryUtils.sanitizeSearchValue(focusedItem.searchQuery)} `); + + if (focusedItem.autocompleteID && focusedItem.text) { + onAutocompleteSuggestionClick(focusedItem.text, focusedItem.autocompleteID); + } }, - [setTextInputValue, textInputValue], + [setTextInputValue, textInputValue, onAutocompleteSuggestionClick], ); - const getItemHeight = useCallback((item: OptionData | SearchQueryItem) => { - if (isSearchQueryItem(item)) { - return 44; - } - return 64; - }, []); - return ( sections={sections} @@ -244,4 +279,4 @@ function SearchRouterList( export default forwardRef(SearchRouterList); export {SearchRouterItem}; -export type {ItemWithQuery}; +export type {AutocompleteItemData}; diff --git a/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts b/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts new file mode 100644 index 000000000000..117745fee480 --- /dev/null +++ b/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts @@ -0,0 +1,50 @@ +import type {SearchAutocompleteQueryRange, SearchFilterKey} from '@components/Search/types'; +import * as parser from '@libs/SearchParser/autocompleteParser'; + +type SubstitutionMap = Record; + +const getSubstitutionMapKey = (filterKey: SearchFilterKey, value: string) => `${filterKey}:${value}`; + +/** + * Given a plaintext query and a SubstitutionMap object, this function will return a transformed query where: + * - any autocomplete mention in the original query will be substituted with an id taken from `substitutions` object + * - anything that does not match will stay as is + * + * Ex: + * query: `A from:@johndoe A` + * substitutions: { + * from:@johndoe: 9876 + * } + * return: `A from:9876 A` + */ +function getQueryWithSubstitutions(changedQuery: string, substitutions: SubstitutionMap) { + const parsed = parser.parse(changedQuery) as {ranges: SearchAutocompleteQueryRange[]}; + + const searchAutocompleteQueryRanges = parsed.ranges; + + if (searchAutocompleteQueryRanges.length === 0) { + return changedQuery; + } + + let resultQuery = changedQuery; + let lengthDiff = 0; + + for (const range of searchAutocompleteQueryRanges) { + const itemKey = getSubstitutionMapKey(range.key, range.value); + const substitutionEntry = substitutions[itemKey]; + + if (substitutionEntry) { + const substitutionStart = range.start + lengthDiff; + const substitutionEnd = range.start + range.length; + + // generate new query but substituting "user-typed" value with the entity id/email from substitutions + resultQuery = resultQuery.slice(0, substitutionStart) + substitutionEntry + changedQuery.slice(substitutionEnd); + lengthDiff = lengthDiff + substitutionEntry.length - range.length; + } + } + + return resultQuery; +} + +export {getQueryWithSubstitutions, getSubstitutionMapKey}; +export type {SubstitutionMap}; diff --git a/src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts b/src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts new file mode 100644 index 000000000000..ee7bf3850259 --- /dev/null +++ b/src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts @@ -0,0 +1,43 @@ +import type {SearchAutocompleteQueryRange, SearchFilterKey} from '@components/Search/types'; +import * as parser from '@libs/SearchParser/autocompleteParser'; +import type {SubstitutionMap} from './getQueryWithSubstitutions'; + +const getSubstitutionsKey = (filterKey: SearchFilterKey, value: string) => `${filterKey}:${value}`; + +/** + * Given a plaintext query and a SubstitutionMap object, + * this function will remove any substitution keys that do not appear in the query and return an updated object + * + * Ex: + * query: `Test from:John1` + * substitutions: { + * from:SomeOtherJohn: 12345 + * } + * return: {} + */ +function getUpdatedSubstitutionsMap(query: string, substitutions: SubstitutionMap): SubstitutionMap { + const parsedQuery = parser.parse(query) as {ranges: SearchAutocompleteQueryRange[]}; + + const searchAutocompleteQueryRanges = parsedQuery.ranges; + + if (searchAutocompleteQueryRanges.length === 0) { + return {}; + } + + const autocompleteQueryKeys = searchAutocompleteQueryRanges.map((range) => getSubstitutionsKey(range.key, range.value)); + + // Build a new substitutions map consisting of only the keys from old map, that appear in query + const updatedSubstitutionMap = autocompleteQueryKeys.reduce((map, key) => { + if (substitutions[key]) { + // eslint-disable-next-line no-param-reassign + map[key] = substitutions[key]; + } + + return map; + }, {} as SubstitutionMap); + + return updatedSubstitutionMap; +} + +// eslint-disable-next-line import/prefer-default-export +export {getUpdatedSubstitutionsMap}; diff --git a/src/components/Search/SearchStatusBar.tsx b/src/components/Search/SearchStatusBar.tsx index 07b57f8acab8..f94e6e1146c5 100644 --- a/src/components/Search/SearchStatusBar.tsx +++ b/src/components/Search/SearchStatusBar.tsx @@ -180,7 +180,7 @@ function SearchStatusBar({queryJSON, onStatusChange}: SearchStatusBarProps) { const query = SearchQueryUtils.buildSearchQueryString({...queryJSON, status: item.status}); Navigation.setParams({q: query}); }); - const isActive = queryJSON.status === item.status; + const isActive = Array.isArray(queryJSON.status) ? queryJSON.status.includes(item.status) : queryJSON.status === item.status; const isFirstItem = index === 0; const isLastItem = index === options.length - 1; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 9238488361b0..77d50e0e4ed5 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -191,7 +191,11 @@ function Search({queryJSON, onSearchListScroll, contentContainerStyle}: SearchPr // There's a race condition in Onyx which makes it return data from the previous Search, so in addition to checking that the data is loaded // we also need to check that the searchResults matches the type and status of the current search - const isDataLoaded = searchResults?.data !== undefined && searchResults?.search?.type === type && searchResults?.search?.status === status; + const isDataLoaded = + searchResults?.data !== undefined && searchResults?.search?.type === type && Array.isArray(status) + ? searchResults?.search?.status === status.join(',') + : searchResults?.search?.status === status; + const shouldShowLoadingState = !isOffline && !isDataLoaded; const shouldShowLoadingMoreItems = !shouldShowLoadingState && searchResults?.search?.isLoading && searchResults?.search?.offset > 0; const isSearchResultsEmpty = !searchResults?.data || SearchUIUtils.isSearchResultsEmpty(searchResults); @@ -402,7 +406,7 @@ function Search({queryJSON, onSearchListScroll, contentContainerStyle}: SearchPr }; const shouldShowYear = SearchUIUtils.shouldShowYear(searchResults?.data); - const shouldShowSorting = sortableSearchStatuses.includes(status); + const shouldShowSorting = Array.isArray(status) ? status.some((s) => sortableSearchStatuses.includes(s)) : sortableSearchStatuses.includes(status); return ( @@ -466,6 +470,7 @@ function Search({queryJSON, onSearchListScroll, contentContainerStyle}: SearchPr } contentContainerStyle={[contentContainerStyle, styles.pb3]} scrollEventThrottle={1} + shouldKeepFocusedItemAtTopOfViewableArea={type === CONST.SEARCH.DATA_TYPES.CHAT} /> ); } diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index d5be896c1c50..74bf7b16d020 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -1,4 +1,4 @@ -import type {ValueOf} from 'react-native-gesture-handler/lib/typescript/typeUtils'; +import type {ValueOf} from 'type-fest'; import type {ReportActionListItemType, ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; import type CONST from '@src/CONST'; import type {SearchDataTypes, SearchReport} from '@src/types/onyx/SearchResults'; @@ -30,7 +30,7 @@ type ExpenseSearchStatus = ValueOf; type InvoiceSearchStatus = ValueOf; type TripSearchStatus = ValueOf; type ChatSearchStatus = ValueOf; -type SearchStatus = ExpenseSearchStatus | InvoiceSearchStatus | TripSearchStatus | ChatSearchStatus; +type SearchStatus = ExpenseSearchStatus | InvoiceSearchStatus | TripSearchStatus | ChatSearchStatus | Array; type SearchContext = { currentSearchHash: number; @@ -56,10 +56,14 @@ type QueryFilter = { value: string | number; }; -type AdvancedFiltersKeys = ValueOf; +type SearchFilterKey = + | ValueOf + | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE + | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS + | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.POLICY_ID; type QueryFilters = Array<{ - key: AdvancedFiltersKeys; + key: SearchFilterKey; filters: QueryFilter[]; }>; @@ -77,21 +81,23 @@ type SearchQueryAST = { type SearchQueryJSON = { inputQuery: SearchQueryString; hash: number; + /** Hash used for putting queries in recent searches list. It ignores sortOrder and sortBy, because we want to treat queries differing only in sort params as the same query */ + recentSearchHash: number; flatFilters: QueryFilters; } & SearchQueryAST; -type AutocompleteRange = { - key: ValueOf; +type SearchAutocompleteResult = { + autocomplete: SearchAutocompleteQueryRange | null; + ranges: SearchAutocompleteQueryRange[]; +}; + +type SearchAutocompleteQueryRange = { + key: SearchFilterKey; length: number; start: number; value: string; }; -type SearchAutocompleteResult = { - autocomplete: AutocompleteRange | null; - ranges: AutocompleteRange[]; -}; - export type { SelectedTransactionInfo, SelectedTransactions, @@ -105,11 +111,11 @@ export type { ASTNode, QueryFilter, QueryFilters, - AdvancedFiltersKeys, + SearchFilterKey, ExpenseSearchStatus, InvoiceSearchStatus, TripSearchStatus, ChatSearchStatus, SearchAutocompleteResult, - AutocompleteRange, + SearchAutocompleteQueryRange, }; diff --git a/src/components/SelectionList/BaseListItem.tsx b/src/components/SelectionList/BaseListItem.tsx index 15a82e327b9a..6570ef020786 100644 --- a/src/components/SelectionList/BaseListItem.tsx +++ b/src/components/SelectionList/BaseListItem.tsx @@ -98,13 +98,21 @@ function BaseListItem({ dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true, [CONST.INNER_BOX_SHADOW_ELEMENT]: true}} onMouseDown={(e) => e.preventDefault()} id={keyForList ?? ''} - style={[pressableStyle, isFocused && StyleUtils.getItemBackgroundColorStyle(!!item.isSelected, !!isFocused, theme.activeComponentBG, theme.hoverComponentBG)]} + style={[ + pressableStyle, + isFocused && StyleUtils.getItemBackgroundColorStyle(!!item.isSelected, !!isFocused, !!item.isDisabled, theme.activeComponentBG, theme.hoverComponentBG), + ]} onFocus={onFocus} onMouseLeave={handleMouseLeave} tabIndex={item.tabIndex} wrapperStyle={pressableWrapperStyle} > - + {typeof children === 'function' ? children(hovered) : children} {!canSelectMultiple && !!item.isSelected && !rightHandSideComponent && ( diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 3e1b3a3c2d70..b7bef18896d1 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -1,4 +1,5 @@ import {useFocusEffect, useIsFocused} from '@react-navigation/native'; +import lodashDebounce from 'lodash/debounce'; import isEmpty from 'lodash/isEmpty'; import type {ForwardedRef} from 'react'; import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; @@ -24,11 +25,12 @@ import useSingleExecution from '@hooks/useSingleExecution'; import useThemeStyles from '@hooks/useThemeStyles'; import getSectionsWithIndexOffset from '@libs/getSectionsWithIndexOffset'; import Log from '@libs/Log'; -import * as SearchUIUtils from '@libs/SearchUIUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import arraysEqual from '@src/utils/arraysEqual'; +import BaseSelectionListItemRenderer from './BaseSelectionListItemRenderer'; +import FocusAwareCellRendererComponent from './FocusAwareCellRendererComponent'; import type {BaseSelectionListProps, ButtonOrCheckBoxRoles, FlattenedSectionsReturn, ListItem, SectionListDataType, SectionWithIndexOffset, SelectionListHandle} from './types'; const getDefaultItemHeight = () => variables.optionRowHeight; @@ -89,6 +91,8 @@ function BaseSelectionList( textInputIconLeft, sectionTitleStyles, textInputAutoFocus = true, + shouldShowTextInputAfterHeader = false, + includeSafeAreaPaddingBottom = true, shouldTextInputInterceptSwipe = false, listHeaderContent, onEndReached = () => {}, @@ -107,6 +111,9 @@ function BaseSelectionList( scrollEventThrottle, contentContainerStyle, shouldHighlightSelectedItem = false, + shouldKeepFocusedItemAtTopOfViewableArea = false, + shouldDebounceScrolling = false, + shouldPreventActiveCellVirtualization = false, }: BaseSelectionListProps, ref: ForwardedRef, ) { @@ -126,6 +133,20 @@ function BaseSelectionList( const [currentPage, setCurrentPage] = useState(1); const isTextInputFocusedRef = useRef(false); const {singleExecution} = useSingleExecution(); + const [itemHeights, setItemHeights] = useState>({}); + + const onItemLayout = (event: LayoutChangeEvent, itemKey: string | null | undefined) => { + if (!itemKey) { + return; + } + + const {height} = event.nativeEvent.layout; + + setItemHeights((prevHeights) => ({ + ...prevHeights, + [itemKey]: height, + })); + }; const incrementPage = () => setCurrentPage((prev) => prev + 1); @@ -151,7 +172,7 @@ function BaseSelectionList( const selectedOptions: TItem[] = []; sections.forEach((section, sectionIndex) => { - const sectionHeaderHeight = variables.optionsListSectionHeaderHeight; + const sectionHeaderHeight = !!section.title || !!section.CustomSectionHeader ? variables.optionsListSectionHeaderHeight : 0; itemLayouts.push({length: sectionHeaderHeight, offset}); offset += sectionHeaderHeight; @@ -175,7 +196,7 @@ function BaseSelectionList( disabledIndex += 1; // Account for the height of the item in getItemLayout - const fullItemHeight = getItemHeight(item); + const fullItemHeight = item?.keyForList && itemHeights[item.keyForList] ? itemHeights[item.keyForList] : getItemHeight(item); itemLayouts.push({length: fullItemHeight, offset}); offset += fullItemHeight; @@ -207,7 +228,7 @@ function BaseSelectionList( itemLayouts, allSelected: selectedOptions.length > 0 && selectedOptions.length === allOptions.length - disabledOptionsIndexes.length, }; - }, [canSelectMultiple, sections, customListHeader, customListHeaderHeight, getItemHeight]); + }, [canSelectMultiple, sections, customListHeader, customListHeaderHeight, itemHeights, getItemHeight]); const [slicedSections, ShowMoreButtonInstance] = useMemo(() => { let remainingOptionsLimit = CONST.MAX_SELECTION_LIST_PAGE_LENGTH * currentPage; @@ -257,8 +278,20 @@ function BaseSelectionList( const itemIndex = item.index ?? -1; const sectionIndex = item.sectionIndex ?? -1; + let viewOffsetToKeepFocusedItemAtTopOfViewableArea = 0; + + // Since there are always two items above the focused item in viewable area, and items can grow beyond the screen size + // in searchType chat, the focused item may move out of view. To prevent this, we will ensure that the focused item remains at + // the top of the viewable area at all times by adjusting the viewOffset. + if (shouldKeepFocusedItemAtTopOfViewableArea) { + const firstPreviousItem = index > 0 ? flattenedSections.allOptions.at(index - 1) : undefined; + const firstPreviousItemHeight = firstPreviousItem && firstPreviousItem.keyForList ? itemHeights[firstPreviousItem.keyForList] : 0; + const secondPreviousItem = index > 1 ? flattenedSections.allOptions.at(index - 2) : undefined; + const secondPreviousItemHeight = secondPreviousItem && secondPreviousItem?.keyForList ? itemHeights[secondPreviousItem.keyForList] : 0; + viewOffsetToKeepFocusedItemAtTopOfViewableArea = firstPreviousItemHeight + secondPreviousItemHeight; + } - listRef.current.scrollToLocation({sectionIndex, itemIndex, animated, viewOffset: variables.contentHeaderHeight}); + listRef.current.scrollToLocation({sectionIndex, itemIndex, animated, viewOffset: variables.contentHeaderHeight - viewOffsetToKeepFocusedItemAtTopOfViewableArea}); }, // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps @@ -275,6 +308,8 @@ function BaseSelectionList( // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [flattenedSections.disabledArrowKeyOptionsIndexes]); + const debouncedScrollToIndex = useMemo(() => lodashDebounce(scrollToIndex, CONST.TIMING.LIST_SCROLLING_DEBOUNCE_TIME, {leading: true, trailing: true}), [scrollToIndex]); + // If `initiallyFocusedOptionKey` is not passed, we fall back to `-1`, to avoid showing the highlight on the first member const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({ initialFocusedIndex: flattenedSections.allOptions.findIndex((option) => option.keyForList === initiallyFocusedOptionKey), @@ -286,7 +321,7 @@ function BaseSelectionList( if (focusedItem) { onArrowFocus(focusedItem); } - scrollToIndex(index, true); + (shouldDebounceScrolling ? debouncedScrollToIndex : scrollToIndex)(index, true); }, isFocused, }); @@ -301,36 +336,50 @@ function BaseSelectionList( * @param item - the list item * @param indexToFocus - the list item index to focus */ - const selectRow = (item: TItem, indexToFocus?: number) => { - // In single-selection lists we don't care about updating the focused index, because the list is closed after selecting an item - if (canSelectMultiple) { - if (sections.length > 1) { - // If the list has only 1 section (e.g. Workspace Members list), we do nothing. - // If the list has multiple sections (e.g. Workspace Invite list), and `shouldUnfocusRow` is false, - // we focus the first one after all the selected (selected items are always at the top). - const selectedOptionsCount = item.isSelected ? flattenedSections.selectedOptions.length - 1 : flattenedSections.selectedOptions.length + 1; - - if (!item.isSelected) { - // If we're selecting an item, scroll to it's position at the top, so we can see it - scrollToIndex(Math.max(selectedOptionsCount - 1, 0), true); + const selectRow = useCallback( + (item: TItem, indexToFocus?: number) => { + // In single-selection lists we don't care about updating the focused index, because the list is closed after selecting an item + if (canSelectMultiple) { + if (sections.length > 1) { + // If the list has only 1 section (e.g. Workspace Members list), we do nothing. + // If the list has multiple sections (e.g. Workspace Invite list), and `shouldUnfocusRow` is false, + // we focus the first one after all the selected (selected items are always at the top). + const selectedOptionsCount = item.isSelected ? flattenedSections.selectedOptions.length - 1 : flattenedSections.selectedOptions.length + 1; + + if (!item.isSelected) { + // If we're selecting an item, scroll to it's position at the top, so we can see it + scrollToIndex(Math.max(selectedOptionsCount - 1, 0), true); + } } - } - if (shouldShowTextInput) { - clearInputAfterSelect(); + if (shouldShowTextInput) { + clearInputAfterSelect(); + } } - } - if (shouldUpdateFocusedIndex && typeof indexToFocus === 'number') { - setFocusedIndex(indexToFocus); - } + if (shouldUpdateFocusedIndex && typeof indexToFocus === 'number') { + setFocusedIndex(indexToFocus); + } - onSelectRow(item); + onSelectRow(item); - if (shouldShowTextInput && shouldPreventDefaultFocusOnSelectRow && innerTextInputRef.current) { - innerTextInputRef.current.focus(); - } - }; + if (shouldShowTextInput && shouldPreventDefaultFocusOnSelectRow && innerTextInputRef.current) { + innerTextInputRef.current.focus(); + } + }, + [ + canSelectMultiple, + sections.length, + flattenedSections.selectedOptions.length, + scrollToIndex, + shouldShowTextInput, + clearInputAfterSelect, + shouldUpdateFocusedIndex, + setFocusedIndex, + onSelectRow, + shouldPreventDefaultFocusOnSelectRow, + ], + ); const selectAllRow = () => { onSelectAll?.(); @@ -442,51 +491,35 @@ function BaseSelectionList( // We only create tooltips for the first 10 users or so since some reports have hundreds of users, causing performance to degrade. const showTooltip = shouldShowTooltips && normalizedIndex < 10; - const handleOnCheckboxPress = () => { - if (SearchUIUtils.isReportListItemType(item)) { - return onCheckboxPress; - } - return onCheckboxPress ? () => onCheckboxPress(item) : undefined; - }; - return ( - <> - onItemLayout(event, item?.keyForList)}> + { - if (shouldSingleExecuteRowSelect) { - singleExecution(() => selectRow(item, index))(); - } else { - selectRow(item, index); - } - }} - onCheckboxPress={handleOnCheckboxPress()} - onDismissError={() => onDismissError?.(item)} + shouldSingleExecuteRowSelect={shouldSingleExecuteRowSelect} + selectRow={selectRow} + onCheckboxPress={onCheckboxPress} + onDismissError={onDismissError} shouldPreventDefaultFocusOnSelectRow={shouldPreventDefaultFocusOnSelectRow} - // We're already handling the Enter key press in the useKeyboardShortcut hook, so we don't want the list item to submit the form - shouldPreventEnterKeySubmit rightHandSideComponent={rightHandSideComponent} - keyForList={item.keyForList ?? ''} isMultilineSupported={isRowMultilineSupported} isAlternateTextMultilineSupported={isAlternateTextMultilineSupported} alternateTextNumberOfLines={alternateTextNumberOfLines} - onFocus={() => { - if (shouldIgnoreFocus || isDisabled) { - return; - } - setFocusedIndex(normalizedIndex); - }} + shouldIgnoreFocus={shouldIgnoreFocus} + setFocusedIndex={setFocusedIndex} + normalizedIndex={normalizedIndex} shouldSyncFocus={!isTextInputFocusedRef.current} - shouldHighlightSelectedItem={shouldHighlightSelectedItem} wrapperStyle={listItemWrapperStyle} + shouldHighlightSelectedItem={shouldHighlightSelectedItem} + singleExecution={singleExecution} /> - {item.footerContent && item.footerContent} - + ); }; @@ -500,6 +533,48 @@ function BaseSelectionList( return null; }; + const renderInput = () => { + return ( + + { + innerTextInputRef.current = element as RNTextInput; + + if (!textInputRef) { + return; + } + + if (typeof textInputRef === 'function') { + textInputRef(element as RNTextInput); + } else { + // eslint-disable-next-line no-param-reassign + textInputRef.current = element as RNTextInput; + } + }} + onFocus={() => (isTextInputFocusedRef.current = true)} + onBlur={() => (isTextInputFocusedRef.current = false)} + label={textInputLabel} + accessibilityLabel={textInputLabel} + hint={textInputHint} + role={CONST.ROLE.PRESENTATION} + value={textInputValue} + placeholder={textInputPlaceholder} + maxLength={textInputMaxLength} + onChangeText={onChangeText} + inputMode={inputMode} + selectTextOnFocus + spellCheck={false} + iconLeft={textInputIconLeft} + onSubmitEditing={selectFocusedOption} + blurOnSubmit={!!flattenedSections.allOptions.length} + isLoading={isLoadingNewOptions} + testID="selection-list-text-input" + shouldInterceptSwipe={shouldTextInputInterceptSwipe} + /> + + ); + }; + const scrollToFocusedIndexOnFirstRender = useCallback( (nativeEvent: LayoutChangeEvent) => { if (shouldUseDynamicMaxToRenderPerBatch) { @@ -676,46 +751,8 @@ function BaseSelectionList( return ( {({safeAreaPaddingBottomStyle}) => ( - - {shouldShowTextInput && ( - - { - innerTextInputRef.current = element as RNTextInput; - - if (!textInputRef) { - return; - } - - if (typeof textInputRef === 'function') { - textInputRef(element as RNTextInput); - } else { - // eslint-disable-next-line no-param-reassign - textInputRef.current = element as RNTextInput; - } - }} - onFocus={() => (isTextInputFocusedRef.current = true)} - onBlur={() => (isTextInputFocusedRef.current = false)} - label={textInputLabel} - accessibilityLabel={textInputLabel} - hint={textInputHint} - role={CONST.ROLE.PRESENTATION} - value={textInputValue} - placeholder={textInputPlaceholder} - maxLength={textInputMaxLength} - onChangeText={onChangeText} - inputMode={inputMode} - selectTextOnFocus - spellCheck={false} - iconLeft={textInputIconLeft} - onSubmitEditing={selectFocusedOption} - blurOnSubmit={!!flattenedSections.allOptions.length} - isLoading={isLoadingNewOptions} - testID="selection-list-text-input" - shouldInterceptSwipe={shouldTextInputInterceptSwipe} - /> - - )} + + {shouldShowTextInput && !shouldShowTextInputAfterHeader && renderInput()} {/* If we are loading new options we will avoid showing any header message. This is mostly because one of the header messages says there are no options. */} {/* This is misleading because we might be in the process of loading fresh options from the server. */} {(!isLoadingNewOptions || headerMessage !== translate('common.noResultsFound') || (flattenedSections.allOptions.length === 0 && !showLoadingPlaceholder)) && @@ -759,12 +796,22 @@ function BaseSelectionList( testID="selection-list" onLayout={onSectionListLayout} style={[(!maxToRenderPerBatch || (shouldHideListOnInitialRender && isInitialSectionListRender)) && styles.opacity0, sectionListStyle]} - ListHeaderComponent={listHeaderContent} + ListHeaderComponent={ + shouldShowTextInput && shouldShowTextInputAfterHeader ? ( + <> + {listHeaderContent} + {renderInput()} + + ) : ( + listHeaderContent + ) + } ListFooterComponent={listFooterContent ?? ShowMoreButtonInstance} onEndReached={onEndReached} onEndReachedThreshold={onEndReachedThreshold} scrollEventThrottle={scrollEventThrottle} contentContainerStyle={contentContainerStyle} + CellRendererComponent={shouldPreventActiveCellVirtualization ? FocusAwareCellRendererComponent : undefined} /> {children} diff --git a/src/components/SelectionList/BaseSelectionListItemRenderer.tsx b/src/components/SelectionList/BaseSelectionListItemRenderer.tsx new file mode 100644 index 000000000000..b08d2ae2cfbc --- /dev/null +++ b/src/components/SelectionList/BaseSelectionListItemRenderer.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import type useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; +import type useSingleExecution from '@hooks/useSingleExecution'; +import * as SearchUIUtils from '@libs/SearchUIUtils'; +import type {BaseListItemProps, BaseSelectionListProps, ListItem} from './types'; + +type BaseSelectionListItemRendererProps = Omit, 'onSelectRow'> & + Pick, 'ListItem' | 'shouldHighlightSelectedItem' | 'shouldIgnoreFocus' | 'shouldSingleExecuteRowSelect'> & { + index: number; + selectRow: (item: TItem, indexToFocus?: number) => void; + setFocusedIndex: ReturnType[1]; + normalizedIndex: number; + singleExecution: ReturnType['singleExecution']; + }; + +function BaseSelectionListItemRenderer({ + ListItem, + item, + index, + isFocused, + isDisabled, + showTooltip, + canSelectMultiple, + onLongPressRow, + shouldSingleExecuteRowSelect, + selectRow, + onCheckboxPress, + onDismissError, + shouldPreventDefaultFocusOnSelectRow, + rightHandSideComponent, + isMultilineSupported, + isAlternateTextMultilineSupported, + alternateTextNumberOfLines, + shouldIgnoreFocus, + setFocusedIndex, + normalizedIndex, + shouldSyncFocus, + shouldHighlightSelectedItem, + wrapperStyle, + singleExecution, +}: BaseSelectionListItemRendererProps) { + const handleOnCheckboxPress = () => { + if (SearchUIUtils.isReportListItemType(item)) { + return onCheckboxPress; + } + return onCheckboxPress ? () => onCheckboxPress(item) : undefined; + }; + + return ( + <> + { + if (shouldSingleExecuteRowSelect) { + singleExecution(() => selectRow(item, index))(); + } else { + selectRow(item, index); + } + }} + onCheckboxPress={handleOnCheckboxPress()} + onDismissError={() => onDismissError?.(item)} + shouldPreventDefaultFocusOnSelectRow={shouldPreventDefaultFocusOnSelectRow} + // We're already handling the Enter key press in the useKeyboardShortcut hook, so we don't want the list item to submit the form + shouldPreventEnterKeySubmit + rightHandSideComponent={rightHandSideComponent} + keyForList={item.keyForList ?? ''} + isMultilineSupported={isMultilineSupported} + isAlternateTextMultilineSupported={isAlternateTextMultilineSupported} + alternateTextNumberOfLines={alternateTextNumberOfLines} + onFocus={() => { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (shouldIgnoreFocus || isDisabled) { + return; + } + setFocusedIndex(normalizedIndex); + }} + shouldSyncFocus={shouldSyncFocus} + shouldHighlightSelectedItem={shouldHighlightSelectedItem} + wrapperStyle={wrapperStyle} + /> + {item.footerContent && item.footerContent} + + ); +} + +BaseSelectionListItemRenderer.displayName = 'BaseSelectionListItemRenderer'; + +export default BaseSelectionListItemRenderer; diff --git a/src/components/SelectionList/FocusAwareCellRendererComponent/index.native.tsx b/src/components/SelectionList/FocusAwareCellRendererComponent/index.native.tsx new file mode 100644 index 000000000000..94833b707acd --- /dev/null +++ b/src/components/SelectionList/FocusAwareCellRendererComponent/index.native.tsx @@ -0,0 +1,3 @@ +const FocusAwareCellRendererComponent = undefined; + +export default FocusAwareCellRendererComponent; diff --git a/src/components/SelectionList/FocusAwareCellRendererComponent/index.tsx b/src/components/SelectionList/FocusAwareCellRendererComponent/index.tsx new file mode 100644 index 000000000000..1df71d0fcea9 --- /dev/null +++ b/src/components/SelectionList/FocusAwareCellRendererComponent/index.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import type {FocusEventHandler} from 'react'; +import {View} from 'react-native'; +import type {CellRendererProps} from 'react-native'; +import type {ListItem} from '@components/SelectionList/types'; + +function FocusAwareCellRendererComponent({onFocusCapture, ...rest}: CellRendererProps) { + return ( + + ); +} + +FocusAwareCellRendererComponent.displayName = 'FocusAwareCellRendererComponent'; + +export default FocusAwareCellRendererComponent; diff --git a/src/components/SelectionList/Search/SearchQueryListItem.tsx b/src/components/SelectionList/Search/SearchQueryListItem.tsx index bba574fa3ac7..77637eed39df 100644 --- a/src/components/SelectionList/Search/SearchQueryListItem.tsx +++ b/src/components/SelectionList/Search/SearchQueryListItem.tsx @@ -12,7 +12,8 @@ import type IconAsset from '@src/types/utils/IconAsset'; type SearchQueryItem = ListItem & { singleIcon?: IconAsset; - query?: string; + searchQuery?: string; + autocompleteID?: string; searchItemType?: ValueOf; }; diff --git a/src/components/SelectionList/index.tsx b/src/components/SelectionList/index.tsx index fc788a7e2b4b..ecb63fc31e74 100644 --- a/src/components/SelectionList/index.tsx +++ b/src/components/SelectionList/index.tsx @@ -3,6 +3,7 @@ import type {ForwardedRef} from 'react'; import {Keyboard} from 'react-native'; import * as Browser from '@libs/Browser'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; +import CONST from '@src/CONST'; import BaseSelectionList from './BaseSelectionList'; import type {BaseSelectionListProps, ListItem, SelectionListHandle} from './types'; @@ -28,6 +29,33 @@ function SelectionList({onScroll, ...props}: BaseSelecti }; }, []); + const [shouldDebounceScrolling, setShouldDebounceScrolling] = useState(false); + + const checkShouldDebounceScrolling = (event: KeyboardEvent) => { + if (!event) { + return; + } + + // Moving through items using the keyboard triggers scrolling by the browser, so we debounce programmatic scrolling to prevent jittering. + if ( + event.key === CONST.KEYBOARD_SHORTCUTS.ARROW_DOWN.shortcutKey || + event.key === CONST.KEYBOARD_SHORTCUTS.ARROW_UP.shortcutKey || + event.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey + ) { + setShouldDebounceScrolling(event.type === 'keydown'); + } + }; + + useEffect(() => { + document.addEventListener('keydown', checkShouldDebounceScrolling, {passive: true}); + document.addEventListener('keyup', checkShouldDebounceScrolling, {passive: true}); + + return () => { + document.removeEventListener('keydown', checkShouldDebounceScrolling); + document.removeEventListener('keyup', checkShouldDebounceScrolling); + }; + }, []); + // In SearchPageBottomTab we use useAnimatedScrollHandler from reanimated(for performance reasons) and it returns object instead of function. In that case we cannot change it to a function call, that's why we have to choose between onScroll and defaultOnScroll. const defaultOnScroll = () => { // Only dismiss the keyboard whenever the user scrolls the screen @@ -46,6 +74,7 @@ function SelectionList({onScroll, ...props}: BaseSelecti // Ignore the focus if it's caused by a touch event on mobile chrome. // For example, a long press will trigger a focus event on mobile chrome. shouldIgnoreFocus={Browser.isMobileChrome() && isScreenTouched} + shouldDebounceScrolling={shouldDebounceScrolling} /> ); } diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index 8fb50456182c..4c7fd330ec18 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -443,6 +443,12 @@ type BaseSelectionListProps = Partial & { /** Item `keyForList` to focus initially */ initiallyFocusedOptionKey?: string | null; + /** Whether the text input should be shown after list header */ + shouldShowTextInputAfterHeader?: boolean; + + /** Whether to include padding bottom */ + includeSafeAreaPaddingBottom?: boolean; + /** Callback to fire when the list is scrolled */ onScroll?: (event: NativeSyntheticEvent) => void; @@ -597,6 +603,15 @@ type BaseSelectionListProps = Partial & { /** Whether we highlight all the selected items */ shouldHighlightSelectedItem?: boolean; + + /** Determines if the focused item should remain at the top of the viewable area when navigating with arrow keys */ + shouldKeepFocusedItemAtTopOfViewableArea?: boolean; + + /** Whether to debounce scrolling on focused index change */ + shouldDebounceScrolling?: boolean; + + /** Whether to prevent the active cell from being virtualized and losing focus in browsers */ + shouldPreventActiveCellVirtualization?: boolean; } & TRightHandSideComponent; type SelectionListHandle = { diff --git a/src/components/SelectionListWithModal/index.tsx b/src/components/SelectionListWithModal/index.tsx index 25123d5454d4..2ea739f531c8 100644 --- a/src/components/SelectionListWithModal/index.tsx +++ b/src/components/SelectionListWithModal/index.tsx @@ -79,7 +79,7 @@ function SelectionListWithModal( const handleLongPressRow = (item: TItem) => { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - if (!turnOnSelectionModeOnLongPress || !isSmallScreenWidth || item?.isDisabled || item?.isDisabledCheckbox) { + if (!turnOnSelectionModeOnLongPress || !isSmallScreenWidth || item?.isDisabled || item?.isDisabledCheckbox || !isFocused) { return; } setLongPressedItem(item); diff --git a/src/components/SelectionScreen.tsx b/src/components/SelectionScreen.tsx index 8382029bc12f..3538b04ed57f 100644 --- a/src/components/SelectionScreen.tsx +++ b/src/components/SelectionScreen.tsx @@ -93,6 +93,18 @@ type SelectionScreenProps = { /** Whether to update the focused index on a row select */ shouldUpdateFocusedIndex?: boolean; + + /** Whether to show the text input */ + shouldShowTextInput?: boolean; + + /** Label for the text input */ + textInputLabel?: string; + + /** Value for the text input */ + textInputValue?: string; + + /** Callback to fire when the text input changes */ + onChangeText?: (text: string) => void; }; function SelectionScreen({ @@ -117,6 +129,10 @@ function SelectionScreen({ onClose, shouldSingleExecuteRowSelect, headerTitleAlreadyTranslated, + textInputLabel, + textInputValue, + onChangeText, + shouldShowTextInput, shouldUpdateFocusedIndex = false, }: SelectionScreenProps) { const {translate} = useLocalize(); @@ -152,9 +168,13 @@ function SelectionScreen({ sections={sections} ListItem={listItem} showScrollIndicator + onChangeText={onChangeText} shouldShowTooltips={false} initiallyFocusedOptionKey={initiallyFocusedOptionKey} listEmptyContent={listEmptyContent} + textInputLabel={textInputLabel} + textInputValue={textInputValue} + shouldShowTextInput={shouldShowTextInput} listFooterContent={listFooterContent} sectionListStyle={!!sections.length && [styles.flexGrow0]} shouldSingleExecuteRowSelect={shouldSingleExecuteRowSelect} diff --git a/src/components/StatePicker/StateSelectorModal.tsx b/src/components/StatePicker/StateSelectorModal.tsx index 95c6067c95a2..0ce488c176ee 100644 --- a/src/components/StatePicker/StateSelectorModal.tsx +++ b/src/components/StatePicker/StateSelectorModal.tsx @@ -8,8 +8,8 @@ import RadioListItem from '@components/SelectionList/RadioListItem'; import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import searchCountryOptions from '@libs/searchCountryOptions'; -import type {CountryData} from '@libs/searchCountryOptions'; +import searchOptions from '@libs/searchOptions'; +import type {Option} from '@libs/searchOptions'; import StringUtils from '@libs/StringUtils'; import CONST from '@src/CONST'; @@ -29,7 +29,7 @@ type StateSelectorModalProps = { currentState: string; /** Function to call when the user selects a state */ - onStateSelected: (value: CountryData) => void; + onStateSelected: (value: Option) => void; /** Function to call when the user presses on the modal backdrop */ onBackdropPress?: () => void; @@ -56,7 +56,7 @@ function StateSelectorModal({isVisible, currentState, onStateSelected, onClose, [translate, currentState], ); - const searchResults = searchCountryOptions(debouncedSearchValue, countryStates); + const searchResults = searchOptions(debouncedSearchValue, countryStates); const headerMessage = debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : ''; const styles = useThemeStyles(); diff --git a/src/components/StatePicker/index.tsx b/src/components/StatePicker/index.tsx index ebcb156fd293..558db66a52ec 100644 --- a/src/components/StatePicker/index.tsx +++ b/src/components/StatePicker/index.tsx @@ -3,7 +3,7 @@ import React, {useState} from 'react'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import useLocalize from '@hooks/useLocalize'; import Navigation from '@libs/Navigation/Navigation'; -import type {CountryData} from '@libs/searchCountryOptions'; +import type {Option} from '@libs/searchOptions'; import CONST from '@src/CONST'; import StateSelectorModal from './StateSelectorModal'; @@ -28,7 +28,7 @@ function StatePicker({value, errorText, onInputChange = () => {}}: StatePickerPr setIsPickerVisible(false); }; - const updateInput = (item: CountryData) => { + const updateInput = (item: Option) => { onInputChange?.(item.value); hidePickerModal(); }; diff --git a/src/components/SubStepForms/AddressStep.tsx b/src/components/SubStepForms/AddressStep.tsx index 9a90d2fa7a42..86e6e328d226 100644 --- a/src/components/SubStepForms/AddressStep.tsx +++ b/src/components/SubStepForms/AddressStep.tsx @@ -1,7 +1,7 @@ -import React, {useCallback} from 'react'; +import React, {useCallback, useEffect, useRef} from 'react'; import {View} from 'react-native'; import FormProvider from '@components/Form/FormProvider'; -import type {FormInputErrors, FormOnyxKeys, FormOnyxValues, FormValue} from '@components/Form/types'; +import type {FormInputErrors, FormOnyxKeys, FormOnyxValues, FormRef, FormValue} from '@components/Form/types'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import type {SubStepProps} from '@hooks/useSubStep/types'; @@ -37,7 +37,7 @@ type AddressStepProps = SubStepProp /** Fields list of the form */ stepFields: Array>; - /* The IDs of the input fields */ + /** The IDs of the input fields */ inputFieldsIDs: AddressValues; /** The default values for the form */ @@ -45,6 +45,24 @@ type AddressStepProps = SubStepProp /** Should show help links */ shouldShowHelpLinks?: boolean; + + /** Indicates if country selector should be displayed */ + shouldDisplayCountrySelector?: boolean; + + /** Indicates if state selector should be displayed */ + shouldDisplayStateSelector?: boolean; + + /** Label for the state selector */ + stateSelectorLabel?: string; + + /** The title of the state selector modal */ + stateSelectorModalHeaderTitle?: string; + + /** The title of the state selector search input */ + stateSelectorSearchInputTitle?: string; + + /** Callback to be called when the country is changed */ + onCountryChange?: (country: unknown) => void; }; function AddressStep({ @@ -58,10 +76,23 @@ function AddressStep({ defaultValues, shouldShowHelpLinks, isEditing, + shouldDisplayCountrySelector = false, + shouldDisplayStateSelector = true, + stateSelectorLabel, + stateSelectorModalHeaderTitle, + stateSelectorSearchInputTitle, + onCountryChange, }: AddressStepProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); + const formRef = useRef(null); + + useEffect(() => { + // When stepFields change (e.g. country changes) we need to reset state errors manually + formRef.current?.resetFormFieldError(inputFieldsIDs.state); + }, [inputFieldsIDs.state, stepFields]); + const validate = useCallback( (values: FormOnyxValues): FormInputErrors => { const errors = ValidationUtils.getFieldRequiredErrors(values, stepFields); @@ -73,14 +104,14 @@ function AddressStep({ } const zipCode = values[inputFieldsIDs.zipCode as keyof typeof values]; - if (zipCode && !ValidationUtils.isValidZipCode(zipCode as string)) { + if (zipCode && (shouldDisplayCountrySelector ? !ValidationUtils.isValidZipCodeInternational(zipCode as string) : !ValidationUtils.isValidZipCode(zipCode as string))) { // @ts-expect-error type mismatch to be fixed errors[inputFieldsIDs.zipCode] = translate('bankAccount.error.zipCode'); } return errors; }, - [inputFieldsIDs.street, inputFieldsIDs.zipCode, stepFields, translate], + [inputFieldsIDs.street, inputFieldsIDs.zipCode, shouldDisplayCountrySelector, stepFields, translate], ); return ( @@ -90,6 +121,7 @@ function AddressStep({ validate={customValidate ?? validate} onSubmit={onSubmit} style={[styles.mh5, styles.flexGrow1]} + ref={formRef} > {formTitle} @@ -99,6 +131,12 @@ function AddressStep({ streetTranslationKey="common.streetAddress" defaultValues={defaultValues} shouldSaveDraft={!isEditing} + shouldDisplayStateSelector={shouldDisplayStateSelector} + shouldDisplayCountrySelector={shouldDisplayCountrySelector} + stateSelectorLabel={stateSelectorLabel} + stateSelectorModalHeaderTitle={stateSelectorModalHeaderTitle} + stateSelectorSearchInputTitle={stateSelectorSearchInputTitle} + onCountryChange={onCountryChange} /> {!!shouldShowHelpLinks && } diff --git a/src/components/TabSelector/TabIcon.tsx b/src/components/TabSelector/TabIcon.tsx index 55aa5d68f65c..0431d4bb5b64 100644 --- a/src/components/TabSelector/TabIcon.tsx +++ b/src/components/TabSelector/TabIcon.tsx @@ -25,12 +25,14 @@ function TabIcon({icon, activeOpacity = 0, inactiveOpacity = 1}: TabIconProps) { diff --git a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx index 392e4b9176e6..425960078b0a 100644 --- a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx +++ b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx @@ -245,6 +245,7 @@ function BaseValidateCodeForm({ /> )} (null); @@ -67,7 +69,7 @@ function ValidateCodeActionModal({ onBackButtonPress={hide} /> - + {description} diff --git a/src/components/VideoPlayer/VideoPlayerControls/ProgressBar/index.tsx b/src/components/VideoPlayer/VideoPlayerControls/ProgressBar/index.tsx index 5d1ea0d85d0b..c0b8c32cedcb 100644 --- a/src/components/VideoPlayer/VideoPlayerControls/ProgressBar/index.tsx +++ b/src/components/VideoPlayer/VideoPlayerControls/ProgressBar/index.tsx @@ -45,21 +45,22 @@ function ProgressBar({duration, position, seekPosition}: ProgressBarProps) { }; const pan = Gesture.Pan() + .runOnJS(true) .onBegin((event) => { - runOnJS(setIsSliderPressed)(true); - runOnJS(checkVideoPlaying)(onCheckVideoPlaying); - runOnJS(pauseVideo)(); - runOnJS(progressBarInteraction)(event); + setIsSliderPressed(true); + checkVideoPlaying(onCheckVideoPlaying); + pauseVideo(); + progressBarInteraction(event); }) .onChange((event) => { - runOnJS(progressBarInteraction)(event); + progressBarInteraction(event); }) .onFinalize(() => { - runOnJS(setIsSliderPressed)(false); + setIsSliderPressed(false); if (!wasVideoPlayingOnCheck.value) { return; } - runOnJS(playVideo)(); + playVideo(); }); useEffect(() => { diff --git a/src/hooks/useOnboardingFlow.ts b/src/hooks/useOnboardingFlow.ts index 5ccd3bab9378..66ef088d0e4f 100644 --- a/src/hooks/useOnboardingFlow.ts +++ b/src/hooks/useOnboardingFlow.ts @@ -21,12 +21,23 @@ function useOnboardingFlowRouter() { selector: hasCompletedHybridAppOnboardingFlowSelector, }); + const [isSingleNewDotEntry, isSingleNewDotEntryMetadata] = useOnyx(ONYXKEYS.IS_SINGLE_NEW_DOT_ENTRY); + useEffect(() => { - if (isLoadingOnyxValue(isOnboardingCompletedMetadata, isHybridAppOnboardingCompletedMetadata)) { + if (isLoadingOnyxValue(isOnboardingCompletedMetadata)) { + return; + } + + if (NativeModules.HybridAppModule && isLoadingOnyxValue(isHybridAppOnboardingCompletedMetadata, isSingleNewDotEntryMetadata)) { return; } if (NativeModules.HybridAppModule) { + // For single entries, such as using the Travel feature from OldDot, we don't want to show onboarding + if (isSingleNewDotEntry) { + return; + } + // When user is transitioning from OldDot to NewDot, we usually show the explanation modal if (isHybridAppOnboardingCompleted === false) { Navigation.navigate(ROUTES.EXPLANATION_MODAL_ROOT); @@ -43,7 +54,7 @@ function useOnboardingFlowRouter() { if (!NativeModules.HybridAppModule && isOnboardingCompleted === false) { OnboardingFlow.startOnboardingFlow(); } - }, [isOnboardingCompleted, isHybridAppOnboardingCompleted, isOnboardingCompletedMetadata, isHybridAppOnboardingCompletedMetadata]); + }, [isOnboardingCompleted, isHybridAppOnboardingCompleted, isOnboardingCompletedMetadata, isHybridAppOnboardingCompletedMetadata, isSingleNewDotEntryMetadata, isSingleNewDotEntry]); return {isOnboardingCompleted, isHybridAppOnboardingCompleted}; } diff --git a/src/hooks/usePermissions.ts b/src/hooks/usePermissions.ts index 22200304fdd5..e60825b610e9 100644 --- a/src/hooks/usePermissions.ts +++ b/src/hooks/usePermissions.ts @@ -1,13 +1,12 @@ import {useContext, useMemo} from 'react'; import {BetasContext} from '@components/OnyxProvider'; import Permissions from '@libs/Permissions'; -import type {IOUType} from '@src/CONST'; type PermissionKey = keyof typeof Permissions; type UsePermissions = Partial>; let permissionKey: PermissionKey; -export default function usePermissions(iouType: IOUType | undefined = undefined): UsePermissions { +export default function usePermissions(): UsePermissions { const betas = useContext(BetasContext); return useMemo(() => { const permissions: UsePermissions = {}; @@ -16,10 +15,10 @@ export default function usePermissions(iouType: IOUType | undefined = undefined) if (betas) { const checkerFunction = Permissions[permissionKey]; - permissions[permissionKey] = checkerFunction(betas, iouType); + permissions[permissionKey] = checkerFunction(betas); } } return permissions; - }, [betas, iouType]); + }, [betas]); } diff --git a/src/hooks/useReportIDs.tsx b/src/hooks/useReportIDs.tsx index 7c35f2661336..284d80f737f2 100644 --- a/src/hooks/useReportIDs.tsx +++ b/src/hooks/useReportIDs.tsx @@ -2,12 +2,10 @@ import React, {createContext, useCallback, useContext, useMemo} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; import {getPolicyEmployeeListByIdWithoutCurrentUser} from '@libs/PolicyUtils'; -import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import SidebarUtils from '@libs/SidebarUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; -import type {Message} from '@src/types/onyx/ReportAction'; import mapOnyxCollectionItems from '@src/utils/mapOnyxCollectionItems'; import useActiveWorkspace from './useActiveWorkspace'; import useCurrentReportID from './useCurrentReportID'; @@ -34,33 +32,6 @@ const ReportIDsContext = createContext({ policyMemberAccountIDs: [], }); -/** - * This function (and the few below it), narrow down the data from Onyx to just the properties that we want to trigger a re-render of the component. This helps minimize re-rendering - * and makes the entire component more performant because it's not re-rendering when a bunch of properties change which aren't ever used in the UI. - */ -const reportActionsSelector = (reportActions: OnyxEntry): ReportActionsSelector => - (reportActions && - Object.values(reportActions) - .filter(Boolean) - .map((reportAction) => { - const {reportActionID, actionName, errors = []} = reportAction; - const originalMessage = ReportActionsUtils.getOriginalMessage(reportAction); - const message = ReportActionsUtils.getReportActionMessage(reportAction); - const decision = message?.moderationDecision?.decision; - - return { - reportActionID, - actionName, - errors, - message: [ - { - moderationDecision: {decision}, - }, - ] as Message[], - originalMessage, - }; - })) as ReportActionsSelector; - const policySelector = (policy: OnyxEntry): PolicySelector => (policy && { type: policy.type, @@ -84,7 +55,6 @@ function ReportIDsContextProvider({ const [priorityMode] = useOnyx(ONYXKEYS.NVP_PRIORITY_MODE, {initialValue: CONST.PRIORITY_MODE.DEFAULT}); const [chatReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: (c) => mapOnyxCollectionItems(c, policySelector)}); - const [allReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS, {selector: (c) => mapOnyxCollectionItems(c, reportActionsSelector)}); const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const [reportsDrafts] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT); const [betas] = useOnyx(ONYXKEYS.BETAS); @@ -99,20 +69,10 @@ function ReportIDsContextProvider({ const getOrderedReportIDs = useCallback( (currentReportID?: string) => - SidebarUtils.getOrderedReportIDs( - currentReportID ?? null, - chatReports, - betas, - policies, - priorityMode, - allReportActions, - transactionViolations, - activeWorkspaceID, - policyMemberAccountIDs, - ), + SidebarUtils.getOrderedReportIDs(currentReportID ?? null, chatReports, betas, policies, priorityMode, transactionViolations, activeWorkspaceID, policyMemberAccountIDs), // we need reports draft in deps array for reloading of list when reportsDrafts will change // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - [chatReports, betas, policies, priorityMode, allReportActions, transactionViolations, activeWorkspaceID, policyMemberAccountIDs, reportsDrafts], + [chatReports, betas, policies, priorityMode, transactionViolations, activeWorkspaceID, policyMemberAccountIDs, reportsDrafts], ); const orderedReportIDs = useMemo(() => getOrderedReportIDs(), [getOrderedReportIDs]); diff --git a/src/languages/en.ts b/src/languages/en.ts index 21f220b747f2..63da3c26bf74 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -44,6 +44,7 @@ import type { ConfirmThatParams, ConnectionNameParams, ConnectionParams, + CurrencyCodeParams, CustomersOrJobsLabelParams, DateParams, DateShouldBeAfterParams, @@ -346,6 +347,7 @@ const translations = { pleaseSelectOne: 'Please select an option above.', invalidRateError: 'Please enter a valid rate.', lowRateError: 'Rate must be greater than 0.', + email: 'Please enter a valid email address.', }, comma: 'comma', semicolon: 'semicolon', @@ -705,7 +707,7 @@ const translations = { reportTypingIndicator: { isTyping: 'is typing...', areTyping: 'are typing...', - multipleUsers: 'Multiple users', + multipleMembers: 'Multiple members', }, reportArchiveReasons: { [CONST.REPORT.ARCHIVE_REASON.DEFAULT]: 'This chat room has been archived.', @@ -1208,7 +1210,7 @@ const translations = { deviceCredentials: 'Device credentials', invalidate: 'Invalidate', destroy: 'Destroy', - maskExportOnyxStateData: 'Mask fragile user data while exporting Onyx state', + maskExportOnyxStateData: 'Mask fragile member data while exporting Onyx state', exportOnyxState: 'Export Onyx state', importOnyxState: 'Import Onyx state', testCrash: 'Test crash', @@ -1401,7 +1403,7 @@ const translations = { enableWalletToSendAndReceiveMoney: 'Enable your wallet to send and receive money with friends.', walletEnabledToSendAndReceiveMoney: 'Your wallet has been enabled to send and receive money with friends.', enableWallet: 'Enable wallet', - addBankAccountToSendAndReceive: 'Adding a bank account allows you to get paid back for expenses you submit to a workspace.', + addBankAccountToSendAndReceive: 'Add a bank account to get paid back for expenses you submit to a workspace.', addBankAccount: 'Add bank account', assignedCards: 'Assigned cards', assignedCardsDescription: 'These are cards assigned by a workspace admin to manage company spend.', @@ -1641,7 +1643,7 @@ const translations = { }, groupChat: { lastMemberTitle: 'Heads up!', - lastMemberWarning: "Since you're the last person here, leaving will make this chat inaccessible to all users. Are you sure you want to leave?", + lastMemberWarning: "Since you're the last person here, leaving will make this chat inaccessible to all members. Are you sure you want to leave?", defaultReportName: ({displayName}: ReportArchiveReasonsClosedParams) => `${displayName}'s group chat`, }, languagePage: { @@ -1918,7 +1920,7 @@ const translations = { noBankAccountAvailable: "Sorry, there's no bank account available.", noBankAccountSelected: 'Please choose an account.', taxID: 'Please enter a valid tax ID number.', - website: 'Please enter a valid website using lower-case letters.', + website: 'Please enter a valid website.', zipCode: `Please enter a valid ZIP code using the format: ${CONST.COUNTRY_ZIP_REGEX_DATA.US.samples}.`, phoneNumber: 'Please enter a valid phone number.', companyName: 'Please enter a valid business name.', @@ -2169,6 +2171,33 @@ const translations = { listOfRestrictedBusinesses: 'list of restricted businesses', confirmCompanyIsNot: 'I confirm that this company is not on the', businessInfoTitle: 'Business info', + legalBusinessName: 'Legal business name', + whatsTheBusinessName: "What's the business name?", + whatsTheBusinessAddress: "What's the business address?", + whatsTheBusinessContactInformation: "What's the business contact information?", + whatsTheBusinessRegistrationNumber: "What's the business registration number?", + whatsThisNumber: "What's this number?", + whereWasTheBusinessIncorporated: 'Where was the business incorporated?', + whatTypeOfBusinessIsIt: 'What type of business is it?', + whatsTheBusinessAnnualPayment: "What's the business's annual payment volume?", + registrationNumber: 'Registration number', + businessAddress: 'Business address', + businessType: 'Business type', + incorporation: 'Incorporation', + incorporationCountry: 'Incorporation country', + incorporationTypeName: 'Incorporation type', + businessCategory: 'Business category', + annualPaymentVolume: 'Annual payment volume', + annualPaymentVolumeInCurrency: ({currencyCode}: CurrencyCodeParams) => `Annual payment volume in ${currencyCode}`, + selectIncorporationType: 'Select incorporation type', + selectBusinessCategory: 'Select business category', + selectAnnualPaymentVolume: 'Select annual payment volume', + selectIncorporationCountry: 'Select incorporation country', + selectIncorporationState: 'Select incorporation state', + findIncorporationType: 'Find incorporation type', + findBusinessCategory: 'Find business category', + findAnnualPaymentVolume: 'Find annual payment volume', + findIncorporationState: 'Find incorporation state', }, beneficialOwnerInfoStep: { doYouOwn25percent: 'Do you own 25% or more of', @@ -2337,6 +2366,7 @@ const translations = { displayedAs: 'Displayed as', plan: 'Plan', profile: 'Profile', + perDiem: 'Per diem', bankAccount: 'Bank account', connectBankAccount: 'Connect bank account', testTransactions: 'Test transactions', @@ -2401,6 +2431,25 @@ const translations = { } }, }, + perDiem: { + subtitle: 'Set per diem rates to control daily employee spend. ', + destination: 'Destination', + subrate: 'Subrate', + amount: 'Amount', + deleteRates: () => ({ + one: 'Delete rate', + other: 'Delete rates', + }), + deletePerDiemRate: 'Delete per diem rate', + areYouSureDelete: () => ({ + one: 'Are you sure you want to delete this rate?', + other: 'Are you sure you want to delete these rates?', + }), + emptyList: { + title: 'Per diem', + subtitle: 'Set per diem rates to control daily employee spend. Import rates from a spreadsheet to get started.', + }, + }, qbd: { exportOutOfPocketExpensesDescription: 'Set how out-of-pocket expenses export to QuickBooks Desktop.', exportOutOfPocketExpensesCheckToogle: 'Mark checks as “print later”', @@ -2477,6 +2526,10 @@ const translations = { setupPage: { title: 'Open this link to connect', body: 'To complete setup, open the following link on the computer where QuickBooks Desktop is running.', + setupErrorTitle: 'Something went wrong', + setupErrorBody1: "The QuickBooks Desktop connection isn't working at the moment. Please try again later or", + setupErrorBody2: 'if the problem persists.', + setupErrorBodyContactConcierge: 'reach out to Concierge', }, importDescription: 'Choose which coding configurations to import from QuickBooks Desktop to Expensify.', classes: 'Classes', @@ -3146,7 +3199,6 @@ const translations = { confirmationDescription: 'We’ll begin importing transactions immediately.', cardholder: 'Cardholder', card: 'Card', - startTransactionDate: 'Start transaction date', cardName: 'Card name', brokenConnectionErrorFirstPart: `Card feed connection is broken. Please `, brokenConnectionErrorLink: 'log into your bank ', @@ -3287,6 +3339,10 @@ const translations = { title: 'Distance rates', subtitle: 'Add, update, and enforce rates.', }, + perDiem: { + title: 'Per diem', + subtitle: 'Set Per diem rates to control daily employee spend.', + }, expensifyCard: { title: 'Expensify Card', subtitle: 'Gain insights and control over spend.', @@ -3539,7 +3595,7 @@ const translations = { getTheExpensifyCardAndMore: 'Get the Expensify Card and more', }, people: { - genericFailureMessage: 'An error occurred removing a user from the workspace, please try again.', + genericFailureMessage: 'An error occurred removing a member from the workspace, please try again.', removeMembersPrompt: ({memberName}: {memberName: string}) => ({ one: `Are you sure you want to remove ${memberName}?`, other: 'Are you sure you want to remove these members?', @@ -3658,6 +3714,8 @@ const translations = { return "Can't connect to Xero."; case CONST.POLICY.CONNECTIONS.NAME.NETSUITE: return "Can't connect to NetSuite."; + case CONST.POLICY.CONNECTIONS.NAME.QBD: + return "Can't connect to QuickBooks Desktop."; default: { return "Can't connect to integration."; } @@ -4053,7 +4111,7 @@ const translations = { }, companyCards: { title: 'Company cards', - description: `Company cards lets you import spend for existing company cards from all major card issuers. You can assign cards to employees, and automatically import transactions.`, + description: `Connect your existing corporate cards to Expensify, assign them to employees, and automatically import transactions.`, onlyAvailableOnPlan: 'Company cards are only available on the Control plan, starting at ', }, rules: { @@ -4061,6 +4119,12 @@ const translations = { description: `Rules run in the background and keep your spend under control so you don't have to sweat the small stuff.\n\nRequire expense details like receipts and descriptions, set limits and defaults, and automate approvals and payments – all in one place.`, onlyAvailableOnPlan: 'Rules are only available on the Control plan, starting at ', }, + perDiem: { + title: 'Per diem', + description: + 'Per diem is a great way to keep your daily costs compliant and predictable whenever your employees travel. Enjoy features like custom rates, default categories, and more granular details like destinations and subrates.', + onlyAvailableOnPlan: 'Per diem are only available on the Control plan, starting at ', + }, pricing: { amount: '$9 ', perActiveMember: 'per active member per month.', @@ -4499,11 +4563,11 @@ const translations = { selectedForRandomAudit: `randomly selected for review`, selectedForRandomAuditMarkdown: `[randomly selected](https://help.expensify.com/articles/expensify-classic/reports/Set-a-random-report-audit-schedule) for review`, share: ({to}: ShareParams) => `invited member ${to}`, - unshare: ({to}: UnshareParams) => `removed user ${to}`, + unshare: ({to}: UnshareParams) => `removed member ${to}`, stripePaid: ({amount, currency}: StripePaidParams) => `paid ${currency}${amount}`, takeControl: `took control`, integrationSyncFailed: ({label, errorMessage}: IntegrationSyncFailedParams) => `failed to sync with ${label}${errorMessage ? ` ("${errorMessage}")` : ''}`, - addEmployee: ({email, role}: AddEmployeeParams) => `added ${email} as ${role === 'user' ? 'member' : 'admin'}`, + addEmployee: ({email, role}: AddEmployeeParams) => `added ${email} as ${role === 'member' || role === 'user' ? 'member' : 'admin'}`, updateRole: ({email, currentRole, newRole}: UpdateRoleParams) => `updated the role of ${email} from ${currentRole} to ${newRole}`, removeMember: ({email, role}: AddEmployeeParams) => `removed ${role} ${email}`, removedConnection: ({connectionName}: ConnectionNameParams) => `removed connection to ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]}`, @@ -4531,7 +4595,7 @@ const translations = { pressKit: 'Press Kit', support: 'Support', expensifyHelp: 'ExpensifyHelp', - community: 'Community', + terms: 'Terms of Service', privacy: 'Privacy', learnMore: 'Learn More', aboutExpensify: 'About Expensify', @@ -4553,7 +4617,7 @@ const translations = { chatMessage: 'Chat message', lastChatMessagePreview: 'Last chat message preview', workspaceName: 'Workspace name', - chatUserDisplayNames: 'Chat user display names', + chatUserDisplayNames: 'Chat member display names', scrollToNewestMessages: 'Scroll to newest messages', prestyledText: 'Prestyled text', viewAttachment: 'View attachment', @@ -5147,11 +5211,12 @@ const translations = { RBR: 'RBR', true: 'true', false: 'false', + viewReport: 'View Report', reasonVisibleInLHN: { hasDraftComment: 'Has draft comment', hasGBR: 'Has GBR', hasRBR: 'Has RBR', - pinnedByUser: 'Pinned by user', + pinnedByUser: 'Pinned by member', hasIOUViolations: 'Has IOU violations', hasAddWorkspaceRoomErrors: 'Has add workspace room errors', isUnread: 'Is unread (focus mode)', diff --git a/src/languages/es.ts b/src/languages/es.ts index ac06741f467e..9a1ec19ef855 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -42,6 +42,7 @@ import type { ConfirmThatParams, ConnectionNameParams, ConnectionParams, + CurrencyCodeParams, CustomersOrJobsLabelParams, DateParams, DateShouldBeAfterParams, @@ -336,6 +337,7 @@ const translations = { pleaseSelectOne: 'Seleccione una de las opciones.', invalidRateError: 'Por favor, introduce una tarifa válida.', lowRateError: 'La tarifa debe ser mayor que 0.', + email: 'Por favor, introduzca una dirección de correo electrónico válida.', }, comma: 'la coma', semicolon: 'el punto y coma', @@ -699,7 +701,7 @@ const translations = { reportTypingIndicator: { isTyping: 'está escribiendo...', areTyping: 'están escribiendo...', - multipleUsers: 'Varios miembros', + multipleMembers: 'Varios miembros', }, reportArchiveReasons: { [CONST.REPORT.ARCHIVE_REASON.DEFAULT]: 'Esta sala de chat ha sido eliminada.', @@ -1206,7 +1208,7 @@ const translations = { deviceCredentials: 'Credenciales del dispositivo', invalidate: 'Invalidar', destroy: 'Destruir', - maskExportOnyxStateData: 'Enmascare los datos frágiles del usuario mientras exporta el estado Onyx', + maskExportOnyxStateData: 'Enmascare los datos frágiles del miembro mientras exporta el estado Onyx', exportOnyxState: 'Exportar estado Onyx', importOnyxState: 'Importar estado Onyx', testCrash: 'Prueba de fallo', @@ -1401,7 +1403,7 @@ const translations = { enableWalletToSendAndReceiveMoney: 'Habilita tu Billetera Expensify para comenzar a enviar y recibir dinero con amigos.', walletEnabledToSendAndReceiveMoney: 'Tu billetera ha sido habilitada para enviar y recibir dinero con amigos.', enableWallet: 'Habilitar billetera', - addBankAccountToSendAndReceive: 'Agregar una cuenta bancaria te permite recibir reembolsos por los gastos que envíes a un espacio de trabajo.', + addBankAccountToSendAndReceive: 'Añade una cuenta bancaria para recibir reembolsos por los gastos que envíes a un espacio de trabajo.', addBankAccount: 'Añadir cuenta bancaria', assignedCards: 'Tarjetas asignadas', assignedCardsDescription: 'Son tarjetas asignadas por un administrador del espacio de trabajo para gestionar los gastos de la empresa.', @@ -1937,7 +1939,7 @@ const translations = { noBankAccountAvailable: 'Lo sentimos, no hay ninguna cuenta bancaria disponible.', noBankAccountSelected: 'Por favor, elige una cuenta bancaria.', taxID: 'Por favor, introduce un número de identificación fiscal válido.', - website: 'Por favor, introduce un sitio web válido. El sitio web debe estar en minúsculas.', + website: 'Por favor, introduce un sitio web válido.', zipCode: `Formato de código postal incorrecto. Formato aceptable: ${CONST.COUNTRY_ZIP_REGEX_DATA.US.samples}.`, phoneNumber: 'Por favor, introduce un teléfono válido.', companyName: 'Por favor, introduce un nombre comercial legal válido.', @@ -2192,6 +2194,33 @@ const translations = { listOfRestrictedBusinesses: 'lista de negocios restringidos', confirmCompanyIsNot: 'Confirmo que esta empresa no está en la', businessInfoTitle: 'Información del negocio', + legalBusinessName: 'Nombre legal de la empresa', + whatsTheBusinessName: '¿Cuál es el nombre de la empresa?', + whatsTheBusinessAddress: '¿Cuál es la dirección de la empresa?', + whatsTheBusinessContactInformation: '¿Cuál es la información de contacto de la empresa?', + whatsTheBusinessRegistrationNumber: '¿Cuál es el número de registro de la empresa?', + whatsThisNumber: '¿Qué es este número?', + whereWasTheBusinessIncorporated: '¿Dónde se constituyó la empresa?', + whatTypeOfBusinessIsIt: '¿Qué tipo de empresa es?', + whatsTheBusinessAnnualPayment: '¿Cuál es el volumen anual de pagos de la empresa?', + registrationNumber: 'Número de registro', + businessAddress: 'Dirección de la empresa', + businessType: 'Tipo de empresa', + incorporation: 'Constitución', + incorporationCountry: 'País de constitución', + incorporationTypeName: 'Tipo de constitución', + businessCategory: 'Categoría de la empresa', + annualPaymentVolume: 'Volumen anual de pagos', + annualPaymentVolumeInCurrency: ({currencyCode}: CurrencyCodeParams) => `Volumen anual de pagos en ${currencyCode}`, + selectIncorporationType: 'Seleccione tipo de constitución', + selectBusinessCategory: 'Seleccione categoría de la empresa', + selectAnnualPaymentVolume: 'Seleccione volumen anual de pagos', + selectIncorporationCountry: 'Seleccione país de constitución', + selectIncorporationState: 'Seleccione estado de constitución', + findIncorporationType: 'Buscar tipo de constitución', + findBusinessCategory: 'Buscar categoría de la empresa', + findAnnualPaymentVolume: 'Buscar volumen anual de pagos', + findIncorporationState: 'Buscar estado de constitución', }, beneficialOwnerInfoStep: { doYouOwn25percent: '¿Posees el 25% o más de', @@ -2358,6 +2387,7 @@ const translations = { rules: 'Reglas', plan: 'Plan', profile: 'Perfil', + perDiem: 'Per diem', bankAccount: 'Cuenta bancaria', displayedAs: 'Mostrado como', connectBankAccount: 'Conectar cuenta bancaria', @@ -2424,6 +2454,25 @@ const translations = { } }, }, + perDiem: { + subtitle: 'Establece las tasas per diem para controlar los gastos diarios de los empleados. ', + destination: 'Destino', + subrate: 'Subtasa', + amount: 'Cantidad', + deleteRates: () => ({ + one: 'Eliminar tasa', + other: 'Eliminar tasas', + }), + deletePerDiemRate: 'Eliminar tasa per diem', + areYouSureDelete: () => ({ + one: '¿Estás seguro de que quieres eliminar esta tasa?', + other: '¿Estás seguro de que quieres eliminar estas tasas?', + }), + emptyList: { + title: 'Per diem', + subtitle: 'Establece dietas per diem para controlar el gasto diario de los empleados. Importa las tarifas desde una hoja de cálculo para comenzar.', + }, + }, qbd: { exportOutOfPocketExpensesDescription: 'Establezca cómo se exportan los gastos de bolsillo a QuickBooks Desktop.', exportOutOfPocketExpensesCheckToogle: 'Marcar los cheques como “imprimir más tarde”', @@ -2502,6 +2551,10 @@ const translations = { setupPage: { title: 'Abre este enlace para conectar', body: 'Para completar la configuración, abre el siguiente enlace en la computadora donde se está ejecutando QuickBooks Desktop.', + setupErrorTitle: '¡Ups! Ha ocurrido un error', + setupErrorBody1: 'La conexión con QuickBooks Desktop no está funcionando en este momento. Por favor, inténtalo de nuevo más tarde o', + setupErrorBody2: 'si el problema persiste.', + setupErrorBodyContactConcierge: 'contacta con Concierge', }, importDescription: 'Elige que configuraciónes de codificación son importadas desde QuickBooks Desktop a Expensify.', classes: 'Clases', @@ -3104,8 +3157,8 @@ const translations = { }, type: { free: 'Gratis', - control: 'Control', - collect: 'Recolectar', + control: 'Controlar', + collect: 'Recopilar', }, companyCards: { addCompanyCards: 'Agregar tarjetas de empresa', @@ -3185,7 +3238,6 @@ const translations = { confirmationDescription: 'Comenzaremos a importar transacciones inmediatamente.', cardholder: 'Titular de la tarjeta', card: 'Tarjeta', - startTransactionDate: 'Fecha de inicio de transacciones', cardName: 'Nombre de la tarjeta', brokenConnectionErrorFirstPart: `La conexión de la fuente de tarjetas está rota. Por favor, `, brokenConnectionErrorLink: 'inicia sesión en tu banco ', @@ -3252,9 +3304,9 @@ const translations = { changeCardMonthlyLimitTypeWarning: ({limit}: CharacterLimitParams) => `Si cambias el tipo de límite de esta tarjeta a Mensual, las nuevas transacciones serán rechazadas porque ya se ha alcanzado el límite de ${limit} mensual.`, addShippingDetails: 'Añadir detalles de envío', - issuedCard: ({assignee}: AssigneeParams) => `¡emitió a ${assignee} una Tarjeta Expensify! La tarjeta llegará en 2-3 días laborables.`, - issuedCardNoShippingDetails: ({assignee}: AssigneeParams) => `¡emitió a ${assignee} una Tarjeta Expensify! La tarjeta se enviará una vez que se agreguen los detalles de envío.`, - issuedCardVirtual: ({assignee, link}: IssueVirtualCardParams) => `¡emitió a ${assignee} una ${link} virtual! La tarjeta puede utilizarse inmediatamente.`, + issuedCard: ({assignee}: AssigneeParams) => `emitió a ${assignee} una Tarjeta Expensify. La tarjeta llegará en 2-3 días laborables.`, + issuedCardNoShippingDetails: ({assignee}: AssigneeParams) => `emitió a ${assignee} una Tarjeta Expensify. La tarjeta se enviará una vez que se agreguen los detalles de envío.`, + issuedCardVirtual: ({assignee, link}: IssueVirtualCardParams) => `emitió a ${assignee} una ${link} virtual. La tarjeta puede utilizarse inmediatamente.`, addedShippingDetails: ({assignee}: AssigneeParams) => `${assignee} agregó los detalles de envío. La Tarjeta Expensify llegará en 2-3 días hábiles.`, verifyingHeader: 'Verificando', bankAccountVerifiedHeader: 'Cuenta bancaria verificada', @@ -3328,6 +3380,10 @@ const translations = { title: 'Tasas de distancia', subtitle: 'Añade, actualiza y haz cumplir las tasas.', }, + perDiem: { + title: 'Per diem', + subtitle: 'Establece las tasas per diem para controlar los gastos diarios de los empleados.', + }, expensifyCard: { title: 'Tarjeta Expensify', subtitle: 'Obtén información y control sobre tus gastos.', @@ -3663,6 +3719,8 @@ const translations = { return 'No se puede conectar a Xero.'; case CONST.POLICY.CONNECTIONS.NAME.NETSUITE: return 'No se puede conectar a NetSuite.'; + case CONST.POLICY.CONNECTIONS.NAME.QBD: + return 'No se puede conectar a QuickBooks Desktop.'; default: { return 'No se ha podido conectar a la integración.'; } @@ -4060,52 +4118,58 @@ const translations = { reportFields: { title: 'Los campos', description: `Los campos de informe permiten especificar detalles a nivel de cabecera, distintos de las etiquetas que pertenecen a los gastos en partidas individuales. Estos detalles pueden incluir nombres de proyectos específicos, información sobre viajes de negocios, ubicaciones, etc.`, - onlyAvailableOnPlan: 'Los campos de informe sólo están disponibles en el plan Control, a partir de ', + onlyAvailableOnPlan: 'Los campos de informe sólo están disponibles en el plan Controlar, a partir de ', }, [CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: { title: 'NetSuite', description: `Disfruta de la sincronización automática y reduce las entradas manuales con la integración Expensify + NetSuite. Obtén información financiera en profundidad y en tiempo real con la compatibilidad nativa y personalizada con segmentos, incluida la asignación de proyectos y clientes.`, - onlyAvailableOnPlan: 'Nuestra integración NetSuite sólo está disponible en el plan Control, a partir de ', + onlyAvailableOnPlan: 'Nuestra integración NetSuite sólo está disponible en el plan Controlar, a partir de ', }, [CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT]: { title: 'Sage Intacct', description: `Disfruta de una sincronización automatizada y reduce las entradas manuales con la integración Expensify + Sage Intacct. Obtén información financiera en profundidad y en tiempo real con dimensiones definidas por el usuario, así como codificación de gastos por departamento, clase, ubicación, cliente y proyecto (trabajo).`, - onlyAvailableOnPlan: 'Nuestra integración Sage Intacct sólo está disponible en el plan Control, a partir de ', + onlyAvailableOnPlan: 'Nuestra integración Sage Intacct sólo está disponible en el plan Controlar, a partir de ', }, [CONST.POLICY.CONNECTIONS.NAME.QBD]: { title: 'QuickBooks Desktop', description: `Disfruta de la sincronización automática y reduce las entradas manuales con la integración de Expensify + QuickBooks Desktop. Obtén la máxima eficiencia con una conexión bidireccional en tiempo real y la codificación de gastos por clase, artículo, cliente y proyecto.`, - onlyAvailableOnPlan: 'Nuestra integración con QuickBooks Desktop solo está disponible en el plan Control, que comienza en ', + onlyAvailableOnPlan: 'Nuestra integración con QuickBooks Desktop solo está disponible en el plan Controlar, que comienza en ', }, [CONST.UPGRADE_FEATURE_INTRO_MAPPING.approvals.id]: { title: 'Aprobaciones anticipadas', description: `Si quieres añadir más niveles de aprobación, o simplemente asegurarte de que los gastos más importantes reciben otro vistazo, no hay problema. Las aprobaciones avanzadas ayudan a realizar las comprobaciones adecuadas a cada nivel para mantener los gastos de tu equipo bajo control.`, - onlyAvailableOnPlan: 'Las aprobaciones avanzadas sólo están disponibles en el plan Control, con precios desde ', + onlyAvailableOnPlan: 'Las aprobaciones avanzadas sólo están disponibles en el plan Controlar, con precios desde ', }, glCodes: { title: 'Códigos de libro mayor', description: `Añada códigos de libro mayor a sus categorías para exportar fácilmente los gastos a sus sistemas de contabilidad y nómina.`, - onlyAvailableOnPlan: 'Los códigos de libro mayor solo están disponibles en el plan Control, a partir de ', + onlyAvailableOnPlan: 'Los códigos de libro mayor solo están disponibles en el plan Controlar, a partir de ', }, glAndPayrollCodes: { title: 'Códigos de libro mayor y nómina', description: `Añada códigos de libro mayor y nómina a sus categorías para exportar fácilmente los gastos a sus sistemas de contabilidad y nómina.`, - onlyAvailableOnPlan: 'Los códigos de libro mayor y nómina solo están disponibles en el plan Control, a partir de ', + onlyAvailableOnPlan: 'Los códigos de libro mayor y nómina solo están disponibles en el plan Controlar, a partir de ', }, taxCodes: { title: 'Código de impuesto', description: `Añada código de impuesto mayor a sus categorías para exportar fácilmente los gastos a sus sistemas de contabilidad y nómina.`, - onlyAvailableOnPlan: 'Los código de impuesto mayor solo están disponibles en el plan Control, a partir de ', + onlyAvailableOnPlan: 'Los código de impuesto mayor solo están disponibles en el plan Controlar, a partir de ', }, companyCards: { title: 'Tarjetas de empresa', - description: `Las tarjetas de empresa le permiten importar los gastos de las tarjetas de empresa existentes de todos los principales emisores de tarjetas. Puede asignar tarjetas a empleados e importar transacciones automáticamente.`, - onlyAvailableOnPlan: 'Las tarjetas de empresa solo están disponibles en el plan Control, a partir de ', + description: `Conecta tus tarjetas corporativas existentes a Expensify, asígnalas a empleados e importa transacciones automáticamente.`, + onlyAvailableOnPlan: 'Las tarjetas de empresa solo están disponibles en el plan Controlar, a partir de ', }, rules: { title: 'Reglas', description: `Las reglas se ejecutan en segundo plano y mantienen tus gastos bajo control para que no tengas que preocuparte por los detalles pequeños.\n\nExige detalles de los gastos, como recibos y descripciones, establece límites y valores predeterminados, y automatiza las aprobaciones y los pagos, todo en un mismo lugar.`, - onlyAvailableOnPlan: 'Las reglas están disponibles solo en el plan Control, que comienza en ', + onlyAvailableOnPlan: 'Las reglas están disponibles solo en el plan Controlar, que comienza en ', + }, + perDiem: { + title: 'Per diem', + description: + 'Las dietas per diem (ej.: $100 por día para comidas) son una excelente forma de mantener los gastos diarios predecibles y ajustados a las políticas de la empresa, especialmente si tus empleados viajan por negocios. Disfruta de funciones como tasas personalizadas, categorías por defecto y detalles más específicos como destinos y subtasas.', + onlyAvailableOnPlan: 'Las dietas per diem solo están disponibles en el plan Control, a partir de ', }, note: { upgradeWorkspace: 'Mejore su espacio de trabajo para acceder a esta función, o', @@ -4119,7 +4183,7 @@ const translations = { upgradeToUnlock: 'Desbloquear esta función', completed: { headline: 'Has mejorado tu espacio de trabajo.', - successMessage: ({policyName}: ReportPolicyNameParams) => `Has actualizado con éxito ${policyName} al plan Control.`, + successMessage: ({policyName}: ReportPolicyNameParams) => `Has actualizado con éxito ${policyName} al plan Controlar.`, viewSubscription: 'Ver su suscripción', moreDetails: 'para obtener más información.', gotIt: 'Entendido, gracias.', @@ -4547,14 +4611,16 @@ const translations = { selectedForRandomAudit: `seleccionado al azar para revisión`, selectedForRandomAuditMarkdown: `[seleccionado al azar](https://help.expensify.com/articles/expensify-classic/reports/Set-a-random-report-audit-schedule) para revisión`, share: ({to}: ShareParams) => `miembro invitado ${to}`, - unshare: ({to}: UnshareParams) => `usuario eliminado ${to}`, + unshare: ({to}: UnshareParams) => `miembro eliminado ${to}`, stripePaid: ({amount, currency}: StripePaidParams) => `pagado ${currency}${amount}`, takeControl: `tomó el control`, integrationSyncFailed: ({label, errorMessage}: IntegrationSyncFailedParams) => `no se pudo sincronizar con ${label}${errorMessage ? ` ("${errorMessage}")` : ''}`, - addEmployee: ({email, role}: AddEmployeeParams) => `agregó a ${email} como ${role === 'user' ? 'miembro' : 'administrador'}`, + addEmployee: ({email, role}: AddEmployeeParams) => `agregó a ${email} como ${role === 'miembro' || role === 'user' ? 'miembro' : 'administrador'}`, updateRole: ({email, currentRole, newRole}: UpdateRoleParams) => - `actualicé el rol ${email} de ${currentRole === 'user' ? 'miembro' : 'administrador'} a ${newRole === 'user' ? 'miembro' : 'administrador'}`, - removeMember: ({email, role}: AddEmployeeParams) => `eliminado ${role === 'user' ? 'miembro' : 'administrador'} ${email}`, + `actualicé el rol ${email} de ${currentRole === 'miembro' || currentRole === 'user' ? 'miembro' : 'administrador'} a ${ + newRole === 'miembro' || newRole === 'user' ? 'miembro' : 'administrador' + }`, + removeMember: ({email, role}: AddEmployeeParams) => `eliminado ${role === 'miembro' || role === 'user' ? 'miembro' : 'administrador'} ${email}`, removedConnection: ({connectionName}: ConnectionNameParams) => `eliminó la conexión a ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]}`, }, }, @@ -4580,7 +4646,7 @@ const translations = { pressKit: 'Kit de Prensa', support: 'Soporte', expensifyHelp: 'ExpensifyHelp', - community: 'Comunidad', + terms: 'Términos de Servicio', privacy: 'Privacidad', learnMore: 'Más Información', aboutExpensify: 'Acerca de Expensify', @@ -5062,7 +5128,7 @@ const translations = { chatMessage: 'mensaje de chat', lastChatMessagePreview: 'Vista previa del último mensaje del chat', workspaceName: 'Nombre del espacio de trabajo', - chatUserDisplayNames: 'Nombres de los usuarios del chat', + chatUserDisplayNames: 'Nombres de los miembros del chat', scrollToNewestMessages: 'Desplázate a los mensajes más recientes', prestyledText: 'texto preestilizado', viewAttachment: 'Ver archivo adjunto', @@ -5476,7 +5542,7 @@ const translations = { yourPlan: { title: 'Tu plan', collect: { - title: 'Recolectar', + title: 'Recopilar', priceAnnual: ({lower, upper}: YourPlanPriceParams) => `Desde ${lower}/miembro activo con la Tarjeta Expensify, ${upper}/miembro activo sin la Tarjeta Expensify.`, pricePayPerUse: ({lower, upper}: YourPlanPriceParams) => `Desde ${lower}/miembro activo con la Tarjeta Expensify, ${upper}/miembro activo sin la Tarjeta Expensify.`, benefit1: 'SmartScans ilimitados y seguimiento de la distancia', @@ -5488,10 +5554,10 @@ const translations = { benefit7: 'Reportes e informes personalizados', }, control: { - title: 'Control', + title: 'Controlar', priceAnnual: ({lower, upper}: YourPlanPriceParams) => `Desde ${lower}/miembro activo con la Tarjeta Expensify, ${upper}/miembro activo sin la Tarjeta Expensify.`, pricePayPerUse: ({lower, upper}: YourPlanPriceParams) => `Desde ${lower}/miembro activo con la Tarjeta Expensify, ${upper}/miembro activo sin la Tarjeta Expensify.`, - benefit1: 'Todo en Recolectar, más:', + benefit1: 'Todo en Recopilar, más:', benefit2: 'Integraciones con NetSuite y Sage Intacct', benefit3: 'Sincronización de Certinia y Workday', benefit4: 'Varios aprobadores de gastos', @@ -5662,11 +5728,12 @@ const translations = { RBR: 'RBR', true: 'verdadero', false: 'falso', + viewReport: 'Ver Informe', reasonVisibleInLHN: { hasDraftComment: 'Tiene comentario en borrador', hasGBR: 'Tiene GBR', hasRBR: 'Tiene RBR', - pinnedByUser: 'Fijado por el usuario', + pinnedByUser: 'Fijado por el miembro', hasIOUViolations: 'Tiene violaciones de IOU', hasAddWorkspaceRoomErrors: 'Tiene errores al agregar sala de espacio de trabajo', isUnread: 'No leído (modo de enfoque)', diff --git a/src/languages/params.ts b/src/languages/params.ts index e9f0c4370357..2d60c13c4dd0 100644 --- a/src/languages/params.ts +++ b/src/languages/params.ts @@ -547,6 +547,10 @@ type CompanyCardBankName = { bankName: string; }; +type CurrencyCodeParams = { + currencyCode: string; +}; + export type { AuthenticationErrorParams, ImportMembersSuccessfullDescriptionParams, @@ -746,4 +750,5 @@ export type { OptionalParam, AssignCardParams, ImportedTypesParams, + CurrencyCodeParams, }; diff --git a/src/libs/API/parameters/ApproveMoneyRequestParams.ts b/src/libs/API/parameters/ApproveMoneyRequestParams.ts index f6eb93270428..521226aeeff2 100644 --- a/src/libs/API/parameters/ApproveMoneyRequestParams.ts +++ b/src/libs/API/parameters/ApproveMoneyRequestParams.ts @@ -12,10 +12,6 @@ type ApproveMoneyRequestParams = { * }> */ optimisticHoldReportExpenseActionIDs?: string; - /** - * Call the optimized version of ApproveMoneyRequest - */ - v2?: boolean; }; export default ApproveMoneyRequestParams; diff --git a/src/libs/API/parameters/AssignCompanyCardParams.ts b/src/libs/API/parameters/AssignCompanyCardParams.ts index c4dcd7c628a0..782345686c62 100644 --- a/src/libs/API/parameters/AssignCompanyCardParams.ts +++ b/src/libs/API/parameters/AssignCompanyCardParams.ts @@ -1,6 +1,7 @@ type AssignCompanyCardParams = { policyID: string; bankName: string; + cardName: string; encryptedCardNumber: string; email: string; startDate: string; diff --git a/src/libs/API/parameters/OpenPolicyPerDiemRatesPageParams.ts b/src/libs/API/parameters/OpenPolicyPerDiemRatesPageParams.ts new file mode 100644 index 000000000000..de2fa3467027 --- /dev/null +++ b/src/libs/API/parameters/OpenPolicyPerDiemRatesPageParams.ts @@ -0,0 +1,5 @@ +type OpenPolicyPerDiemRatesPageParams = { + policyID: string; +}; + +export default OpenPolicyPerDiemRatesPageParams; diff --git a/src/libs/API/parameters/ReportVirtualExpensifyCardFraudParams.ts b/src/libs/API/parameters/ReportVirtualExpensifyCardFraudParams.ts index 350795d46355..ebb5abcb5d00 100644 --- a/src/libs/API/parameters/ReportVirtualExpensifyCardFraudParams.ts +++ b/src/libs/API/parameters/ReportVirtualExpensifyCardFraudParams.ts @@ -1,4 +1,5 @@ type ReportVirtualExpensifyCardFraudParams = { cardID: number; + validateCode: string; }; export default ReportVirtualExpensifyCardFraudParams; diff --git a/src/libs/API/parameters/RequestReplacementExpensifyCardParams.ts b/src/libs/API/parameters/RequestReplacementExpensifyCardParams.ts index bc86923a83a4..f8e62bd3bc6f 100644 --- a/src/libs/API/parameters/RequestReplacementExpensifyCardParams.ts +++ b/src/libs/API/parameters/RequestReplacementExpensifyCardParams.ts @@ -1,6 +1,7 @@ type RequestReplacementExpensifyCardParams = { cardID: number; reason: string; + validateCode: string; }; export default RequestReplacementExpensifyCardParams; diff --git a/src/libs/API/parameters/TogglePolicyPerDiemParams.ts b/src/libs/API/parameters/TogglePolicyPerDiemParams.ts new file mode 100644 index 000000000000..363020ec5c66 --- /dev/null +++ b/src/libs/API/parameters/TogglePolicyPerDiemParams.ts @@ -0,0 +1,7 @@ +type TogglePolicyPerDiemParams = { + policyID: string; + enabled: boolean; + customUnitID: string; +}; + +export default TogglePolicyPerDiemParams; diff --git a/src/libs/API/parameters/UnassignCompanyCard.ts b/src/libs/API/parameters/UnassignCompanyCard.ts index 10e04aa13f82..d433d9e537c3 100644 --- a/src/libs/API/parameters/UnassignCompanyCard.ts +++ b/src/libs/API/parameters/UnassignCompanyCard.ts @@ -1,6 +1,6 @@ type UnassignCompanyCard = { authToken?: string | null; - cardID: string; + cardID: number; }; export default UnassignCompanyCard; diff --git a/src/libs/API/parameters/UpdateCompanyCard.ts b/src/libs/API/parameters/UpdateCompanyCard.ts index 3d5eb3c580cb..113f0e1d7511 100644 --- a/src/libs/API/parameters/UpdateCompanyCard.ts +++ b/src/libs/API/parameters/UpdateCompanyCard.ts @@ -1,6 +1,6 @@ type UpdateCompanyCard = { authToken?: string | null; - cardID: string; + cardID: number; }; export default UpdateCompanyCard; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 26da6b2f6f03..fb5558fb0350 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -346,4 +346,6 @@ export type {default as UpdateInvoiceCompanyNameParams} from './UpdateInvoiceCom export type {default as UpdateInvoiceCompanyWebsiteParams} from './UpdateInvoiceCompanyWebsiteParams'; export type {default as UpdateQuickbooksDesktopExpensesExportDestinationTypeParams} from './UpdateQuickbooksDesktopExpensesExportDestinationTypeParams'; export type {default as UpdateQuickbooksDesktopCompanyCardExpenseAccountTypeParams} from './UpdateQuickbooksDesktopCompanyCardExpenseAccountTypeParams'; +export type {default as TogglePolicyPerDiemParams} from './TogglePolicyPerDiemParams'; +export type {default as OpenPolicyPerDiemRatesPageParams} from './OpenPolicyPerDiemRatesPageParams'; export type {default as TogglePlatformMuteParams} from './TogglePlatformMuteParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index bf3f749f5bac..b8b4bb749701 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -221,6 +221,7 @@ const WRITE_COMMANDS = { ENABLE_POLICY_WORKFLOWS: 'EnablePolicyWorkflows', ENABLE_POLICY_REPORT_FIELDS: 'EnablePolicyReportFields', ENABLE_POLICY_EXPENSIFY_CARDS: 'EnablePolicyExpensifyCards', + TOGGLE_POLICY_PER_DIEM: 'TogglePolicyPerDiem', ENABLE_POLICY_COMPANY_CARDS: 'EnablePolicyCompanyCards', ENABLE_POLICY_INVOICING: 'EnablePolicyInvoicing', SET_POLICY_RULES_ENABLED: 'SetPolicyRulesEnabled', @@ -661,6 +662,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.ENABLE_POLICY_WORKFLOWS]: Parameters.EnablePolicyWorkflowsParams; [WRITE_COMMANDS.ENABLE_POLICY_REPORT_FIELDS]: Parameters.EnablePolicyReportFieldsParams; [WRITE_COMMANDS.ENABLE_POLICY_EXPENSIFY_CARDS]: Parameters.EnablePolicyExpensifyCardsParams; + [WRITE_COMMANDS.TOGGLE_POLICY_PER_DIEM]: Parameters.TogglePolicyPerDiemParams; [WRITE_COMMANDS.ENABLE_POLICY_COMPANY_CARDS]: Parameters.EnablePolicyCompanyCardsParams; [WRITE_COMMANDS.ENABLE_POLICY_INVOICING]: Parameters.EnablePolicyInvoicingParams; [WRITE_COMMANDS.SET_POLICY_RULES_ENABLED]: Parameters.SetPolicyRulesEnabledParams; @@ -936,6 +938,7 @@ const READ_COMMANDS = { OPEN_DRAFT_WORKSPACE_REQUEST: 'OpenDraftWorkspaceRequest', OPEN_POLICY_WORKFLOWS_PAGE: 'OpenPolicyWorkflowsPage', OPEN_POLICY_DISTANCE_RATES_PAGE: 'OpenPolicyDistanceRatesPage', + OPEN_POLICY_PER_DIEM_RATES_PAGE: 'OpenPolicyPerDiemRatesPage', OPEN_POLICY_MORE_FEATURES_PAGE: 'OpenPolicyMoreFeaturesPage', OPEN_POLICY_ACCOUNTING_PAGE: 'OpenPolicyAccountingPage', OPEN_POLICY_PROFILE_PAGE: 'OpenPolicyProfilePage', @@ -993,6 +996,7 @@ type ReadCommandParameters = { [READ_COMMANDS.OPEN_DRAFT_WORKSPACE_REQUEST]: Parameters.OpenDraftWorkspaceRequestParams; [READ_COMMANDS.OPEN_POLICY_WORKFLOWS_PAGE]: Parameters.OpenPolicyWorkflowsPageParams; [READ_COMMANDS.OPEN_POLICY_DISTANCE_RATES_PAGE]: Parameters.OpenPolicyDistanceRatesPageParams; + [READ_COMMANDS.OPEN_POLICY_PER_DIEM_RATES_PAGE]: Parameters.OpenPolicyPerDiemRatesPageParams; [READ_COMMANDS.OPEN_POLICY_MORE_FEATURES_PAGE]: Parameters.OpenPolicyMoreFeaturesPageParams; [READ_COMMANDS.OPEN_POLICY_ACCOUNTING_PAGE]: Parameters.OpenPolicyAccountingPageParams; [READ_COMMANDS.OPEN_POLICY_EXPENSIFY_CARDS_PAGE]: Parameters.OpenPolicyExpensifyCardsPageParams; diff --git a/src/libs/BankAccountUtils.ts b/src/libs/BankAccountUtils.ts index c781ccab3f33..89bcf96c642f 100644 --- a/src/libs/BankAccountUtils.ts +++ b/src/libs/BankAccountUtils.ts @@ -3,7 +3,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import type * as OnyxTypes from '@src/types/onyx'; function getDefaultCompanyWebsite(session: OnyxEntry, user: OnyxEntry): string { - return user?.isFromPublicDomain ? 'https://' : `https://www.${Str.extractEmailDomain(session?.email ?? '')}`; + return user?.isFromPublicDomain ? '' : `https://www.${Str.extractEmailDomain(session?.email ?? '')}`; } function getLastFourDigits(bankAccountNumber: string): string { diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index 80b784a162cf..ad9bf7f4b90e 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -190,7 +190,7 @@ function getCompanyCardNumber(cardList: Record, lastFourPAN?: st return ''; } - return Object.keys(cardList).find((card) => card.endsWith(lastFourPAN)) ?? ''; + return Object.keys(cardList).find((card) => card.endsWith(lastFourPAN)) ?? maskCard(lastFourPAN); } function getCardFeedIcon(cardFeed: CompanyCardFeed | typeof CONST.EXPENSIFY_CARD.BANK): IconAsset { diff --git a/src/libs/CategoryUtils.ts b/src/libs/CategoryUtils.ts index 479ae557eab6..789026f91af6 100644 --- a/src/libs/CategoryUtils.ts +++ b/src/libs/CategoryUtils.ts @@ -24,9 +24,9 @@ function formatDefaultTaxRateText(translate: LocaleContextProps['translate'], ta return `${taxRateText}${suffix ? ` ${CONST.DOT_SEPARATOR} ${suffix}` : ``}`; } -function formatRequireReceiptsOverText(translate: LocaleContextProps['translate'], policy: Policy, categoryMaxExpenseAmountNoReceipt?: number | null) { - const isAlwaysSelected = categoryMaxExpenseAmountNoReceipt === 0; - const isNeverSelected = categoryMaxExpenseAmountNoReceipt === CONST.DISABLED_MAX_EXPENSE_VALUE; +function formatRequireReceiptsOverText(translate: LocaleContextProps['translate'], policy: Policy, categoryMaxAmountNoReceipt?: number | null) { + const isAlwaysSelected = categoryMaxAmountNoReceipt === 0; + const isNeverSelected = categoryMaxAmountNoReceipt === CONST.DISABLED_MAX_EXPENSE_VALUE; if (isAlwaysSelected) { return translate(`workspace.rules.categoryRules.requireReceiptsOverList.always`); diff --git a/src/libs/CurrencyUtils.ts b/src/libs/CurrencyUtils.ts index f9ac681cb468..b701a32a7c98 100644 --- a/src/libs/CurrencyUtils.ts +++ b/src/libs/CurrencyUtils.ts @@ -164,7 +164,8 @@ function convertAmountToDisplayString(amount = 0, currency: string = CONST.CURRE return NumberFormatUtils.format(BaseLocaleListener.getPreferredLocale(), convertedAmount, { style: 'currency', currency, - minimumFractionDigits: CONST.MAX_TAX_RATE_DECIMAL_PLACES, + minimumFractionDigits: CONST.MIN_TAX_RATE_DECIMAL_PLACES, + maximumFractionDigits: CONST.MAX_TAX_RATE_DECIMAL_PLACES, }); } diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts index d63758761c3c..6b3b2a70ede0 100644 --- a/src/libs/DebugUtils.ts +++ b/src/libs/DebugUtils.ts @@ -44,14 +44,12 @@ type PropertyTypes = Array<'string' | 'number' | 'object' | 'boolean' | 'undefin const OPTIONAL_BOOLEAN_STRINGS = ['true', 'false', 'undefined']; const REPORT_NUMBER_PROPERTIES: Array = [ - 'lastMessageTimestamp', 'lastReadSequenceNumber', 'managerID', 'lastActorAccountID', 'ownerAccountID', 'total', 'unheldTotal', - 'iouReportAmount', 'nonReimbursableTotal', ] satisfies Array; @@ -63,20 +61,16 @@ const REPORT_BOOLEAN_PROPERTIES: Array = [ 'isPinned', 'hasParentAccess', 'isDeletedParentAction', - 'openOnAdminRoom', 'isOptimisticReport', 'isWaitingOnBankAccount', 'isCancelledIOU', - 'isLastMessageDeletedParentAction', 'isHidden', 'isChatRoom', 'isLoadingPrivateNotes', 'selected', ] satisfies Array; -const REPORT_DATE_PROPERTIES: Array = ['lastVisibleActionCreated', 'lastReadCreated', 'lastReadTime', 'lastMentionedTime', 'lastVisibleActionLastModified'] satisfies Array< - keyof Report ->; +const REPORT_DATE_PROPERTIES: Array = ['lastVisibleActionCreated', 'lastReadTime', 'lastMentionedTime', 'lastVisibleActionLastModified'] satisfies Array; const REPORT_REQUIRED_PROPERTIES: Array = ['reportID'] satisfies Array; @@ -502,9 +496,6 @@ function validateReportDraftProperty(key: keyof Report, value: string) { if (key === 'pendingFields') { return validateObject(value, {}); } - if (key === 'visibleChatMemberAccountIDs') { - return validateArray(value, 'number'); - } if (key === 'participantAccountIDs') { return validateArray(value, 'number'); } diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index 286f952b3484..86e9c23af97b 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -50,6 +50,10 @@ function getMileageRates(policy: OnyxInputOrEntry, includeDisabledRates return; } + if (!distanceUnit.attributes) { + return; + } + mileageRates[rateID] = { rate: rate.rate, currency: rate.currency, @@ -79,7 +83,7 @@ function getDefaultMileageRate(policy: OnyxInputOrEntry): MileageRate | } const distanceUnit = PolicyUtils.getDistanceRateCustomUnit(policy); - if (!distanceUnit?.rates) { + if (!distanceUnit?.rates || !distanceUnit.attributes) { return; } const mileageRates = Object.values(getMileageRates(policy)); @@ -277,7 +281,7 @@ function convertToDistanceInMeters(distance: number, unit: Unit): number { /** * Returns custom unit rate ID for the distance transaction */ -function getCustomUnitRateID(reportID: string, shouldUseDefault?: boolean) { +function getCustomUnitRateID(reportID: string) { const allReports = ReportConnection.getAllReports(); const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`]; @@ -288,7 +292,7 @@ function getCustomUnitRateID(reportID: string, shouldUseDefault?: boolean) { const distanceUnit = Object.values(policy?.customUnits ?? {}).find((unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); const lastSelectedDistanceRateID = lastSelectedDistanceRates?.[policy?.id ?? '-1'] ?? '-1'; const lastSelectedDistanceRate = distanceUnit?.rates[lastSelectedDistanceRateID] ?? {}; - if (lastSelectedDistanceRate.enabled && lastSelectedDistanceRateID && !shouldUseDefault) { + if (lastSelectedDistanceRate.enabled && lastSelectedDistanceRateID) { customUnitRateID = lastSelectedDistanceRateID; } else { customUnitRateID = getDefaultMileageRate(policy)?.customUnitRateID ?? '-1'; diff --git a/src/libs/Firebase/index.native.ts b/src/libs/Firebase/index.native.ts index 39a23440c77d..ba899dee1732 100644 --- a/src/libs/Firebase/index.native.ts +++ b/src/libs/Firebase/index.native.ts @@ -2,7 +2,7 @@ import crashlytics from '@react-native-firebase/crashlytics'; import perf from '@react-native-firebase/perf'; import * as Environment from '@libs/Environment/Environment'; -import type {Log, StartTrace, StopTrace, TraceMap} from './types'; +import type {FirebaseAttributes, Log, StartTrace, StopTrace, TraceMap} from './types'; import utils from './utils'; const traceMap: TraceMap = {}; @@ -17,7 +17,7 @@ const startTrace: StartTrace = (customEventName) => { return; } - const attributes = utils.getAttributes(); + const attributes: FirebaseAttributes = utils.getAttributes(['accountId', 'personalDetailsLength', 'reportActionsLength', 'reportsLength', 'policiesLength']); perf() .startTrace(customEventName) diff --git a/src/libs/Firebase/index.web.ts b/src/libs/Firebase/index.web.ts index 2d42154d3c26..483e63617978 100644 --- a/src/libs/Firebase/index.web.ts +++ b/src/libs/Firebase/index.web.ts @@ -1,7 +1,7 @@ import {trace} from '@firebase/performance'; import * as Environment from '@libs/Environment/Environment'; import {firebasePerfWeb} from './firebaseWebConfig'; -import type {Log, StartTrace, StopTrace, TraceMap} from './types'; +import type {FirebaseAttributes, Log, StartTrace, StopTrace, TraceMap} from './types'; import utils from './utils'; const traceMap: TraceMap = {}; @@ -19,7 +19,7 @@ const startTrace: StartTrace = (customEventName) => { const perfTrace = trace(firebasePerfWeb, customEventName); - const attributes = utils.getAttributes(); + const attributes: FirebaseAttributes = utils.getAttributes(['accountId', 'personalDetailsLength', 'reportActionsLength', 'reportsLength', 'policiesLength']); Object.entries(attributes).forEach(([name, value]) => { perfTrace.putAttribute(name, value); diff --git a/src/libs/Firebase/types.ts b/src/libs/Firebase/types.ts index 4c970375c226..2f6a8b42505e 100644 --- a/src/libs/Firebase/types.ts +++ b/src/libs/Firebase/types.ts @@ -9,7 +9,7 @@ type TraceMap = Record; type StartTrace = (customEventName: string) => void; type StopTrace = (customEventName: string) => void; type Log = (action: string) => void; -type FirebaseAttributes = { +type PerfAttributes = { accountId: string; personalDetailsLength: string; reportsLength: string; @@ -21,4 +21,7 @@ type FirebaseAttributes = { policyRole: string; }; -export type {StartTrace, StopTrace, TraceMap, Log, FirebaseAttributes}; +// TODO confirm which attributes are required for Firebase +type FirebaseAttributes = Pick; + +export type {StartTrace, StopTrace, TraceMap, Log, PerfAttributes, FirebaseAttributes}; diff --git a/src/libs/Firebase/utils.ts b/src/libs/Firebase/utils.ts index 4f7718e691ed..01df2bfc8a7e 100644 --- a/src/libs/Firebase/utils.ts +++ b/src/libs/Firebase/utils.ts @@ -4,31 +4,33 @@ import {getActivePolicy, getAllPoliciesLength} from '@libs/PolicyUtils'; import {getReportActionsLength} from '@libs/ReportActionsUtils'; import * as ReportConnection from '@libs/ReportConnection'; import * as SessionUtils from '@libs/SessionUtils'; -import type {FirebaseAttributes} from './types'; +import type {PerfAttributes} from './types'; -function getAttributes(): FirebaseAttributes { +function getAttributes(attributes?: T[]): Pick { const session = SessionUtils.getSession(); - - const accountId = session?.accountID?.toString() ?? 'N/A'; - const reportsLength = ReportConnection.getAllReportsLength().toString(); - const reportActionsLength = getReportActionsLength().toString(); - const personalDetailsLength = PersonalDetailsUtils.getPersonalDetailsLength().toString(); - const transactionViolationsLength = getAllTransactionViolationsLength().toString(); - const policiesLength = getAllPoliciesLength().toString(); - const transactionsLength = getAllTransactions().toString(); const policy = getActivePolicy(); - return { - accountId, - reportsLength, - reportActionsLength, - personalDetailsLength, - transactionViolationsLength, - policiesLength, - transactionsLength, + const allAttributes: PerfAttributes = { + accountId: session?.accountID?.toString() ?? 'N/A', + reportsLength: ReportConnection.getAllReportsLength().toString(), + reportActionsLength: getReportActionsLength().toString(), + personalDetailsLength: PersonalDetailsUtils.getPersonalDetailsLength().toString(), + transactionViolationsLength: getAllTransactionViolationsLength().toString(), + policiesLength: getAllPoliciesLength().toString(), + transactionsLength: getAllTransactions().toString(), policyType: policy?.type ?? 'N/A', policyRole: policy?.role ?? 'N/A', }; + + if (attributes && attributes.length > 0) { + const selectedAttributes = {} as Pick; + attributes.forEach((attribute) => { + selectedAttributes[attribute] = allAttributes[attribute]; + }); + return selectedAttributes; + } + + return allAttributes; } export default { diff --git a/src/libs/Fullstory/index.native.ts b/src/libs/Fullstory/index.native.ts index 8d97b8d4307e..30a5a77ae9f3 100644 --- a/src/libs/Fullstory/index.native.ts +++ b/src/libs/Fullstory/index.native.ts @@ -40,7 +40,8 @@ const FS = { // after the init function since this function is also called on updates for // UserMetadata onyx key. Environment.getEnvironment().then((envName: string) => { - if (envName !== CONST.ENVIRONMENT.PRODUCTION) { + const isTestEmail = value.email !== undefined && value.email.startsWith('fullstory') && value.email.endsWith(CONST.EMAIL.QA_DOMAIN); + if (CONST.ENVIRONMENT.PRODUCTION !== envName && !isTestEmail) { return; } FullStory.restart(); diff --git a/src/libs/Fullstory/index.ts b/src/libs/Fullstory/index.ts index df65af358a55..0aa0b2094591 100644 --- a/src/libs/Fullstory/index.ts +++ b/src/libs/Fullstory/index.ts @@ -57,7 +57,8 @@ const FS = { } try { Environment.getEnvironment().then((envName: string) => { - if (CONST.ENVIRONMENT.PRODUCTION !== envName) { + const isTestEmail = value.email !== undefined && value.email.startsWith('fullstory') && value.email.endsWith(CONST.EMAIL.QA_DOMAIN); + if (CONST.ENVIRONMENT.PRODUCTION !== envName && !isTestEmail) { return; } FS.onReady().then(() => { diff --git a/src/libs/HttpUtils.ts b/src/libs/HttpUtils.ts index de6aea96cb47..66ce71451c17 100644 --- a/src/libs/HttpUtils.ts +++ b/src/libs/HttpUtils.ts @@ -58,6 +58,11 @@ function processHTTPRequest(url: string, method: RequestType = 'get', body: Form signal: abortSignal, method, body, + // On Web fetch already defaults to 'omit' for credentials, but it seems that this is not the case for the ReactNative implementation + // so to avoid sending cookies with the request we set it to 'omit' explicitly + // this avoids us sending specially the expensifyWeb cookie, which makes a CSRF token required + // more on that here: https://stackoverflowteams.com/c/expensify/questions/93 + credentials: 'omit', }) .then((response) => { // We are calculating the skew to minimize the delay when posting the messages diff --git a/src/libs/Navigation/AppNavigator/Navigators/FullScreenNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/FullScreenNavigator.tsx index 2f513fe804bb..4aac30587725 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/FullScreenNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/FullScreenNavigator.tsx @@ -29,6 +29,7 @@ const CENTRAL_PANE_WORKSPACE_SCREENS = { [SCREENS.WORKSPACE.REPORT_FIELDS]: () => require('../../../../pages/workspace/reportFields/WorkspaceReportFieldsPage').default, [SCREENS.WORKSPACE.EXPENSIFY_CARD]: () => require('../../../../pages/workspace/expensifyCard/WorkspaceExpensifyCardPage').default, [SCREENS.WORKSPACE.COMPANY_CARDS]: () => require('../../../../pages/workspace/companyCards/WorkspaceCompanyCardsPage').default, + [SCREENS.WORKSPACE.PER_DIEM]: () => require('../../../../pages/workspace/perDiem/WorkspacePerDiemPage').default, [SCREENS.WORKSPACE.DISTANCE_RATES]: () => require('../../../../pages/workspace/distanceRates/PolicyDistanceRatesPage').default, [SCREENS.WORKSPACE.RULES]: () => require('../../../../pages/workspace/rules/PolicyRulesPage').default, } satisfies Screens; diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx index 93b3954d2f2b..34bdf866dbb8 100644 --- a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx +++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx @@ -7,6 +7,7 @@ import {PressableWithFeedback} from '@components/Pressable'; import type {SearchQueryString} from '@components/Search/types'; import Text from '@components/Text'; import useActiveWorkspace from '@hooks/useActiveWorkspace'; +import useCurrentReportID from '@hooks/useCurrentReportID'; import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -65,15 +66,23 @@ function BottomTabBar({selectedTab}: BottomTabBarProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const {activeWorkspaceID} = useActiveWorkspace(); + const {currentReportID} = useCurrentReportID() ?? {currentReportID: null}; const [user] = useOnyx(ONYXKEYS.USER); + const [betas] = useOnyx(ONYXKEYS.BETAS); + const [priorityMode] = useOnyx(ONYXKEYS.NVP_PRIORITY_MODE); const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); + const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [reportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); - const [chatTabBrickRoad, setChatTabBrickRoad] = useState(getChatTabBrickRoad(activeWorkspaceID)); + const [chatTabBrickRoad, setChatTabBrickRoad] = useState( + getChatTabBrickRoad(activeWorkspaceID, currentReportID, reports, betas, policies, priorityMode, transactionViolations), + ); useEffect(() => { - setChatTabBrickRoad(getChatTabBrickRoad(activeWorkspaceID)); - }, [activeWorkspaceID, transactionViolations, reports, reportActions]); + setChatTabBrickRoad(getChatTabBrickRoad(activeWorkspaceID, currentReportID, reports, betas, policies, priorityMode, transactionViolations)); + // We need to get a new brick road state when report actions are updated, otherwise we'll be showing an outdated brick road. + // That's why reportActions is added as a dependency here + }, [activeWorkspaceID, transactionViolations, reports, reportActions, betas, policies, priorityMode, currentReportID]); const navigateToChats = useCallback(() => { if (selectedTab === SCREENS.HOME) { @@ -118,6 +127,12 @@ function BottomTabBar({selectedTab}: BottomTabBarProps) { selectedTab={selectedTab} chatTabBrickRoad={chatTabBrickRoad} activeWorkspaceID={activeWorkspaceID} + reports={reports} + currentReportID={currentReportID} + betas={betas} + policies={policies} + transactionViolations={transactionViolations} + priorityMode={priorityMode} /> )} diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/DebugTabView.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/DebugTabView.tsx index 5336954486e6..354529941e0c 100644 --- a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/DebugTabView.tsx +++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/DebugTabView.tsx @@ -1,6 +1,6 @@ import React, {useCallback, useMemo} from 'react'; import {View} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; import Icon from '@components/Icon'; @@ -21,12 +21,18 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; -import type {ReimbursementAccount} from '@src/types/onyx'; +import type {Beta, Policy, PriorityMode, ReimbursementAccount, Report, TransactionViolations} from '@src/types/onyx'; type DebugTabViewProps = { selectedTab?: string; chatTabBrickRoad: BrickRoad; activeWorkspaceID?: string; + currentReportID: string | null; + reports: OnyxCollection; + betas: OnyxEntry; + policies: OnyxCollection; + transactionViolations: OnyxCollection; + priorityMode: OnyxEntry; }; function getSettingsMessage(status: IndicatorStatus | undefined): TranslationPaths | undefined { @@ -91,7 +97,7 @@ function getSettingsRoute(status: IndicatorStatus | undefined, reimbursementAcco } } -function DebugTabView({selectedTab = '', chatTabBrickRoad, activeWorkspaceID}: DebugTabViewProps) { +function DebugTabView({selectedTab = '', chatTabBrickRoad, activeWorkspaceID, currentReportID, reports, betas, policies, transactionViolations, priorityMode}: DebugTabViewProps) { const StyleUtils = useStyleUtils(); const theme = useTheme(); const styles = useThemeStyles(); @@ -131,10 +137,10 @@ function DebugTabView({selectedTab = '', chatTabBrickRoad, activeWorkspaceID}: D const navigateTo = useCallback(() => { if (selectedTab === SCREENS.HOME && !!chatTabBrickRoad) { - const report = getChatTabBrickRoadReport(activeWorkspaceID); + const report = getChatTabBrickRoadReport(activeWorkspaceID, currentReportID, reports, betas, policies, priorityMode, transactionViolations); if (report) { - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(report.reportID)); + Navigation.navigate(ROUTES.DEBUG_REPORT.getRoute(report.reportID)); } } if (selectedTab === SCREENS.SETTINGS.ROOT) { @@ -144,7 +150,7 @@ function DebugTabView({selectedTab = '', chatTabBrickRoad, activeWorkspaceID}: D Navigation.navigate(route); } } - }, [selectedTab, chatTabBrickRoad, activeWorkspaceID, status, reimbursementAccount, policyIDWithErrors]); + }, [selectedTab, chatTabBrickRoad, activeWorkspaceID, currentReportID, reports, betas, policies, priorityMode, transactionViolations, status, reimbursementAccount, policyIDWithErrors]); if (!([SCREENS.HOME, SCREENS.SETTINGS.ROOT] as string[]).includes(selectedTab) || !indicator) { return null; diff --git a/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/CustomFullScreenRouter.tsx b/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/CustomFullScreenRouter.tsx index 18cb758c5703..a5746f6f8e81 100644 --- a/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/CustomFullScreenRouter.tsx +++ b/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/CustomFullScreenRouter.tsx @@ -1,6 +1,9 @@ import type {ParamListBase, PartialState, RouterConfigOptions, StackNavigationState} from '@react-navigation/native'; import {StackRouter} from '@react-navigation/native'; +import Onyx from 'react-native-onyx'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; +import * as PolicyUtils from '@libs/PolicyUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; import type {FullScreenNavigatorRouterOptions} from './types'; @@ -8,12 +11,29 @@ type StackState = StackNavigationState | PartialState state.routes.some((route) => route.name === screenName); +let isLoadingReportData = true; +Onyx.connect({ + key: ONYXKEYS.IS_LOADING_REPORT_DATA, + initWithStoredValues: false, + callback: (value) => (isLoadingReportData = value ?? false), +}); + function adaptStateIfNecessary(state: StackState) { const isNarrowLayout = getIsNarrowLayout(); const workspaceCentralPane = state.routes.at(-1); + const policyID = + workspaceCentralPane?.params && 'policyID' in workspaceCentralPane.params && typeof workspaceCentralPane.params.policyID === 'string' + ? workspaceCentralPane.params.policyID + : undefined; + const policy = PolicyUtils.getPolicy(policyID ?? ''); + const isPolicyAccessible = PolicyUtils.isPolicyAccessible(policy); // There should always be WORKSPACE.INITIAL screen in the state to make sure go back works properly if we deeplinkg to a subpage of settings. + // The only exception is when the workspace is invalid or inaccessible. if (!isAtLeastOneInState(state, SCREENS.WORKSPACE.INITIAL)) { + if (isNarrowLayout && !isLoadingReportData && !isPolicyAccessible) { + return; + } // @ts-expect-error Updating read only property // noinspection JSConstantReassignment state.stale = true; // eslint-disable-line diff --git a/src/libs/Navigation/AppNavigator/createCustomPlatformStackBottomTabNavigator/BottomTabBar.tsx b/src/libs/Navigation/AppNavigator/createCustomPlatformStackBottomTabNavigator/BottomTabBar.tsx index 8ac3845b52c2..0c5e9bf20741 100644 --- a/src/libs/Navigation/AppNavigator/createCustomPlatformStackBottomTabNavigator/BottomTabBar.tsx +++ b/src/libs/Navigation/AppNavigator/createCustomPlatformStackBottomTabNavigator/BottomTabBar.tsx @@ -8,11 +8,13 @@ import {PressableWithFeedback} from '@components/Pressable'; import type {SearchQueryString} from '@components/Search/types'; import Tooltip from '@components/Tooltip'; import useActiveWorkspace from '@hooks/useActiveWorkspace'; +import useCurrentReportID from '@hooks/useCurrentReportID'; import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as Session from '@libs/actions/Session'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import DebugTabView from '@libs/Navigation/AppNavigator/createCustomBottomTabNavigator/DebugTabView'; import Navigation from '@libs/Navigation/Navigation'; import type {AuthScreensParamList} from '@libs/Navigation/types'; import {isCentralPaneName} from '@libs/NavigationUtils'; @@ -72,12 +74,23 @@ function BottomTabBar({selectedTab}: BottomTabBarProps) { const navigation = useNavigation(); const {activeWorkspaceID} = useActiveWorkspace(); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); - const transactionViolations = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); - const [chatTabBrickRoad, setChatTabBrickRoad] = useState(getChatTabBrickRoad(activeWorkspaceID)); + const {currentReportID} = useCurrentReportID() ?? {currentReportID: null}; + const [user] = useOnyx(ONYXKEYS.USER); + const [betas] = useOnyx(ONYXKEYS.BETAS); + const [priorityMode] = useOnyx(ONYXKEYS.NVP_PRIORITY_MODE); + const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); + const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const [reportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); + const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); + const [chatTabBrickRoad, setChatTabBrickRoad] = useState( + getChatTabBrickRoad(activeWorkspaceID, currentReportID, reports, betas, policies, priorityMode, transactionViolations), + ); useEffect(() => { - setChatTabBrickRoad(getChatTabBrickRoad(activeWorkspaceID)); - }, [activeWorkspaceID, transactionViolations]); + setChatTabBrickRoad(getChatTabBrickRoad(activeWorkspaceID, currentReportID, reports, betas, policies, priorityMode, transactionViolations)); + // We need to get a new brick road state when report actions are updated, otherwise we'll be showing an outdated brick road. + // That's why reportActions is added as a dependency here + }, [activeWorkspaceID, transactionViolations, reports, reportActions, betas, policies, priorityMode, currentReportID]); useEffect(() => { const navigationState = navigation.getState(); @@ -138,51 +151,66 @@ function BottomTabBar({selectedTab}: BottomTabBarProps) { }, [activeWorkspaceID, selectedTab]); return ( - - - - - - {!!chatTabBrickRoad && ( - - )} - - - - - - - - - - - - - + <> + {!!user?.isDebugModeEnabled && ( + + )} + + + + + + {!!chatTabBrickRoad && ( + + )} + + + + + + + + + + + + + + - + ); } diff --git a/src/libs/Navigation/Navigation.ts b/src/libs/Navigation/Navigation.ts index d5e9c5229a89..d54668bf3f69 100644 --- a/src/libs/Navigation/Navigation.ts +++ b/src/libs/Navigation/Navigation.ts @@ -12,6 +12,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {HybridAppRoute, Route} from '@src/ROUTES'; import ROUTES, {HYBRID_APP_ROUTES} from '@src/ROUTES'; import {PROTECTED_SCREENS} from '@src/SCREENS'; +import type {Screen} from '@src/SCREENS'; import type {Report} from '@src/types/onyx'; import originalCloseRHPFlow from './closeRHPFlow'; import originalDismissModal from './dismissModal'; @@ -418,6 +419,20 @@ function getTopMostCentralPaneRouteFromRootState() { return getTopmostCentralPaneRoute(navigationRef.getRootState() as State); } +function removeScreenFromNavigationState(screen: Screen) { + isNavigationReady().then(() => { + navigationRef.dispatch((state) => { + const routes = state.routes?.filter((item) => item.name !== screen); + + return CommonActions.reset({ + ...state, + routes, + index: routes.length < state.routes.length ? state.index - 1 : state.index, + }); + }); + }); +} + export default { setShouldPopAllStateOnUP, navigate, @@ -442,6 +457,7 @@ export default { closeRHPFlow, setNavigationActionToMicrotaskQueue, getTopMostCentralPaneRouteFromRootState, + removeScreenFromNavigationState, }; export {navigationRef}; diff --git a/src/libs/Navigation/NavigationRoot.tsx b/src/libs/Navigation/NavigationRoot.tsx index bb005fc6b763..c23c3783b3bf 100644 --- a/src/libs/Navigation/NavigationRoot.tsx +++ b/src/libs/Navigation/NavigationRoot.tsx @@ -108,7 +108,8 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady, sh } // If there is no lastVisitedPath, we can do early return. We won't modify the default behavior. - if (!lastVisitedPath) { + // The same applies to HybridApp, as we always define the route to which we want to transition. + if (!lastVisitedPath || NativeModules.HybridAppModule) { return undefined; } diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 330d5f113503..7a5b31489764 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1418,6 +1418,9 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.COMPANY_CARDS]: { path: ROUTES.WORKSPACE_COMPANY_CARDS.route, }, + [SCREENS.WORKSPACE.PER_DIEM]: { + path: ROUTES.WORKSPACE_PER_DIEM.route, + }, [SCREENS.WORKSPACE.WORKFLOWS]: { path: ROUTES.WORKSPACE_WORKFLOWS.route, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 3eae46ac2855..ba859efff944 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1394,6 +1394,9 @@ type FullScreenNavigatorParamList = { [SCREENS.WORKSPACE.COMPANY_CARDS_ADD_NEW]: { policyID: string; }; + [SCREENS.WORKSPACE.PER_DIEM]: { + policyID: string; + }; [SCREENS.WORKSPACE.WORKFLOWS]: { policyID: string; }; diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 497a2d33cf56..f6decd6fb2f4 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -1301,7 +1301,10 @@ function getTagListSections( * Verifies that there is at least one enabled tag */ function hasEnabledTags(policyTagList: Array) { - const policyTagValueList = policyTagList.map(({tags}) => Object.values(tags)).flat(); + const policyTagValueList = policyTagList + .filter((tag) => tag && tag.tags) + .map(({tags}) => Object.values(tags)) + .flat(); return hasEnabledOptions(policyTagValueList); } @@ -1893,7 +1896,7 @@ function getOptions( allPersonalDetailsOptions = lodashOrderBy(allPersonalDetailsOptions, [(personalDetail) => personalDetail.text?.toLowerCase()], 'asc'); } - const optionsToExclude: Option[] = []; + const optionsToExclude: Option[] = [{login: CONST.EMAIL.NOTIFICATIONS}]; // If we're including selected options from the search results, we only want to exclude them if the search input is empty // This is because on certain pages, we show the selected options at the top when the search input is empty diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts index 009724e73e93..b0591d1ad42b 100644 --- a/src/libs/Permissions.ts +++ b/src/libs/Permissions.ts @@ -1,6 +1,5 @@ import type {OnyxEntry} from 'react-native-onyx'; import CONST from '@src/CONST'; -import type {IOUType} from '@src/CONST'; import type Beta from '@src/types/onyx/Beta'; function canUseAllBetas(betas: OnyxEntry): boolean { @@ -15,23 +14,10 @@ function canUseDupeDetection(betas: OnyxEntry): boolean { return !!betas?.includes(CONST.BETAS.DUPE_DETECTION) || canUseAllBetas(betas); } -function canUseP2PDistanceRequests(betas: OnyxEntry, iouType: IOUType | undefined): boolean { - // Allow using P2P distance request for TrackExpense outside of the beta, because that project doesn't want to be limited by the more cautious P2P distance beta - return !!betas?.includes(CONST.BETAS.P2P_DISTANCE_REQUESTS) || canUseAllBetas(betas) || iouType === CONST.IOU.TYPE.TRACK; -} - function canUseSpotnanaTravel(betas: OnyxEntry): boolean { return !!betas?.includes(CONST.BETAS.SPOTNANA_TRAVEL) || canUseAllBetas(betas); } -function canUseCompanyCardFeeds(betas: OnyxEntry): boolean { - return !!betas?.includes(CONST.BETAS.COMPANY_CARD_FEEDS) || canUseAllBetas(betas); -} - -function canUseDirectFeeds(betas: OnyxEntry): boolean { - return !!betas?.includes(CONST.BETAS.DIRECT_FEEDS) || canUseAllBetas(betas); -} - function canUseNetSuiteUSATax(betas: OnyxEntry): boolean { return !!betas?.includes(CONST.BETAS.NETSUITE_USA_TAX) || canUseAllBetas(betas); } @@ -60,10 +46,7 @@ export default { canUseDefaultRooms, canUseLinkPreviews, canUseDupeDetection, - canUseP2PDistanceRequests, canUseSpotnanaTravel, - canUseCompanyCardFeeds, - canUseDirectFeeds, canUseNetSuiteUSATax, canUseCombinedTrackSubmit, canUseCategoryAndTagApprovers, diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index c596357585bc..48879c6b0a6e 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -126,7 +126,7 @@ function getNumericValue(value: number | string, toLocaleDigit: (arg: string) => if (Number.isNaN(numValue)) { return NaN; } - return numValue.toFixed(CONST.CUSTOM_UNITS.RATE_DECIMALS); + return numValue; } /** @@ -136,6 +136,13 @@ function getDistanceRateCustomUnit(policy: OnyxEntry): CustomUnit | unde return Object.values(policy?.customUnits ?? {}).find((unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); } +/** + * Retrieves the per diem custom unit object for the given policy + */ +function getPerDiemCustomUnit(policy: OnyxEntry): CustomUnit | undefined { + return Object.values(policy?.customUnits ?? {}).find((unit) => unit.name === CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL); +} + /** * Retrieves custom unit rate object from the given customUnitRateID */ @@ -151,11 +158,10 @@ function getRateDisplayValue(value: number, toLocaleDigit: (arg: string) => stri } if (withDecimals) { - const decimalPart = numValue.toString().split('.').at(1); - if (decimalPart) { - const fixedDecimalPoints = decimalPart.length > 2 && !decimalPart.endsWith('0') ? 3 : 2; - return Number(numValue).toFixed(fixedDecimalPoints).toString().replace('.', toLocaleDigit('.')); - } + const decimalPart = numValue.toString().split('.').at(1) ?? ''; + // Set the fraction digits to be between 2 and 4 (OD Behavior) + const fractionDigits = Math.min(Math.max(decimalPart.length, CONST.MIN_TAX_RATE_DECIMAL_PLACES), CONST.MAX_TAX_RATE_DECIMAL_PLACES); + return Number(numValue).toFixed(fractionDigits).toString().replace('.', toLocaleDigit('.')); } return numValue.toString().replace('.', toLocaleDigit('.')).substring(0, value.toString().length); @@ -188,10 +194,11 @@ function getPolicyRole(policy: OnyxInputOrEntry, currentUserLogin: strin */ function shouldShowPolicy(policy: OnyxEntry, isOffline: boolean, currentUserLogin: string | undefined): boolean { return ( - !!policy && - (policy?.type !== CONST.POLICY.TYPE.PERSONAL || !!policy?.isJoinRequestPending) && - (isOffline || policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || Object.keys(policy.errors ?? {}).length > 0) && - !!getPolicyRole(policy, currentUserLogin) + !!policy?.isJoinRequestPending || + (!!policy && + policy?.type !== CONST.POLICY.TYPE.PERSONAL && + (isOffline || policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || Object.keys(policy.errors ?? {}).length > 0) && + !!getPolicyRole(policy, currentUserLogin)) ); } @@ -611,6 +618,11 @@ function canSendInvoice(policies: OnyxCollection | null, currentUserLogi return getActiveAdminWorkspaces(policies, currentUserLogin).some((policy) => canSendInvoiceFromWorkspace(policy.id)); } +function hasWorkspaceWithInvoices(currentUserLogin: string | undefined): boolean { + const activePolicies = getActivePolicies(allPolicies); + return activePolicies.some((policy) => shouldShowPolicy(policy, NetworkStore.isOffline(), currentUserLogin) && policy.areInvoicesEnabled); +} + function hasDependentTags(policy: OnyxEntry, policyTagList: OnyxEntry) { if (!policy?.hasMultipleTagLists) { return false; @@ -1064,6 +1076,10 @@ function getActivePolicy(): OnyxEntry { return getPolicy(activePolicyId); } +function isPolicyAccessible(policy: OnyxEntry): boolean { + return !isEmptyObject(policy) && (Object.keys(policy).length !== 1 || isEmptyObject(policy.errors)) && !!policy?.id; +} + export { canEditTaxRate, extractPolicyIDFromPath, @@ -1122,6 +1138,7 @@ export { getOwnedPaidPolicies, canSendInvoiceFromWorkspace, canSendInvoice, + hasWorkspaceWithInvoices, hasDependentTags, getXeroTenants, findCurrentXeroOrganization, @@ -1149,6 +1166,7 @@ export { getSageIntacctCreditCards, getSageIntacctBankAccounts, getDistanceRateCustomUnit, + getPerDiemCustomUnit, getDistanceRateCustomUnitRate, sortWorkspacesBySelected, removePendingFieldsFromCustomUnit, @@ -1181,6 +1199,7 @@ export { getNetSuiteImportCustomFieldLabel, getAllPoliciesLength, getActivePolicy, + isPolicyAccessible, }; export type {MemberEmailsToAccountIDs}; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index e1f036e2c698..4d8556da46ab 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -53,7 +53,7 @@ import type {OriginalMessageChangeLog, PaymentMethodType} from '@src/types/onyx/ import type {Status} from '@src/types/onyx/PersonalDetails'; import type {ConnectionName} from '@src/types/onyx/Policy'; import type {NotificationPreference, Participants, PendingChatMember, Participant as ReportParticipant} from '@src/types/onyx/Report'; -import type {Message, ReportActions} from '@src/types/onyx/ReportAction'; +import type {Message, OldDotReportAction, ReportActions} from '@src/types/onyx/ReportAction'; import type {Comment, TransactionChanges, WaypointCollection} from '@src/types/onyx/Transaction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type IconAsset from '@src/types/utils/IconAsset'; @@ -258,6 +258,11 @@ type OptimisticCancelPaymentReportAction = Pick< 'actionName' | 'actorAccountID' | 'message' | 'originalMessage' | 'person' | 'reportActionID' | 'shouldShow' | 'created' | 'pendingAction' >; +type OptimisticChangeFieldAction = Pick< + OldDotReportAction & ReportAction, + 'actionName' | 'actorAccountID' | 'originalMessage' | 'person' | 'reportActionID' | 'created' | 'pendingAction' | 'message' +>; + type OptimisticEditedTaskReportAction = Pick< ReportAction, 'reportActionID' | 'actionName' | 'pendingAction' | 'actorAccountID' | 'automatic' | 'avatar' | 'created' | 'shouldShow' | 'message' | 'person' | 'delegateAccountID' @@ -515,6 +520,8 @@ type OptionData = { isConciergeChat?: boolean; isBold?: boolean; lastIOUCreationDate?: string; + icons?: Icon[]; + iouReportAmount?: number; } & Report; type OnyxDataTaskAssigneeChat = { @@ -2571,6 +2578,56 @@ function getReimbursementDeQueuedActionMessage( return Localize.translateLocal('iou.canceledRequest', {submitterDisplayName, amount: formattedAmount}); } +/** + * Builds an optimistic REIMBURSEMENT_DEQUEUED report action with a randomly generated reportActionID. + * + */ +function buildOptimisticChangeFieldAction(reportField: PolicyReportField, previousReportField: PolicyReportField): OptimisticChangeFieldAction { + return { + actionName: CONST.REPORT.ACTIONS.TYPE.CHANGE_FIELD, + actorAccountID: currentUserAccountID, + message: [ + { + type: 'TEXT', + style: 'strong', + text: 'You', + }, + { + type: 'TEXT', + style: 'normal', + text: ` modified field '${reportField.name}'.`, + }, + { + type: 'TEXT', + style: 'normal', + text: ` New value is '${reportField.value}'`, + }, + { + type: 'TEXT', + style: 'normal', + text: ` (previously '${previousReportField.value}').`, + }, + ], + originalMessage: { + fieldName: reportField.name, + newType: reportField.type, + newValue: reportField.value, + oldType: previousReportField.type, + oldValue: previousReportField.value, + }, + person: [ + { + style: 'strong', + text: getCurrentUserDisplayNameOrEmail(), + type: 'TEXT', + }, + ], + reportActionID: NumberUtils.rand64(), + created: DateUtils.getDBTime(), + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }; +} + /** * Builds an optimistic REIMBURSEMENT_DEQUEUED report action with a randomly generated reportActionID. * @@ -7271,8 +7328,7 @@ function canCreateRequest(report: OnyxEntry, policy: OnyxEntry, return requestOptions.includes(iouType); } -function getWorkspaceChats(policyID: string, accountIDs: number[]): Array> { - const allReports = ReportConnection.getAllReports(); +function getWorkspaceChats(policyID: string, accountIDs: number[], allReports: OnyxCollection = ReportConnection.getAllReports()): Array> { return Object.values(allReports ?? {}).filter((report) => isPolicyExpenseChat(report) && (report?.policyID ?? '-1') === policyID && accountIDs.includes(report?.ownerAccountID ?? -1)); } @@ -8190,8 +8246,8 @@ function shouldShowMerchantColumn(transactions: Transaction[]) { * only use the Concierge chat. */ function isChatUsedForOnboarding(optionOrReport: OnyxEntry | OptionData): boolean { - // onboarding can be an array for old accounts and accounts created from olddot - if (onboarding && !Array.isArray(onboarding) && onboarding.chatReportID) { + // onboarding can be an array or an empty object for old accounts and accounts created from olddot + if (onboarding && !Array.isArray(onboarding) && !isEmptyObject(onboarding) && onboarding.chatReportID) { return onboarding.chatReportID === optionOrReport?.reportID; } @@ -8352,6 +8408,11 @@ function isExpenseReportWithoutParentAccess(report: OnyxEntry) { return isExpenseReport(report) && report?.hasParentAccess === false; } +function hasInvoiceReports() { + const allReports = Object.values(ReportConnection.getAllReports() ?? {}); + return allReports.some((report) => isInvoiceReport(report)); +} + export { addDomainToShortMention, completeShortMention, @@ -8671,11 +8732,13 @@ export { hasMissingInvoiceBankAccount, reasonForReportToBeInOptionList, getReasonAndReportActionThatRequiresAttention, + buildOptimisticChangeFieldAction, isPolicyRelatedReport, hasReportErrorsOtherThanFailedReceipt, shouldShowViolations, getAllReportErrors, getAllReportActionsErrorsAndReportActionThatRequiresAttention, + hasInvoiceReports, }; export type { diff --git a/src/libs/SearchAutocompleteUtils.ts b/src/libs/SearchAutocompleteUtils.ts index f33e2a82d445..fd427b7480c6 100644 --- a/src/libs/SearchAutocompleteUtils.ts +++ b/src/libs/SearchAutocompleteUtils.ts @@ -5,6 +5,10 @@ import type {Policy, PolicyCategories, PolicyTagLists, RecentlyUsedCategories, R import {getTagNamesFromTagsLists} from './PolicyUtils'; import * as autocompleteParser from './SearchParser/autocompleteParser'; +/** + * Parses given query using the autocomplete parser. + * This is a smaller and simpler version of search parser used for autocomplete displaying logic. + */ function parseForAutocomplete(text: string) { try { const parsedAutocomplete = autocompleteParser.parse(text) as SearchAutocompleteResult; @@ -14,6 +18,9 @@ function parseForAutocomplete(text: string) { } } +/** + * Returns data for computing the `Tag` filter autocomplete list. + */ function getAutocompleteTags(allPoliciesTagsLists: OnyxCollection, policyID?: string) { const singlePolicyTagsList: PolicyTagLists | undefined = allPoliciesTagsLists?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`]; if (!singlePolicyTagsList) { @@ -28,6 +35,9 @@ function getAutocompleteTags(allPoliciesTagsLists: OnyxCollection, policyID?: string) { const singlePolicyRecentTags: RecentlyUsedTags | undefined = allRecentTags?.[`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${policyID}`]; if (!singlePolicyRecentTags) { @@ -41,6 +51,9 @@ function getAutocompleteRecentTags(allRecentTags: OnyxCollection, policyID?: string) { const singlePolicyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]; if (!singlePolicyCategories) { @@ -51,6 +64,9 @@ function getAutocompleteCategories(allPolicyCategories: OnyxCollection category.name); } +/** + * Returns data for computing the recent categories autocomplete list. + */ function getAutocompleteRecentCategories(allRecentCategories: OnyxCollection, policyID?: string) { const singlePolicyRecentCategories = allRecentCategories?.[`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${policyID}`]; if (!singlePolicyRecentCategories) { @@ -61,18 +77,43 @@ function getAutocompleteRecentCategories(allRecentCategories: OnyxCollection category); } -function getAutocompleteTaxList(allTaxRates: Record, policy?: OnyxEntry) { +/** + * Returns data for computing the `Tax` filter autocomplete list + * + * Please note: taxes are stored in a quite convoluted and non-obvious way, and there can be multiple taxes with the same id + * because tax ids are generated based on a tax name, so they look like this: `id_My_Tax` and are not numeric. + * That is why this function may seem a bit complex. + */ +function getAutocompleteTaxList(taxRates: Record, policy?: OnyxEntry) { if (policy) { - return Object.keys(policy?.taxRates?.taxes ?? {}).map((taxRateName) => taxRateName); + const policyTaxes = policy?.taxRates?.taxes ?? {}; + + return Object.keys(policyTaxes).map((taxID) => ({ + taxRateName: policyTaxes[taxID].name, + taxRateIds: [taxID], + })); } - return Object.keys(allTaxRates).map((taxRateName) => taxRateName); + + return Object.keys(taxRates).map((taxName) => ({ + taxRateName: taxName, + taxRateIds: taxRates[taxName].map((id) => taxRates[id] ?? id).flat(), + })); } -function trimSearchQueryForAutocomplete(searchQuery: string) { - const lastColonIndex = searchQuery.lastIndexOf(':'); - const lastCommaIndex = searchQuery.lastIndexOf(','); - const trimmedUserSearchQuery = lastColonIndex > lastCommaIndex ? searchQuery.slice(0, lastColonIndex + 1) : searchQuery.slice(0, lastCommaIndex + 1); - return trimmedUserSearchQuery; +/** + * Given a query string, this function parses it with the autocomplete parser + * and returns only the part of the string before autocomplete. + * + * Ex: "test from:john@doe" -> "test from:" + */ +function getQueryWithoutAutocompletedPart(searchQuery: string) { + const parsedQuery = parseForAutocomplete(searchQuery); + if (!parsedQuery?.autocomplete) { + return searchQuery; + } + + const sliceEnd = parsedQuery.autocomplete.start; + return searchQuery.slice(0, sliceEnd); } export { @@ -82,5 +123,5 @@ export { getAutocompleteCategories, getAutocompleteRecentCategories, getAutocompleteTaxList, - trimSearchQueryForAutocomplete, + getQueryWithoutAutocompletedPart, }; diff --git a/src/libs/SearchParser/autocompleteParser.js b/src/libs/SearchParser/autocompleteParser.js index be57ff8a67a5..bd114b56e099 100644 --- a/src/libs/SearchParser/autocompleteParser.js +++ b/src/libs/SearchParser/autocompleteParser.js @@ -186,12 +186,13 @@ function peg$parse(input, options) { var peg$c8 = "expenseType"; var peg$c9 = "type"; var peg$c10 = "status"; - var peg$c11 = "!="; - var peg$c12 = ">="; - var peg$c13 = ">"; - var peg$c14 = "<="; - var peg$c15 = "<"; - var peg$c16 = "\""; + var peg$c11 = "cardID"; + var peg$c12 = "!="; + var peg$c13 = ">="; + var peg$c14 = ">"; + var peg$c15 = "<="; + var peg$c16 = "<"; + var peg$c17 = "\""; var peg$r0 = /^[:=]/; var peg$r1 = /^[^ ,"\t\n\r]/; @@ -211,21 +212,22 @@ function peg$parse(input, options) { var peg$e9 = peg$literalExpectation("expenseType", false); var peg$e10 = peg$literalExpectation("type", false); var peg$e11 = peg$literalExpectation("status", false); - var peg$e12 = peg$otherExpectation("operator"); - var peg$e13 = peg$classExpectation([":", "="], false, false); - var peg$e14 = peg$literalExpectation("!=", false); - var peg$e15 = peg$literalExpectation(">=", false); - var peg$e16 = peg$literalExpectation(">", false); - var peg$e17 = peg$literalExpectation("<=", false); - var peg$e18 = peg$literalExpectation("<", false); - var peg$e19 = peg$otherExpectation("quote"); - var peg$e20 = peg$classExpectation([" ", ",", "\"", "\t", "\n", "\r"], true, false); - var peg$e21 = peg$literalExpectation("\"", false); - var peg$e22 = peg$classExpectation(["\"", "\r", "\n"], true, false); - var peg$e23 = peg$classExpectation([" ", ",", "\t", "\n", "\r"], true, false); - var peg$e24 = peg$otherExpectation("word"); - var peg$e25 = peg$otherExpectation("whitespace"); - var peg$e26 = peg$classExpectation([" ", "\t", "\r", "\n"], false, false); + var peg$e12 = peg$literalExpectation("cardID", false); + var peg$e13 = peg$otherExpectation("operator"); + var peg$e14 = peg$classExpectation([":", "="], false, false); + var peg$e15 = peg$literalExpectation("!=", false); + var peg$e16 = peg$literalExpectation(">=", false); + var peg$e17 = peg$literalExpectation(">", false); + var peg$e18 = peg$literalExpectation("<=", false); + var peg$e19 = peg$literalExpectation("<", false); + var peg$e20 = peg$otherExpectation("quote"); + var peg$e21 = peg$classExpectation([" ", ",", "\"", "\t", "\n", "\r"], true, false); + var peg$e22 = peg$literalExpectation("\"", false); + var peg$e23 = peg$classExpectation(["\"", "\r", "\n"], true, false); + var peg$e24 = peg$classExpectation([" ", ",", "\t", "\n", "\r"], true, false); + var peg$e25 = peg$otherExpectation("word"); + var peg$e26 = peg$otherExpectation("whitespace"); + var peg$e27 = peg$classExpectation([" ", "\t", "\r", "\n"], false, false); var peg$f0 = function(ranges) { return { autocomplete, ranges }; }; var peg$f1 = function(filters) { return filters.filter(Boolean).flat(); }; @@ -644,6 +646,15 @@ function peg$parse(input, options) { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e11); } } + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 6) === peg$c11) { + s1 = peg$c11; + peg$currPos += 6; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e12); } + } + } } } } @@ -740,7 +751,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e13); } + if (peg$silentFails === 0) { peg$fail(peg$e14); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; @@ -749,12 +760,12 @@ function peg$parse(input, options) { s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c11) { - s1 = peg$c11; + if (input.substr(peg$currPos, 2) === peg$c12) { + s1 = peg$c12; peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e14); } + if (peg$silentFails === 0) { peg$fail(peg$e15); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; @@ -763,12 +774,12 @@ function peg$parse(input, options) { s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c12) { - s1 = peg$c12; + if (input.substr(peg$currPos, 2) === peg$c13) { + s1 = peg$c13; peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e15); } + if (peg$silentFails === 0) { peg$fail(peg$e16); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; @@ -778,11 +789,11 @@ function peg$parse(input, options) { if (s0 === peg$FAILED) { s0 = peg$currPos; if (input.charCodeAt(peg$currPos) === 62) { - s1 = peg$c13; + s1 = peg$c14; peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e16); } + if (peg$silentFails === 0) { peg$fail(peg$e17); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; @@ -791,12 +802,12 @@ function peg$parse(input, options) { s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c14) { - s1 = peg$c14; + if (input.substr(peg$currPos, 2) === peg$c15) { + s1 = peg$c15; peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e17); } + if (peg$silentFails === 0) { peg$fail(peg$e18); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; @@ -806,11 +817,11 @@ function peg$parse(input, options) { if (s0 === peg$FAILED) { s0 = peg$currPos; if (input.charCodeAt(peg$currPos) === 60) { - s1 = peg$c15; + s1 = peg$c16; peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e18); } + if (peg$silentFails === 0) { peg$fail(peg$e19); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; @@ -825,7 +836,7 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e12); } + if (peg$silentFails === 0) { peg$fail(peg$e13); } } return s0; @@ -842,7 +853,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e20); } + if (peg$silentFails === 0) { peg$fail(peg$e21); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -851,15 +862,15 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e20); } + if (peg$silentFails === 0) { peg$fail(peg$e21); } } } if (input.charCodeAt(peg$currPos) === 34) { - s2 = peg$c16; + s2 = peg$c17; peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e21); } + if (peg$silentFails === 0) { peg$fail(peg$e22); } } if (s2 !== peg$FAILED) { s3 = []; @@ -868,7 +879,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e22); } + if (peg$silentFails === 0) { peg$fail(peg$e23); } } while (s4 !== peg$FAILED) { s3.push(s4); @@ -877,15 +888,15 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e22); } + if (peg$silentFails === 0) { peg$fail(peg$e23); } } } if (input.charCodeAt(peg$currPos) === 34) { - s4 = peg$c16; + s4 = peg$c17; peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e21); } + if (peg$silentFails === 0) { peg$fail(peg$e22); } } if (s4 !== peg$FAILED) { s5 = []; @@ -894,7 +905,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e23); } + if (peg$silentFails === 0) { peg$fail(peg$e24); } } while (s6 !== peg$FAILED) { s5.push(s6); @@ -903,7 +914,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e23); } + if (peg$silentFails === 0) { peg$fail(peg$e24); } } } peg$savedPos = s0; @@ -919,7 +930,7 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e19); } + if (peg$silentFails === 0) { peg$fail(peg$e20); } } return s0; @@ -936,7 +947,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e23); } + if (peg$silentFails === 0) { peg$fail(peg$e24); } } if (s2 !== peg$FAILED) { while (s2 !== peg$FAILED) { @@ -946,7 +957,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e23); } + if (peg$silentFails === 0) { peg$fail(peg$e24); } } } } else { @@ -960,7 +971,7 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e24); } + if (peg$silentFails === 0) { peg$fail(peg$e25); } } return s0; @@ -988,7 +999,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e26); } + if (peg$silentFails === 0) { peg$fail(peg$e27); } } while (s1 !== peg$FAILED) { s0.push(s1); @@ -997,12 +1008,12 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e26); } + if (peg$silentFails === 0) { peg$fail(peg$e27); } } } peg$silentFails--; s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e25); } + if (peg$silentFails === 0) { peg$fail(peg$e26); } return s0; } diff --git a/src/libs/SearchParser/autocompleteParser.peggy b/src/libs/SearchParser/autocompleteParser.peggy index 89d89fd07cd4..e2a8bed9a9cc 100644 --- a/src/libs/SearchParser/autocompleteParser.peggy +++ b/src/libs/SearchParser/autocompleteParser.peggy @@ -61,6 +61,7 @@ autocompleteKey "key" / "expenseType" / "type" / "status" + / "cardID" ) identifier diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index c84e42704fb9..6c2e570d58f9 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -125,11 +125,11 @@ function getFilters(queryJSON: SearchQueryJSON) { return; } - if (typeof node?.left === 'object' && node.left) { + if (typeof node.left === 'object' && node.left) { traverse(node.left); } - if (typeof node?.right === 'object' && node.right && !Array.isArray(node.right)) { + if (typeof node.right === 'object' && node.right && !Array.isArray(node.right)) { traverse(node.right); } @@ -148,7 +148,7 @@ function getFilters(queryJSON: SearchQueryJSON) { node.right.forEach((element) => { filterArray.push({ operator: node.operator, - value: element as string | number, + value: element, }); }); } @@ -163,52 +163,66 @@ function getFilters(queryJSON: SearchQueryJSON) { } /** - * @private * Given a filter name and its value, this function returns the corresponding ID found in Onyx data. + * Returns a function that can be used as a computeNodeValue callback for traversing the filters tree */ -function findIDFromDisplayValue(filterName: ValueOf, filter: string | string[], cardList: OnyxTypes.CardList, taxRates: Record) { - if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM || filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.TO) { - if (typeof filter === 'string') { - const email = filter; - return PersonalDetailsUtils.getPersonalDetailByEmail(email)?.accountID.toString() ?? filter; +function getFindIDFromDisplayValue(cardList: OnyxTypes.CardList, taxRates: Record) { + return (filterName: ValueOf, filter: string | string[]) => { + if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM || filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.TO) { + if (typeof filter === 'string') { + const email = filter; + return PersonalDetailsUtils.getPersonalDetailByEmail(email)?.accountID.toString() ?? filter; + } + const emails = filter; + return emails.map((email) => PersonalDetailsUtils.getPersonalDetailByEmail(email)?.accountID.toString() ?? email); } - const emails = filter; - return emails.map((email) => PersonalDetailsUtils.getPersonalDetailByEmail(email)?.accountID.toString() ?? email); - } - if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE) { - const names = Array.isArray(filter) ? filter : ([filter] as string[]); - return names.map((name) => taxRates[name] ?? name).flat(); - } - if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID) { - if (typeof filter === 'string') { - const bank = filter; - const ids = - Object.values(cardList) - .filter((card) => card.bank === bank) - .map((card) => card.cardID.toString()) ?? filter; - return ids.length > 0 ? ids : bank; + if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE) { + const names = Array.isArray(filter) ? filter : ([filter] as string[]); + return names.map((name) => taxRates[name] ?? name).flat(); } - const banks = filter; - return banks - .map( - (bank) => + if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID) { + if (typeof filter === 'string') { + const bank = filter; + const ids = Object.values(cardList) .filter((card) => card.bank === bank) - .map((card) => card.cardID.toString()) ?? bank, - ) - .flat(); - } - if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT) { - if (typeof filter === 'string') { - const backendAmount = CurrencyUtils.convertToBackendAmount(Number(filter)); - return Number.isNaN(backendAmount) ? filter : backendAmount.toString(); + .map((card) => card.cardID.toString()) ?? filter; + return ids.length > 0 ? ids : bank; + } + const banks = filter; + return banks + .map( + (bank) => + Object.values(cardList) + .filter((card) => card.bank === bank) + .map((card) => card.cardID.toString()) ?? bank, + ) + .flat(); + } + if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT) { + return getUpdatedAmountValue(filterName, filter); } - return filter.map((amount) => { - const backendAmount = CurrencyUtils.convertToBackendAmount(Number(amount)); - return Number.isNaN(backendAmount) ? amount : backendAmount.toString(); - }); + + return filter; + }; +} + +/** + * Returns an updated amount value for query filters, correctly formatted to "backend" amount + */ +function getUpdatedAmountValue(filterName: ValueOf, filter: string | string[]) { + if (filterName !== CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT) { + return filter; } - return filter; + + if (typeof filter === 'string') { + const backendAmount = CurrencyUtils.convertToBackendAmount(Number(filter)); + return Number.isNaN(backendAmount) ? filter : backendAmount.toString(); + } + return filter.map((amount) => { + const backendAmount = CurrencyUtils.convertToBackendAmount(Number(amount)); + return Number.isNaN(backendAmount) ? amount : backendAmount.toString(); + }); } /** @@ -216,15 +230,10 @@ function findIDFromDisplayValue(filterName: ValueOf { filter.filters.sort((a, b) => localeCompare(a.value.toString(), b.value.toString())); @@ -235,7 +244,16 @@ function getQueryHash(query: SearchQueryJSON): number { .sort() .forEach((filterString) => (orderedQuery += ` ${filterString}`)); - return UserUtils.hashText(orderedQuery, 2 ** 32); + const recentSearchHash = UserUtils.hashText(orderedQuery, 2 ** 32); + + orderedQuery += ` ${CONST.SEARCH.SYNTAX_ROOT_KEYS.SORT_BY}:${query.sortBy}`; + orderedQuery += ` ${CONST.SEARCH.SYNTAX_ROOT_KEYS.SORT_ORDER}:${query.sortOrder}`; + if (query.policyID) { + orderedQuery += ` ${CONST.SEARCH.SYNTAX_ROOT_KEYS.POLICY_ID}:${query.policyID} `; + } + const primaryHash = UserUtils.hashText(orderedQuery, 2 ** 32); + + return {primaryHash, recentSearchHash}; } /** @@ -252,7 +270,9 @@ function buildSearchQueryJSON(query: SearchQueryString) { // Add the full input and hash to the results result.inputQuery = query; result.flatFilters = flatFilters; - result.hash = getQueryHash(result); + const {primaryHash, recentSearchHash} = getQueryHashes(result); + result.hash = primaryHash; + result.recentSearchHash = recentSearchHash; return result; } catch (e) { @@ -275,7 +295,11 @@ function buildSearchQueryString(queryJSON?: SearchQueryJSON) { const queryFieldValue = existingFieldValue ?? defaultQueryJSON?.[key]; if (queryFieldValue) { - queryParts.push(`${key}:${queryFieldValue}`); + if (Array.isArray(queryFieldValue)) { + queryParts.push(`${key}:${queryFieldValue.join(',')}`); + } else { + queryParts.push(`${key}:${queryFieldValue}`); + } } } @@ -468,8 +492,16 @@ function buildFilterFormValuesFromQuery( const [typeKey = '', typeValue] = Object.entries(CONST.SEARCH.DATA_TYPES).find(([, value]) => value === queryJSON.type) ?? []; filtersForm[FILTER_KEYS.TYPE] = typeValue ? queryJSON.type : CONST.SEARCH.DATA_TYPES.EXPENSE; - const [statusKey] = Object.entries(CONST.SEARCH.STATUS).find(([, value]) => Object.values(value).includes(queryJSON.status)) ?? []; - filtersForm[FILTER_KEYS.STATUS] = typeKey === statusKey ? queryJSON.status : CONST.SEARCH.STATUS.EXPENSE.ALL; + const [statusKey] = + Object.entries(CONST.SEARCH.STATUS).find(([, value]) => + Array.isArray(queryJSON.status) ? queryJSON.status.some((status) => Object.values(value).includes(status)) : Object.values(value).includes(queryJSON.status), + ) ?? []; + + if (typeKey === statusKey) { + filtersForm[FILTER_KEYS.STATUS] = Array.isArray(queryJSON.status) ? queryJSON.status.join(',') : queryJSON.status; + } else { + filtersForm[FILTER_KEYS.STATUS] = CONST.SEARCH.STATUS.EXPENSE.ALL; + } if (queryJSON.policyID) { filtersForm[FILTER_KEYS.POLICY_ID] = queryJSON.policyID; @@ -537,7 +569,7 @@ function buildUserReadableQueryString( const {type, status} = queryJSON; const filters = queryJSON.flatFilters ?? {}; - let title = `type:${type} status:${status}`; + let title = `type:${type} status:${Array.isArray(status) ? status.join(',') : status}`; for (const filterObject of filters) { const key = filterObject.key; @@ -555,7 +587,9 @@ function buildUserReadableQueryString( }) .flat(); - displayQueryFilters = taxRateNames.map((taxRate) => ({ + const uniqueTaxRateNames = [...new Set(taxRateNames)]; + + displayQueryFilters = uniqueTaxRateNames.map((taxRate) => ({ operator: queryFilter.at(0)?.operator ?? CONST.SEARCH.SYNTAX_OPERATORS.AND, value: taxRate, })); @@ -583,7 +617,9 @@ function buildCannedSearchQuery({ status?: SearchStatus; policyID?: string; } = {}): SearchQueryString { - const queryString = policyID ? `type:${type} status:${status} policyID:${policyID}` : `type:${type} status:${status}`; + const queryString = policyID + ? `type:${type} status:${Array.isArray(status) ? status.join(',') : status} policyID:${policyID}` + : `type:${type} status:${Array.isArray(status) ? status.join(',') : status}`; // Parse the query to fill all default query fields with values const normalizedQueryJSON = buildSearchQueryJSON(queryString); @@ -604,23 +640,23 @@ function isCannedSearchQuery(queryJSON: SearchQueryJSON) { /** * Given a search query, this function will standardize the query by replacing display values with their corresponding IDs. */ -function standardizeQueryJSON(queryJSON: SearchQueryJSON, cardList: OnyxTypes.CardList, taxRates: Record) { +function traverseAndUpdatedQuery(queryJSON: SearchQueryJSON, computeNodeValue: (left: ValueOf, right: string | string[]) => string | string[]) { const standardQuery = cloneDeep(queryJSON); const filters = standardQuery.filters; const traverse = (node: ASTNode) => { if (!node.operator) { return; } - if (typeof node.left === 'object' && node.left) { + if (typeof node.left === 'object') { traverse(node.left); } - if (typeof node.right === 'object' && node.right && !Array.isArray(node.right)) { + if (typeof node.right === 'object' && !Array.isArray(node.right)) { traverse(node.right); } - if (typeof node.left !== 'object') { + if (typeof node.left !== 'object' && (Array.isArray(node.right) || typeof node.right === 'string')) { // eslint-disable-next-line no-param-reassign - node.right = findIDFromDisplayValue(node.left, node.right as string | string[], cardList, taxRates); + node.right = computeNodeValue(node.left, node.right); } }; @@ -641,6 +677,8 @@ export { getPolicyIDFromSearchQuery, buildCannedSearchQuery, isCannedSearchQuery, - standardizeQueryJSON, + traverseAndUpdatedQuery, + getFindIDFromDisplayValue, + getUpdatedAmountValue, sanitizeSearchValue, }; diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index e7399a6d3982..d47cee3745a0 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -2,7 +2,7 @@ import {Str} from 'expensify-common'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; -import type {PolicySelector, ReportActionsSelector} from '@hooks/useReportIDs'; +import type {PolicySelector} from '@hooks/useReportIDs'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetails, PersonalDetailsList, ReportActions, TransactionViolation} from '@src/types/onyx'; @@ -92,7 +92,6 @@ function getOrderedReportIDs( betas: OnyxEntry, policies: OnyxCollection, priorityMode: OnyxEntry, - allReportActions: OnyxCollection, transactionViolations: OnyxCollection, currentPolicyID = '', policyMemberAccountIDs: number[] = [], diff --git a/src/libs/Sound/BaseSound.ts b/src/libs/Sound/BaseSound.ts index e7fc5fadd259..1b1853eb30a6 100644 --- a/src/libs/Sound/BaseSound.ts +++ b/src/libs/Sound/BaseSound.ts @@ -1,11 +1,15 @@ import Onyx from 'react-native-onyx'; +import getPlatform from '@libs/getPlatform'; import ONYXKEYS from '@src/ONYXKEYS'; let isMuted = false; Onyx.connect({ - key: ONYXKEYS.USER, - callback: (val) => (isMuted = !!val?.isMutedAllSounds), + key: ONYXKEYS.NVP_MUTED_PLATFORMS, + callback: (val) => { + const platform = getPlatform(true); + isMuted = !!val?.[platform]; + }, }); const SOUNDS = { diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 17d0e361e7d2..99a97ea08672 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -1,9 +1,12 @@ +import lodashDeepClone from 'lodash/cloneDeep'; import lodashHas from 'lodash/has'; import lodashIsEqual from 'lodash/isEqual'; import lodashSet from 'lodash/set'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; +import {getPolicyCategoriesData} from '@libs/actions/Policy/Category'; +import {getPolicyTagsData} from '@libs/actions/Policy/Tag'; import type {TransactionMergeParams} from '@libs/API/parameters'; import {getCurrencyDecimals} from '@libs/CurrencyUtils'; import DateUtils from '@libs/DateUtils'; @@ -247,7 +250,7 @@ function areRequiredFieldsEmpty(transaction: OnyxEntry): boolean { */ function getUpdatedTransaction(transaction: Transaction, transactionChanges: TransactionChanges, isFromExpenseReport: boolean, shouldUpdateReceiptState = true): Transaction { // Only changing the first level fields so no need for deep clone now - const updatedTransaction = {...transaction}; + const updatedTransaction = lodashDeepClone(transaction); let shouldStopSmartscan = false; // The comment property does not have its modifiedComment counterpart @@ -299,7 +302,7 @@ function getUpdatedTransaction(transaction: Transaction, transactionChanges: Tra const conversionFactor = existingDistanceUnit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES ? CONST.CUSTOM_UNITS.MILES_TO_KILOMETERS : CONST.CUSTOM_UNITS.KILOMETERS_TO_MILES; const distance = NumberUtils.roundToTwoDecimalPlaces((transaction?.comment?.customUnit?.quantity ?? 0) * conversionFactor); lodashSet(updatedTransaction, 'comment.customUnit.quantity', distance); - lodashSet(updatedTransaction, 'pendingFields.waypoints', CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE); + lodashSet(updatedTransaction, 'pendingFields.merchant', CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE); } } @@ -339,6 +342,7 @@ function getUpdatedTransaction(transaction: Transaction, transactionChanges: Tra } updatedTransaction.pendingFields = { + ...(updatedTransaction?.pendingFields ?? {}), ...(Object.hasOwn(transactionChanges, 'comment') && {comment: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), ...(Object.hasOwn(transactionChanges, 'created') && {created: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), ...(Object.hasOwn(transactionChanges, 'amount') && {amount: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), @@ -1034,7 +1038,7 @@ function removeSettledAndApprovedTransactions(transactionIDs: string[]) { * 6. It returns the 'keep' and 'change' objects. */ -function compareDuplicateTransactionFields(transactionID: string): {keep: Partial; change: FieldsToChange} { +function compareDuplicateTransactionFields(transactionID: string, reportID: string): {keep: Partial; change: FieldsToChange} { const transactionViolations = allTransactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`]; const duplicates = transactionViolations?.find((violation) => violation.name === CONST.VIOLATIONS.DUPLICATED_TRANSACTION)?.data?.duplicates ?? []; const transactions = removeSettledAndApprovedTransactions([transactionID, ...duplicates]).map((item) => getTransaction(item)); @@ -1095,7 +1099,10 @@ function compareDuplicateTransactionFields(transactionID: string): {keep: Partia const keys = fieldsToCompare[fieldName]; const firstTransaction = transactions.at(0); const isFirstTransactionCommentEmptyObject = typeof firstTransaction?.comment === 'object' && firstTransaction?.comment?.comment === ''; + const report = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] ?? null; + const policy = PolicyUtils.getPolicy(report?.policyID); + const areAllFieldsEqualForKey = areAllFieldsEqual(transactions, (item) => keys.map((key) => item?.[key]).join('|')); if (fieldName === 'description') { const allCommentsAreEqual = areAllCommentsEqual(transactions, firstTransaction); const allCommentsAreEmpty = isFirstTransactionCommentEmptyObject && transactions.every((item) => getDescription(item) === ''); @@ -1110,7 +1117,52 @@ function compareDuplicateTransactionFields(transactionID: string): {keep: Partia } else { processChanges(fieldName, transactions, keys); } - } else if (areAllFieldsEqual(transactions, (item) => keys.map((key) => item?.[key]).join('|'))) { + } else if (fieldName === 'taxCode') { + const differentValues = getDifferentValues(transactions, keys); + const validTaxes = differentValues?.filter((taxID) => { + const tax = PolicyUtils.getTaxByID(policy, (taxID as string) ?? ''); + return tax?.name && !tax.isDisabled && tax.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + }); + + if (!areAllFieldsEqualForKey && validTaxes.length > 1) { + change[fieldName] = validTaxes; + } else if (areAllFieldsEqualForKey) { + keep[fieldName] = firstTransaction?.[keys[0]] ?? firstTransaction?.[keys[1]]; + } + } else if (fieldName === 'category') { + const differentValues = getDifferentValues(transactions, keys); + const policyCategories = getPolicyCategoriesData(report?.policyID ?? '-1'); + const availableCategories = Object.values(policyCategories) + .filter((category) => differentValues.includes(category.name) && category.enabled && category.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) + .map((e) => e.name); + + if (!areAllFieldsEqualForKey && policy?.areCategoriesEnabled && (availableCategories.length > 1 || (availableCategories.length === 1 && differentValues.includes('')))) { + change[fieldName] = [...availableCategories, ...(differentValues.includes('') ? [''] : [])]; + } else if (areAllFieldsEqualForKey) { + keep[fieldName] = firstTransaction?.[keys[0]] ?? firstTransaction?.[keys[1]]; + } + } else if (fieldName === 'tag') { + const policyTags = getPolicyTagsData(report?.policyID ?? '-1'); + const isMultiLevelTags = PolicyUtils.isMultiLevelTags(policyTags); + if (isMultiLevelTags) { + if (areAllFieldsEqualForKey || !policy?.areTagsEnabled) { + keep[fieldName] = firstTransaction?.[keys[0]] ?? firstTransaction?.[keys[1]]; + } else { + processChanges(fieldName, transactions, keys); + } + } else { + const differentValues = getDifferentValues(transactions, keys); + const policyTagsObj = Object.values(Object.values(policyTags).at(0)?.tags ?? {}); + const availableTags = policyTagsObj + .filter((tag) => differentValues.includes(tag.name) && tag.enabled && tag.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) + .map((e) => e.name); + if (!areAllFieldsEqualForKey && policy?.areTagsEnabled && (availableTags.length > 1 || (availableTags.length === 1 && differentValues.includes('')))) { + change[fieldName] = [...availableTags, ...(differentValues.includes('') ? [''] : [])]; + } else if (areAllFieldsEqualForKey) { + keep[fieldName] = firstTransaction?.[keys[0]] ?? firstTransaction?.[keys[1]]; + } + } + } else if (areAllFieldsEqualForKey) { keep[fieldName] = firstTransaction?.[keys[0]] ?? firstTransaction?.[keys[1]]; } else { processChanges(fieldName, transactions, keys); diff --git a/src/libs/TripReservationUtils.ts b/src/libs/TripReservationUtils.ts index b7f754f9cac6..f2ce5113af81 100644 --- a/src/libs/TripReservationUtils.ts +++ b/src/libs/TripReservationUtils.ts @@ -1,5 +1,6 @@ import {Str} from 'expensify-common'; import type {Dispatch, SetStateAction} from 'react'; +import {NativeModules} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {LocaleContextProps} from '@components/LocaleContextProvider'; @@ -13,6 +14,7 @@ import type Transaction from '@src/types/onyx/Transaction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type IconAsset from '@src/types/utils/IconAsset'; import * as Link from './actions/Link'; +import Log from './Log'; import Navigation from './Navigation/Navigation'; import * as PolicyUtils from './PolicyUtils'; @@ -40,6 +42,14 @@ Onyx.connect({ }, }); +let isSingleNewDotEntry: boolean | undefined; +Onyx.connect({ + key: ONYXKEYS.IS_SINGLE_NEW_DOT_ENTRY, + callback: (val) => { + isSingleNewDotEntry = val; + }, +}); + function getTripReservationIcon(reservationType: ReservationType): IconAsset { switch (reservationType) { case CONST.RESERVATION_TYPE.FLIGHT: @@ -91,8 +101,17 @@ function bookATrip(translate: LocaleContextProps['translate'], setCtaErrorMessag if (ctaErrorMessage) { setCtaErrorMessage(''); } - Link.openTravelDotLink(activePolicyID)?.catch(() => { - setCtaErrorMessage(translate('travel.errorMessage')); - }); + Link.openTravelDotLink(activePolicyID) + ?.then(() => { + if (!NativeModules.HybridAppModule || !isSingleNewDotEntry) { + return; + } + + Log.info('[HybridApp] Returning to OldDot after opening TravelDot'); + NativeModules.HybridAppModule.closeReactNativeApp(false, false); + }) + ?.catch(() => { + setCtaErrorMessage(translate('travel.errorMessage')); + }); } export {getTripReservationIcon, getReservationsFromTripTransactions, getTripEReceiptIcon, bookATrip}; diff --git a/src/libs/ValidationUtils.ts b/src/libs/ValidationUtils.ts index 80c765f0edf1..0367325db6b1 100644 --- a/src/libs/ValidationUtils.ts +++ b/src/libs/ValidationUtils.ts @@ -20,16 +20,24 @@ import StringUtils from './StringUtils'; */ function validateCardNumber(value: string): boolean { let sum = 0; - for (let i = 0; i < value.length; i++) { - let intVal = parseInt(value.substr(i, 1), 10); - if (i % 2 === 0) { + let shouldDouble = false; + + // Loop through the card number from right to left + for (let i = value.length - 1; i >= 0; i--) { + let intVal = parseInt(value[i], 10); + + // Double every second digit from the right + if (shouldDouble) { intVal *= 2; if (intVal > 9) { - intVal = 1 + (intVal % 10); + intVal -= 9; } } + sum += intVal; + shouldDouble = !shouldDouble; } + return sum % 10 === 0; } @@ -238,8 +246,7 @@ function getDatePassedError(inputDate: string): string { * http/https/ftp URL scheme required. */ function isValidWebsite(url: string): boolean { - const isLowerCase = url === url.toLowerCase(); - return new RegExp(`^${Url.URL_REGEX_WITH_REQUIRED_PROTOCOL}$`, 'i').test(url) && isLowerCase; + return new RegExp(`^${Url.URL_REGEX_WITH_REQUIRED_PROTOCOL}$`, 'i').test(url); } /** Checks if the domain is public */ @@ -495,6 +502,33 @@ function isValidSubscriptionSize(subscriptionSize: string): boolean { return !Number.isNaN(parsedSubscriptionSize) && parsedSubscriptionSize > 0 && parsedSubscriptionSize <= CONST.SUBSCRIPTION_SIZE_LIMIT && Number.isInteger(parsedSubscriptionSize); } +/** + * Validates the given value if it is correct email address. + * @param email + */ +function isValidEmail(email: string): boolean { + return Str.isValidEmail(email); +} + +/** + * Validates the given value if it is correct phone number in E164 format (international standard). + * @param phoneNumber + */ +function isValidPhoneInternational(phoneNumber: string): boolean { + const phoneNumberWithCountryCode = LoginUtils.appendCountryCode(phoneNumber); + const parsedPhoneNumber = parsePhoneNumber(phoneNumberWithCountryCode); + + return parsedPhoneNumber.possible && Str.isValidE164Phone(parsedPhoneNumber.number?.e164 ?? ''); +} + +/** + * Validates the given value if it is correct zip code for international addresses. + * @param zipCode + */ +function isValidZipCodeInternational(zipCode: string): boolean { + return /^[a-z0-9][a-z0-9\- ]{0,10}[a-z0-9]$/.test(zipCode); +} + export { meetsMinimumAgeRequirement, meetsMaximumAgeRequirement, @@ -539,4 +573,7 @@ export { isValidSubscriptionSize, isExistingTaxCode, isPublicDomain, + isValidEmail, + isValidPhoneInternational, + isValidZipCodeInternational, }; diff --git a/src/libs/WorkspacesSettingsUtils.ts b/src/libs/WorkspacesSettingsUtils.ts index a27d518fe727..e06382edffdc 100644 --- a/src/libs/WorkspacesSettingsUtils.ts +++ b/src/libs/WorkspacesSettingsUtils.ts @@ -2,17 +2,18 @@ import Onyx from 'react-native-onyx'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import type {LocaleContextProps} from '@components/LocaleContextProvider'; +import type {PolicySelector} from '@hooks/useReportIDs'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy, ReimbursementAccount, Report, ReportAction, ReportActions, TransactionViolations} from '@src/types/onyx'; +import type {Beta, Policy, PriorityMode, ReimbursementAccount, Report, ReportAction, ReportActions, TransactionViolation, TransactionViolations} from '@src/types/onyx'; import type {PolicyConnectionSyncProgress, Unit} from '@src/types/onyx/Policy'; import {isConnectionInProgress} from './actions/connections'; import * as CurrencyUtils from './CurrencyUtils'; import {hasCustomUnitsError, hasEmployeeListError, hasPolicyError, hasSyncError, hasTaxRateError} from './PolicyUtils'; import * as ReportActionsUtils from './ReportActionsUtils'; -import * as ReportConnection from './ReportConnection'; import * as ReportUtils from './ReportUtils'; +import SidebarUtils from './SidebarUtils'; type CheckingMethod = () => boolean; @@ -119,12 +120,23 @@ function hasWorkspaceSettingsRBR(policy: Policy) { return Object.keys(reimbursementAccount?.errors ?? {}).length > 0 || hasPolicyError(policy) || hasCustomUnitsError(policy) || policyMemberError || taxRateError; } -function getChatTabBrickRoadReport(policyID?: string): OnyxEntry { - const allReports = ReportConnection.getAllReports(); - if (!allReports) { +function getChatTabBrickRoadReport( + policyID: string | undefined, + currentReportId: string | null, + reports: OnyxCollection, + betas: OnyxEntry, + policies: OnyxCollection, + priorityMode: OnyxEntry, + transactionViolations: OnyxCollection, + policyMemberAccountIDs: number[] = [], +): OnyxEntry { + const reportIDs = SidebarUtils.getOrderedReportIDs(currentReportId, reports, betas, policies, priorityMode, transactionViolations, policyID, policyMemberAccountIDs); + if (!reportIDs.length) { return undefined; } + const allReports = reportIDs.map((reportID) => reports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]); + // If policyID is undefined, then all reports are checked whether they contain any brick road const policyReports = policyID ? Object.values(allReports).filter((report) => report?.policyID === policyID) : Object.values(allReports); @@ -150,8 +162,17 @@ function getChatTabBrickRoadReport(policyID?: string): OnyxEntry { return undefined; } -function getChatTabBrickRoad(policyID?: string): BrickRoad | undefined { - const report = getChatTabBrickRoadReport(policyID); +function getChatTabBrickRoad( + policyID: string | undefined, + currentReportId: string | null, + reports: OnyxCollection, + betas: OnyxEntry, + policies: OnyxCollection, + priorityMode: OnyxEntry, + transactionViolations: OnyxCollection, + policyMemberAccountIDs: number[] = [], +): BrickRoad | undefined { + const report = getChatTabBrickRoadReport(policyID, currentReportId, reports, betas, policies, priorityMode, transactionViolations, policyMemberAccountIDs); return report ? getBrickRoadForPolicy(report) : undefined; } diff --git a/src/libs/actions/Card.ts b/src/libs/actions/Card.ts index 3ffdd6778b12..b04a5e49bea5 100644 --- a/src/libs/actions/Card.ts +++ b/src/libs/actions/Card.ts @@ -37,7 +37,7 @@ type IssueNewCardFlowData = { data?: Partial; }; -function reportVirtualExpensifyCardFraud(card?: Card) { +function reportVirtualExpensifyCardFraud(card: Card, validateCode: string) { const cardID = card?.cardID ?? -1; const optimisticData: OnyxUpdate[] = [ { @@ -45,6 +45,7 @@ function reportVirtualExpensifyCardFraud(card?: Card) { key: ONYXKEYS.FORMS.REPORT_VIRTUAL_CARD_FRAUD, value: { isLoading: true, + errors: null, }, }, { @@ -105,6 +106,7 @@ function reportVirtualExpensifyCardFraud(card?: Card) { const parameters: ReportVirtualExpensifyCardFraudParams = { cardID, + validateCode, }; API.write(WRITE_COMMANDS.REPORT_VIRTUAL_EXPENSIFY_CARD_FRAUD, parameters, { @@ -119,7 +121,7 @@ function reportVirtualExpensifyCardFraud(card?: Card) { * @param cardID - id of the card that is going to be replaced * @param reason - reason for replacement */ -function requestReplacementExpensifyCard(cardID: number, reason: ReplacementReason) { +function requestReplacementExpensifyCard(cardID: number, reason: ReplacementReason, validateCode: string) { const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -154,6 +156,7 @@ function requestReplacementExpensifyCard(cardID: number, reason: ReplacementReas const parameters: RequestReplacementExpensifyCardParams = { cardID, reason, + validateCode, }; API.write(WRITE_COMMANDS.REQUEST_REPLACEMENT_EXPENSIFY_CARD, parameters, { diff --git a/src/libs/actions/CompanyCards.ts b/src/libs/actions/CompanyCards.ts index a106fbeff510..18779a284278 100644 --- a/src/libs/actions/CompanyCards.ts +++ b/src/libs/actions/CompanyCards.ts @@ -17,6 +17,7 @@ import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {Card} from '@src/types/onyx'; import type {AssignCard, AssignCardData} from '@src/types/onyx/AssignCard'; import type {AddNewCardFeedData, AddNewCardFeedStep, CompanyCardFeed} from '@src/types/onyx/CardFeeds'; import type {OnyxData} from '@src/types/onyx/Request'; @@ -51,13 +52,37 @@ function clearAddNewCardFlow() { }); } -function addNewCompanyCardsFeed(policyID: string, feedType: string, feedDetails: string) { +function addNewCompanyCardsFeed(policyID: string, feedType: CompanyCardFeed, feedDetails: string, lastSelectedFeed?: CompanyCardFeed) { const authToken = NetworkStore.getAuthToken(); if (!authToken) { return; } + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.LAST_SELECTED_FEED}${policyID}`, + value: feedType, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.LAST_SELECTED_FEED}${policyID}`, + value: lastSelectedFeed ?? null, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.LAST_SELECTED_FEED}${policyID}`, + value: feedType, + }, + ]; + const parameters: RequestFeedSetupParams = { policyID, authToken, @@ -65,7 +90,7 @@ function addNewCompanyCardsFeed(policyID: string, feedType: string, feedDetails: feedDetails, }; - API.write(WRITE_COMMANDS.REQUEST_FEED_SETUP, parameters); + API.write(WRITE_COMMANDS.REQUEST_FEED_SETUP, parameters, {optimisticData, failureData, successData}); } function setWorkspaceCompanyCardFeedName(policyID: string, workspaceAccountID: number, bankName: string, userDefinedName: string) { @@ -159,7 +184,7 @@ function assignWorkspaceCompanyCard(policyID: string, data?: Partial [key, CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE])); + let pendingFields: OnyxTypes.Transaction['pendingFields'] = Object.fromEntries(Object.keys(transactionChanges).map((key) => [key, CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE])); const errorFields = Object.fromEntries(Object.keys(pendingFields).map((key) => [key, {[DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericEditFailureMessage')}])); const allReports = ReportConnection.getAllReports(); @@ -2519,6 +2519,13 @@ function getUpdateMoneyRequestParams( let updatedTransaction: OnyxEntry = transaction ? TransactionUtils.getUpdatedTransaction(transaction, transactionChanges, isFromExpenseReport) : undefined; const transactionDetails = ReportUtils.getTransactionDetails(updatedTransaction); + if (updatedTransaction?.pendingFields) { + pendingFields = { + ...pendingFields, + ...updatedTransaction?.pendingFields, + }; + } + if (transactionDetails?.waypoints) { // This needs to be a JSON string since we're sending this to the MapBox API transactionDetails.waypoints = JSON.stringify(transactionDetails.waypoints); @@ -4982,7 +4989,6 @@ function completeSplitBill(chatReportID: string, reportAction: OnyxTypes.ReportA const splitParticipants: Split[] = updatedTransaction?.comment?.splits ?? []; const amount = updatedTransaction?.modifiedAmount; const currency = updatedTransaction?.modifiedCurrency; - console.debug(updatedTransaction); // Exclude the current user when calculating the split amount, `calculateAmount` takes it into account const splitAmount = IOUUtils.calculateAmount(splitParticipants.length - 1, amount ?? 0, currency ?? '', false); @@ -7049,9 +7055,19 @@ function canApproveIOU(iouReport: OnyxTypes.OnyxInputOrEntry, const iouSettled = ReportUtils.isSettled(iouReport?.reportID); const reportNameValuePairs = ReportUtils.getReportNameValuePairs(iouReport?.reportID); const isArchivedReport = ReportUtils.isArchivedRoom(iouReport, reportNameValuePairs); - const unheldTotalIsZero = iouReport && iouReport.unheldTotal === 0; + let isTransactionBeingScanned = false; + const reportTransactions = TransactionUtils.getAllReportTransactions(iouReport?.reportID); + for (const transaction of reportTransactions) { + const hasReceipt = TransactionUtils.hasReceipt(transaction); + const isReceiptBeingScanned = TransactionUtils.isReceiptBeingScanned(transaction); + + // If transaction has receipt (scan) and its receipt is being scanned, we shouldn't be able to Approve + if (hasReceipt && isReceiptBeingScanned) { + isTransactionBeingScanned = true; + } + } - return isCurrentUserManager && !isOpenExpenseReport && !isApproved && !iouSettled && !isArchivedReport && !unheldTotalIsZero; + return isCurrentUserManager && !isOpenExpenseReport && !isApproved && !iouSettled && !isArchivedReport && !isTransactionBeingScanned; } function canIOUBePaid( @@ -7315,7 +7331,6 @@ function approveMoneyRequest(expenseReport: OnyxEntry, full?: optimisticHoldReportID, optimisticHoldActionID, optimisticHoldReportExpenseActionIDs, - v2: PolicyUtils.isControlOnAdvancedApprovalMode(PolicyUtils.getPolicy(expenseReport?.policyID)), }; API.write(WRITE_COMMANDS.APPROVE_MONEY_REQUEST, parameters, {optimisticData, successData, failureData}); diff --git a/src/libs/actions/Link.ts b/src/libs/actions/Link.ts index 13fcea0df85d..4cda676d89e8 100644 --- a/src/libs/actions/Link.ts +++ b/src/libs/actions/Link.ts @@ -111,7 +111,7 @@ function openTravelDotLink(policyID: OnyxEntry, postLoginPath?: string) policyID, }; - return new Promise((_, reject) => { + return new Promise((resolve, reject) => { const error = new Error('Failed to generate spotnana token.'); asyncOpenURL( @@ -122,7 +122,9 @@ function openTravelDotLink(policyID: OnyxEntry, postLoginPath?: string) reject(error); throw error; } - return buildTravelDotURL(response.spotnanaToken, postLoginPath); + const travelURL = buildTravelDotURL(response.spotnanaToken, postLoginPath); + resolve(undefined); + return travelURL; }) .catch(() => { reject(error); diff --git a/src/libs/actions/Policy/Category.ts b/src/libs/actions/Policy/Category.ts index 78b0f2dec9e2..dced49976c5a 100644 --- a/src/libs/actions/Policy/Category.ts +++ b/src/libs/actions/Policy/Category.ts @@ -390,8 +390,8 @@ function setPolicyCategoryDescriptionRequired(policyID: string, categoryName: st API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_DESCRIPTION_REQUIRED, parameters, onyxData); } -function setPolicyCategoryReceiptsRequired(policyID: string, categoryName: string, maxExpenseAmountNoReceipt: number) { - const originalMaxExpenseAmountNoReceipt = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]?.[categoryName]?.maxExpenseAmountNoReceipt; +function setPolicyCategoryReceiptsRequired(policyID: string, categoryName: string, maxAmountNoReceipt: number) { + const originalMaxAmountNoReceipt = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]?.[categoryName]?.maxAmountNoReceipt; const onyxData: OnyxData = { optimisticData: [ @@ -402,9 +402,9 @@ function setPolicyCategoryReceiptsRequired(policyID: string, categoryName: strin [categoryName]: { pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, pendingFields: { - maxExpenseAmountNoReceipt: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + maxAmountNoReceipt: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, }, - maxExpenseAmountNoReceipt, + maxAmountNoReceipt, }, }, }, @@ -417,9 +417,9 @@ function setPolicyCategoryReceiptsRequired(policyID: string, categoryName: strin [categoryName]: { pendingAction: null, pendingFields: { - maxExpenseAmountNoReceipt: null, + maxAmountNoReceipt: null, }, - maxExpenseAmountNoReceipt, + maxAmountNoReceipt, }, }, }, @@ -433,9 +433,9 @@ function setPolicyCategoryReceiptsRequired(policyID: string, categoryName: strin errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), pendingAction: null, pendingFields: { - maxExpenseAmountNoReceipt: null, + maxAmountNoReceipt: null, }, - maxExpenseAmountNoReceipt: originalMaxExpenseAmountNoReceipt, + maxAmountNoReceipt: originalMaxAmountNoReceipt, }, }, }, @@ -445,14 +445,14 @@ function setPolicyCategoryReceiptsRequired(policyID: string, categoryName: strin const parameters: SetPolicyCategoryReceiptsRequiredParams = { policyID, categoryName, - maxExpenseAmountNoReceipt, + maxExpenseAmountNoReceipt: maxAmountNoReceipt, }; API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_RECEIPTS_REQUIRED, parameters, onyxData); } function removePolicyCategoryReceiptsRequired(policyID: string, categoryName: string) { - const originalMaxExpenseAmountNoReceipt = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]?.[categoryName]?.maxExpenseAmountNoReceipt; + const originalMaxAmountNoReceipt = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]?.[categoryName]?.maxAmountNoReceipt; const onyxData: OnyxData = { optimisticData: [ @@ -463,9 +463,9 @@ function removePolicyCategoryReceiptsRequired(policyID: string, categoryName: st [categoryName]: { pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, pendingFields: { - maxExpenseAmountNoReceipt: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + maxAmountNoReceipt: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, }, - maxExpenseAmountNoReceipt: null, + maxAmountNoReceipt: null, }, }, }, @@ -478,9 +478,9 @@ function removePolicyCategoryReceiptsRequired(policyID: string, categoryName: st [categoryName]: { pendingAction: null, pendingFields: { - maxExpenseAmountNoReceipt: null, + maxAmountNoReceipt: null, }, - maxExpenseAmountNoReceipt: null, + maxAmountNoReceipt: null, }, }, }, @@ -494,9 +494,9 @@ function removePolicyCategoryReceiptsRequired(policyID: string, categoryName: st errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), pendingAction: null, pendingFields: { - maxExpenseAmountNoReceipt: null, + maxAmountNoReceipt: null, }, - maxExpenseAmountNoReceipt: originalMaxExpenseAmountNoReceipt, + maxAmountNoReceipt: originalMaxAmountNoReceipt, }, }, }, @@ -1346,6 +1346,10 @@ function setPolicyCategoryTax(policyID: string, categoryName: string, taxID: str API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_TAX, parameters, onyxData); } +function getPolicyCategoriesData(policyID: string) { + return allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`] ?? {}; +} + export { getPolicyCategories, openPolicyCategoriesPage, @@ -1370,4 +1374,5 @@ export { setPolicyCategoryTax, importPolicyCategories, downloadCategoriesCSV, + getPolicyCategoriesData, }; diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts index d5b2adc54de3..8fb551cdec81 100644 --- a/src/libs/actions/Policy/Member.ts +++ b/src/libs/actions/Policy/Member.ts @@ -159,7 +159,10 @@ function buildAnnounceRoomMembersOnyxData(policyID: string, accountIDs: number[] onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport?.reportID}`, value: { - participants: announceReport?.participants ?? null, + participants: accountIDs.reduce((acc, curr) => { + Object.assign(acc, {[curr]: null}); + return acc; + }, {}), pendingChatMembers: announceReport?.pendingChatMembers ?? null, }, }); diff --git a/src/libs/actions/Policy/PerDiem.ts b/src/libs/actions/Policy/PerDiem.ts new file mode 100644 index 000000000000..2ce31fd4c921 --- /dev/null +++ b/src/libs/actions/Policy/PerDiem.ts @@ -0,0 +1,122 @@ +import type {NullishDeep, OnyxCollection} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; +import * as API from '@libs/API'; +import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import getIsNarrowLayout from '@libs/getIsNarrowLayout'; +import * as NumberUtils from '@libs/NumberUtils'; +import {navigateWhenEnableFeature} from '@libs/PolicyUtils'; +import * as ReportUtils from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, Report} from '@src/types/onyx'; +import type {OnyxData} from '@src/types/onyx/Request'; + +const allPolicies: OnyxCollection = {}; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.POLICY, + callback: (val, key) => { + if (!key) { + return; + } + if (val === null || val === undefined) { + // If we are deleting a policy, we have to check every report linked to that policy + // and unset the draft indicator (pencil icon) alongside removing any draft comments. Clearing these values will keep the newly archived chats from being displayed in the LHN. + // More info: https://github.com/Expensify/App/issues/14260 + const policyID = key.replace(ONYXKEYS.COLLECTION.POLICY, ''); + const policyReports = ReportUtils.getAllPolicyReports(policyID); + const cleanUpMergeQueries: Record<`${typeof ONYXKEYS.COLLECTION.REPORT}${string}`, NullishDeep> = {}; + const cleanUpSetQueries: Record<`${typeof ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${string}` | `${typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${string}`, null> = {}; + policyReports.forEach((policyReport) => { + if (!policyReport) { + return; + } + const {reportID} = policyReport; + cleanUpSetQueries[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`] = null; + cleanUpSetQueries[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`] = null; + }); + Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT, cleanUpMergeQueries); + Onyx.multiSet(cleanUpSetQueries); + delete allPolicies[key]; + return; + } + + allPolicies[key] = val; + }, +}); + +/** + * Returns a client generated 13 character hexadecimal value for a custom unit ID + */ +function generateCustomUnitID(): string { + return NumberUtils.generateHexadecimalValue(13); +} + +function enablePerDiem(policyID: string, enabled: boolean, customUnitID?: string) { + const doesCustomUnitExists = !!customUnitID; + const finalCustomUnitID = doesCustomUnitExists ? customUnitID : generateCustomUnitID(); + const optimisticCustomUnit = { + name: CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL, + customUnitID: finalCustomUnitID, + enabled: true, + defaultCategory: '', + rates: {}, + }; + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + arePerDiemRatesEnabled: enabled, + pendingFields: { + arePerDiemRatesEnabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + ...(doesCustomUnitExists ? {} : {customUnits: {[finalCustomUnitID]: optimisticCustomUnit}}), + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + arePerDiemRatesEnabled: null, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + arePerDiemRatesEnabled: !enabled, + pendingFields: { + arePerDiemRatesEnabled: null, + }, + }, + }, + ], + }; + + const parameters = {policyID, enabled, customUnitID: finalCustomUnitID}; + + API.write(WRITE_COMMANDS.TOGGLE_POLICY_PER_DIEM, parameters, onyxData); + + if (enabled && getIsNarrowLayout()) { + navigateWhenEnableFeature(policyID); + } +} + +function openPolicyPerDiemPage(policyID?: string) { + if (!policyID) { + return; + } + + const params = {policyID}; + + API.read(READ_COMMANDS.OPEN_POLICY_PER_DIEM_RATES_PAGE, params); +} + +export {enablePerDiem, openPolicyPerDiemPage}; diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index b419431bbbb3..d87f0321bab0 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -1013,6 +1013,16 @@ function createPolicyExpenseChats(policyID: string, invitedEmailsToAccountIDs: I isLoadingInitialReportActions: false, }, }); + + workspaceMembersChats.onyxFailureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticReport.reportID}`, + value: { + errorFields: { + createChat: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('report.genericCreateReportFailureMessage'), + }, + }, + }); }); return workspaceMembersChats; } @@ -1498,7 +1508,7 @@ function generateCustomUnitID(): string { return NumberUtils.generateHexadecimalValue(13); } -function buildOptimisticCustomUnits(reportCurrency?: string): OptimisticCustomUnits { +function buildOptimisticDistanceRateCustomUnits(reportCurrency?: string): OptimisticCustomUnits { const currency = reportCurrency ?? allPersonalDetails?.[sessionAccountID]?.localCurrencyCode ?? CONST.CURRENCY.USD; const customUnitID = generateCustomUnitID(); const customUnitRateID = generateCustomUnitID(); @@ -1540,7 +1550,7 @@ function buildOptimisticCustomUnits(reportCurrency?: string): OptimisticCustomUn */ function createDraftInitialWorkspace(policyOwnerEmail = '', policyName = '', policyID = generatePolicyID(), makeMeAdmin = false) { const workspaceName = policyName || generateDefaultWorkspaceName(policyOwnerEmail); - const {customUnits, outputCurrency} = buildOptimisticCustomUnits(); + const {customUnits, outputCurrency} = buildOptimisticDistanceRateCustomUnits(); const optimisticData: OnyxUpdate[] = [ { @@ -1595,7 +1605,7 @@ function createDraftInitialWorkspace(policyOwnerEmail = '', policyName = '', pol function buildPolicyData(policyOwnerEmail = '', makeMeAdmin = false, policyName = '', policyID = generatePolicyID(), expenseReportId?: string, engagementChoice?: string) { const workspaceName = policyName || generateDefaultWorkspaceName(policyOwnerEmail); - const {customUnits, customUnitID, customUnitRateID, outputCurrency} = buildOptimisticCustomUnits(); + const {customUnits, customUnitID, customUnitRateID, outputCurrency} = buildOptimisticDistanceRateCustomUnits(); const { adminsChatReportID, @@ -1842,7 +1852,7 @@ function createWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policyName function createDraftWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policyName = '', policyID = generatePolicyID()): CreateWorkspaceParams { const workspaceName = policyName || generateDefaultWorkspaceName(policyOwnerEmail); - const {customUnits, customUnitID, customUnitRateID, outputCurrency} = buildOptimisticCustomUnits(); + const {customUnits, customUnitID, customUnitRateID, outputCurrency} = buildOptimisticDistanceRateCustomUnits(); const {expenseChatData, adminsChatReportID, adminsCreatedReportActionID, expenseChatReportID, expenseCreatedReportActionID} = ReportUtils.buildOptimisticWorkspaceChats( policyID, @@ -2134,7 +2144,7 @@ function createWorkspaceFromIOUPayment(iouReport: OnyxEntry): WorkspaceF const workspaceName = generateDefaultWorkspaceName(sessionEmail); const employeeAccountID = iouReport.ownerAccountID; const employeeEmail = iouReport.ownerEmail ?? ''; - const {customUnits, customUnitID, customUnitRateID} = buildOptimisticCustomUnits(iouReport.currency); + const {customUnits, customUnitID, customUnitRateID} = buildOptimisticDistanceRateCustomUnits(iouReport.currency); const oldPersonalPolicyID = iouReport.policyID; const iouReportID = iouReport.reportID; @@ -2630,7 +2640,7 @@ function enableExpensifyCard(policyID: string, enabled: boolean) { } } -function enableCompanyCards(policyID: string, enabled: boolean) { +function enableCompanyCards(policyID: string, enabled: boolean, disableRedirect = false) { const authToken = NetworkStore.getAuthToken(); const onyxData: OnyxData = { @@ -2675,7 +2685,7 @@ function enableCompanyCards(policyID: string, enabled: boolean) { API.write(WRITE_COMMANDS.ENABLE_POLICY_COMPANY_CARDS, parameters, onyxData); - if (enabled && getIsNarrowLayout()) { + if (enabled && getIsNarrowLayout() && !disableRedirect) { navigateWhenEnableFeature(policyID); } } diff --git a/src/libs/actions/Policy/Tag.ts b/src/libs/actions/Policy/Tag.ts index 7708921f57b5..772e748ad4f2 100644 --- a/src/libs/actions/Policy/Tag.ts +++ b/src/libs/actions/Policy/Tag.ts @@ -1040,6 +1040,10 @@ function downloadTagsCSV(policyID: string, onDownloadFailed: () => void) { fileDownload(ApiUtils.getCommandURL({command: WRITE_COMMANDS.EXPORT_TAGS_CSV}), fileName, '', false, formData, CONST.NETWORK.METHOD.POST, onDownloadFailed); } +function getPolicyTagsData(policyID: string) { + return allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {}; +} + export { buildOptimisticPolicyRecentlyUsedTags, setPolicyRequiresTag, @@ -1058,6 +1062,7 @@ export { setPolicyTagApprover, importPolicyTags, downloadTagsCSV, + getPolicyTagsData, }; export type {NewCustomUnit}; diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 0c66ae8c3eb1..a0441e729582 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -58,6 +58,8 @@ import DateUtils from '@libs/DateUtils'; import {prepareDraftComment} from '@libs/DraftCommentUtils'; import * as EmojiUtils from '@libs/EmojiUtils'; import * as Environment from '@libs/Environment/Environment'; +import getEnvironment from '@libs/Environment/getEnvironment'; +import type EnvironmentType from '@libs/Environment/getEnvironment/types'; import * as ErrorUtils from '@libs/ErrorUtils'; import fileDownload from '@libs/fileDownload'; import HttpUtils from '@libs/HttpUtils'; @@ -84,6 +86,7 @@ import type {OptimisticAddCommentReportAction} from '@libs/ReportUtils'; import * as ReportUtils from '@libs/ReportUtils'; import {doesReportBelongToWorkspace} from '@libs/ReportUtils'; import shouldSkipDeepLinkNavigation from '@libs/shouldSkipDeepLinkNavigation'; +import {getNavatticURL} from '@libs/TourUtils'; import Visibility from '@libs/Visibility'; import CONFIG from '@src/CONFIG'; import type {OnboardingAccountingType, OnboardingCompanySizeType, OnboardingPurposeType} from '@src/CONST'; @@ -181,6 +184,11 @@ Onyx.connect({ }, }); +Onyx.connect({ + key: ONYXKEYS.CONCIERGE_REPORT_ID, + callback: (value) => (conciergeChatReportID = value), +}); + let preferredSkinTone: number = CONST.EMOJI_DEFAULT_SKIN_TONE; Onyx.connect({ key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, @@ -281,6 +289,11 @@ Onyx.connect({ let environmentURL: string; Environment.getEnvironmentURL().then((url: string) => (environmentURL = url)); +let environment: EnvironmentType; +getEnvironment().then((env) => { + environment = env; +}); + registerPaginationConfig({ initialCommand: WRITE_COMMANDS.OPEN_REPORT, previousCommand: READ_COMMANDS.GET_OLDER_ACTIONS, @@ -1935,6 +1948,8 @@ function updateReportField(reportID: string, reportField: PolicyReportField, pre const fieldViolation = ReportUtils.getFieldViolation(reportViolations, reportField); const recentlyUsedValues = allRecentlyUsedReportFields?.[fieldKey] ?? []; + const optimisticChangeFieldAction = ReportUtils.buildOptimisticChangeFieldAction(reportField, previousReportField); + const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -1948,6 +1963,13 @@ function updateReportField(reportID: string, reportField: PolicyReportField, pre }, }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + value: { + [optimisticChangeFieldAction.reportActionID]: optimisticChangeFieldAction, + }, + }, ]; if (fieldViolation) { @@ -1988,6 +2010,15 @@ function updateReportField(reportID: string, reportField: PolicyReportField, pre }, }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + value: { + [optimisticChangeFieldAction.reportActionID]: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('report.genericUpdateReportFieldFailureMessage'), + }, + }, + }, ]; if (reportField.type === 'dropdown') { @@ -2013,11 +2044,21 @@ function updateReportField(reportID: string, reportField: PolicyReportField, pre }, }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + value: { + [optimisticChangeFieldAction.reportActionID]: { + pendingAction: null, + }, + }, + }, ]; const parameters = { reportID, reportFields: JSON.stringify({[fieldKey]: reportField}), + reportFieldsActionIDs: JSON.stringify({[fieldKey]: optimisticChangeFieldAction.reportActionID}), }; API.write(WRITE_COMMANDS.SET_REPORT_FIELD, parameters, {optimisticData, failureData, successData}); @@ -3436,7 +3477,6 @@ function completeOnboarding( reportComment: videoComment.commentText, }; } - const tasksData = data.tasks .filter((task) => { if (task.type === 'addAccountingIntegration' && !userReportedIntegration) { @@ -3452,6 +3492,7 @@ function completeOnboarding( workspaceCategoriesLink: `${environmentURL}/${ROUTES.WORKSPACE_CATEGORIES.getRoute(onboardingPolicyID ?? '-1')}`, workspaceMembersLink: `${environmentURL}/${ROUTES.WORKSPACE_MEMBERS.getRoute(onboardingPolicyID ?? '-1')}`, workspaceMoreFeaturesLink: `${environmentURL}/${ROUTES.WORKSPACE_MORE_FEATURES.getRoute(onboardingPolicyID ?? '-1')}`, + navatticURL: getNavatticURL(environment, engagementChoice), integrationName, workspaceAccountingLink: `${environmentURL}/${ROUTES.POLICY_ACCOUNTING.getRoute(onboardingPolicyID ?? '-1')}`, }) @@ -3640,6 +3681,9 @@ function completeOnboarding( }, []); const optimisticData: OnyxUpdate[] = [...tasksForOptimisticData]; + const lastVisibleActionCreated = + tasksData.at(-1)?.completedTaskReportAction?.created ?? tasksData.at(-1)?.taskReportAction.reportAction.created ?? videoCommentAction?.created ?? textCommentAction.created; + optimisticData.push( { onyxMethod: Onyx.METHOD.MERGE, @@ -3647,6 +3691,7 @@ function completeOnboarding( value: { lastMentionedTime: DateUtils.getDBTime(), hasOutstandingChildTask, + lastVisibleActionCreated, }, }, { @@ -3688,12 +3733,12 @@ function completeOnboarding( const {lastMessageText = '', lastMessageTranslationKey = ''} = ReportActionsUtils.getLastVisibleMessage(targetChatReportID); if (lastMessageText || lastMessageTranslationKey) { const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(targetChatReportID); - const lastVisibleActionCreated = lastVisibleAction?.created; + const prevLastVisibleActionCreated = lastVisibleAction?.created; const lastActorAccountID = lastVisibleAction?.actorAccountID; failureReport = { lastMessageTranslationKey, lastMessageText, - lastVisibleActionCreated, + lastVisibleActionCreated: prevLastVisibleActionCreated, lastActorAccountID, }; } diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index 37488442525d..d75c5064f93a 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -483,28 +483,43 @@ function signUpUser() { function signInAfterTransitionFromOldDot(transitionURL: string) { const [route, queryParams] = transitionURL.split('?'); - const {email, authToken, encryptedAuthToken, accountID, autoGeneratedLogin, autoGeneratedPassword, clearOnyxOnStart, completedHybridAppOnboarding} = Object.fromEntries( - queryParams.split('&').map((param) => { - const [key, value] = param.split('='); - return [key, value]; - }), - ); - - const setSessionDataAndOpenApp = () => { - Onyx.multiSet({ - [ONYXKEYS.SESSION]: {email, authToken, encryptedAuthToken: decodeURIComponent(encryptedAuthToken), accountID: Number(accountID)}, - [ONYXKEYS.CREDENTIALS]: {autoGeneratedLogin, autoGeneratedPassword}, - [ONYXKEYS.NVP_TRYNEWDOT]: {classicRedirect: {completedHybridAppOnboarding: completedHybridAppOnboarding === 'true'}}, - }).then(App.openApp); + const {email, authToken, encryptedAuthToken, accountID, autoGeneratedLogin, autoGeneratedPassword, clearOnyxOnStart, completedHybridAppOnboarding, isSingleNewDotEntry, primaryLogin} = + Object.fromEntries( + queryParams.split('&').map((param) => { + const [key, value] = param.split('='); + return [key, value]; + }), + ); + + const clearOnyxForNewAccount = () => { + if (clearOnyxOnStart !== 'true') { + return Promise.resolve(); + } + + return Onyx.clear(KEYS_TO_PRESERVE); }; - if (clearOnyxOnStart === 'true') { - Onyx.clear(KEYS_TO_PRESERVE).then(setSessionDataAndOpenApp); - } else { - setSessionDataAndOpenApp(); - } + const setSessionDataAndOpenApp = new Promise((resolve) => { + clearOnyxForNewAccount() + .then(() => + Onyx.multiSet({ + [ONYXKEYS.SESSION]: {email, authToken, encryptedAuthToken: decodeURIComponent(encryptedAuthToken), accountID: Number(accountID)}, + [ONYXKEYS.ACCOUNT]: {primaryLogin}, + [ONYXKEYS.CREDENTIALS]: {autoGeneratedLogin, autoGeneratedPassword}, + [ONYXKEYS.IS_SINGLE_NEW_DOT_ENTRY]: isSingleNewDotEntry === 'true', + [ONYXKEYS.NVP_TRYNEWDOT]: {classicRedirect: {completedHybridAppOnboarding: completedHybridAppOnboarding === 'true'}}, + }), + ) + .then(App.openApp) + .catch((error) => { + Log.hmmm('[HybridApp] Initialization of HybridApp has failed. Forcing transition', {error}); + }) + .finally(() => { + resolve(`${route}?singleNewDotEntry=${isSingleNewDotEntry}` as Route); + }); + }); - return route as Route; + return setSessionDataAndOpenApp; } /** diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index fe15515bcb4a..e7a3465f1d25 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -8,8 +8,9 @@ import * as API from '@libs/API'; import type {DismissViolationParams, GetRouteParams, MarkAsCashParams} from '@libs/API/parameters'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import * as CollectionUtils from '@libs/CollectionUtils'; +import * as NumberUtils from '@libs/NumberUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; -import {buildOptimisticDismissedViolationReportAction, buildOptimisticUnHoldReportAction, isCurrentUserSubmitter} from '@libs/ReportUtils'; +import {buildOptimisticDismissedViolationReportAction} from '@libs/ReportUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -352,11 +353,7 @@ function dismissDuplicateTransactionViolation(transactionIDs: string[], dissmiss const currentTransactionViolations = transactionIDs.map((id) => ({transactionID: id, violations: allTransactionViolation?.[id] ?? []})); const currentTransactions = transactionIDs.map((id) => allTransactions?.[id]); const transactionsReportActions = currentTransactions.map((transaction) => ReportActionsUtils.getIOUActionForReportID(transaction.reportID ?? '', transaction.transactionID ?? '')); - const isSubmitter = currentTransactions.every((transaction) => isCurrentUserSubmitter(transaction.reportID ?? '')); const optimisticDissmidedViolationReportActions = transactionsReportActions.map(() => { - if (isSubmitter) { - return buildOptimisticUnHoldReportAction(); - } return buildOptimisticDismissedViolationReportAction({reason: 'manual', violationName: CONST.VIOLATIONS.DUPLICATED_TRANSACTION}); }); @@ -427,16 +424,17 @@ function dismissDuplicateTransactionViolation(transactionIDs: string[], dissmiss onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${action?.childReportID ?? '-1'}`, value: { - [optimisticDissmidedViolationReportActions.at(index)?.reportActionID ?? '']: { - pendingAction: null, - }, + [optimisticDissmidedViolationReportActions.at(index)?.reportActionID ?? '']: null, }, })); - + // We are creating duplicate resolved report actions for each duplicate transactions and all the report actions + // should be correctly linked with their parent report but the BE is sometimes linking report actions to different + // parent reports than the one we set optimistically, resulting in duplicate report actions. Therefore, we send the BE + // random report action ids and onSuccessData we reset the report actions we added optimistically to avoid duplicate actions. const params: DismissViolationParams = { name: CONST.VIOLATIONS.DUPLICATED_TRANSACTION, transactionIDList: transactionIDs.join(','), - reportActionIDList: optimisticDissmidedViolationReportActions.map((action) => action.reportActionID).join(','), + reportActionIDList: optimisticDissmidedViolationReportActions.map(() => NumberUtils.rand64()).join(','), }; API.write(WRITE_COMMANDS.DISMISS_VIOLATION, params, { diff --git a/src/libs/actions/Welcome/index.ts b/src/libs/actions/Welcome/index.ts index 19a570ab610f..5a92ff5e5435 100644 --- a/src/libs/actions/Welcome/index.ts +++ b/src/libs/actions/Welcome/index.ts @@ -8,6 +8,7 @@ import type {OnboardingCompanySizeType, OnboardingPurposeType} from '@src/CONST' import ONYXKEYS from '@src/ONYXKEYS'; import type Onboarding from '@src/types/onyx/Onboarding'; import type TryNewDot from '@src/types/onyx/TryNewDot'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; import * as OnboardingFlow from './OnboardingFlow'; type OnboardingData = Onboarding | [] | undefined; @@ -41,7 +42,7 @@ function onServerDataReady(): Promise { let isOnboardingInProgress = false; function isOnboardingFlowCompleted({onCompleted, onNotCompleted, onCanceled}: HasCompletedOnboardingFlowProps) { isOnboardingFlowStatusKnownPromise.then(() => { - if (Array.isArray(onboarding) || onboarding?.hasCompletedGuidedSetupFlow === undefined) { + if (Array.isArray(onboarding) || isEmptyObject(onboarding) || onboarding?.hasCompletedGuidedSetupFlow === undefined) { onCanceled?.(); return; } diff --git a/src/libs/actions/connections/index.ts b/src/libs/actions/connections/index.ts index e4ef3e4ed047..b2cb6ffffe94 100644 --- a/src/libs/actions/connections/index.ts +++ b/src/libs/actions/connections/index.ts @@ -327,6 +327,21 @@ function isConnectionUnverified(policy: OnyxEntry, connectionName: Polic return !(policy?.connections?.[connectionName]?.lastSync?.isConnected ?? true); } +function setConnectionError(policyID: string, connectionName: PolicyConnectionName, errorMessage?: string) { + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { + connections: { + [connectionName]: { + lastSync: { + isSuccessful: false, + isConnected: false, + errorDate: new Date().toISOString(), + errorMessage, + }, + }, + }, + }); +} + function copyExistingPolicyConnection(connectedPolicyID: string, targetPolicyID: string, connectionName: ConnectionName) { let stageInProgress; switch (connectionName) { @@ -389,4 +404,5 @@ export { isConnectionUnverified, isConnectionInProgress, hasSynchronizationErrorMessage, + setConnectionError, }; diff --git a/src/libs/getPlatform/index.ts b/src/libs/getPlatform/index.ts index 5f5b45ac6e7d..aedb4610673e 100644 --- a/src/libs/getPlatform/index.ts +++ b/src/libs/getPlatform/index.ts @@ -1,6 +1,10 @@ +import * as Browser from '@libs/Browser'; import CONST from '@src/CONST'; import type Platform from './types'; -export default function getPlatform(): Platform { +export default function getPlatform(shouldMobileWebBeDistinctFromWeb = false): Platform { + if (shouldMobileWebBeDistinctFromWeb && Browser.isMobile()) { + return CONST.PLATFORM.MOBILEWEB; + } return CONST.PLATFORM.WEB; } diff --git a/src/libs/onboardingSelectors.ts b/src/libs/onboardingSelectors.ts index c1e7d0ed0778..bdbb3ff142f4 100644 --- a/src/libs/onboardingSelectors.ts +++ b/src/libs/onboardingSelectors.ts @@ -1,5 +1,6 @@ import type {OnyxValue} from 'react-native-onyx'; import type ONYXKEYS from '@src/ONYXKEYS'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; /** * Selector to get the value of hasCompletedGuidedSetupFlow from the Onyx store @@ -9,8 +10,8 @@ import type ONYXKEYS from '@src/ONYXKEYS'; * `false` means the user has not completed the NewDot onboarding flow */ function hasCompletedGuidedSetupFlowSelector(onboarding: OnyxValue): boolean | undefined { - // Onboarding is an array for old accounts and accounts created from OldDot - if (Array.isArray(onboarding)) { + // Onboarding is an array or an empty object for old accounts and accounts created from OldDot + if (Array.isArray(onboarding) || isEmptyObject(onboarding)) { return true; } @@ -43,11 +44,11 @@ function hasCompletedHybridAppOnboardingFlowSelector(tryNewDotData: OnyxValue): boolean | undefined { - if (Array.isArray(onboarding)) { + if (Array.isArray(onboarding) || isEmptyObject(onboarding)) { return false; } - return onboarding?.selfTourViewed; + return !!onboarding?.selfTourViewed; } export {hasCompletedGuidedSetupFlowSelector, hasCompletedHybridAppOnboardingFlowSelector, hasSeenTourSelector}; diff --git a/src/libs/searchCountryOptions.ts b/src/libs/searchOptions.ts similarity index 59% rename from src/libs/searchCountryOptions.ts rename to src/libs/searchOptions.ts index 953a5c81c77f..4c8021dffa10 100644 --- a/src/libs/searchCountryOptions.ts +++ b/src/libs/searchOptions.ts @@ -1,6 +1,6 @@ import StringUtils from './StringUtils'; -type CountryData = { +type Option = { value: string; keyForList: string; text: string; @@ -9,32 +9,32 @@ type CountryData = { }; /** - * Searches the countries/states data and returns sorted results based on the search query - * @param countriesData - An array of country data objects - * @returns An array of countries/states sorted based on the search query + * Searches the options and returns sorted results based on the search query + * @param options - An array of option objects + * @returns An array of options sorted based on the search query */ -function searchCountryOptions(searchValue: string, countriesData: CountryData[]): CountryData[] { +function searchOptions(searchValue: string, options: Option[]): Option[] { if (!searchValue) { - return countriesData; + return options; } const trimmedSearchValue = StringUtils.sanitizeString(searchValue); if (!trimmedSearchValue) { return []; } - const filteredData = countriesData.filter((country) => country.searchValue.includes(trimmedSearchValue)); + const filteredData = options.filter((option) => option.searchValue.includes(trimmedSearchValue)); const halfSorted = filteredData.sort((a, b) => { // Prioritize matches at the beginning of the string // e.g. For the search term "Bar" "Barbados" should be prioritized over Antigua & Barbuda // The first two characters are the country code, so we start at index 2 // and end at the length of the search term - const countryNameASubstring = a.searchValue.toLowerCase().substring(2, trimmedSearchValue.length + 2); - const countryNameBSubstring = b.searchValue.toLowerCase().substring(2, trimmedSearchValue.length + 2); - if (countryNameASubstring === trimmedSearchValue.toLowerCase()) { + const optionASubstring = a.searchValue.toLowerCase().substring(2, trimmedSearchValue.length + 2); + const optionBSubstring = b.searchValue.toLowerCase().substring(2, trimmedSearchValue.length + 2); + if (optionASubstring === trimmedSearchValue.toLowerCase()) { return -1; } - if (countryNameBSubstring === trimmedSearchValue.toLowerCase()) { + if (optionBSubstring === trimmedSearchValue.toLowerCase()) { return 1; } return 0; @@ -46,12 +46,12 @@ function searchCountryOptions(searchValue: string, countriesData: CountryData[]) // Diacritic detected, prioritize diacritic matches // We search for diacritic matches by using the unsanitized country name and search term fullSorted = halfSorted.sort((a, b) => { - const unsanitizedCountryNameA = a.text.toLowerCase(); - const unsanitizedCountryNameB = b.text.toLowerCase(); - if (unsanitizedCountryNameA.includes(unsanitizedSearchValue)) { + const unsanitizedOptionA = a.text.toLowerCase(); + const unsanitizedOptionB = b.text.toLowerCase(); + if (unsanitizedOptionA.includes(unsanitizedSearchValue)) { return -1; } - if (unsanitizedCountryNameB.includes(unsanitizedSearchValue)) { + if (unsanitizedOptionB.includes(unsanitizedSearchValue)) { return 1; } return 0; @@ -72,5 +72,5 @@ function searchCountryOptions(searchValue: string, countriesData: CountryData[]) return fullSorted; } -export default searchCountryOptions; -export type {CountryData}; +export default searchOptions; +export type {Option}; diff --git a/src/pages/Debug/DebugDetails.tsx b/src/pages/Debug/DebugDetails.tsx index c64e8e3a9331..6ee14660dbe9 100644 --- a/src/pages/Debug/DebugDetails.tsx +++ b/src/pages/Debug/DebugDetails.tsx @@ -14,7 +14,6 @@ import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import type {ObjectType, OnyxDataType} from '@libs/DebugUtils'; import DebugUtils from '@libs/DebugUtils'; -import Navigation from '@libs/Navigation/Navigation'; import Debug from '@userActions/Debug'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -236,7 +235,6 @@ function DebugDetails({data, children, onSave, onDelete, validate}: DebugDetails text={translate('common.delete')} onPress={() => { onDelete(); - Navigation.goBack(); }} /> diff --git a/src/pages/Debug/Report/DebugReportPage.tsx b/src/pages/Debug/Report/DebugReportPage.tsx index 675ff28b3be3..5fa26cbf1835 100644 --- a/src/pages/Debug/Report/DebugReportPage.tsx +++ b/src/pages/Debug/Report/DebugReportPage.tsx @@ -4,6 +4,7 @@ import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import * as Expensicons from '@components/Icon/Expensicons'; import ScreenWrapper from '@components/ScreenWrapper'; import TabSelector from '@components/TabSelector/TabSelector'; import Text from '@components/Text'; @@ -11,6 +12,7 @@ import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import {navigateToConciergeChatAndDeleteReport} from '@libs/actions/Report'; import DebugUtils from '@libs/DebugUtils'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import Navigation from '@libs/Navigation/Navigation'; @@ -19,6 +21,7 @@ import type {DebugParamList} from '@libs/Navigation/types'; import * as ReportUtils from '@libs/ReportUtils'; import DebugDetails from '@pages/Debug/DebugDetails'; import DebugJSON from '@pages/Debug/DebugJSON'; +import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import Debug from '@userActions/Debug'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -112,6 +115,10 @@ function DebugReportPage({ ]; }, [parentReportAction, report, reportActions, reportID, transactionViolations, translate]); + if (!report) { + return ; + } + return ( { Debug.mergeDebugData(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, null); + navigateToConciergeChatAndDeleteReport(reportID, true, true); }} validate={DebugUtils.validateReportDraftProperty} > @@ -157,6 +165,13 @@ function DebugReportPage({ )} ))} +