diff --git a/android/app/build.gradle b/android/app/build.gradle index 60429549fbec..dc48f3137f27 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 1009005709 - versionName "9.0.57-9" + versionCode 1009005801 + versionName "9.0.58-1" // 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/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/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..8a4605807355 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 { 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 a3a8a9267ca7..04030d1972f0 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.0.57 + 9.0.58 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.57.9 + 9.0.58.1 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index d5615a0d382b..a1fc5be5e7ae 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 9.0.57 + 9.0.58 CFBundleSignature ???? CFBundleVersion - 9.0.57.9 + 9.0.58.1 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 231fc4f51089..4fedc3fe0674 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.0.57 + 9.0.58 CFBundleVersion - 9.0.57.9 + 9.0.58.1 NSExtension NSExtensionPointIdentifier diff --git a/package-lock.json b/package-lock.json index 832c46ac06f8..2d40dfece91d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.0.57-9", + "version": "9.0.58-1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.57-9", + "version": "9.0.58-1", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -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.103", "expo": "51.0.31", "expo-av": "14.0.7", "expo-image": "1.12.15", @@ -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.103", + "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.103.tgz", + "integrity": "sha512-Q42bUK6TeB87qN4MEBDlhNH1qQqUXY+tJKCZTt01Zv+lcn7KemudOCt7GNoEwfR7LLWsWuec7Vb5x45rQJNC2A==", "license": "MIT", "dependencies": { "awesome-phonenumber": "^5.4.0", diff --git a/package.json b/package.json index 0778eba5fd9c..b87d36c7aa4f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.57-9", + "version": "9.0.58-1", "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.", @@ -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.103", "expo": "51.0.31", "expo-av": "14.0.7", "expo-image": "1.12.15", 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/src/CONST.ts b/src/CONST.ts index 8a1e9cfbf67c..ad119874c461 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -314,9 +314,6 @@ const CONST = { ANIMATED_HIGHLIGHT_END_DURATION: 2000, ANIMATED_TRANSITION: 300, ANIMATED_TRANSITION_FROM_VALUE: 100, - ANIMATED_PROGRESS_BAR_DELAY: 300, - ANIMATED_PROGRESS_BAR_OPACITY_DURATION: 300, - ANIMATED_PROGRESS_BAR_DURATION: 750, ANIMATION_IN_TIMING: 100, ANIMATION_DIRECTION: { IN: 'in', @@ -477,6 +474,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', @@ -3053,10 +3051,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, diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 7d3d0edef36e..49dd42fa8281 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -449,6 +449,9 @@ 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', @@ -1018,6 +1021,7 @@ 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; 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/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/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/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/LoadingBar.tsx b/src/components/LoadingBar.tsx deleted file mode 100644 index 163ffe2aa66b..000000000000 --- a/src/components/LoadingBar.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import React, {useEffect} from 'react'; -import Animated, {cancelAnimation, Easing, runOnJS, useAnimatedStyle, useSharedValue, withDelay, withRepeat, withSequence, withTiming} from 'react-native-reanimated'; -import useThemeStyles from '@hooks/useThemeStyles'; -import CONST from '@src/CONST'; - -type LoadingBarProps = { - // Whether or not to show the loading bar - shouldShow: boolean; -}; - -function LoadingBar({shouldShow}: LoadingBarProps) { - const left = useSharedValue(0); - const width = useSharedValue(0); - const opacity = useSharedValue(0); - const isVisible = useSharedValue(false); - const styles = useThemeStyles(); - - useEffect(() => { - if (shouldShow) { - // eslint-disable-next-line react-compiler/react-compiler - isVisible.value = true; - left.value = 0; - width.value = 0; - opacity.value = withTiming(1, {duration: CONST.ANIMATED_PROGRESS_BAR_OPACITY_DURATION}); - left.value = withDelay( - CONST.ANIMATED_PROGRESS_BAR_DELAY, - withRepeat( - withSequence( - withTiming(0, {duration: 0}), - withTiming(0, {duration: CONST.ANIMATED_PROGRESS_BAR_DURATION, easing: Easing.bezier(0.65, 0, 0.35, 1)}), - withTiming(100, {duration: CONST.ANIMATED_PROGRESS_BAR_DURATION, easing: Easing.bezier(0.65, 0, 0.35, 1)}), - ), - -1, - false, - ), - ); - - width.value = withDelay( - CONST.ANIMATED_PROGRESS_BAR_DELAY, - withRepeat( - withSequence( - withTiming(0, {duration: 0}), - withTiming(100, {duration: CONST.ANIMATED_PROGRESS_BAR_DURATION, easing: Easing.bezier(0.65, 0, 0.35, 1)}), - withTiming(0, {duration: CONST.ANIMATED_PROGRESS_BAR_DURATION, easing: Easing.bezier(0.65, 0, 0.35, 1)}), - ), - -1, - false, - ), - ); - } else if (isVisible.value) { - opacity.value = withTiming(0, {duration: CONST.ANIMATED_PROGRESS_BAR_OPACITY_DURATION}, () => { - runOnJS(() => { - isVisible.value = false; - cancelAnimation(left); - cancelAnimation(width); - }); - }); - } - // we want to update only when shouldShow changes - // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - }, [shouldShow]); - - const animatedIndicatorStyle = useAnimatedStyle(() => { - return { - left: `${left.value}%`, - width: `${width.value}%`, - }; - }); - - const animatedContainerStyle = useAnimatedStyle(() => { - return { - opacity: opacity.value, - }; - }); - - return ( - - {isVisible.value ? : null} - - ); -} - -LoadingBar.displayName = 'ProgressBar'; - -export default LoadingBar; diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index c94938c3d103..3caf7a15d50e 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); diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 01fd15c52bb4..ebb927b2a279 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -305,6 +305,8 @@ function MoneyRequestConfirmationList({ return false; }; + const routeError = Object.values(transaction?.errorFields?.route ?? {}).at(0); + useEffect(() => { if (shouldDisplayFieldError && didConfirmSplit) { setFormError('iou.error.genericSmartscanFailureMessage'); @@ -719,6 +721,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; @@ -791,6 +796,7 @@ function MoneyRequestConfirmationList({ transactionID, reportID, policy, + routeError, ], ); @@ -806,6 +812,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; @@ -848,33 +864,18 @@ 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 = ( { 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/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/index.tsx b/src/components/Search/index.tsx index 9238488361b0..a13b816fd8b8 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -466,6 +466,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 2fb034131c86..3e5c158660f1 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'; @@ -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[]; }>; @@ -82,18 +86,18 @@ type SearchQueryJSON = { 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, @@ -107,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..bf8aa5454caa 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -107,6 +107,7 @@ function BaseSelectionList( scrollEventThrottle, contentContainerStyle, shouldHighlightSelectedItem = false, + shouldKeepFocusedItemAtTopOfViewableArea = false, }: BaseSelectionListProps, ref: ForwardedRef, ) { @@ -126,6 +127,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 +166,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 +190,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 +222,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 +272,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 @@ -450,7 +477,7 @@ function BaseSelectionList( }; return ( - <> + onItemLayout(event, item?.keyForList)}> ( wrapperStyle={listItemWrapperStyle} /> {item.footerContent && item.footerContent} - + ); }; 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/types.ts b/src/components/SelectionList/types.ts index 8fb50456182c..a534ba4a1623 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -597,6 +597,9 @@ 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; } & 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/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/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 f1339ed88373..38b11e9fea38 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -2477,6 +2477,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', @@ -3658,6 +3662,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 +4059,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: { diff --git a/src/languages/es.ts b/src/languages/es.ts index a9ebfedf1cc3..ab404143d030 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -2502,6 +2502,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 +3108,8 @@ const translations = { }, type: { free: 'Gratis', - control: 'Control', - collect: 'Recolectar', + control: 'Controlar', + collect: 'Recopilar', }, companyCards: { addCompanyCards: 'Agregar tarjetas de empresa', @@ -3663,6 +3667,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 +4066,52 @@ 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 ', }, note: { upgradeWorkspace: 'Mejore su espacio de trabajo para acceder a esta función, o', @@ -4119,7 +4125,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.', @@ -5476,7 +5482,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 +5494,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', 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/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 054ced8bc9bb..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,7 +137,7 @@ 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.DEBUG_REPORT.getRoute(report.reportID)); @@ -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/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/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/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 497a2d33cf56..1296a64e571d 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -1893,7 +1893,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..0853bd9c18ce 100644 --- a/src/libs/Permissions.ts +++ b/src/libs/Permissions.ts @@ -24,14 +24,6 @@ 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); } @@ -62,8 +54,6 @@ export default { canUseDupeDetection, canUseP2PDistanceRequests, canUseSpotnanaTravel, - canUseCompanyCardFeeds, - canUseDirectFeeds, canUseNetSuiteUSATax, canUseCombinedTrackSubmit, canUseCategoryAndTagApprovers, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index d8133991d62b..a62716975c01 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -7326,8 +7326,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)); } 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 62d00f8091ed..5e2a6d737984 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(); + }); } /** @@ -561,7 +575,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, })); @@ -610,23 +626,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); } }; @@ -647,6 +663,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..6d08a128a253 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -4,6 +4,8 @@ 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'; @@ -1034,7 +1036,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 +1097,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 +1115,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..fbc1aefe30ce 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; } 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/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..41771ac5aa0e 100644 --- a/src/libs/actions/Policy/Category.ts +++ b/src/libs/actions/Policy/Category.ts @@ -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/Policy.ts b/src/libs/actions/Policy/Policy.ts index aac5f69ff241..129e4c301e83 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -1014,6 +1014,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; } 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/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/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/pages/NewChatPage.tsx b/src/pages/NewChatPage.tsx index 13fbbc35b5da..c28290e353e7 100755 --- a/src/pages/NewChatPage.tsx +++ b/src/pages/NewChatPage.tsx @@ -34,13 +34,9 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {SelectedParticipant} from '@src/types/onyx/NewGroupChatDraft'; -type NewChatPageProps = { - isGroupChat?: boolean; -}; +const excludedGroupEmails: string[] = CONST.EXPENSIFY_EMAILS.filter((value) => value !== CONST.EMAIL.CONCIERGE); -const excludedGroupEmails = CONST.EXPENSIFY_EMAILS.filter((value) => value !== CONST.EMAIL.CONCIERGE); - -function useOptions({isGroupChat}: NewChatPageProps) { +function useOptions() { const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); const [selectedOptions, setSelectedOptions] = useState>([]); const [betas] = useOnyx(ONYXKEYS.BETAS); @@ -57,22 +53,20 @@ function useOptions({isGroupChat}: NewChatPageProps) { personalDetails: listOptions.personalDetails ?? [], betas: betas ?? [], selectedOptions, - excludeLogins: isGroupChat ? excludedGroupEmails : [], maxRecentReportsToShow: 0, includeSelfDM: true, }); return filteredOptions; - }, [betas, isGroupChat, listOptions.personalDetails, listOptions.reports, selectedOptions]); + }, [betas, listOptions.personalDetails, listOptions.reports, selectedOptions]); const options = useMemo(() => { const filteredOptions = OptionsListUtils.filterOptions(defaultOptions, debouncedSearchTerm, { selectedOptions, - excludeLogins: isGroupChat ? excludedGroupEmails : [], maxRecentReportsToShow: CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW, }); return filteredOptions; - }, [debouncedSearchTerm, defaultOptions, isGroupChat, selectedOptions]); + }, [debouncedSearchTerm, defaultOptions, selectedOptions]); const cleanSearchTerm = useMemo(() => debouncedSearchTerm.trim().toLowerCase(), [debouncedSearchTerm]); const headerMessage = useMemo(() => { return OptionsListUtils.getHeaderMessage( @@ -129,7 +123,7 @@ function useOptions({isGroupChat}: NewChatPageProps) { }; } -function NewChatPage({isGroupChat}: NewChatPageProps) { +function NewChatPage() { const {translate} = useLocalize(); const {isOffline} = useNetwork(); // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to show offline indicator on small screen only @@ -142,9 +136,7 @@ function NewChatPage({isGroupChat}: NewChatPageProps) { const selectionListRef = useRef(null); const {headerMessage, searchTerm, debouncedSearchTerm, setSearchTerm, selectedOptions, setSelectedOptions, recentReports, personalDetails, userToInvite, areOptionsInitialized} = - useOptions({ - isGroupChat, - }); + useOptions(); const [sections, firstKeyForList] = useMemo(() => { const sectionsList: OptionsListUtils.CategorySection[] = []; @@ -217,7 +209,7 @@ function NewChatPage({isGroupChat}: NewChatPageProps) { const itemRightSideComponent = useCallback( (item: ListItem & OptionsListUtils.Option, isFocused?: boolean) => { - if (!!item.isSelfDM || (item.accountID && CONST.NON_ADDABLE_ACCOUNT_IDS.includes(item.accountID))) { + if (!!item.isSelfDM || (item.login && excludedGroupEmails.includes(item.login))) { return null; } /** diff --git a/src/pages/ReimbursementAccount/BusinessInfo/BusinessInfo.tsx b/src/pages/ReimbursementAccount/BusinessInfo/BusinessInfo.tsx index 0a94e22cde1f..7f45de42122b 100644 --- a/src/pages/ReimbursementAccount/BusinessInfo/BusinessInfo.tsx +++ b/src/pages/ReimbursementAccount/BusinessInfo/BusinessInfo.tsx @@ -1,3 +1,4 @@ +import {Str} from 'expensify-common'; import lodashPick from 'lodash/pick'; import React, {useCallback, useMemo} from 'react'; import {useOnyx} from 'react-native-onyx'; @@ -60,6 +61,7 @@ function BusinessInfo({onBackButtonPress}: BusinessInfoProps) { const submit = useCallback( (isConfirmPage: boolean) => { + const companyWebsite = Str.sanitizeURL(values.website, CONST.COMPANY_WEBSITE_DEFAULT_SCHEME); BankAccounts.updateCompanyInformationForBankAccount( Number(reimbursementAccount?.achData?.bankAccountID ?? '-1'), { @@ -67,7 +69,7 @@ function BusinessInfo({onBackButtonPress}: BusinessInfoProps) { ...getBankAccountFields(['routingNumber', 'accountNumber', 'bankName', 'plaidAccountID', 'plaidAccessToken', 'isSavings']), companyTaxID: values.companyTaxID?.replace(CONST.REGEX.NON_NUMERIC, ''), companyPhone: parsePhoneNumber(values.companyPhone ?? '', {regionCode: CONST.COUNTRY.US}).number?.significant, - website: ValidationUtils.isValidWebsite(values.website) ? values.website : undefined, + website: ValidationUtils.isValidWebsite(companyWebsite) ? companyWebsite : undefined, }, policyID, isConfirmPage, diff --git a/src/pages/ReimbursementAccount/BusinessInfo/substeps/WebsiteBusiness.tsx b/src/pages/ReimbursementAccount/BusinessInfo/substeps/WebsiteBusiness.tsx index 3d8fcd944f4f..5ef5e7c75c8d 100644 --- a/src/pages/ReimbursementAccount/BusinessInfo/substeps/WebsiteBusiness.tsx +++ b/src/pages/ReimbursementAccount/BusinessInfo/substeps/WebsiteBusiness.tsx @@ -1,3 +1,4 @@ +import {Str} from 'expensify-common'; import React, {useCallback, useMemo} from 'react'; import {useOnyx} from 'react-native-onyx'; import FormProvider from '@components/Form/FormProvider'; @@ -33,7 +34,7 @@ function WebsiteBusiness({onNext, isEditing}: SubStepProps) { (values: FormOnyxValues): FormInputErrors => { const errors = ValidationUtils.getFieldRequiredErrors(values, STEP_FIELDS); - if (values.website && !ValidationUtils.isValidWebsite(values.website)) { + if (values.website && !ValidationUtils.isValidWebsite(Str.sanitizeURL(values.website, CONST.COMPANY_WEBSITE_DEFAULT_SCHEME))) { errors.website = translate('bankAccount.error.website'); } @@ -44,7 +45,8 @@ function WebsiteBusiness({onNext, isEditing}: SubStepProps) { const handleSubmit = useReimbursementAccountStepFormSubmit({ fieldIds: STEP_FIELDS, onNext: (values) => { - BankAccounts.addBusinessWebsiteForDraft((values as {website: string})?.website); + const website = Str.sanitizeURL((values as {website: string})?.website, CONST.COMPANY_WEBSITE_DEFAULT_SCHEME); + BankAccounts.addBusinessWebsiteForDraft(website); onNext(); }, shouldSaveDraft: true, diff --git a/src/pages/ReimbursementAccount/utils/getInitialSubstepForBusinessInfo.ts b/src/pages/ReimbursementAccount/utils/getInitialSubstepForBusinessInfo.ts index 099eacaa690f..7173455afd05 100644 --- a/src/pages/ReimbursementAccount/utils/getInitialSubstepForBusinessInfo.ts +++ b/src/pages/ReimbursementAccount/utils/getInitialSubstepForBusinessInfo.ts @@ -1,4 +1,6 @@ +import {Str} from 'expensify-common'; import * as ValidationUtils from '@libs/ValidationUtils'; +import CONST from '@src/CONST'; import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; import type {CompanyStepProps} from '@src/types/form/ReimbursementAccountForm'; @@ -16,7 +18,7 @@ function getInitialSubstepForBusinessInfo(data: CompanyStepProps): number { return 1; } - if (!ValidationUtils.isValidWebsite(data[businessInfoStepKeys.COMPANY_WEBSITE])) { + if (!ValidationUtils.isValidWebsite(Str.sanitizeURL(data[businessInfoStepKeys.COMPANY_WEBSITE], CONST.COMPANY_WEBSITE_DEFAULT_SCHEME))) { return 2; } diff --git a/src/pages/ReportDetailsPage.tsx b/src/pages/ReportDetailsPage.tsx index 9ec3691f49a8..9e438f0549e2 100644 --- a/src/pages/ReportDetailsPage.tsx +++ b/src/pages/ReportDetailsPage.tsx @@ -298,6 +298,10 @@ function ReportDetailsPage({policies, report, route}: ReportDetailsPageProps) { const shouldShowCancelPaymentButton = caseID === CASES.MONEY_REPORT && isPayer && isSettled && ReportUtils.isExpenseReport(moneyRequestReport); const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${moneyRequestReport?.chatReportID ?? '-1'}`); + const iouTransactionID = ReportActionsUtils.isMoneyRequestAction(requestParentReportAction) + ? ReportActionsUtils.getOriginalMessage(requestParentReportAction)?.IOUTransactionID ?? '' + : ''; + const cancelPayment = useCallback(() => { if (!chatReport) { return; @@ -371,6 +375,42 @@ function ReportDetailsPage({policies, report, route}: ReportDetailsPageProps) { }); } + if (isTrackExpenseReport) { + const actionReportID = ReportUtils.getOriginalReportID(report.reportID, parentReportAction) ?? '0'; + const whisperAction = ReportActionsUtils.getTrackExpenseActionableWhisper(iouTransactionID, moneyRequestReport?.reportID ?? '0'); + const actionableWhisperReportActionID = whisperAction?.reportActionID ?? '0'; + items.push({ + key: CONST.REPORT_DETAILS_MENU_ITEM.SETTINGS, + translationKey: 'actionableMentionTrackExpense.submit', + icon: Expensicons.Send, + isAnonymousAction: false, + shouldShowRightIcon: true, + action: () => { + ReportUtils.createDraftTransactionAndNavigateToParticipantSelector(iouTransactionID, actionReportID, CONST.IOU.ACTION.SUBMIT, actionableWhisperReportActionID); + }, + }); + items.push({ + key: CONST.REPORT_DETAILS_MENU_ITEM.SETTINGS, + translationKey: 'actionableMentionTrackExpense.categorize', + icon: Expensicons.Folder, + isAnonymousAction: false, + shouldShowRightIcon: true, + action: () => { + ReportUtils.createDraftTransactionAndNavigateToParticipantSelector(iouTransactionID, actionReportID, CONST.IOU.ACTION.CATEGORIZE, actionableWhisperReportActionID); + }, + }); + items.push({ + key: CONST.REPORT_DETAILS_MENU_ITEM.SETTINGS, + translationKey: 'actionableMentionTrackExpense.share', + icon: Expensicons.UserPlus, + isAnonymousAction: false, + shouldShowRightIcon: true, + action: () => { + ReportUtils.createDraftTransactionAndNavigateToParticipantSelector(iouTransactionID, actionReportID, CONST.IOU.ACTION.SHARE, actionableWhisperReportActionID); + }, + }); + } + // Prevent displaying private notes option for threads and task reports if (!isChatThread && !isMoneyRequestReport && !isInvoiceReport && !isTaskReport) { items.push({ @@ -517,6 +557,10 @@ function ReportDetailsPage({policies, report, route}: ReportDetailsPageProps) { isExpenseReport, backTo, canActionTask, + isTrackExpenseReport, + iouTransactionID, + parentReportAction, + moneyRequestReport?.reportID, ]); const displayNamesWithTooltips = useMemo(() => { @@ -590,10 +634,6 @@ function ReportDetailsPage({policies, report, route}: ReportDetailsPageProps) { ); }, [report, icons, isMoneyRequestReport, isInvoiceReport, isGroupChat, isThread, styles]); - const iouTransactionID = ReportActionsUtils.isMoneyRequestAction(requestParentReportAction) - ? ReportActionsUtils.getOriginalMessage(requestParentReportAction)?.IOUTransactionID ?? '' - : ''; - const canHoldUnholdReportAction = ReportUtils.canHoldUnholdReportAction(moneyRequestAction); const shouldShowHoldAction = caseID !== CASES.DEFAULT && diff --git a/src/pages/Search/AdvancedSearchFilters.tsx b/src/pages/Search/AdvancedSearchFilters.tsx index ce4daabc983a..58fd159b5bed 100644 --- a/src/pages/Search/AdvancedSearchFilters.tsx +++ b/src/pages/Search/AdvancedSearchFilters.tsx @@ -9,7 +9,7 @@ import type {LocaleContextProps} from '@components/LocaleContextProvider'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {usePersonalDetails} from '@components/OnyxProvider'; import ScrollView from '@components/ScrollView'; -import type {AdvancedFiltersKeys} from '@components/Search/types'; +import type {SearchFilterKey} from '@components/Search/types'; import useLocalize from '@hooks/useLocalize'; import useSingleExecution from '@hooks/useSingleExecution'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -150,8 +150,8 @@ const sortOptionsWithEmptyValue = (a: string, b: string) => { return localeCompare(a, b); }; -function getFilterDisplayTitle(filters: Partial, fieldName: AdvancedFiltersKeys, translate: LocaleContextProps['translate']) { - if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE) { +function getFilterDisplayTitle(filters: Partial, filterKey: SearchFilterKey, translate: LocaleContextProps['translate']) { + if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE) { // the value of date filter is a combination of dateBefore + dateAfter values const {dateAfter, dateBefore} = filters; let dateValue = ''; @@ -168,7 +168,7 @@ function getFilterDisplayTitle(filters: Partial, fiel return dateValue; } - if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT) { + if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT) { const {lessThan, greaterThan} = filters; if (lessThan && greaterThan) { return translate('search.filters.amount.between', { @@ -186,32 +186,32 @@ function getFilterDisplayTitle(filters: Partial, fiel return; } - if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY && filters[fieldName]) { - const filterArray = filters[fieldName] ?? []; + if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY && filters[filterKey]) { + const filterArray = filters[filterKey] ?? []; return filterArray.sort(localeCompare).join(', '); } - if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY && filters[fieldName]) { - const filterArray = filters[fieldName] ?? []; + if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY && filters[filterKey]) { + const filterArray = filters[filterKey] ?? []; return filterArray .sort(sortOptionsWithEmptyValue) .map((value) => (value === CONST.SEARCH.EMPTY_VALUE ? translate('search.noCategory') : value)) .join(', '); } - if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG && filters[fieldName]) { - const filterArray = filters[fieldName] ?? []; + if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG && filters[filterKey]) { + const filterArray = filters[filterKey] ?? []; return filterArray .sort(sortOptionsWithEmptyValue) .map((value) => (value === CONST.SEARCH.EMPTY_VALUE ? translate('search.noTag') : value)) .join(', '); } - if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.DESCRIPTION) { - return filters[fieldName]; + if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.DESCRIPTION) { + return filters[filterKey]; } - const filterValue = filters[fieldName]; + const filterValue = filters[filterKey]; return Array.isArray(filterValue) ? filterValue.join(', ') : filterValue; } diff --git a/src/pages/TransactionDuplicate/Confirmation.tsx b/src/pages/TransactionDuplicate/Confirmation.tsx index 87748a9697a7..90497a05a4fc 100644 --- a/src/pages/TransactionDuplicate/Confirmation.tsx +++ b/src/pages/TransactionDuplicate/Confirmation.tsx @@ -40,7 +40,7 @@ function Confirmation() { const [reviewDuplicates, reviewDuplicatesResult] = useOnyx(ONYXKEYS.REVIEW_DUPLICATES); const transaction = useMemo(() => TransactionUtils.buildNewTransactionAfterReviewingDuplicates(reviewDuplicates), [reviewDuplicates]); const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? ''); - const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID); + const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID, reviewDuplicates?.reportID ?? '-1'); const {goBack} = useReviewDuplicatesNavigation(Object.keys(compareResult.change ?? {}), 'confirmation', route.params.threadReportID, route.params.backTo); const [report, reportResult] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${route.params.threadReportID}`); const [iouReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID}`); diff --git a/src/pages/TransactionDuplicate/ReviewBillable.tsx b/src/pages/TransactionDuplicate/ReviewBillable.tsx index 666741daf303..166c61209a42 100644 --- a/src/pages/TransactionDuplicate/ReviewBillable.tsx +++ b/src/pages/TransactionDuplicate/ReviewBillable.tsx @@ -1,6 +1,7 @@ import type {RouteProp} from '@react-navigation/native'; import {useRoute} from '@react-navigation/native'; import React, {useMemo} from 'react'; +import {useOnyx} from 'react-native-onyx'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import useLocalize from '@hooks/useLocalize'; @@ -8,6 +9,7 @@ import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation' import {setReviewDuplicatesKey} from '@libs/actions/Transaction'; import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types'; import * as TransactionUtils from '@libs/TransactionUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import type {FieldItemType} from './ReviewFields'; import ReviewFields from './ReviewFields'; @@ -16,7 +18,8 @@ function ReviewBillable() { const route = useRoute>(); const {translate} = useLocalize(); const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? ''); - const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID); + const [reviewDuplicates] = useOnyx(ONYXKEYS.REVIEW_DUPLICATES); + const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID, reviewDuplicates?.reportID ?? '-1'); const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString()); const {currentScreenIndex, goBack, navigateToNextScreen} = useReviewDuplicatesNavigation( Object.keys(compareResult.change ?? {}), diff --git a/src/pages/TransactionDuplicate/ReviewCategory.tsx b/src/pages/TransactionDuplicate/ReviewCategory.tsx index 09cbdcd28327..b28cb6863137 100644 --- a/src/pages/TransactionDuplicate/ReviewCategory.tsx +++ b/src/pages/TransactionDuplicate/ReviewCategory.tsx @@ -1,6 +1,7 @@ import type {RouteProp} from '@react-navigation/native'; import {useRoute} from '@react-navigation/native'; import React, {useMemo} from 'react'; +import {useOnyx} from 'react-native-onyx'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import useLocalize from '@hooks/useLocalize'; @@ -8,6 +9,7 @@ import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation' import {setReviewDuplicatesKey} from '@libs/actions/Transaction'; import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types'; import * as TransactionUtils from '@libs/TransactionUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import type {FieldItemType} from './ReviewFields'; import ReviewFields from './ReviewFields'; @@ -16,7 +18,8 @@ function ReviewCategory() { const route = useRoute>(); const {translate} = useLocalize(); const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? ''); - const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID); + const [reviewDuplicates] = useOnyx(ONYXKEYS.REVIEW_DUPLICATES); + const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID, reviewDuplicates?.reportID ?? '-1'); const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString()); const {currentScreenIndex, goBack, navigateToNextScreen} = useReviewDuplicatesNavigation( Object.keys(compareResult.change ?? {}), diff --git a/src/pages/TransactionDuplicate/ReviewDescription.tsx b/src/pages/TransactionDuplicate/ReviewDescription.tsx index 3d74d8cc36e1..d3c379517cf1 100644 --- a/src/pages/TransactionDuplicate/ReviewDescription.tsx +++ b/src/pages/TransactionDuplicate/ReviewDescription.tsx @@ -1,6 +1,7 @@ import type {RouteProp} from '@react-navigation/native'; import {useRoute} from '@react-navigation/native'; import React, {useMemo} from 'react'; +import {useOnyx} from 'react-native-onyx'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import useLocalize from '@hooks/useLocalize'; @@ -8,6 +9,7 @@ import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation' import {setReviewDuplicatesKey} from '@libs/actions/Transaction'; import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types'; import * as TransactionUtils from '@libs/TransactionUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import type {FieldItemType} from './ReviewFields'; import ReviewFields from './ReviewFields'; @@ -16,7 +18,8 @@ function ReviewDescription() { const route = useRoute>(); const {translate} = useLocalize(); const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? ''); - const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID); + const [reviewDuplicates] = useOnyx(ONYXKEYS.REVIEW_DUPLICATES); + const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID, reviewDuplicates?.reportID ?? '-1'); const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString()); const {currentScreenIndex, goBack, navigateToNextScreen} = useReviewDuplicatesNavigation( Object.keys(compareResult.change ?? {}), diff --git a/src/pages/TransactionDuplicate/ReviewMerchant.tsx b/src/pages/TransactionDuplicate/ReviewMerchant.tsx index 47dd43d1d334..d49a67d7d911 100644 --- a/src/pages/TransactionDuplicate/ReviewMerchant.tsx +++ b/src/pages/TransactionDuplicate/ReviewMerchant.tsx @@ -1,6 +1,7 @@ import type {RouteProp} from '@react-navigation/native'; import {useRoute} from '@react-navigation/native'; import React, {useMemo} from 'react'; +import {useOnyx} from 'react-native-onyx'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import useLocalize from '@hooks/useLocalize'; @@ -8,6 +9,7 @@ import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation' import {setReviewDuplicatesKey} from '@libs/actions/Transaction'; import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types'; import * as TransactionUtils from '@libs/TransactionUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import type {FieldItemType} from './ReviewFields'; import ReviewFields from './ReviewFields'; @@ -16,7 +18,8 @@ function ReviewMerchant() { const route = useRoute>(); const {translate} = useLocalize(); const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? ''); - const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID); + const [reviewDuplicates] = useOnyx(ONYXKEYS.REVIEW_DUPLICATES); + const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID, reviewDuplicates?.reportID ?? '-1'); const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString()); const {currentScreenIndex, goBack, navigateToNextScreen} = useReviewDuplicatesNavigation( Object.keys(compareResult.change ?? {}), diff --git a/src/pages/TransactionDuplicate/ReviewReimbursable.tsx b/src/pages/TransactionDuplicate/ReviewReimbursable.tsx index 0b932e8085db..361b92c2af5a 100644 --- a/src/pages/TransactionDuplicate/ReviewReimbursable.tsx +++ b/src/pages/TransactionDuplicate/ReviewReimbursable.tsx @@ -1,6 +1,7 @@ import type {RouteProp} from '@react-navigation/native'; import {useRoute} from '@react-navigation/native'; import React, {useMemo} from 'react'; +import {useOnyx} from 'react-native-onyx'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import useLocalize from '@hooks/useLocalize'; @@ -8,6 +9,7 @@ import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation' import {setReviewDuplicatesKey} from '@libs/actions/Transaction'; import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types'; import * as TransactionUtils from '@libs/TransactionUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import type {FieldItemType} from './ReviewFields'; import ReviewFields from './ReviewFields'; @@ -16,7 +18,8 @@ function ReviewReimbursable() { const route = useRoute>(); const {translate} = useLocalize(); const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? ''); - const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID); + const [reviewDuplicates] = useOnyx(ONYXKEYS.REVIEW_DUPLICATES); + const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID, reviewDuplicates?.reportID ?? '-1'); const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString()); const {currentScreenIndex, goBack, navigateToNextScreen} = useReviewDuplicatesNavigation( Object.keys(compareResult.change ?? {}), diff --git a/src/pages/TransactionDuplicate/ReviewTag.tsx b/src/pages/TransactionDuplicate/ReviewTag.tsx index 03fb627abd8e..16138865cfd0 100644 --- a/src/pages/TransactionDuplicate/ReviewTag.tsx +++ b/src/pages/TransactionDuplicate/ReviewTag.tsx @@ -1,6 +1,7 @@ import type {RouteProp} from '@react-navigation/native'; import {useRoute} from '@react-navigation/native'; import React, {useMemo} from 'react'; +import {useOnyx} from 'react-native-onyx'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import useLocalize from '@hooks/useLocalize'; @@ -9,6 +10,7 @@ import {setReviewDuplicatesKey} from '@libs/actions/Transaction'; import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types'; import * as PolicyUtils from '@libs/PolicyUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import type {FieldItemType} from './ReviewFields'; import ReviewFields from './ReviewFields'; @@ -18,7 +20,8 @@ function ReviewTag() { const {translate} = useLocalize(); const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? ''); - const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID); + const [reviewDuplicates] = useOnyx(ONYXKEYS.REVIEW_DUPLICATES); + const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID, reviewDuplicates?.reportID ?? '-1'); const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString()); const {currentScreenIndex, goBack, navigateToNextScreen} = useReviewDuplicatesNavigation( Object.keys(compareResult.change ?? {}), diff --git a/src/pages/TransactionDuplicate/ReviewTaxCode.tsx b/src/pages/TransactionDuplicate/ReviewTaxCode.tsx index 78b7c1934715..857a93429f00 100644 --- a/src/pages/TransactionDuplicate/ReviewTaxCode.tsx +++ b/src/pages/TransactionDuplicate/ReviewTaxCode.tsx @@ -20,10 +20,11 @@ import ReviewFields from './ReviewFields'; function ReviewTaxRate() { const route = useRoute>(); const {translate} = useLocalize(); - const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${route.params.threadReportID}`); + const [reviewDuplicates] = useOnyx(ONYXKEYS.REVIEW_DUPLICATES); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reviewDuplicates?.reportID ?? route.params.threadReportID}`); const policy = PolicyUtils.getPolicy(report?.policyID ?? ''); const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? ''); - const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID); + const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID, reviewDuplicates?.reportID ?? '-1'); const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString()); const {currentScreenIndex, goBack, navigateToNextScreen} = useReviewDuplicatesNavigation( Object.keys(compareResult.change ?? {}), diff --git a/src/pages/Travel/MyTripsPage.tsx b/src/pages/Travel/MyTripsPage.tsx index be29e8dc8c12..58db57685ab7 100644 --- a/src/pages/Travel/MyTripsPage.tsx +++ b/src/pages/Travel/MyTripsPage.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import {NativeModules} from 'react-native'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -20,7 +21,7 @@ function MyTripsPage() { > - + Navigation.goBack()} diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 0ca9dcdc2de3..4c3ed5c705a5 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -10,7 +10,6 @@ import {useOnyx} from 'react-native-onyx'; import Banner from '@components/Banner'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import DragAndDropProvider from '@components/DragAndDrop/Provider'; -import LoadingBar from '@components/LoadingBar'; import MoneyReportHeader from '@components/MoneyReportHeader'; import MoneyRequestHeader from '@components/MoneyRequestHeader'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -130,7 +129,6 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); const [workspaceTooltip] = useOnyx(ONYXKEYS.NVP_WORKSPACE_TOOLTIP); const wasLoadingApp = usePrevious(isLoadingApp); - const [isLoadingReportData] = useOnyx(ONYXKEYS.IS_LOADING_REPORT_DATA, {initialValue: true}); const finishedLoadingApp = wasLoadingApp && !isLoadingApp; const isDeletedParentAction = ReportActionsUtils.isDeletedParentAction(parentReportAction); const prevIsDeletedParentAction = usePrevious(isDeletedParentAction); @@ -758,7 +756,6 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro needsOffscreenAlphaCompositing > {headerView} - {shouldUseNarrowLayout && !!isLoadingReportData && } {!!report && ReportUtils.isTaskReport(report) && shouldUseNarrowLayout && ReportUtils.isOpenTaskReport(report, parentReportAction) && ( diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e.tsx index f3390fe10694..336b5ab22424 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e.tsx +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e.tsx @@ -57,11 +57,12 @@ function ComposerWithSuggestionsE2e(props: ComposerWithSuggestionsProps, ref: Fo textInputRef.current?.blur(); setFocus(); - // Simulate user behavior and don't set focus immediately - }, 5_000); + // 1000ms is enough time for any keyboard to open + }, 1_000); }; - setFocus(); + // Simulate user behavior and don't set focus immediately + setTimeout(setFocus, 2_000); }, []); return ( diff --git a/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.tsx b/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.tsx index 77c21d4ab2e1..e77f2000b85f 100644 --- a/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.tsx +++ b/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.tsx @@ -1,7 +1,6 @@ import React, {useEffect} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; -import LoadingBar from '@components/LoadingBar'; import ScreenWrapper from '@components/ScreenWrapper'; import useActiveWorkspaceFromNavigationState from '@hooks/useActiveWorkspaceFromNavigationState'; import useLocalize from '@hooks/useLocalize'; @@ -32,8 +31,6 @@ function BaseSidebarScreen() { const {shouldUseNarrowLayout} = useResponsiveLayout(); const [activeWorkspace] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activeWorkspaceID ?? -1}`); - const [isLoadingReportData] = useOnyx(ONYXKEYS.IS_LOADING_REPORT_DATA, {initialValue: true}); - useEffect(() => { Performance.markStart(CONST.TIMING.SIDEBAR_LOADED); Timing.start(CONST.TIMING.SIDEBAR_LOADED); @@ -65,7 +62,6 @@ function BaseSidebarScreen() { activeWorkspaceID={activeWorkspaceID} shouldDisplaySearch={shouldDisplaySearch} /> - { + if (isEmptyObject(activePolicy) || !activePolicy?.isPolicyExpenseChatEnabled) { + return {} as OnyxTypes.Report; + } + const policyChatsForActivePolicy = ReportUtils.getWorkspaceChats(activePolicyID ?? '-1', [session?.accountID ?? -1], allReports); + return policyChatsForActivePolicy.length > 0 ? policyChatsForActivePolicy.at(0) : ({} as OnyxTypes.Report); + }, [activePolicy, activePolicyID, session?.accountID, allReports]); const [quickActionPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${quickActionReport?.policyID}`); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: (c) => mapOnyxCollectionItems(c, policySelector)}); const [hasSeenTrackTraining] = useOnyx(ONYXKEYS.NVP_HAS_SEEN_TRACK_TRAINING); @@ -179,10 +189,13 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: Fl const avatars = ReportUtils.getIcons(quickActionReport, personalDetails); return avatars.length <= 1 || ReportUtils.isPolicyExpenseChat(quickActionReport) ? avatars : avatars.filter((avatar) => avatar.id !== session?.accountID); } + if (!isEmptyObject(policyChatForActivePolicy)) { + return ReportUtils.getIcons(policyChatForActivePolicy, personalDetails); + } return []; // Policy is needed as a dependency in order to update the shortcut details when the workspace changes // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - }, [personalDetails, session?.accountID, quickActionReport, quickActionPolicy]); + }, [personalDetails, session?.accountID, quickActionReport, quickActionPolicy, policyChatForActivePolicy]); const renderQuickActionTooltip = useCallback( () => ( @@ -217,16 +230,18 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: Fl return quickAction?.action === CONST.QUICK_ACTIONS.SEND_MONEY && displayName.length === 0; }, [personalDetails, quickActionReport, quickAction?.action, quickActionAvatars]); - const navigateToQuickAction = () => { - const selectOption = (onSelected: () => void, shouldRestrictAction: boolean) => { + const selectOption = useCallback( + (onSelected: () => void, shouldRestrictAction: boolean) => { if (shouldRestrictAction && quickActionReport?.policyID && SubscriptionUtils.shouldRestrictUserBillableActions(quickActionReport.policyID)) { Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(quickActionReport.policyID)); return; } - onSelected(); - }; + }, + [quickActionReport?.policyID], + ); + const navigateToQuickAction = useCallback(() => { const isValidReport = !(isEmptyObject(quickActionReport) || ReportUtils.isArchivedRoom(quickActionReport, reportNameValuePairs)); const quickActionReportID = isValidReport ? quickActionReport?.reportID ?? '-1' : ReportUtils.generateReportID(); @@ -266,7 +281,7 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: Fl break; default: } - }; + }, [quickAction, quickActionReport, reportNameValuePairs, selectOption]); /** * Check if LHN status changed from active to inactive. @@ -397,6 +412,77 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: Fl ]; }, [canUseCombinedTrackSubmit, translate, selfDMReportID, hasSeenTrackTraining, isOffline]); + const quickActionMenuItems = useMemo(() => { + // Define common properties in baseQuickAction + const baseQuickAction = { + label: translate('quickAction.header'), + isLabelHoverable: false, + floatRightAvatars: quickActionAvatars, + floatRightAvatarSize: CONST.AVATAR_SIZE.SMALL, + numberOfLinesDescription: 1, + tooltipAnchorAlignment: { + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, + }, + tooltipShiftHorizontal: styles.popoverMenuItem.paddingHorizontal, + tooltipShiftVertical: styles.popoverMenuItem.paddingVertical / 2, + renderTooltipContent: renderQuickActionTooltip, + tooltipWrapperStyle: styles.quickActionTooltipWrapper, + }; + + if (quickAction?.action) { + return [ + { + ...baseQuickAction, + icon: getQuickActionIcon(quickAction?.action), + text: quickActionTitle, + description: !hideQABSubtitle ? ReportUtils.getReportName(quickActionReport) ?? translate('quickAction.updateDestination') : '', + onSelected: () => interceptAnonymousUser(() => navigateToQuickAction()), + shouldShowSubscriptRightAvatar: ReportUtils.isPolicyExpenseChat(quickActionReport), + shouldRenderTooltip: quickAction.isFirstQuickAction, + }, + ]; + } + if (!isEmptyObject(policyChatForActivePolicy)) { + return [ + { + ...baseQuickAction, + icon: Expensicons.ReceiptScan, + text: translate('quickAction.scanReceipt'), + description: ReportUtils.getReportName(policyChatForActivePolicy), + onSelected: () => + interceptAnonymousUser(() => { + selectOption(() => { + const isValidReport = !(isEmptyObject(policyChatForActivePolicy) || ReportUtils.isArchivedRoom(policyChatForActivePolicy, reportNameValuePairs)); + const quickActionReportID = isValidReport ? policyChatForActivePolicy?.reportID ?? '-1' : ReportUtils.generateReportID(); + IOU.startMoneyRequest(CONST.IOU.TYPE.SUBMIT, quickActionReportID ?? '-1', CONST.IOU.REQUEST_TYPE.SCAN, true); + }, true); + }), + shouldShowSubscriptRightAvatar: true, + shouldRenderTooltip: false, + }, + ]; + } + + return []; + }, [ + translate, + quickActionAvatars, + styles.popoverMenuItem.paddingHorizontal, + styles.popoverMenuItem.paddingVertical, + styles.quickActionTooltipWrapper, + renderQuickActionTooltip, + quickAction?.action, + quickAction?.isFirstQuickAction, + policyChatForActivePolicy, + quickActionTitle, + hideQABSubtitle, + quickActionReport, + navigateToQuickAction, + selectOption, + reportNameValuePairs, + ]); + return ( interceptAnonymousUser(() => navigateToQuickAction()), - shouldShowSubscriptRightAvatar: ReportUtils.isPolicyExpenseChat(quickActionReport), - shouldRenderTooltip: quickAction.isFirstQuickAction, - tooltipAnchorAlignment: { - vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, - horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, - }, - tooltipShiftHorizontal: styles.popoverMenuItem.paddingHorizontal, - tooltipShiftVertical: styles.popoverMenuItem.paddingVertical / 2, - renderTooltipContent: renderQuickActionTooltip, - tooltipWrapperStyle: styles.quickActionTooltipWrapper, - }, - ] - : []), + ...quickActionMenuItems, ]} withoutOverlay anchorRef={fabRef} diff --git a/src/pages/iou/request/step/IOURequestStepAmount.tsx b/src/pages/iou/request/step/IOURequestStepAmount.tsx index ea2b58931039..9abc22fedb3b 100644 --- a/src/pages/iou/request/step/IOURequestStepAmount.tsx +++ b/src/pages/iou/request/step/IOURequestStepAmount.tsx @@ -11,6 +11,7 @@ import * as CurrencyUtils from '@libs/CurrencyUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as ReportUtils from '@libs/ReportUtils'; +import playSound, {SOUNDS} from '@libs/Sound'; import * as TransactionUtils from '@libs/TransactionUtils'; import {getRequestType} from '@libs/TransactionUtils'; import MoneyRequestAmountForm from '@pages/iou/MoneyRequestAmountForm'; @@ -50,6 +51,12 @@ function IOURequestStepAmount({ currentUserPersonalDetails, shouldKeepUserInput = false, }: IOURequestStepAmountProps) { + const [splitDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID ?? -1}`); + const [draftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID ?? -1}`); + const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID ?? -1}`); + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report ? report.policyID : -1}`); + const {translate} = useLocalize(); const textInput = useRef(null); const focusTimeoutRef = useRef(null); @@ -69,7 +76,7 @@ function IOURequestStepAmount({ const isEditingSplitBill = isEditing && isSplitBill; const currentTransaction = isEditingSplitBill && !isEmptyObject(splitDraftTransaction) ? splitDraftTransaction : transaction; const {amount: transactionAmount} = ReportUtils.getTransactionDetails(currentTransaction) ?? {amount: 0}; - const {currency: originalCurrency} = ReportUtils.getTransactionDetails(isEditing ? draftTransaction : transaction) ?? {currency: CONST.CURRENCY.USD}; + const {currency: originalCurrency} = ReportUtils.getTransactionDetails(isEditing && !isEmptyObject(draftTransaction) ? draftTransaction : transaction) ?? {currency: CONST.CURRENCY.USD}; const currency = CurrencyUtils.isValidCurrencyCode(selectedCurrency) ? selectedCurrency : originalCurrency; // For quick button actions, we'll skip the confirmation page unless the report is archived or this is a workspace request, as @@ -179,6 +186,7 @@ function IOURequestStepAmount({ if (shouldSkipConfirmation) { // Only skip confirmation when the split is not configurable, for now Smartscanned splits cannot be configured if (iouType === CONST.IOU.TYPE.SPLIT && transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.SCAN) { + playSound(SOUNDS.DONE); IOU.splitBill({ participants, currentUserLogin: currentUserPersonalDetails.login ?? '', @@ -207,6 +215,7 @@ function IOURequestStepAmount({ return; } if (iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.REQUEST) { + playSound(SOUNDS.DONE); IOU.requestMoney( report, backendAmount, @@ -223,6 +232,7 @@ function IOURequestStepAmount({ return; } if (iouType === CONST.IOU.TYPE.TRACK) { + playSound(SOUNDS.DONE); IOU.trackExpense( report, backendAmount, diff --git a/src/pages/iou/request/step/IOURequestStepCompanyInfo.tsx b/src/pages/iou/request/step/IOURequestStepCompanyInfo.tsx index d53940a4cfcb..1c2f14dbbb2b 100644 --- a/src/pages/iou/request/step/IOURequestStepCompanyInfo.tsx +++ b/src/pages/iou/request/step/IOURequestStepCompanyInfo.tsx @@ -1,5 +1,5 @@ import {Str} from 'expensify-common'; -import React, {useCallback} from 'react'; +import React, {useCallback, useMemo} from 'react'; import {useOnyx} from 'react-native-onyx'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; @@ -11,6 +11,7 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useLocalize from '@hooks/useLocalize'; import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; +import {getDefaultCompanyWebsite} from '@libs/BankAccountUtils'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import playSound, {SOUNDS} from '@libs/Sound'; import * as Url from '@libs/Url'; @@ -37,6 +38,9 @@ function IOURequestStepCompanyInfo({route, report, transaction}: IOURequestStepC const {translate} = useLocalize(); const {inputCallbackRef} = useAutoFocusInput(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const [session] = useOnyx(ONYXKEYS.SESSION); + const [user] = useOnyx(ONYXKEYS.USER); + const defaultWebsiteExample = useMemo(() => getDefaultCompanyWebsite(session, user), [session, user]); const policy = usePolicy(IOU.getIOURequestPolicyID(transaction, report)); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${IOU.getIOURequestPolicyID(transaction, report)}`); @@ -47,9 +51,9 @@ function IOURequestStepCompanyInfo({route, report, transaction}: IOURequestStepC const validate = useCallback( (values: FormOnyxValues): FormInputErrors => { const errors = ValidationUtils.getFieldRequiredErrors(values, [INPUT_IDS.COMPANY_NAME, INPUT_IDS.COMPANY_WEBSITE]); - if (values.companyWebsite) { - if (!ValidationUtils.isValidWebsite(values.companyWebsite)) { + const companyWebsite = Str.sanitizeURL(values.companyWebsite, CONST.COMPANY_WEBSITE_DEFAULT_SCHEME); + if (!ValidationUtils.isValidWebsite(companyWebsite)) { errors.companyWebsite = translate('bankAccount.error.website'); } else { const domain = Url.extractUrlDomain(values.companyWebsite); @@ -68,8 +72,9 @@ function IOURequestStepCompanyInfo({route, report, transaction}: IOURequestStepC ); const submit = (values: FormOnyxValues) => { + const companyWebsite = Str.sanitizeURL(values.companyWebsite, CONST.COMPANY_WEBSITE_DEFAULT_SCHEME); playSound(SOUNDS.DONE); - IOU.sendInvoice(currentUserPersonalDetails.accountID, transaction, report, undefined, policy, policyTags, policyCategories, values.companyName, values.companyWebsite); + IOU.sendInvoice(currentUserPersonalDetails.accountID, transaction, report, undefined, policy, policyTags, policyCategories, values.companyName, companyWebsite); }; return ( @@ -107,6 +112,7 @@ function IOURequestStepCompanyInfo({route, report, transaction}: IOURequestStepC accessibilityLabel={translate('iou.yourCompanyWebsite')} role={CONST.ROLE.PRESENTATION} hint={translate('iou.yourCompanyWebsiteNote')} + defaultValue={defaultWebsiteExample} /> diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx index e61c1c3c404a..ae3ecff9adb2 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx @@ -29,6 +29,7 @@ import Navigation from '@libs/Navigation/Navigation'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReportUtils from '@libs/ReportUtils'; +import playSound, {SOUNDS} from '@libs/Sound'; import * as TransactionUtils from '@libs/TransactionUtils'; import * as IOU from '@userActions/IOU'; import * as MapboxToken from '@userActions/MapboxToken'; @@ -162,9 +163,11 @@ function IOURequestStepDistance({ } return ( - !ReportUtils.isArchivedRoom(report, reportNameValuePairs) && !(ReportUtils.isPolicyExpenseChat(report) && ((policy?.requiresCategory ?? false) || (policy?.requiresTag ?? false))) + iouType !== CONST.IOU.TYPE.SPLIT && + !ReportUtils.isArchivedRoom(report, reportNameValuePairs) && + !(ReportUtils.isPolicyExpenseChat(report) && ((policy?.requiresCategory ?? false) || (policy?.requiresTag ?? false))) ); - }, [report, skipConfirmation, policy, reportNameValuePairs]); + }, [report, skipConfirmation, policy, reportNameValuePairs, iouType]); let buttonText = !isCreatingNewRequest ? translate('common.save') : translate('common.next'); if (shouldSkipConfirmation) { if (iouType === CONST.IOU.TYPE.SPLIT) { @@ -301,28 +304,11 @@ function IOURequestStepDistance({ }); setDistanceRequestData(participants); if (shouldSkipConfirmation) { - if (iouType === CONST.IOU.TYPE.SPLIT) { - IOU.splitBill({ - participants, - currentUserLogin: currentUserPersonalDetails.login ?? '', - currentUserAccountID: currentUserPersonalDetails.accountID, - amount: 0, - comment: '', - currency: transaction?.currency ?? 'USD', - merchant: translate('iou.fieldPending'), - created: transaction?.created ?? '', - category: '', - tag: '', - billable: false, - iouRequestType, - existingSplitChatReportID: report?.reportID, - }); - return; - } IOU.setMoneyRequestPendingFields(transactionID, {waypoints: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}); IOU.setMoneyRequestMerchant(transactionID, translate('iou.fieldPending'), false); const participant = participants.at(0); if (iouType === CONST.IOU.TYPE.TRACK && participant) { + playSound(SOUNDS.DONE); IOU.trackExpense( report, 0, @@ -354,6 +340,7 @@ function IOURequestStepDistance({ return; } + playSound(SOUNDS.DONE); IOU.createDistanceRequest( report, participants, @@ -402,7 +389,6 @@ function IOURequestStepDistance({ navigateToParticipantPage, navigateToConfirmationPage, policy, - iouRequestType, reportNameValuePairs, customUnitRateID, setDistanceRequestData, diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx index 83fea45848a6..418474e117ed 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx @@ -30,11 +30,13 @@ import useThemeStyles from '@hooks/useThemeStyles'; import * as FileUtils from '@libs/fileDownload/FileUtils'; import getPhotoSource from '@libs/fileDownload/getPhotoSource'; import getCurrentPosition from '@libs/getCurrentPosition'; +import getPlatform from '@libs/getPlatform'; import * as IOUUtils from '@libs/IOUUtils'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as ReportUtils from '@libs/ReportUtils'; +import playSound, {SOUNDS} from '@libs/Sound'; import * as TransactionUtils from '@libs/TransactionUtils'; import StepScreenWrapper from '@pages/iou/request/step/StepScreenWrapper'; import withFullTransactionOrNotFound from '@pages/iou/request/step/withFullTransactionOrNotFound'; @@ -74,7 +76,9 @@ function IOURequestStepScan({ const policy = usePolicy(report?.policyID); const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID ?? -1}`); - const [user] = useOnyx(ONYXKEYS.USER); + const platform = getPlatform(true); + const [mutedPlatforms = {}] = useOnyx(ONYXKEYS.NVP_MUTED_PLATFORMS); + const isPlatformMuted = mutedPlatforms[platform]; const [cameraPermissionStatus, setCameraPermissionStatus] = useState(null); const [didCapturePhoto, setDidCapturePhoto] = useState(false); const [isLoadingReceipt, setIsLoadingReceipt] = useState(false); @@ -296,6 +300,7 @@ function IOURequestStepScan({ receipt.source = source; receipt.state = CONST.IOU.RECEIPT_STATE.SCANREADY; if (iouType === CONST.IOU.TYPE.SPLIT) { + playSound(SOUNDS.DONE); IOU.startSplitBill({ participants, currentUserLogin: currentUserPersonalDetails?.login ?? '', @@ -319,6 +324,7 @@ function IOURequestStepScan({ if (locationPermissionGranted) { getCurrentPosition( (successData) => { + playSound(SOUNDS.DONE); if (iouType === CONST.IOU.TYPE.TRACK && report) { IOU.trackExpense( report, @@ -376,6 +382,7 @@ function IOURequestStepScan({ (errorData) => { Log.info('[IOURequestStepScan] getCurrentPosition failed', false, errorData); // When there is an error, the money can still be requested, it just won't include the GPS coordinates + playSound(SOUNDS.DONE); createTransaction(receipt, participant); }, { @@ -385,6 +392,7 @@ function IOURequestStepScan({ ); return; } + playSound(SOUNDS.DONE); createTransaction(receipt, participant); return; } @@ -491,7 +499,7 @@ function IOURequestStepScan({ camera?.current ?.takePhoto({ flash: flash && hasFlash ? 'on' : 'off', - enableShutterSound: !user?.isMutedAllSounds, + enableShutterSound: !isPlatformMuted, }) .then((photo: PhotoFile) => { // Store the receipt on the transaction object in Onyx @@ -537,7 +545,7 @@ function IOURequestStepScan({ didCapturePhoto, flash, hasFlash, - user?.isMutedAllSounds, + isPlatformMuted, translate, transactionID, isEditing, diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.tsx index 9f89303ac63b..1a81037826cf 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.tsx @@ -35,6 +35,7 @@ import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as ReportUtils from '@libs/ReportUtils'; +import playSound, {SOUNDS} from '@libs/Sound'; import * as TransactionUtils from '@libs/TransactionUtils'; import ReceiptDropUI from '@pages/iou/ReceiptDropUI'; import StepScreenDragAndDropWrapper from '@pages/iou/request/step/StepScreenDragAndDropWrapper'; @@ -329,6 +330,7 @@ function IOURequestStepScan({ receipt.source = source; receipt.state = CONST.IOU.RECEIPT_STATE.SCANREADY; if (iouType === CONST.IOU.TYPE.SPLIT) { + playSound(SOUNDS.DONE); IOU.startSplitBill({ participants, currentUserLogin: currentUserPersonalDetails?.login ?? '', @@ -352,6 +354,7 @@ function IOURequestStepScan({ if (locationPermissionGranted) { getCurrentPosition( (successData) => { + playSound(SOUNDS.DONE); if (iouType === CONST.IOU.TYPE.TRACK && report) { IOU.trackExpense( report, @@ -409,6 +412,7 @@ function IOURequestStepScan({ (errorData) => { Log.info('[IOURequestStepScan] getCurrentPosition failed', false, errorData); // When there is an error, the money can still be requested, it just won't include the GPS coordinates + playSound(SOUNDS.DONE); createTransaction(receipt, participant); }, { @@ -418,6 +422,7 @@ function IOURequestStepScan({ ); return; } + playSound(SOUNDS.DONE); createTransaction(receipt, participant); return; } diff --git a/src/pages/settings/Preferences/PreferencesPage.tsx b/src/pages/settings/Preferences/PreferencesPage.tsx index 5dee30518533..6616d342aa3c 100755 --- a/src/pages/settings/Preferences/PreferencesPage.tsx +++ b/src/pages/settings/Preferences/PreferencesPage.tsx @@ -13,7 +13,6 @@ import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; -import * as Browser from '@libs/Browser'; import getPlatform from '@libs/getPlatform'; import LocaleUtils from '@libs/LocaleUtils'; import Navigation from '@libs/Navigation/Navigation'; @@ -25,7 +24,7 @@ import ROUTES from '@src/ROUTES'; function PreferencesPage() { const [priorityMode] = useOnyx(ONYXKEYS.NVP_PRIORITY_MODE); - const platform = Browser.isMobile() ? CONST.PLATFORM.MOBILEWEB : getPlatform(); + const platform = getPlatform(true); const [mutedPlatforms = {}] = useOnyx(ONYXKEYS.NVP_MUTED_PLATFORMS); const isPlatformMuted = mutedPlatforms[platform]; const [user] = useOnyx(ONYXKEYS.USER); diff --git a/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx b/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx index 2c9c2975d1d2..11acbda1ebbe 100644 --- a/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx +++ b/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx @@ -12,7 +12,6 @@ import Section from '@components/Section'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; -import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import * as CardUtils from '@libs/CardUtils'; @@ -63,7 +62,6 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro const styles = useThemeStyles(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {translate} = useLocalize(); - const {canUseCompanyCardFeeds} = usePermissions(); const hasAccountingConnection = !isEmptyObject(policy?.connections); const isAccountingEnabled = !!policy?.areConnectionsEnabled || !isEmptyObject(policy?.connections); const isSyncTaxEnabled = @@ -120,31 +118,27 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro }, ]; - if (canUseCompanyCardFeeds) { - spendItems.push({ - icon: Illustrations.CompanyCard, - titleTranslationKey: 'workspace.moreFeatures.companyCards.title', - subtitleTranslationKey: 'workspace.moreFeatures.companyCards.subtitle', - isActive: policy?.areCompanyCardsEnabled ?? false, - pendingAction: policy?.pendingFields?.areCompanyCardsEnabled, - disabled: !isEmptyObject(CardUtils.removeExpensifyCardFromCompanyCards(cardFeeds?.settings?.companyCards)), - action: (isEnabled: boolean) => { - if (!policyID) { - return; - } - if (isEnabled && !isControlPolicy(policy)) { - Navigation.navigate( - ROUTES.WORKSPACE_UPGRADE.getRoute(policyID, CONST.UPGRADE_FEATURE_INTRO_MAPPING.companyCards.alias, ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID)), - ); - return; - } - Policy.enableCompanyCards(policyID, isEnabled); - }, - disabledAction: () => { - setIsDisableCompanyCardsWarningModalOpen(true); - }, - }); - } + spendItems.push({ + icon: Illustrations.CompanyCard, + titleTranslationKey: 'workspace.moreFeatures.companyCards.title', + subtitleTranslationKey: 'workspace.moreFeatures.companyCards.subtitle', + isActive: policy?.areCompanyCardsEnabled ?? false, + pendingAction: policy?.pendingFields?.areCompanyCardsEnabled, + disabled: !isEmptyObject(CardUtils.removeExpensifyCardFromCompanyCards(cardFeeds?.settings?.companyCards)), + action: (isEnabled: boolean) => { + if (!policyID) { + return; + } + if (isEnabled && !isControlPolicy(policy)) { + Navigation.navigate(ROUTES.WORKSPACE_UPGRADE.getRoute(policyID, CONST.UPGRADE_FEATURE_INTRO_MAPPING.companyCards.alias, ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID))); + return; + } + Policy.enableCompanyCards(policyID, isEnabled); + }, + disabledAction: () => { + setIsDisableCompanyCardsWarningModalOpen(true); + }, + }); const manageItems: Item[] = [ { diff --git a/src/pages/workspace/accounting/qbd/QuickBooksDesktopSetupPage.tsx b/src/pages/workspace/accounting/qbd/QuickBooksDesktopSetupPage.tsx index 559cdc0c7377..899629c4723f 100644 --- a/src/pages/workspace/accounting/qbd/QuickBooksDesktopSetupPage.tsx +++ b/src/pages/workspace/accounting/qbd/QuickBooksDesktopSetupPage.tsx @@ -8,16 +8,22 @@ import CopyTextToClipboard from '@components/CopyTextToClipboard'; import FixedFooter from '@components/FixedFooter'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import Icon from '@components/Icon'; +import * as Illustrations from '@components/Icon/Illustrations'; import ImageSVG from '@components/ImageSVG'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; +import TextLink from '@components/TextLink'; +import useEnvironment from '@hooks/useEnvironment'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; +import {setConnectionError} from '@libs/actions/connections'; import * as QuickbooksDesktop from '@libs/actions/connections/QuickbooksDesktop'; import Navigation from '@libs/Navigation/Navigation'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import * as PolicyAction from '@userActions/Policy/Policy'; +import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; @@ -26,20 +32,32 @@ type RequireQuickBooksDesktopModalProps = StackScreenProps(''); + const hasResultOfFetchingSetupLink = !!codatSetupLink || hasError; - const ContentWrapper = codatSetupLink ? ({children}: React.PropsWithChildren) => children : FullPageOfflineBlockingView; + const ContentWrapper = hasResultOfFetchingSetupLink ? ({children}: React.PropsWithChildren) => children : FullPageOfflineBlockingView; const fetchSetupLink = useCallback(() => { setIsLoading(true); + setHasError(false); // eslint-disable-next-line rulesdir/no-thenable-actions-in-views QuickbooksDesktop.getQuickbooksDesktopCodatSetupLink(policyID).then((response) => { - setCodatSetupLink(String(response?.setupUrl ?? '')); + if (response?.jsonCode) { + if (response.jsonCode === CONST.JSON_CODE.SUCCESS) { + setCodatSetupLink(String(response?.setupUrl ?? '')); + } else { + setConnectionError(policyID, CONST.POLICY.CONNECTIONS.NAME.QBD, translate('workspace.qbd.setupPage.setupErrorTitle')); + setHasError(true); + } + } + setIsLoading(false); }); - }, [policyID]); + }, [policyID, translate]); useEffect(() => { // Since QBD doesn't support Taxes, we should disable them from the LHN when connecting to QBD @@ -52,13 +70,16 @@ function RequireQuickBooksDesktopModal({route}: RequireQuickBooksDesktopModalPro useNetwork({ onReconnect: () => { - if (codatSetupLink) { + if (hasResultOfFetchingSetupLink) { return; } fetchSetupLink(); }, }); + const shouldShowLoading = isLoading || !hasResultOfFetchingSetupLink; + const shouldShowError = !shouldShowLoading && hasError; + return ( Navigation.dismissModal()} /> - {isLoading || !codatSetupLink ? ( - - ) : ( + {shouldShowLoading && } + {shouldShowError && ( + + + {translate('workspace.qbd.setupPage.setupErrorTitle')} + + {translate('workspace.qbd.setupPage.setupErrorBody1')}{' '} + + {translate('workspace.qbd.setupPage.setupErrorBodyContactConcierge')} + {' '} + {translate('workspace.qbd.setupPage.setupErrorBody2')} + + + )} + {!shouldShowLoading && !shouldShowError && ( diff --git a/src/pages/workspace/companyCards/addNew/AddNewCardPage.tsx b/src/pages/workspace/companyCards/addNew/AddNewCardPage.tsx index 1a3dce65788f..2fef7016df97 100644 --- a/src/pages/workspace/companyCards/addNew/AddNewCardPage.tsx +++ b/src/pages/workspace/companyCards/addNew/AddNewCardPage.tsx @@ -1,6 +1,5 @@ import React from 'react'; import {useOnyx} from 'react-native-onyx'; -import usePermissions from '@hooks/usePermissions'; import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; import CONST from '@src/CONST'; @@ -17,44 +16,27 @@ import SelectFeedType from './SelectFeedType'; function AddNewCardPage({policy}: WithPolicyAndFullscreenLoadingProps) { const policyID = policy?.id; const [addNewCardFeed] = useOnyx(ONYXKEYS.ADD_NEW_COMPANY_CARD); - const {canUseDirectFeeds} = usePermissions(); - const {currentStep} = addNewCardFeed ?? {}; - if (canUseDirectFeeds) { - switch (currentStep) { - case CONST.COMPANY_CARDS.STEP.SELECT_BANK: - return ; - case CONST.COMPANY_CARDS.STEP.SELECT_FEED_TYPE: - return ; - case CONST.COMPANY_CARDS.STEP.CARD_TYPE: - return ; - case CONST.COMPANY_CARDS.STEP.BANK_CONNECTION: - return ; - case CONST.COMPANY_CARDS.STEP.CARD_INSTRUCTIONS: - return ; - case CONST.COMPANY_CARDS.STEP.CARD_NAME: - return ; - case CONST.COMPANY_CARDS.STEP.CARD_DETAILS: - return ; - case CONST.COMPANY_CARDS.STEP.AMEX_CUSTOM_FEED: - return ; - default: - return ; - } - } else { - switch (currentStep) { - case CONST.COMPANY_CARDS.STEP.CARD_TYPE: - return ; - case CONST.COMPANY_CARDS.STEP.CARD_INSTRUCTIONS: - return ; - case CONST.COMPANY_CARDS.STEP.CARD_NAME: - return ; - case CONST.COMPANY_CARDS.STEP.CARD_DETAILS: - return ; - default: - return ; - } + switch (currentStep) { + case CONST.COMPANY_CARDS.STEP.SELECT_BANK: + return ; + case CONST.COMPANY_CARDS.STEP.SELECT_FEED_TYPE: + return ; + case CONST.COMPANY_CARDS.STEP.CARD_TYPE: + return ; + case CONST.COMPANY_CARDS.STEP.BANK_CONNECTION: + return ; + case CONST.COMPANY_CARDS.STEP.CARD_INSTRUCTIONS: + return ; + case CONST.COMPANY_CARDS.STEP.CARD_NAME: + return ; + case CONST.COMPANY_CARDS.STEP.CARD_DETAILS: + return ; + case CONST.COMPANY_CARDS.STEP.AMEX_CUSTOM_FEED: + return ; + default: + return ; } } diff --git a/src/pages/workspace/companyCards/addNew/CardInstructionsStep.tsx b/src/pages/workspace/companyCards/addNew/CardInstructionsStep.tsx index 1b49074a116a..969625b224d6 100644 --- a/src/pages/workspace/companyCards/addNew/CardInstructionsStep.tsx +++ b/src/pages/workspace/companyCards/addNew/CardInstructionsStep.tsx @@ -9,7 +9,6 @@ import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; -import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; import * as CardUtils from '@libs/CardUtils'; import Parser from '@libs/Parser'; @@ -26,7 +25,6 @@ function CardInstructionsStep({policyID}: CardInstructionsStepProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const {isOffline} = useNetwork(); - const {canUseDirectFeeds} = usePermissions(); const [addNewCard] = useOnyx(ONYXKEYS.ADD_NEW_COMPANY_CARD); @@ -40,12 +38,12 @@ function CardInstructionsStep({policyID}: CardInstructionsStepProps) { const buttonTranslation = isStripeFeedProvider ? translate('common.submit') : translate('common.next'); const submit = () => { - if (canUseDirectFeeds && isStripeFeedProvider) { + if (isStripeFeedProvider) { Card.updateSelectedFeed(feedProvider, policyID ?? '-1'); Navigation.goBack(); return; } - if (!canUseDirectFeeds || isOtherBankSelected) { + if (isOtherBankSelected) { CompanyCards.setAddNewCompanyCardStepAndData({ step: CONST.COMPANY_CARDS.STEP.CARD_NAME, }); @@ -57,13 +55,13 @@ function CardInstructionsStep({policyID}: CardInstructionsStepProps) { }; const handleBackButtonPress = () => { - if (canUseDirectFeeds && isAmexFeedProvider) { + if (isAmexFeedProvider) { CompanyCards.setAddNewCompanyCardStepAndData({ step: CONST.COMPANY_CARDS.STEP.AMEX_CUSTOM_FEED, }); return; } - if (canUseDirectFeeds && isStripeFeedProvider) { + if (isStripeFeedProvider) { CompanyCards.setAddNewCompanyCardStepAndData({step: CONST.COMPANY_CARDS.STEP.SELECT_BANK}); return; } diff --git a/src/pages/workspace/companyCards/addNew/CardTypeStep.tsx b/src/pages/workspace/companyCards/addNew/CardTypeStep.tsx index 03dd2e9e63cd..7fded06b75f6 100644 --- a/src/pages/workspace/companyCards/addNew/CardTypeStep.tsx +++ b/src/pages/workspace/companyCards/addNew/CardTypeStep.tsx @@ -12,9 +12,7 @@ import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/RadioListItem'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; -import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; -import Navigation from '@navigation/Navigation'; import variables from '@styles/variables'; import * as CompanyCards from '@userActions/CompanyCards'; import CONST from '@src/CONST'; @@ -22,14 +20,13 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {CardFeedProvider} from '@src/types/onyx/CardFeeds'; type AvailableCompanyCardTypes = { - isAmexAvailable?: boolean; translate: LocaleContextProps['translate']; typeSelected?: CardFeedProvider; styles: StyleProp; }; -function getAvailableCompanyCardTypes({isAmexAvailable, translate, typeSelected, styles}: AvailableCompanyCardTypes) { - const defaultTypes = [ +function getAvailableCompanyCardTypes({translate, typeSelected, styles}: AvailableCompanyCardTypes) { + return [ { value: CONST.COMPANY_CARD.FEED_BANK_NAME.MASTER_CARD, text: translate('workspace.companyCards.addNewCard.cardProviders.cdf'), @@ -59,28 +56,6 @@ function getAvailableCompanyCardTypes({isAmexAvailable, translate, typeSelected, ), }, ]; - - if (!isAmexAvailable) { - return defaultTypes; - } - - return [ - { - value: CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX, - text: translate('workspace.companyCards.addNewCard.cardProviders.gl1025'), - keyForList: CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX, - isSelected: typeSelected === CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX, - leftElement: ( - - ), - }, - ...defaultTypes, - ]; } function CardTypeStep() { @@ -88,9 +63,11 @@ function CardTypeStep() { const styles = useThemeStyles(); const [addNewCard] = useOnyx(ONYXKEYS.ADD_NEW_COMPANY_CARD); const [typeSelected, setTypeSelected] = useState(); - const {canUseDirectFeeds} = usePermissions(); const [isError, setIsError] = useState(false); - const data = getAvailableCompanyCardTypes({isAmexAvailable: !canUseDirectFeeds, translate, typeSelected, styles: styles.mr3}); + const data = getAvailableCompanyCardTypes({translate, typeSelected, styles: styles.mr3}); + const {bankName, selectedBank, feedType} = addNewCard?.data ?? {}; + const isOtherBankSelected = selectedBank === CONST.COMPANY_CARDS.BANKS.OTHER; + const isNewCardTypeSelected = typeSelected !== feedType; const submit = () => { if (!typeSelected) { @@ -100,6 +77,7 @@ function CardTypeStep() { step: CONST.COMPANY_CARDS.STEP.CARD_INSTRUCTIONS, data: { feedType: typeSelected, + bankName: isNewCardTypeSelected && isOtherBankSelected ? '' : bankName, }, isEditing: false, }); @@ -111,11 +89,7 @@ function CardTypeStep() { }, [addNewCard?.data.feedType]); const handleBackButtonPress = () => { - if (canUseDirectFeeds) { - CompanyCards.setAddNewCompanyCardStepAndData({step: CONST.COMPANY_CARDS.STEP.SELECT_BANK}); - } else { - Navigation.goBack(); - } + CompanyCards.setAddNewCompanyCardStepAndData({step: CONST.COMPANY_CARDS.STEP.SELECT_BANK}); }; return ( diff --git a/src/pages/workspace/companyCards/addNew/DetailsStep.tsx b/src/pages/workspace/companyCards/addNew/DetailsStep.tsx index 1501124f15b7..a313275b6bb1 100644 --- a/src/pages/workspace/companyCards/addNew/DetailsStep.tsx +++ b/src/pages/workspace/companyCards/addNew/DetailsStep.tsx @@ -14,7 +14,6 @@ import TextInput from '@components/TextInput'; import TextLink from '@components/TextLink'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useLocalize from '@hooks/useLocalize'; -import usePermissions from '@hooks/usePermissions'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as ValidationUtils from '@libs/ValidationUtils'; @@ -36,7 +35,6 @@ function DetailsStep({policyID}: DetailsStepProps) { const theme = useTheme(); const styles = useThemeStyles(); const {inputCallbackRef} = useAutoFocusInput(); - const {canUseDirectFeeds} = usePermissions(); const [addNewCard] = useOnyx(ONYXKEYS.ADD_NEW_COMPANY_CARD); const [lastSelectedFeed] = useOnyx(`${ONYXKEYS.COLLECTION.LAST_SELECTED_FEED}${policyID}`); const feedProvider = addNewCard?.data?.feedType; @@ -61,7 +59,7 @@ function DetailsStep({policyID}: DetailsStepProps) { }; const handleBackButtonPress = () => { - if (!canUseDirectFeeds || isOtherBankSelected) { + if (isOtherBankSelected) { CompanyCards.setAddNewCompanyCardStepAndData({step: CONST.COMPANY_CARDS.STEP.CARD_NAME}); return; } diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardBankAccounts.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardBankAccounts.tsx index c14990ab720b..c42fd980470d 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardBankAccounts.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardBankAccounts.tsx @@ -152,7 +152,10 @@ function WorkspaceExpensifyCardBankAccounts({route}: WorkspaceExpensifyCardBankA text={translate('workspace.expensifyCard.gotIt')} style={[styles.m5]} pressOnEnter - onPress={() => Navigation.navigate(ROUTES.WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW.getRoute(policyID))} + onPress={() => { + Navigation.dismissModal(); + Navigation.navigate(ROUTES.WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW.getRoute(policyID)); + }} /> ); diff --git a/src/pages/workspace/invoices/WorkspaceInvoicingDetailsWebsite.tsx b/src/pages/workspace/invoices/WorkspaceInvoicingDetailsWebsite.tsx index cd2f559da3fa..e9c5d8e35187 100644 --- a/src/pages/workspace/invoices/WorkspaceInvoicingDetailsWebsite.tsx +++ b/src/pages/workspace/invoices/WorkspaceInvoicingDetailsWebsite.tsx @@ -1,6 +1,6 @@ import type {StackScreenProps} from '@react-navigation/stack'; import {Str} from 'expensify-common'; -import React from 'react'; +import React, {useMemo} from 'react'; import {useOnyx} from 'react-native-onyx'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; @@ -11,6 +11,7 @@ import TextInput from '@components/TextInput'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import {getDefaultCompanyWebsite} from '@libs/BankAccountUtils'; import * as Url from '@libs/Url'; import * as ValidationUtils from '@libs/ValidationUtils'; import Navigation from '@navigation/Navigation'; @@ -31,10 +32,14 @@ function WorkspaceInvoicingDetailsWebsite({route}: WorkspaceInvoicingDetailsWebs const {inputCallbackRef} = useAutoFocusInput(); const styles = useThemeStyles(); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); + const [session] = useOnyx(ONYXKEYS.SESSION); + const [user] = useOnyx(ONYXKEYS.USER); + const defaultWebsiteExample = useMemo(() => getDefaultCompanyWebsite(session, user), [session, user]); // eslint-disable-next-line @typescript-eslint/no-unused-vars const submit = (values: FormOnyxValues) => { - Policy.updateInvoiceCompanyWebsite(policyID, values[INPUT_IDS.COMPANY_WEBSITE]); + const companyWebsite = Str.sanitizeURL(values[INPUT_IDS.COMPANY_WEBSITE], CONST.COMPANY_WEBSITE_DEFAULT_SCHEME); + Policy.updateInvoiceCompanyWebsite(policyID, companyWebsite); Navigation.goBack(); }; @@ -44,7 +49,7 @@ function WorkspaceInvoicingDetailsWebsite({route}: WorkspaceInvoicingDetailsWebs const errors = ValidationUtils.getFieldRequiredErrors(values, [INPUT_IDS.COMPANY_WEBSITE]); if (values.companyWebsite) { - if (!ValidationUtils.isValidWebsite(values.companyWebsite)) { + if (!ValidationUtils.isValidWebsite(Str.sanitizeURL(values.companyWebsite, CONST.COMPANY_WEBSITE_DEFAULT_SCHEME))) { errors.companyWebsite = translate('bankAccount.error.website'); } else { const domain = Url.extractUrlDomain(values.companyWebsite); @@ -86,7 +91,7 @@ function WorkspaceInvoicingDetailsWebsite({route}: WorkspaceInvoicingDetailsWebs label={translate('workspace.invoices.companyWebsite')} accessibilityLabel={translate('workspace.invoices.companyWebsite')} role={CONST.ROLE.PRESENTATION} - defaultValue={policy?.invoice?.companyWebsite} + defaultValue={policy?.invoice?.companyWebsite ?? defaultWebsiteExample} ref={inputCallbackRef} inputMode={CONST.INPUT_MODE.URL} /> diff --git a/src/styles/index.ts b/src/styles/index.ts index fa47a4b071ce..b08987459a1a 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -5307,20 +5307,6 @@ const styles = (theme: ThemeColors) => left: 12, }, - progressBarWrapper: { - height: 2, - width: '100%', - backgroundColor: theme.border, - borderRadius: 5, - overflow: 'hidden', - }, - - progressBar: { - height: '100%', - backgroundColor: theme.success, - width: '100%', - }, - qbdSetupLinkBox: { backgroundColor: theme.hoverComponentBG, borderRadius: variables.componentBorderRadiusMedium, diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index f517a19c5ebf..070e78265ff2 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -1119,9 +1119,12 @@ function getAmountWidth(amount: string): number { return width; } -function getItemBackgroundColorStyle(isSelected: boolean, isFocused: boolean, selectedBG: string, focusedBG: string): ViewStyle { +function getItemBackgroundColorStyle(isSelected: boolean, isFocused: boolean, isDisabled: boolean, selectedBG: string, focusedBG: string): ViewStyle { let backgroundColor; - if (isSelected) { + + if (isDisabled) { + backgroundColor = undefined; + } else if (isSelected) { backgroundColor = selectedBG; } else if (isFocused) { backgroundColor = focusedBG; diff --git a/src/types/onyx/ReviewDuplicates.ts b/src/types/onyx/ReviewDuplicates.ts index 0682ed0a7f7c..6c5ccbd93481 100644 --- a/src/types/onyx/ReviewDuplicates.ts +++ b/src/types/onyx/ReviewDuplicates.ts @@ -8,6 +8,9 @@ type ReviewDuplicates = { /** ID of transaction we want to keep */ transactionID: string; + /** ID of the transaction report we want to keep */ + reportID: string; + /** Merchant which user want to keep */ merchant: string; diff --git a/src/types/onyx/User.ts b/src/types/onyx/User.ts index 56b7a83d1618..eb5f1d888c46 100644 --- a/src/types/onyx/User.ts +++ b/src/types/onyx/User.ts @@ -6,9 +6,6 @@ type User = { /** Whether we should use the staging version of the secure API server */ shouldUseStagingServer?: boolean; - /** Whether user muted all sounds in application */ - isMutedAllSounds?: boolean; - /** Is the user account validated? */ validated: boolean; diff --git a/tests/perf-test/SearchRouter.perf-test.tsx b/tests/perf-test/SearchRouter.perf-test.tsx index e5a0b2e30533..4154e80ab6b8 100644 --- a/tests/perf-test/SearchRouter.perf-test.tsx +++ b/tests/perf-test/SearchRouter.perf-test.tsx @@ -52,6 +52,8 @@ jest.mock('@react-navigation/native', () => { useFocusEffect: jest.fn(), useIsFocused: () => true, useRoute: () => jest.fn(), + // eslint-disable-next-line @typescript-eslint/naming-convention + UNSTABLE_usePreventRemove: () => jest.fn(), useNavigation: () => ({ navigate: jest.fn(), addListener: () => jest.fn(), diff --git a/tests/perf-test/SidebarUtils.perf-test.ts b/tests/perf-test/SidebarUtils.perf-test.ts index 4a6b12d726d9..4ea4e1d04b50 100644 --- a/tests/perf-test/SidebarUtils.perf-test.ts +++ b/tests/perf-test/SidebarUtils.perf-test.ts @@ -2,7 +2,6 @@ import {rand} from '@ngneat/falso'; import type {OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import {measureFunction} from 'reassure'; -import {getReportActionMessage} from '@libs/ReportActionsUtils'; import SidebarUtils from '@libs/SidebarUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -53,25 +52,6 @@ const policies = createCollection( const mockedBetas = Object.values(CONST.BETAS); -const allReportActions = Object.fromEntries( - Object.keys(reportActions).map((key) => [ - `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${key}`, - [ - { - errors: reportActions[key].errors ?? [], - message: [ - { - moderationDecision: { - decision: getReportActionMessage(reportActions[key])?.moderationDecision?.decision, - }, - }, - ], - reportActionID: reportActions[key].reportActionID, - }, - ], - ]), -) as unknown as OnyxCollection; - const currentReportId = '1'; const transactionViolations = {} as OnyxCollection; @@ -114,13 +94,11 @@ describe('SidebarUtils', () => { test('[SidebarUtils] getOrderedReportIDs on 15k reports for default priorityMode', async () => { await waitForBatchedUpdates(); - await measureFunction(() => - SidebarUtils.getOrderedReportIDs(currentReportId, allReports, mockedBetas, policies, CONST.PRIORITY_MODE.DEFAULT, allReportActions, transactionViolations), - ); + await measureFunction(() => SidebarUtils.getOrderedReportIDs(currentReportId, allReports, mockedBetas, policies, CONST.PRIORITY_MODE.DEFAULT, transactionViolations)); }); test('[SidebarUtils] getOrderedReportIDs on 15k reports for GSD priorityMode', async () => { await waitForBatchedUpdates(); - await measureFunction(() => SidebarUtils.getOrderedReportIDs(currentReportId, allReports, mockedBetas, policies, CONST.PRIORITY_MODE.GSD, allReportActions, transactionViolations)); + await measureFunction(() => SidebarUtils.getOrderedReportIDs(currentReportId, allReports, mockedBetas, policies, CONST.PRIORITY_MODE.GSD, transactionViolations)); }); }); diff --git a/tests/unit/Search/getQueryWithSubstitutionsTest.ts b/tests/unit/Search/getQueryWithSubstitutionsTest.ts new file mode 100644 index 000000000000..8ca2eec31256 --- /dev/null +++ b/tests/unit/Search/getQueryWithSubstitutionsTest.ts @@ -0,0 +1,92 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +// we need "dirty" object key names in these tests +import {getQueryWithSubstitutions} from '@src/components/Search/SearchRouter/getQueryWithSubstitutions'; + +describe('getQueryWithSubstitutions should compute and return correct new query', () => { + test('when both queries contain no substitutions', () => { + // given this previous query: "foo" + const userTypedQuery = 'foo bar'; + const substitutionsMock = {}; + + const result = getQueryWithSubstitutions(userTypedQuery, substitutionsMock); + + expect(result).toBe('foo bar'); + }); + + test('when query has a substitution and plain text was added after it', () => { + // given this previous query: "foo from:@mateusz" + const userTypedQuery = 'foo from:Mat test'; + const substitutionsMock = { + 'from:Mat': '@mateusz', + }; + + const result = getQueryWithSubstitutions(userTypedQuery, substitutionsMock); + + expect(result).toBe('foo from:@mateusz test'); + }); + + test('when query has a substitution and plain text was added after before it', () => { + // given this previous query: "foo from:@mateusz1" + const userTypedQuery = 'foo bar from:Mat1'; + const substitutionsMock = { + 'from:Mat1': '@mateusz1', + }; + + const result = getQueryWithSubstitutions(userTypedQuery, substitutionsMock); + + expect(result).toBe('foo bar from:@mateusz1'); + }); + + test('when query has a substitution and then it was removed', () => { + // given this previous query: "foo from:@mateusz" + const userTypedQuery = 'foo from:Ma'; + const substitutionsMock = { + 'from:Mat': '@mateusz', + }; + + const result = getQueryWithSubstitutions(userTypedQuery, substitutionsMock); + + expect(result).toBe('foo from:Ma'); + }); + + test('when query has a substitution and then it was changed', () => { + // given this previous query: "foo from:@mateusz1" + const userTypedQuery = 'foo from:Maat1'; + const substitutionsMock = { + 'from:Mat1': '@mateusz1', + }; + + const result = getQueryWithSubstitutions(userTypedQuery, substitutionsMock); + + expect(result).toBe('foo from:Maat1'); + }); + + test('when query has multiple substitutions and one was changed on the last position', () => { + // given this previous query: "foo in:123,456 from:@jakub" + // oldHumanReadableQ = 'foo in:admin,admins from:Jakub' + const userTypedQuery = 'foo in:admin,admins from:Jakub2'; + const substitutionsMock = { + 'in:admin': '123', + 'in:admins': '456', + 'from:Jakub': '@jakub', + }; + + const result = getQueryWithSubstitutions(userTypedQuery, substitutionsMock); + + expect(result).toBe('foo in:123,456 from:Jakub2'); + }); + + test('when query has multiple substitutions and one was changed in the middle', () => { + // given this previous query: "foo in:aabbccdd123,zxcv123 from:@jakub" + const userTypedQuery = 'foo in:wave2,waveControl from:zzzz'; + + const substM = { + 'in:wave': 'aabbccdd123', + 'in:waveControl': 'zxcv123', + }; + + const result = getQueryWithSubstitutions(userTypedQuery, substM); + + expect(result).toBe('foo in:wave2,zxcv123 from:zzzz'); + }); +}); diff --git a/tests/unit/Search/getUpdatedSubstitutionsMapTest.ts b/tests/unit/Search/getUpdatedSubstitutionsMapTest.ts new file mode 100644 index 000000000000..43829af9f873 --- /dev/null +++ b/tests/unit/Search/getUpdatedSubstitutionsMapTest.ts @@ -0,0 +1,55 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +// we need "dirty" object key names in these tests +import {getUpdatedSubstitutionsMap} from '@src/components/Search/SearchRouter/getUpdatedSubstitutionsMap'; + +describe('getUpdatedSubstitutionsMap should return updated and cleaned substitutions map', () => { + test('when there were no substitutions', () => { + const userTypedQuery = 'foo bar'; + const substitutionsMock = {}; + + const result = getUpdatedSubstitutionsMap(userTypedQuery, substitutionsMock); + + expect(result).toStrictEqual({}); + }); + + test('when query has a substitution and it did not change', () => { + const userTypedQuery = 'foo from:Mat'; + const substitutionsMock = { + 'from:Mat': '@mateusz', + }; + + const result = getUpdatedSubstitutionsMap(userTypedQuery, substitutionsMock); + + expect(result).toStrictEqual({ + 'from:Mat': '@mateusz', + }); + }); + + test('when query has a substitution and it changed', () => { + const userTypedQuery = 'foo from:Johnny'; + const substitutionsMock = { + 'from:Steven': '@steven', + }; + + const result = getUpdatedSubstitutionsMap(userTypedQuery, substitutionsMock); + + expect(result).toStrictEqual({}); + }); + + test('when query has multiple substitutions and some changed but some stayed', () => { + const userTypedQuery = 'from:Johnny to:Steven category:Fruitzzzz'; + const substitutionsMock = { + 'from:Johnny': '@johnny', + 'to:Steven': '@steven', + 'from:OldName': '@oldName', + 'category:Fruit': '123456', + }; + + const result = getUpdatedSubstitutionsMap(userTypedQuery, substitutionsMock); + + expect(result).toStrictEqual({ + 'from:Johnny': '@johnny', + 'to:Steven': '@steven', + }); + }); +});