diff --git a/.well-known/assetlinks.json b/.well-known/assetlinks.json index a28004b45b08..803946f438d6 100644 --- a/.well-known/assetlinks.json +++ b/.well-known/assetlinks.json @@ -5,4 +5,12 @@ "package_name": "com.expensify.chat", "sha256_cert_fingerprints": ["2E:65:6F:1C:34:F5:7E:BF:FC:C0:2D:A3:14:0E:83:FE:61:51:F2:9B:5D:59:58:61:C4:4D:A9:99:0C:CA:F4:8E"] } -}] \ No newline at end of file + }, + { + "relation": ["delegate_permission/common.handle_all_urls"], + "target": { + "namespace": "android_app", + "package_name": "org.me.mobiexpensifyg", + "sha256_cert_fingerprints": ["87:03:DC:2B:20:99:CB:F7:AF:39:0C:8F:F2:E4:78:F2:61:E9:D1:7E:F4:AF:E5:02:D9:72:F2:4D:1F:29:FF:65"] + } +}] diff --git a/android/app/build.gradle b/android/app/build.gradle index dc48f3137f27..64e6ec12309b 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -110,8 +110,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009005801 - versionName "9.0.58-1" + versionCode 1009005900 + versionName "9.0.59-0" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/assets/images/simple-illustrations/simple-illustration__perdiem.svg b/assets/images/simple-illustrations/simple-illustration__perdiem.svg new file mode 100644 index 000000000000..ea5a865a2694 --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__perdiem.svg @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/_includes/footer.html b/docs/_includes/footer.html index 798fb2cf7e96..c7b55b28cfd5 100644 --- a/docs/_includes/footer.html +++ b/docs/_includes/footer.html @@ -50,10 +50,10 @@

Resources

ExpensifyHelp
  • - Community + Terms of Service
  • - Privacy + Privacy
  • diff --git a/docs/articles/expensify-classic/expensify-billing/Change-Plan-Or-Subscription.md b/docs/articles/expensify-classic/expensify-billing/Change-Plan-Or-Subscription.md index 1e631a53b0b3..b245a26d10a0 100644 --- a/docs/articles/expensify-classic/expensify-billing/Change-Plan-Or-Subscription.md +++ b/docs/articles/expensify-classic/expensify-billing/Change-Plan-Or-Subscription.md @@ -50,7 +50,7 @@ If Auto Renew is disabled then the last bill at the annual rate will be issued o # How to downgrade to a free account from an Individual Plan ## Web 1. Log in to your account through a web browser. -1. Go to **Settings > Policies > Individual > Subscription**. +1. Go to **Settings > Workspaces > Individual > Subscription**. 1. Click "Cancel Subscription" to end your Monthly Subscription. Note: Your subscription is a pre-purchase for 30 days of unlimited SmartScanning. This means that when you cancel, you do not get a refund and instead get to use the remainder of the month of unlimited SmartScanning you purchased. diff --git a/docs/articles/expensify-classic/settings/Set-Notifications.md b/docs/articles/expensify-classic/settings/Set-Notifications.md index 0e18d6f22cf5..da55dafb833c 100644 --- a/docs/articles/expensify-classic/settings/Set-Notifications.md +++ b/docs/articles/expensify-classic/settings/Set-Notifications.md @@ -4,72 +4,66 @@ description: This article is about how to troubleshoot notifications from Expens --- # Overview -Sometimes, members may have trouble receiving important email notifications from Expensify, such as Expensify Magic Code emails, account validation emails, secondary login validations, integration emails, or report action notifications (rejections, approvals, etc.). - -# Here's how to troubleshoot missing Expensify notifications: - -1. **No error message, but the email is never received** -The email might be delayed; give it 30-60 minutes to arrive in your inbox. -Check **Email Preferences** on the web via **Settings > Your Account > Preferences**In the **Contact Preferences** section. Ensure that the relevant boxes are checked for the email type you're missing. Check your email spam and trash folders, as Expensify messages might end up there inadvertently. -Check to make sure you haven't unintentionally blocked Expensify emails and whitelist [expensify.com](https://community.expensify.com/home/leaving?allowTrusted=1&target=http%3A%2F%2Fexpensify.com%2F), mg.expensify.com, and [amazonSES.com](https://community.expensify.com/home/leaving?allowTrusted=1&target=http%3A%2F%2Famazonses.com%2F) with your email provider. - -2. **A "We're having trouble emailing you" banner at the top of your screen** -Verify that your email address in your account settings is correct and is a real deliverable email address. -Re-send Verification Email: Look for an option to re-send a verification email, usually provided when this banner appears. - -![ExpensifyHelp_EmailError]({{site.url}}/assets/images/ExpensifyHelp_EmailError.png){:width="100%"} - -# Deep Dive +Sometimes members may have trouble receiving important email notifications from Expensify, such as Expensify Magic Code emails, account validation emails, secondary login validations, integration emails, or report action notifications (rejections, approvals, etc.). + +# Troubleshooting missing Expensify notifications + +## Issue: The email or notification is never received, and no message, banner, or additional context is provided +Emails can sometimes be delayed and could take up to 30-60 minutes to arrive in your inbox. If you're expecting a notification that still hasn't arrived after waiting: + - Check your **Email Preferences** on the web via **Settings > Account > Preferences**. In the **Contact Preferences** section, ensure that the relevant boxes are checked for the email type you're missing. + - Check your email spam and trash folders, as Expensify messages might end up there inadvertently. + - Check to make sure you haven't unintentionally blocked Expensify emails. Allowlist the domain expensify.com with your email provider. + +## Issue: A banner that says “We’re having trouble emailing you” shows the top of your screen. +Confirm the email address on your Expensify account is a deliverable email address, and then click the link in the banner that says "here". If successful, you will see a confirmation that your email was unblocked. + + ![ExpensifyHelp_EmailError]({{site.url}}/assets/images/ExpensifyHelp_EmailError.png){:width="100%"} + + **If unsuccessful, you will see another error:** + - If the new error or SMTP message includes a URL, navigate to that URL for further instructions. + - If the new error or SMTP message includes "mimecast.com", consult with your company's IT team. + - If the new error or SMTP message includes "blacklist", it means your company has configured their email servers to use a third-party email reputation or blocklisting service. Consult with your company's IT team. + +![ExpensifyHelp_SMTPError]({{site.url}}/assets/images/ExpensifyHelp_SMTPError.png){:width="100%"} -**For Private Domains**: +# Further troubleshooting for public domains -If your organization uses a private domain, consult your IT department or IT person to ensure that the following domains are whitelisted to receive our emails: expensify.com, mg.expensify.com, and amazonSES.com. These domains are the sources of various notification emails, so make sure they aren't being blocked. +If you are still not receiving Expensify notifications and have an email address on a public domain such as gmail.com or yahoo.com, you may need to add Expensify's domain expensify.com to your email's allowlist by taking the following steps: -**For Public Domains (e.g., Gmail, Yahoo, Hotmail)**: + - Search for messages from expensify.com in your spam folder, open them, and click “Not Spam” at the top of each message. + - Configure an email filter that identifies Expensify's email domain expensify.com and directs all incoming messages to your inbox, to avoid messages going to spam. + - Add specific known Expensify email addresses such as concierge@expensify.com to your email contacts list. -To whitelist our emails on public email services: +# Further troubleshooting for private domains -1. Check your Spam Folder: Search for messages from expensify.com in your Spam folder, open them, and click "Not Spam" at the top of the message. -2. Create a Filter: Set up a filter that identifies the entire expensify.com domain and directs all incoming messages to your inbox, preventing them from going to Spam. -3. Add Specific Contacts: While optional, adding specific email addresses from Expensify as contacts can further prevent emails from going to Spam. +If your organization uses a private domain, Expensify emails may be blocked at the server level. This can sometimes happen unexpectedly due to broader changes in email provider's handling or filtering of incoming messages. Consult your internal IT team to assist with the following: -Please note that even if you receive emails from our Concierge support communication, ensure that both expensify.com and mg.expensify.com are whitelisted as they use different servers. + - Ensure that the domain expensify.com is allowlisted on domain email servers. This domains is the sources of various notification emails, so it's important it is allowlisted. + - Confirm there is no server-level email blocking and that spam filters are not blocking Expensify emails. Even if you have received messages from our Concierge support in the past, ensure that expensify.com is allowlisted. -**Email Server Blocking**: -Your email server may be blocking our emails due to spam filters or other services. Check with your IT department to investigate and resolve any server-level email blocking issues. +## Companies using Outlook -**Mimecast**: -If your company uses Mimecast, a service that can affect email deliverability, check with your IT department. If Mimecast is in use, reach out to us at concierge@expensify.com through a new email, as this should ensure delivery to your inbox. Mimecast should eventually recognize the Expensify domain, preventing future filtering. +- Add Expensify to your personal Safe Senders list by following these steps: [Outlook email client](https://support.microsoft.com/en-us/office/add-recipients-of-my-email-messages-to-the-safe-senders-list-be1baea0-beab-4a30-b968-9004332336ce) / [Outlook.com](https://support.microsoft.com/en-us/office/safe-senders-in-outlook-com-470d4ee6-e3b6-402b-8cd9-a6f00eda7339) +- **Company IT administrators:** Add Expensify to your domain's Safe Sender list by following the steps here: [Create safe sender lists in EOP](https://learn.microsoft.com/en-us/defender-office-365/create-safe-sender-lists-in-office-365) +- **Company IT administrators:** Add expensify.com to the domain's explicit allowlist. You may need to contact Outlook support for specific instructions, as each company's setup varies. +- **Company administrators:** Contact Outlook support to see if there are additional steps to take based on your domain's email configuration. -**For Outlook Users**: -For Outlook users specifically: +## Companies using Google Workspaces: -1. Click the gear icon in Outlook and select "View all Outlook settings." -2. Choose "Mail" from the settings menu. -3. Under the "Junk email" submenu, click "Add" under "Safe senders and domains." -4. Enter the email address you want to whitelist. -5. Click "Save." +- **Company IT administrators:** Adjust your domain's email allowlist and safe senders lists to include expensify.com by following these steps: [Allowlists, denylists, and approved senders](https://support.google.com/a/answer/60752) -When you click the "Settings" link in the banner in Expensify, you'll be directed to your account settings page, where you may encounter a few different scenarios: +{% include faq-begin.md %} -- "Temporarily Suspended Emails": If the message mentions "temporarily suspended emails to," follow the steps provided in the yellow box. This situation typically occurs when we can't find a valid inbox to send our emails to. Possible reasons include: - - A misspelled email address during account creation. - - Use of a distribution list email (acting as an "alias" email) without a linked inbox. - - An auto-responder that has been responding to our emails for an extended period. -- To resolve this issue, confirm that the email address is indeed associated with an active inbox. Then, click the link that says "here," and your email should be unblocked shortly. -- SMTP Error (Gray Box): In some cases, you might encounter a gray box with an SMTP error message. This error can vary, but it typically looks something like this: +## How can I be sure that emails from Expensify are legitimate and not spam? -![ExpensifyHelp_SMTPError]({{site.url}}/assets/images/ExpensifyHelp_SMTPError.png){:width="100%"} +Expensify's emails are SPF and DKIM-signed, meaning they are cryptographically signed and encrypted to prevent spoofing. -**These look a bit cryptic, yes, but hang in there!** +## Why do legitimate emails from Expensify sometimes end up marked as spam? -The error messages you see are the raw message text received from your email provider's server to Amazon. These messages can vary in text, but the best course of action is to follow the link provided (by copying and pasting) in the text for the next steps. +The problem typically arises when our domain or one of our sending IP addresses gets erroneously flagged by a 3rd party domain or IP reputation services. Many IT departments use lists published by such services to filter email for the entire company. -**Scenario 1**: If the message in the gray box includes "mimecast.com": It means that our emails are being blocked by the server. In this case, you should contact your IT person or team to address the issue. +## What is the best way to ensure emails are not accidentally marked as Spam? -**Scenario 2**: If the message in the gray box mentions "blacklist at org/.com/.net," or resembles the screenshot provided, it indicates that your IT team has configured your email to use a third-party email reputation or blacklisting service. Here's what you need to know: -- All our emails are SPF and DKIM-signed, meaning they are cryptographically signed as coming from us and are not spam. -- The problem arises because we send mail from a cloud-based service. This means that the sender's IP serves multiple vendors, including Expensify. If one of those vendors is marked as spam, it can block all messages from that IP, even if they're from different vendors (including us). -- The better approach is for the server to flag spam via DKIM and SPF (rather than solely relying on the sender's IP address), as our messages are correctly signed and encrypted to prevent spoofing. +For server-level spam detection, the safest approach to allowlisting email from Expensify is to verify DKIM and SPF, rather than solely relying on the third-party reputation of the sending IP address. -To resolve these issues, consider discussing them with your IT team, as they can help implement the necessary changes to ensure you receive our emails without interruption. +{% include faq-end.md %} diff --git a/docs/articles/new-expensify/expenses-&-payments/Approve-and-pay-expenses.md b/docs/articles/new-expensify/expenses-&-payments/Approve-and-pay-expenses.md deleted file mode 100644 index 26634d9a33df..000000000000 --- a/docs/articles/new-expensify/expenses-&-payments/Approve-and-pay-expenses.md +++ /dev/null @@ -1,109 +0,0 @@ ---- -title: Approve and Pay Expenses -description: Approve, hold, or pay expenses submitted to you ---- -
    - -As a workspace admin, you can set an approval workflow for the expenses submitted to you. Expenses can be, - -- Instantly submitted without needing approval. -- Submitted at a desired frequency (daily, weekly, monthly) and follow an approval workflow. - -**Setting approval workflow and submission frequencies** - -Approval workflow settings and submission frequencies can be set in the Workflow settings of your workspace. - -# Manually approve expense - -When someone sends an expense or a group of expenses to you for approval, you’ll receive the expense in Expensify Chat for the related workspace. Chats with new updates appear with a green dot to the right of the chat message. Concierge also sends you an email notification for the new expense. - -{% include info.html %} -If an expense is sent to you by a friend, you will not need to approve the expense. Instead, you can immediately pay the expense when you are ready. -{% include end-info.html %} - -# Approve expenses - -To approve an expense, - -1. Open the Expensify Chat thread for the expense. -2. Click the expense or group of expenses. -3. Review the expense details to ensure they are correct. Look at each receipt, the amount, the description, and any additional details. -4. Determine the next steps. - - **Approve**: When you’re satisfied with the expense, click **Approve**. - - **Handle holds**: If any of the expenses are on hold, you can choose to either approve only the expenses that are not on hold or approve the full amount, including any held expenses. - - **Request changes**: You can add a comment to the expense’s chat thread in your Expensify Chat inbox to request changes to the expense details. - -{% include info.html %} -If the transaction is pending (a common occurrence with recent company card expenses or SmartScan expenses), you’ll need to wait until the transaction posts before approving it. -{% include end-info.html %} - -![The approve button in an expense]({{site.url}}/assets/images/ExpensifyHelp_ApproveExpense_1.png){:width="100%"} - -![The approve button when you click into the expense]({{site.url}}/assets/images/ExpensifyHelp_ApproveExpense_2.png){:width="100%"} - -You’re now ready to pay the expense. - -# Hold an expense - -If you need to delay a payment or if you need more information on the expense before it can be approved, you can hold the expense. - -To hold an expense, - -1. Open the Expensify Chat thread for the expense. -2. Click the expense or group of expenses. -3. Click the three dot menu at the top right of the expense and select **Hold**. -4. Enter a reason for the delay. -5. Review the Hold Overview page and click **Got It**. - -When you’re ready, you can choose to: -- **Remove the hold**: Complete the steps above and select **Unhold**. -- **Approve the expense**: Complete the steps above for “Approve expenses.” -Once the expense has been approved, you can now pay the expense. - -{% include info.html %} -Held expenses will not be available for payment until they have been approved. -{% include end-info.html %} - -# Unapprove an expense - -Some details of approved expenses and reports cannot be edited. If you need to edit an expense that has been approved, admins and the last approver have the option to unapprove reports. - -1. Click the workspace logo in the top left corner. -2. Select the workspace associated with the expense report. -3. Find the approved report by searching for the submitter. -4. Click the dropdown arrow at the top of the report to view the report actions. -5. Click **Unapprove**. - -The unapproved report will return to an editable state, and the submitter will receive an email and chat notification that the expense has been unapproved. - -{% include info.html %} -Reports that have been paid cannot be unapproved. If the approved expense has already been exported to an accounting package, you’ll see a warning that unapproving an expense can cause data discrepancies and Expensify Card reconciliation issues. Ideally, you’ll want to delete the data that has already been exported to the accounting package before approving the expense again. -{% include end-info.html %} - -# Pay expenses - -Once you’ve approved an expense—or if the expense does not require approval—you’ll be able to pay it. - -{% include info.html %} -To pay expenses within Expensify, you’ll need to [set up your Expensify Wallet](https://help.expensify.com/articles/new-expensify/expenses-&-payments/Set-up-your-wallet). -{% include end-info.html %} - -To pay an expense, - -1. Open the Expensify Chat thread for the expense. -2. Click the expense or group of expenses. -3. Select a payment option. - - Click **Pay** to pay the full expense within Expensify. If the expenses contain one that has been held, the pay amount will only include the expenses that have not been held. Then you’ll select your payment method. - - Click **Pay Elsewhere** to indicate that a payment has been sent using a method outside of Expensify, such as cash or a check. This will label the expense as Paid. - -# FAQ - -**Why was an expense automatically approved?** - -We refer to this as **Instant Submit**. If a workspace doesn’t have Delayed Submission enabled, an expense report will automatically be submitted. - -**Why is an employee expense showing as ‘pending?’** - -An Expensify Card expense will show as pending if the merchant hasn’t posted it. This is usually the case with hotel holds, or card rental holds. A hold will normally last no more than 7-10 business days unless it’s a hotel hold, which can last 31 days. - -
    diff --git a/docs/articles/new-expensify/expenses-&-payments/Approve-expenses.md b/docs/articles/new-expensify/expenses-&-payments/Approve-expenses.md new file mode 100644 index 000000000000..77587cc124f0 --- /dev/null +++ b/docs/articles/new-expensify/expenses-&-payments/Approve-expenses.md @@ -0,0 +1,139 @@ +--- +title: Approve Expenses +description: Approve, hold, and unapprove submitted expenses +--- +
    + +Expenses can be created through manual entry, tracking distance, or scanning a receipt. They can be submitted to an individual or a workspace. + +This help article has more details about creating and submitting an expense to an individual or a workspace. + +# Receiving an expense from an Individual + +When an expense is submitted to an individual, it doesn’t need approval. It only needs to be paid. + +This help article has the steps to pay the expense. + +# Receiving a workspace expense + +When an expense is submitted to a workspace with an “approval workflow”, it must be approved before it can be paid. + +As a workspace admin, you can set an [approval workflow](https://help.expensify.com/articles/new-expensify/workspaces/Add-approvals) in the workspace settings. For each expense report, you’ll have the option to: + +- **Approve:** Click Approve if you’re satisfied with the expense details. +- **Hold the expense:** If you need to delay a payment or provide more information before approval, you can hold an expense. +- **Unapprove the expense:** You can return the expense to the submitter for revisions. + +# Approve workspace expenses + +{% include selector.html values="desktop, mobile" %} + +{% include option.html value="desktop or WebApp" %} +1. When an expense is submitted, you will receive an email and in-app notification with the details of the expense. +2. Click the expense in the email to be directed to New Expensify, where you can review it. +3. Click on the expense to view the receipt, amount, description, and additional details the submitter provides. +4. Click **Approve**. +5. When you are ready to pay the expense, follow the steps in this help article. +{% include end-option.html %} + +{% include option.html value="mobile" %} +1. When an expense is submitted, you will receive a text message and in-app notification with its details. +2. Tap on the expense in the text or notification to be directed to New Expensify, where you can review it. +3. Tap on the expense to view the receipt, amount, description, and any additional details the submitter provides. +4. Tap **Approve**. +{% include end-option.html %} + +{% include end-selector.html %} + +{% include info.html %} +If the transaction is pending (a common occurrence with recent company cards or SmartScan expenses), you’ll need to wait until the transaction posts before approving it. +{% include end-info.html %} + + +# Hold a workspace expense + +{% include selector.html values="desktop, mobile" %} + +{% include option.html value="desktop" %} +1. Locate the expense on the **Search** page. +2. Click **View**. +3. Click the drop-down arrow at the top of the expense. +4. Click the **Hold** button. +5. Enter a reason for the delay. The reason for the hold will be added to the expense report. + +

     

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

     

    + +When you’re ready to remove the hold, + +1. Tap **Search** and select the expense. +2. Tap the drop-down arrow at the top of the expense. +3. Select **UnHold**. +4. Complete the steps above to “Approve expenses.” Once the expense has been approved, you can pay it. +{% include end-option.html %} + +{% include end-selector.html %} + +{% include info.html %} +Held expenses will not be available for payment until they have been approved. +{% include end-info.html %} + +# Unapprove a workspace expense + +{% include selector.html values="desktop, mobile" %} + +{% include option.html value="desktop" %} +1. Locate the expense on the **Search** page. +2. Click **View**. +3. Click the drop-down arrow at the top of the report +4. Click **Unapprove**. +5. The submitter will receive an email and in-app notification that the expense has been unapproved. +6. An unapproved expense can be deleted by clicking the drop-down arrow at the top of the expense. +{% include end-option.html %} + +{% include option.html value="mobile" %} +1. Locate the expense on the **Search** page. +2. Tap **View**. +3. Tap the drop-down arrow at the top of the expense. +4. Tap **Unapprove**. +5. The submitter will receive a text and in-app notification that the expense has been unapproved. +6. An unapproved expense can be deleted by clicking the drop-down arrow at the top of the expense. +{% include end-option.html %} + +{% include end-selector.html %} + +Reports that have been paid cannot be unapproved. + +If the approved expense has already been exported to an accounting package, you’ll see a warning that unapproving an expense can cause data discrepancies and Expensify Card reconciliation issues. Ideally, you’ll want to delete the data already exported to the accounting package before approving the expense again. + +{% include faq-begin.md %} + +**Why is an employee expense showing as ‘pending?’** + +An Expensify Card expense will show as pending if the merchant hasn’t posted it. This is usually the case with hotel holds, or card rental holds. A hold will normally last no more than 7-10 business days unless it’s a hotel hold, which can last 31 days. + +**What are expense reports?** + +In Expensify, expense reports group expenses in a batch to be paid or reconciled. When a draft report is open, all new expenses are added to it. + +Once a report is submitted, you can track the status from the **Search** section. Click the **View** button for a specific expense or expense report. The status is displayed at the top of the expense or report. +{% include faq-end.md %} + +
    diff --git a/docs/articles/new-expensify/expenses-&-payments/Create-an-expense.md b/docs/articles/new-expensify/expenses-&-payments/Create-an-expense.md index cf6a13f9d5ac..38f1e0fdd466 100644 --- a/docs/articles/new-expensify/expenses-&-payments/Create-an-expense.md +++ b/docs/articles/new-expensify/expenses-&-payments/Create-an-expense.md @@ -1,68 +1,83 @@ --- title: Create an expense -description: Request payment from an employer or a friend +description: How to create an expense as an individual or workspace member redirect_from: articles/request-money/Request-and-Split-Bills/ ---
    -You can create an expense to request payment from an employer’s workspace or from a friend using any of the following options: -- **SmartScan**: Take a picture of a receipt to capture the expense details automatically. -- **Add manually**: Manually enter the expense details. -- **Create a distance expense**: Capture mileage expenses by entering the addresses you traveled to. Expensify automatically calculates the distance, the rate per mile, and the total cost. +Expenses can be created through SmartScanning a receipt, emailing a receipt, tracking distance, and manually creating an expense. + +They can be submitted to an individual or a workspace. Before we outline the steps to create an expense, let’s go over the reasons to send an expense to an individual or a workspace. + +# Sending an expense to an Individual + +If you use Expensify for personal use, submitting to an individual is likely best. + +Once the expense is created, you will see the option to send it to an email or phone number. Alternatively, add an expense to a chat, which will go straight to the person you are chatting with. + +When an expense is submitted to an individual’s email or phone number, the payor will receive an email or text notification with the amount that needs to be paid. They can click on the amount in the email or text to pay the expense. + +# Submit an expense to a workspace or employer + +If you are an employee or a workspace member, you should submit the expense to the workspace instead of an individual. A workspace is designed to code expenses to the company's requirements. + +When an expense is submitted to a workspace, your approver will receive an email or text notification prompting them to approve and pay it. + +# How to Create an Expense # SmartScan a receipt {% include selector.html values="desktop, mobile" %} -{% include option.html value="desktop" %} -1. Click the + icon in the bottom left menu and select **Submit Expense**. +{% include option.html value="desktop or WebApp" %} +1. Click the **Global Create** button and select **Submit Expense**. 2. Click **Scan**. -3. Drag and drop the receipt into Expensify, or click **Choose File** to select it from your saved files. *Note: The SmartScan process will auto-populate the merchant, date, and amount.* -4. Use the search field to find the desired workspace or an individual’s name, email, or phone number. -5. Add a description, category, tags, or tax as desired, or as required by your workspace. +3. You can drag and drop the receipt into Expensify or click **Choose File** to select it from your saved files. _The SmartScan process will auto-populate the merchant, date, and amount._ +4. Enter the desired workspace or an individual’s email or phone number to receive the expense report. +5. Add a description, category, tags, or tax as desired or as required by your workspace. 6. (Optional) Enable the expense as billable if it should be billed to a client. -7. Click **Submit Expense**. +7. Click **Submit expense**. {% include end-option.html %} {% include option.html value="mobile" %} -1. Tap the + icon at the bottom of the screen and select **Submit Expense**. +1. ​​Tap the **Global Create** button and select **Submit Expense**. 2. Tap **Scan**. -3. Tap the green button to take a photo of a receipt, or tap the Image icon to the left of it to upload a receipt from your phone. *Note: The SmartScan process will auto-populate the merchant, date, and amount.* -4. Use the search field to find the desired workspace or an individual’s name, email, or phone number. -5. Add a description, category, tags, or tax as desired, or as required by your workspace. +3. Tap the green button to take a photo of a receipt, or tap the Image icon to upload a receipt from your phone. _The SmartScan process will auto-populate the merchant, date, and amount._ +4. Enter the desired workspace or an individual’s email or phone number to receive the expense report. +5. Add a description, category, tags, or tax as desired or as required by your workspace. 6. (Optional) Enable the expense as billable if it should be billed to a client. -7. Tap **Submit**. +7. Tap **Submit expense**. {% include end-option.html %} {% include end-selector.html %} {% include info.html %} -You can also forward receipts to receipts@expensify.com using an email address that is your primary or secondary email address. SmartScan will automatically pull all of the details from the receipt and add it to your expenses. +You can also forward receipts to receipts@expensify.com using your primary or secondary email address. SmartScan will automatically extract all the details from the receipt and add them to your expenses. {% include end-info.html %} # Manually add an expense {% include selector.html values="desktop, mobile" %} -{% include option.html value="desktop" %} -1. Click the + icon in the bottom left menu and select **Submit Expense**. +{% include option.html value="desktop or WebApp" %} +1. Click the **Global Create** button and select **Submit Expense**. 2. Click **Manual**. -3. Enter the amount on the receipt and click **Next**. *Note: Click the currency symbol to select a different currency.* -4. Use the search field to find the desired workspace or an individual’s name, email, or phone number. -5. (Optional) Add a description. -6. Add a merchant. -7. Click **Show more** to add additional fields (like a category) as desired, or as required by your workspace. +3. Enter the currency and amount. +4. Click **Next**. +5. Enter the desired workspace or an individual’s email or phone number to receive the expense report. +6. Add a description, category, tags, or tax as desired or as required by your workspace. Click **Show More** to see all coding options. +7. (Optional) Enable the expense as billable if it should be billed to a client. 8. Click **Submit**. {% include end-option.html %} {% include option.html value="mobile" %} -1. Tap the + icon at the bottom of the screen and select **Submit Expense**. +1. Tap the **Global Create** button and select **Submit Expense**. 2. Tap **Manual**. -3. Enter the amount on the receipt and tap **Next**. *Note: Click the currency symbol to select a different currency.* -4. Use the search field to find the desired workspace or an individual’s name, email, or phone number. -5. (Optional) Add a description. -6. Add a merchant. -7. Tap **Show more** to add additional fields (like a category) as desired, or as required by your workspace. +3. Enter the currency and amount. +4. Tap **Next**. +5. Enter the desired workspace or an individual’s email or phone number to receive the expense report. +6. Add a description, category, tags, or tax as desired or as required by your workspace. Tap **Show More** to see all coding options. +7. (Optional) Enable the expense as billable if it should be billed to a client. 8. Tap **Submit**. {% include end-option.html %} @@ -72,54 +87,65 @@ You can also forward receipts to receipts@expensify.com using an email address t {% include selector.html values="desktop, mobile" %} -{% include option.html value="desktop" %} -1. Click the + icon in the bottom left menu and select **Submit Expense**. +{% include option.html value="desktop or WebApp" %} +1. Click the **Global Create** button and select **Submit Expense**. 2. Click **Distance**. 3. Click **Start** and enter the starting location of your trip. -4. Click **Stop** and enter the ending location of your trip. -5. (Optional) Click **Add stop** to add additional stops, if applicable. +4. Click **Stop** and enter the ending location of your trip. +5. (Optional) Click **Add Stop** to add additional stops, if applicable. Drag and drop on the parallel lines (=) to reorder the stops if needed. 6. Tap **Next**. -7. Use the search field to find the desired workspace or an individual’s name, email, or phone number. -8. (Optional) Add a description. -9. Click **Submit**. +7. Enter the desired workspace or an individual’s email or phone number to receive the expense report. +8. Add a description, category, tags, or tax as desired or as required by your workspace. Click **Show More** to see all coding options. +9. (Optional) Enable the expense as billable if it should be billed to a client. +10. Click **Submit**. {% include end-option.html %} {% include option.html value="mobile" %} -1. Tap the + icon at the bottom of the screen and select **Submit Expense**. +1. Tap the **Global Create** button and select **Submit Expense**. 2. Tap **Distance**. 3. Tap **Start** and enter the starting location of your trip. -4. Tap **Stop** and enter the ending location of your trip. -5. (Optional) Tap **Add stop** to add additional stops, if applicable. -6. Tap **Next**. -7. Use the search field to find the desired workspace or an individual’s name, email, or phone number. -8.(Optional) Add a description. -9. Tap **Submit**. +4. Tap **Stop** and enter the ending location of your trip. +5. (Optional) Tap **Add Stop** to add additional stops, if applicable. Drag and drop on the parallel lines (=) to reorder the stops if needed. +6. Tap Next. +7. Enter the desired workspace or an individual’s email or phone number to receive the expense report. +8. Add a description, category, tags, or tax as desired or as required by your workspace. Tap **Show More** to see all coding options. +9. (Optional) Enable the expense as billable if it should be billed to a client. +10. Click **Submit**. {% include end-option.html %} {% include end-selector.html %} -# Next Steps +# Next Steps for expenses sent to an Individual -The next steps for the expense depend on whether it was submitted to a workspace or to an individual: -- **Expenses submitted to a workspace** are automatically added to a report and checked for any violations or inconsistencies. A chat thread for the expense is also added to your chat inbox. When you open the chat, the top banner will show the expense status and any next steps. By default, reports are automatically submitted for approval every Sunday. However, if it is ready for early submission, you can manually submit a report for approval. Once a report is submitted, your approver will be prompted to review your expense report. If changes are required, you will receive a notification to resolve any violations and resubmit. You will also be notified once your approver approves or denies your expenses. -- **Expenses submitted to a friend** are sent right to that individual via email or text. You can chat with them about the expense in Expensify Chat, and you can receive payments through your Expensify Wallet or outside of Expensify. +- Expenses submitted to an individual are instantly sent. +- The payer will receive an email or text prompting them to review and pay the expense. +- You can chat with the paying individual in Expensify. +- Make sure to [connect your personal bank account](https://help.expensify.com/articles/new-expensify/expenses-&-payments/Connect-a-Personal-Bank-Account) to receive payment. -{% include faq-begin.md %} -**Can I divide a payment between multiple people?** +# Next Steps for expense sent to a workspace -Yes, you can split an expense to share the cost between multiple people. +- Expenses submitted to a workspace are automatically added to a report and checked for violations or inconsistencies. +- You can view the details and status of the expense on the **Search** tab. +- Workspace settings determine the frequency of report submission. However, if the report is ready for early submission, you can manually submit a report for approval. +- Once a report is submitted, your approver will get an email or text to review and pay the expense. +- If changes are required, you will receive a notification to fix the expense and resubmit. +- You will also be notified once your approver approves or denies your expenses. +- Make sure to [connect your personal bank account](https://help.expensify.com/articles/new-expensify/expenses-&-payments/Connect-a-Personal-Bank-Account) to receive payment. -**Can I pay someone in another currency?** +{% include faq-begin.md %} +**Can I divide a payment between multiple people?** -While you can record your expenses in different currencies, Expensify wallets are only available for members who can add a U.S. personal bank account. +Yes, you can [split an expense](https://help.expensify.com/articles/new-expensify/expenses-&-payments/Split-an-expense) in a group chat. **Can I change an expense once I’ve submitted it?** -Yes, you can edit an expense until it is paid. When an expense is submitted to a workspace, you, your approvers, and admins can edit the details on an expense except for the amount and date. +Yes, you can edit an expense until it is paid. When an expense is submitted, the details can be edited except for the amount and date. **What are expense reports?** -In Expensify, expenses are submitted on an expense report. When a draft report is open, all new expenses are added to the draft report. Once a report is submitted, it shows what stage of the approval process the expenses are in and any required next steps. +In Expensify, expense reports group expenses in a batch to be paid or reconciled. When a draft report is open, all new expenses are added to it. + +Once a report is submitted, you can track the status from the **Search** section. Click the **View** button for a specific expense or expense report. The status is displayed at the top of the expense or report. {% include faq-end.md %}
    diff --git a/docs/redirects.csv b/docs/redirects.csv index bb6729245f83..d9f18ebb0227 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -597,3 +597,4 @@ https://help.expensify.com/articles/expensify-classic/expenses/Track-mileage-exp https://help.expensify.com/articles/expensify-classic/expenses/Track-per-diem-expenses,https://help.expensify.com/articles/expensify-classic/expenses/Add-an-expense https://community.expensify.com/discussion/5116/faq-where-can-i-use-the-expensify-card,https://help.expensify.com/articles/new-expensify/expensify-card/Use-your-Expensify-Card#where-can-i-use-my-expensify-card https://help.expensify.com/articles/other/Expensify-Lounge,https://help.expensify.com/Hidden/Expensify-Lounge +https://help.expensify.com/articles/new-expensify/expenses-&-payments/Approve-and-pay-expenses,https://help.expensify.com/articles/new-expensify/expenses-&-payments/Approve-expenses diff --git a/help/_layouts/default.html b/help/_layouts/default.html index 8a4605807355..7fcd95c1b325 100644 --- a/help/_layouts/default.html +++ b/help/_layouts/default.html @@ -214,7 +214,7 @@ .footer-column { flex: 1; max-width: 300px; /* Set a max-width for each column */ - padding: 0 20px; /* Add padding for some space between the columns */ + padding: 0 20px; /* Add padding for some space between the columns */ } @@ -273,8 +273,8 @@

    Resources

  • Press Kit
  • Support
  • ExpensifyHelp
  • -
  • Community
  • -
  • Privacy
  • +
  • Terms of Service
  • +
  • Privacy
  • Expensify App
  • diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 04030d1972f0..e4f525da1e9f 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.0.58 + 9.0.59 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.58.1 + 9.0.59.0 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index a1fc5be5e7ae..96070daa066c 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 9.0.58 + 9.0.59 CFBundleSignature ???? CFBundleVersion - 9.0.58.1 + 9.0.59.0 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 4fedc3fe0674..e0bef4291004 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.0.58 + 9.0.59 CFBundleVersion - 9.0.58.1 + 9.0.59.0 NSExtension NSExtensionPointIdentifier diff --git a/package-lock.json b/package-lock.json index 7ad752018914..10e4f5412337 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.0.58-1", + "version": "9.0.59-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.58-1", + "version": "9.0.59-0", "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.103", + "expensify-common": "2.0.106", "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.103", - "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.103.tgz", - "integrity": "sha512-Q42bUK6TeB87qN4MEBDlhNH1qQqUXY+tJKCZTt01Zv+lcn7KemudOCt7GNoEwfR7LLWsWuec7Vb5x45rQJNC2A==", + "version": "2.0.106", + "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.106.tgz", + "integrity": "sha512-KmxKvglbIUJb0sAcmNxb/AXYAqa3GIZfu3MbmtlYDNJx24mjDjtbGkKhm+16TICDoPj2PDRNogIqgUGWmSSZFQ==", "license": "MIT", "dependencies": { "awesome-phonenumber": "^5.4.0", diff --git a/package.json b/package.json index ae85af0fa85a..aea0d97ea7f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.58-1", + "version": "9.0.59-0", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -108,7 +108,7 @@ "date-fns-tz": "^3.2.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "2.0.103", + "expensify-common": "2.0.106", "expo": "51.0.31", "expo-av": "14.0.7", "expo-image": "1.12.15", diff --git a/src/CONST.ts b/src/CONST.ts index e0e78f04fe60..d5dcb0e33f63 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -20,6 +20,7 @@ const CLOUDFRONT_DOMAIN = 'cloudfront.net'; const CLOUDFRONT_URL = `https://d2k5nsl2zxldvw.${CLOUDFRONT_DOMAIN}`; const ACTIVE_EXPENSIFY_URL = Url.addTrailingForwardSlash(Config?.NEW_EXPENSIFY_URL ?? 'https://new.expensify.com'); const USE_EXPENSIFY_URL = 'https://use.expensify.com'; +const EXPENSIFY_URL = 'https://www.expensify.com'; const PLATFORM_OS_MACOS = 'Mac OS'; const PLATFORM_IOS = 'iOS'; const ANDROID_PACKAGE_NAME = 'com.expensify.chat'; @@ -832,6 +833,7 @@ const CONST = { EMPTY_ARRAY, EMPTY_OBJECT, USE_EXPENSIFY_URL, + EXPENSIFY_URL, GOOGLE_MEET_URL_ANDROID: 'https://meet.google.com', GOOGLE_DOC_IMAGE_LINK_MATCH: 'googleusercontent.com', IMAGE_BASE64_MATCH: 'base64', @@ -844,13 +846,14 @@ const CONST = { UPWORK_URL: 'https://github.com/Expensify/App/issues?q=is%3Aopen+is%3Aissue+label%3A%22Help+Wanted%22', DEEP_DIVE_EXPENSIFY_CARD: 'https://community.expensify.com/discussion/4848/deep-dive-expensify-card-and-quickbooks-online-auto-reconciliation-how-it-works', DEEP_DIVE_ERECEIPTS: 'https://community.expensify.com/discussion/5542/deep-dive-what-are-ereceipts/', + DEEP_DIVE_PER_DIEM: 'https://community.expensify.com/discussion/4772/how-to-add-a-single-rate-per-diem', GITHUB_URL: 'https://github.com/Expensify/App', - TERMS_URL: `${USE_EXPENSIFY_URL}/terms`, - PRIVACY_URL: `${USE_EXPENSIFY_URL}/privacy`, + TERMS_URL: `${EXPENSIFY_URL}/terms`, + PRIVACY_URL: `${EXPENSIFY_URL}/privacy`, LICENSES_URL: `${USE_EXPENSIFY_URL}/licenses`, - ACH_TERMS_URL: `${USE_EXPENSIFY_URL}/achterms`, - WALLET_AGREEMENT_URL: `${USE_EXPENSIFY_URL}/walletagreement`, - BANCORP_WALLET_AGREEMENT_URL: `${USE_EXPENSIFY_URL}/bancorp-bank-wallet-terms-of-service`, + ACH_TERMS_URL: `${EXPENSIFY_URL}/achterms`, + WALLET_AGREEMENT_URL: `${EXPENSIFY_URL}/expensify-payments-wallet-terms-of-service`, + BANCORP_WALLET_AGREEMENT_URL: `${EXPENSIFY_URL}/bancorp-bank-wallet-terms-of-service`, HELP_LINK_URL: `${USE_EXPENSIFY_URL}/usa-patriot-act`, ELECTRONIC_DISCLOSURES_URL: `${USE_EXPENSIFY_URL}/esignagreement`, GITHUB_RELEASE_URL: 'https://api.github.com/repos/expensify/app/releases/latest', @@ -863,7 +866,6 @@ const CONST = { NEWHELP_URL: 'https://help.expensify.com', INTERNAL_DEV_EXPENSIFY_URL: 'https://www.expensify.com.dev', STAGING_EXPENSIFY_URL: 'https://staging.expensify.com', - EXPENSIFY_URL: 'https://www.expensify.com', BANK_ACCOUNT_PERSONAL_DOCUMENTATION_INFO_URL: 'https://community.expensify.com/discussion/6983/faq-why-do-i-need-to-provide-personal-documentation-when-setting-up-updating-my-bank-account', PERSONAL_DATA_PROTECTION_INFO_URL: 'https://community.expensify.com/discussion/5677/deep-dive-security-how-expensify-protects-your-information', @@ -871,7 +873,7 @@ const CONST = { ONFIDO_PRIVACY_POLICY_URL: 'https://onfido.com/privacy/', ONFIDO_TERMS_OF_SERVICE_URL: 'https://onfido.com/terms-of-service/', LIST_OF_RESTRICTED_BUSINESSES: 'https://community.expensify.com/discussion/6191/list-of-restricted-businesses', - TRAVEL_TERMS_URL: `${USE_EXPENSIFY_URL}/travelterms`, + TRAVEL_TERMS_URL: `${EXPENSIFY_URL}/travelterms`, EXPENSIFY_PACKAGE_FOR_SAGE_INTACCT: 'https://www.expensify.com/tools/integrations/downloadPackage', EXPENSIFY_PACKAGE_FOR_SAGE_INTACCT_FILE_NAME: 'ExpensifyPackageForSageIntacct', SAGE_INTACCT_INSTRUCTIONS: 'https://help.expensify.com/articles/expensify-classic/integrations/accounting-integrations/Sage-Intacct', @@ -2466,6 +2468,7 @@ const CONST = { ARE_INVOICES_ENABLED: 'areInvoicesEnabled', ARE_TAXES_ENABLED: 'tax', ARE_RULES_ENABLED: 'areRulesEnabled', + ARE_PER_DIEM_RATES_ENABLED: 'arePerDiemRatesEnabled', }, DEFAULT_CATEGORIES: [ 'Advertising', @@ -2632,6 +2635,7 @@ const CONST = { CUSTOM_UNITS: { NAME_DISTANCE: 'Distance', + NAME_PER_DIEM_INTERNATIONAL: 'Per Diem International', DISTANCE_UNIT_MILES: 'mi', DISTANCE_UNIT_KILOMETERS: 'km', MILEAGE_IRS_RATE: 0.67, @@ -2696,6 +2700,7 @@ const CONST = { STEP: { ASSIGNEE: 'Assignee', CARD: 'Card', + CARD_NAME: 'CardName', TRANSACTION_START_DATE: 'TransactionStartDate', CONFIRMATION: 'Confirmation', }, @@ -3064,6 +3069,7 @@ const CONST = { // Character Limits FORM_CHARACTER_LIMIT: 50, STANDARD_LENGTH_LIMIT: 100, + STANDARD_LIST_ITEM_LIMIT: 8, LEGAL_NAMES_CHARACTER_LIMIT: 150, LOGIN_CHARACTER_LIMIT: 254, CATEGORY_NAME_LIMIT: 256, @@ -3137,8 +3143,8 @@ const CONST = { EXPENSIFY_APPROVED_URL: `${USE_EXPENSIFY_URL}/accountants`, PRESS_KIT_URL: 'https://we.are.expensify.com/press-kit', SUPPORT_URL: `${USE_EXPENSIFY_URL}/support`, - COMMUNITY_URL: 'https://community.expensify.com/', - PRIVACY_URL: `${USE_EXPENSIFY_URL}/privacy`, + TERMS_URL: `${EXPENSIFY_URL}/terms`, + PRIVACY_URL: `${EXPENSIFY_URL}/privacy`, ABOUT_URL: 'https://we.are.expensify.com/how-we-got-here', BLOG_URL: 'https://blog.expensify.com/', JOBS_URL: 'https://we.are.expensify.com/apply', @@ -6093,6 +6099,14 @@ const CONST = { description: 'workspace.upgrade.rules.description' as const, icon: 'Rules', }, + perDiem: { + id: 'perDiem' as const, + alias: 'per-diem', + name: 'Per diem', + title: 'workspace.upgrade.perDiem.title' as const, + description: 'workspace.upgrade.perDiem.description' as const, + icon: 'PerDiem', + }, }; }, REPORT_FIELD_TYPES: { diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 45501bf46374..cd94035e0fff 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1273,6 +1273,10 @@ const ROUTES = { route: 'settings/workspaces/:policyID/distance-rates/:rateID/tax-rate/edit', getRoute: (policyID: string, rateID: string) => `settings/workspaces/${policyID}/distance-rates/${rateID}/tax-rate/edit` as const, }, + WORKSPACE_PER_DIEM: { + route: 'settings/workspaces/:policyID/per-diem', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/per-diem` as const, + }, RULES_CUSTOM_NAME: { route: 'settings/workspaces/:policyID/rules/name', getRoute: (policyID: string) => `settings/workspaces/${policyID}/rules/name` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index dea0f028e1a0..9b8fe54111cf 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -542,6 +542,7 @@ const SCREENS = { RULES_MAX_EXPENSE_AMOUNT: 'Rules_Max_Expense_Amount', RULES_MAX_EXPENSE_AGE: 'Rules_Max_Expense_Age', RULES_BILLABLE_DEFAULT: 'Rules_Billable_Default', + PER_DIEM: 'Per_Diem', }, EDIT_REQUEST: { diff --git a/src/components/AddressSearch/index.tsx b/src/components/AddressSearch/index.tsx index 9c72b371c40f..f4067d357c9d 100644 --- a/src/components/AddressSearch/index.tsx +++ b/src/components/AddressSearch/index.tsx @@ -63,6 +63,7 @@ function AddressSearch( onBlur, onInputChange, onPress, + onCountryChange, predefinedPlaces = [], preferredLocale, renamedInputKeys = { @@ -195,7 +196,7 @@ function AddressSearch( // If the address is not in the US, use the full length state name since we're displaying the address's // state / province in a TextInput instead of in a picker. - if (country !== CONST.COUNTRY.US) { + if (country !== CONST.COUNTRY.US && country !== CONST.COUNTRY.CA) { values.state = longStateName; } @@ -244,6 +245,7 @@ function AddressSearch( onInputChange?.(values); } + onCountryChange?.(values.country); onPress?.(values); }; diff --git a/src/components/AddressSearch/types.ts b/src/components/AddressSearch/types.ts index b654fcad99da..daa28c3d69af 100644 --- a/src/components/AddressSearch/types.ts +++ b/src/components/AddressSearch/types.ts @@ -87,6 +87,9 @@ type AddressSearchProps = { /** The user's preferred locale e.g. 'en', 'es-ES' */ preferredLocale?: Locale; + + /** Callback to be called when the country is changed */ + onCountryChange?: (country: unknown) => void; }; type IsCurrentTargetInsideContainerType = (event: FocusEvent | NativeSyntheticEvent, containerRef: RefObject) => boolean; diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index 1b1f7fbdcf15..e9021ec11c03 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -293,11 +293,11 @@ function Button( )} @@ -312,6 +312,7 @@ function Button( small={small} medium={medium} large={large} + isButtonIcon /> ) : ( )} diff --git a/src/components/CountryPicker/CountrySelectorModal.tsx b/src/components/CountryPicker/CountrySelectorModal.tsx index 76fd53138019..2daa74dcb4e8 100644 --- a/src/components/CountryPicker/CountrySelectorModal.tsx +++ b/src/components/CountryPicker/CountrySelectorModal.tsx @@ -7,8 +7,8 @@ import RadioListItem from '@components/SelectionList/RadioListItem'; import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import searchCountryOptions from '@libs/searchCountryOptions'; -import type {CountryData} from '@libs/searchCountryOptions'; +import searchOptions from '@libs/searchOptions'; +import type {Option} from '@libs/searchOptions'; import StringUtils from '@libs/StringUtils'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; @@ -27,7 +27,7 @@ type CountrySelectorModalProps = { currentCountry: string; /** Function to call when the user selects a country */ - onCountrySelected: (value: CountryData) => void; + onCountrySelected: (value: Option) => void; /** Function to call when the user presses on the modal backdrop */ onBackdropPress?: () => void; @@ -52,7 +52,7 @@ function CountrySelectorModal({isVisible, currentCountry, onCountrySelected, onC [translate, currentCountry], ); - const searchResults = searchCountryOptions(debouncedSearchValue, countries); + const searchResults = searchOptions(debouncedSearchValue, countries); const headerMessage = debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : ''; const styles = useThemeStyles(); diff --git a/src/components/CountryPicker/index.tsx b/src/components/CountryPicker/index.tsx index cc51b3c5f537..3f30fcbafb75 100644 --- a/src/components/CountryPicker/index.tsx +++ b/src/components/CountryPicker/index.tsx @@ -2,7 +2,7 @@ import React, {useState} from 'react'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import useLocalize from '@hooks/useLocalize'; import Navigation from '@libs/Navigation/Navigation'; -import type {CountryData} from '@libs/searchCountryOptions'; +import type {Option} from '@libs/searchOptions'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import CountrySelectorModal from './CountrySelectorModal'; @@ -26,7 +26,7 @@ function CountryPicker({value, errorText, onInputChange = () => {}}: CountryPick setIsPickerVisible(false); }; - const updateInput = (item: CountryData) => { + const updateInput = (item: Option) => { onInputChange?.(item.value); hidePickerModal(); }; diff --git a/src/components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS.ts b/src/components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS.ts index 4673b4f269ec..32e063f03109 100644 --- a/src/components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS.ts +++ b/src/components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS.ts @@ -35,6 +35,7 @@ const WIDE_LAYOUT_INACTIVE_SCREENS: string[] = [ SCREENS.SETTINGS.TROUBLESHOOT, SCREENS.SETTINGS.SAVE_THE_WORLD, SCREENS.WORKSPACE.RULES, + SCREENS.WORKSPACE.PER_DIEM, ]; export default WIDE_LAYOUT_INACTIVE_SCREENS; diff --git a/src/components/Form/FormProvider.tsx b/src/components/Form/FormProvider.tsx index 1d66953c1070..5c5c28b82fb9 100644 --- a/src/components/Form/FormProvider.tsx +++ b/src/components/Form/FormProvider.tsx @@ -12,6 +12,7 @@ import CONST from '@src/CONST'; import type {OnyxFormDraftKey, OnyxFormKey} from '@src/ONYXKEYS'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Form} from '@src/types/form'; +import type {Errors} from '@src/types/onyx/OnyxCommon'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {RegisterInput} from './FormContext'; import FormContext from './FormContext'; @@ -244,9 +245,20 @@ function FormProvider( setErrors({}); }, [formID]); + const resetFormFieldError = useCallback( + (inputID: keyof Form) => { + const newErrors = {...errors}; + delete newErrors[inputID]; + FormActions.setErrors(formID, newErrors as Errors); + setErrors(newErrors); + }, + [errors, formID], + ); + useImperativeHandle(forwardedRef, () => ({ resetForm, resetErrors, + resetFormFieldError, })); const registerInput = useCallback( diff --git a/src/components/Form/types.ts b/src/components/Form/types.ts index a77fabc52ce9..ab9260a6b5d9 100644 --- a/src/components/Form/types.ts +++ b/src/components/Form/types.ts @@ -33,6 +33,7 @@ import type NetSuiteCustomListPicker from '@pages/workspace/accounting/netsuite/ import type NetSuiteMenuWithTopDescriptionForm from '@pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteMenuWithTopDescriptionForm'; import type {Country} from '@src/CONST'; import type {OnyxFormKey, OnyxValues} from '@src/ONYXKEYS'; +import type {Form} from '@src/types/form'; import type {BaseForm} from '@src/types/form/Form'; /** @@ -164,6 +165,7 @@ type FormProps = { type FormRef = { resetForm: (optionalValue: FormOnyxValues) => void; resetErrors: () => void; + resetFormFieldError: (fieldID: keyof Form) => void; }; type InputRefs = Record>; diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts index 0efb65ed7a61..991aaea86513 100644 --- a/src/components/Icon/Illustrations.ts +++ b/src/components/Icon/Illustrations.ts @@ -101,6 +101,7 @@ import MoneyWings from '@assets/images/simple-illustrations/simple-illustration_ import OpenSafe from '@assets/images/simple-illustrations/simple-illustration__opensafe.svg'; import PalmTree from '@assets/images/simple-illustrations/simple-illustration__palmtree.svg'; import Pencil from '@assets/images/simple-illustrations/simple-illustration__pencil.svg'; +import PerDiem from '@assets/images/simple-illustrations/simple-illustration__perdiem.svg'; import PiggyBank from '@assets/images/simple-illustrations/simple-illustration__piggybank.svg'; import Profile from '@assets/images/simple-illustrations/simple-illustration__profile.svg'; import QRCode from '@assets/images/simple-illustrations/simple-illustration__qr-code.svg'; @@ -264,4 +265,5 @@ export { OtherCompanyCardDetail, StripeCompanyCardDetail, WellsFargoCompanyCardDetail, + PerDiem, }; diff --git a/src/components/Icon/index.tsx b/src/components/Icon/index.tsx index b4da5c0b0fa2..4ec4556e1c86 100644 --- a/src/components/Icon/index.tsx +++ b/src/components/Icon/index.tsx @@ -40,9 +40,6 @@ type IconProps = { /** Is icon pressed */ pressed?: boolean; - /** Is icon will be used with text */ - hasText?: boolean; - /** Additional styles to add to the Icon */ additionalStyles?: StyleProp; @@ -51,6 +48,9 @@ type IconProps = { /** Determines how the image should be resized to fit its container */ contentFit?: ImageContentFit; + + /** Determines whether the icon is being used within a button. The icon size will remain the same for both icon-only buttons and buttons with text. */ + isButtonIcon?: boolean; }; function Icon({ @@ -59,7 +59,6 @@ function Icon({ height = variables.iconSizeNormal, fill = undefined, small = false, - hasText = false, large = false, medium = false, inline = false, @@ -68,10 +67,11 @@ function Icon({ pressed = false, testID = '', contentFit = 'cover', + isButtonIcon = false, }: IconProps) { const StyleUtils = useStyleUtils(); const styles = useThemeStyles(); - const {width: iconWidth, height: iconHeight} = StyleUtils.getIconWidthAndHeightStyle(small, medium, large, width, height, hasText); + const {width: iconWidth, height: iconHeight} = StyleUtils.getIconWidthAndHeightStyle(small, medium, large, width, height, isButtonIcon); const iconStyles = [StyleUtils.getWidthAndHeightStyle(width ?? 0, height), IconWrapperStyles, styles.pAbsolute, additionalStyles]; if (inline) { diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 3caf7a15d50e..8bf07e2d3a02 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -177,7 +177,7 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea const [isNoDelegateAccessMenuVisible, setIsNoDelegateAccessMenuVisible] = useState(false); const isReportInRHP = isReportOpenInRHP(navigationRef?.getRootState()); - const shouldDisplaySearchRouter = !isReportInRHP; + const shouldDisplaySearchRouter = !isReportInRHP || isSmallScreenWidth; const confirmPayment = useCallback( (type?: PaymentMethodType | undefined, payAsBusiness?: boolean) => { diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index ebb927b2a279..3d8f2a6bed33 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -8,7 +8,6 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import {MouseProvider} from '@hooks/useMouseContext'; -import usePermissions from '@hooks/usePermissions'; import usePrevious from '@hooks/usePrevious'; import useThemeStyles from '@hooks/useThemeStyles'; import blurActiveElement from '@libs/Accessibility/blurActiveElement'; @@ -195,7 +194,6 @@ function MoneyRequestConfirmationList({ const styles = useThemeStyles(); const {translate, toLocaleDigit} = useLocalize(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const {canUseP2PDistanceRequests} = usePermissions(iouType); const isTypeRequest = iouType === CONST.IOU.TYPE.SUBMIT; const isTypeSplit = iouType === CONST.IOU.TYPE.SPLIT; @@ -214,9 +212,9 @@ function MoneyRequestConfirmationList({ const defaultRate = defaultMileageRate?.customUnitRateID ?? ''; const lastSelectedRate = lastSelectedDistanceRates?.[policy?.id ?? ''] ?? defaultRate; - const rateID = canUseP2PDistanceRequests ? lastSelectedRate : defaultRate; + const rateID = lastSelectedRate; IOU.setCustomUnitRateID(transactionID, rateID); - }, [defaultMileageRate, customUnitRateID, lastSelectedDistanceRates, policy?.id, canUseP2PDistanceRequests, transactionID, isDistanceRequest]); + }, [defaultMileageRate, customUnitRateID, lastSelectedDistanceRates, policy?.id, transactionID, isDistanceRequest]); const mileageRate = DistanceRequestUtils.getRate({transaction, policy, policyDraft}); const rate = mileageRate.rate; @@ -880,7 +878,6 @@ function MoneyRequestConfirmationList({ const listFooterContent = ( Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams()))} - disabled={didConfirm} - // todo: handle edit for transaction while moving from track expense - interactive={!isReadOnly && !isMovingTransactionFromTrackExpense} - /> - ), - shouldShow: isDistanceRequest && !canUseP2PDistanceRequests, - isSupplementary: false, - }, { item: ( ), - shouldShow: isDistanceRequest && canUseP2PDistanceRequests, + shouldShow: isDistanceRequest, isSupplementary: false, }, { @@ -398,7 +372,7 @@ function MoneyRequestConfirmationListFooter({ interactive={!!rate && !isReadOnly && isPolicyExpenseChat} /> ), - shouldShow: isDistanceRequest && canUseP2PDistanceRequests, + shouldShow: isDistanceRequest, isSupplementary: false, }, { @@ -692,7 +666,6 @@ export default memo( MoneyRequestConfirmationListFooter, (prevProps, nextProps) => lodashIsEqual(prevProps.action, nextProps.action) && - prevProps.canUseP2PDistanceRequests === nextProps.canUseP2PDistanceRequests && prevProps.currency === nextProps.currency && prevProps.didConfirm === nextProps.didConfirm && prevProps.distance === nextProps.distance && @@ -711,7 +684,6 @@ export default memo( prevProps.isEditingSplitBill === nextProps.isEditingSplitBill && prevProps.isMerchantEmpty === nextProps.isMerchantEmpty && prevProps.isMerchantRequired === nextProps.isMerchantRequired && - prevProps.isMovingTransactionFromTrackExpense === nextProps.isMovingTransactionFromTrackExpense && prevProps.isPolicyExpenseChat === nextProps.isPolicyExpenseChat && prevProps.isReadOnly === nextProps.isReadOnly && prevProps.isTypeInvoice === nextProps.isTypeInvoice && diff --git a/src/components/MoneyRequestHeader.tsx b/src/components/MoneyRequestHeader.tsx index 2dde3e9e2aa9..93ac363cff62 100644 --- a/src/components/MoneyRequestHeader.tsx +++ b/src/components/MoneyRequestHeader.tsx @@ -66,7 +66,7 @@ function MoneyRequestHeader({report, parentReportAction, policy, onBackButtonPre const reportID = report?.reportID; const isReportInRHP = isReportOpenInRHP(navigationRef?.getRootState()); - const shouldDisplaySearchRouter = !isReportInRHP; + const shouldDisplaySearchRouter = !isReportInRHP || isSmallScreenWidth; const hasAllPendingRTERViolations = TransactionUtils.allHavePendingRTERViolation([transaction?.transactionID ?? '-1']); diff --git a/src/components/OptionListContextProvider.tsx b/src/components/OptionListContextProvider.tsx index 537919622540..b14a26138b6e 100644 --- a/src/components/OptionListContextProvider.tsx +++ b/src/components/OptionListContextProvider.tsx @@ -1,6 +1,5 @@ import React, {createContext, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; -import {withOnyx} from 'react-native-onyx'; -import type {OnyxCollection} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import usePrevious from '@hooks/usePrevious'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import type {OptionList} from '@libs/OptionsListUtils'; @@ -17,14 +16,11 @@ type OptionsListContextProps = { initializeOptions: () => void; /** Flag to check if the options are initialized */ areOptionsInitialized: boolean; + /** Function to reset the options */ + resetOptions: () => void; }; -type OptionsListProviderOnyxProps = { - /** Collection of reports */ - reports: OnyxCollection; -}; - -type OptionsListProviderProps = OptionsListProviderOnyxProps & { +type OptionsListProviderProps = { /** Actual content wrapped by this component */ children: React.ReactNode; }; @@ -36,6 +32,7 @@ const OptionsListContext = createContext({ }, initializeOptions: () => {}, areOptionsInitialized: false, + resetOptions: () => {}, }); const isEqualPersonalDetail = (prevPersonalDetail: PersonalDetails | null, personalDetail: PersonalDetails | null) => @@ -44,12 +41,13 @@ const isEqualPersonalDetail = (prevPersonalDetail: PersonalDetails | null, perso prevPersonalDetail?.login === personalDetail?.login && prevPersonalDetail?.displayName === personalDetail?.displayName; -function OptionsListContextProvider({reports, children}: OptionsListProviderProps) { +function OptionsListContextProvider({children}: OptionsListProviderProps) { const areOptionsInitialized = useRef(false); const [options, setOptions] = useState({ reports: [], personalDetails: [], }); + const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; const prevPersonalDetails = usePrevious(personalDetails); @@ -144,9 +142,22 @@ function OptionsListContextProvider({reports, children}: OptionsListProviderProp areOptionsInitialized.current = true; }, [loadOptions]); + const resetOptions = useCallback(() => { + if (!areOptionsInitialized.current) { + return; + } + + areOptionsInitialized.current = false; + setOptions({ + reports: [], + personalDetails: [], + }); + }, []); + return ( - // eslint-disable-next-line react-compiler/react-compiler - ({options, initializeOptions, areOptionsInitialized: areOptionsInitialized.current}), [options, initializeOptions])}> + ({options, initializeOptions, areOptionsInitialized: areOptionsInitialized.current, resetOptions}), [options, initializeOptions, resetOptions])} + > {children} ); @@ -157,7 +168,7 @@ const useOptionsListContext = () => useContext(OptionsListContext); // Hook to use the OptionsListContext with an initializer to load the options const useOptionsList = (options?: {shouldInitialize: boolean}) => { const {shouldInitialize = true} = options ?? {}; - const {initializeOptions, options: optionsList, areOptionsInitialized} = useOptionsListContext(); + const {initializeOptions, options: optionsList, areOptionsInitialized, resetOptions} = useOptionsListContext(); useEffect(() => { if (!shouldInitialize || areOptionsInitialized) { @@ -171,13 +182,10 @@ const useOptionsList = (options?: {shouldInitialize: boolean}) => { initializeOptions, options: optionsList, areOptionsInitialized, + resetOptions, }; }; -export default withOnyx({ - reports: { - key: ONYXKEYS.COLLECTION.REPORT, - }, -})(OptionsListContextProvider); +export default OptionsListContextProvider; export {useOptionsListContext, useOptionsList, OptionsListContext}; diff --git a/src/components/PushRowWithModal/PushRowModal.tsx b/src/components/PushRowWithModal/PushRowModal.tsx index 79fbc53c1e2c..aa9fa0538dff 100644 --- a/src/components/PushRowWithModal/PushRowModal.tsx +++ b/src/components/PushRowWithModal/PushRowModal.tsx @@ -1,10 +1,13 @@ -import React, {useEffect, useState} from 'react'; +import React, {useMemo} from 'react'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Modal from '@components/Modal'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/RadioListItem'; +import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; +import searchOptions from '@libs/searchOptions'; +import StringUtils from '@libs/StringUtils'; import CONST from '@src/CONST'; type PushRowModalProps = { @@ -40,44 +43,28 @@ type ListItemType = { function PushRowModal({isVisible, selectedOption, onOptionChange, onClose, optionsList, headerTitle, searchInputTitle}: PushRowModalProps) { const {translate} = useLocalize(); - const allOptions = Object.entries(optionsList).map(([key, value]) => ({ - value: key, - text: value, - keyForList: key, - isSelected: key === selectedOption, - })); - const [searchbarInputText, setSearchbarInputText] = useState(''); - const [optionListItems, setOptionListItems] = useState(allOptions); - - useEffect(() => { - setOptionListItems((prevOptionListItems) => - prevOptionListItems.map((option) => ({ - ...option, - isSelected: option.value === selectedOption, + const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); + + const options = useMemo( + () => + Object.entries(optionsList).map(([key, value]) => ({ + value: key, + text: value, + keyForList: key, + isSelected: key === selectedOption, + searchValue: StringUtils.sanitizeString(value), })), - ); - }, [selectedOption]); - - const filterShownOptions = (searchText: string) => { - setSearchbarInputText(searchText); - const searchWords = searchText.toLowerCase().match(/[a-z0-9]+/g) ?? []; - setOptionListItems( - allOptions.filter((option) => - searchWords.every((word) => - option.text - .toLowerCase() - .replace(/[^a-z0-9]/g, ' ') - .includes(word), - ), - ), - ); - }; + [optionsList, selectedOption], + ); const handleSelectRow = (option: ListItemType) => { onOptionChange(option.value); onClose(); }; + const searchResults = searchOptions(debouncedSearchValue, options); + const headerMessage = debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : ''; + return ( option.value === selectedOption)?.keyForList} + sections={[{data: searchResults}]} + initiallyFocusedOptionKey={selectedOption} showScrollIndicator shouldShowTooltips={false} ListItem={RadioListItem} diff --git a/src/components/PushRowWithModal/index.tsx b/src/components/PushRowWithModal/index.tsx index 65c1969fdf8b..83128899b50f 100644 --- a/src/components/PushRowWithModal/index.tsx +++ b/src/components/PushRowWithModal/index.tsx @@ -8,11 +8,11 @@ type PushRowWithModalProps = { /** The list of options that we want to display where key is option code and value is option name */ optionsList: Record; - /** The currently selected option */ - selectedOption: string; + /** Current value of the selected item */ + value?: string; - /** Function to call when the user selects an option */ - onOptionChange: (option: string) => void; + /** Function called whenever list item is selected */ + onInputChange?: (value: string, key?: string) => void; /** Additional styles to apply to container */ wrapperStyles?: StyleProp; @@ -32,13 +32,12 @@ type PushRowWithModalProps = { /** Text to display on error message */ errorText?: string; - /** Function called whenever option changes */ - onInputChange?: (value: string) => void; + /** The ID of the input that should be reset when the value changes */ + stateInputIDToReset?: string; }; function PushRowWithModal({ - selectedOption, - onOptionChange, + value, optionsList, wrapperStyles, description, @@ -47,6 +46,7 @@ function PushRowWithModal({ shouldAllowChange = true, errorText, onInputChange = () => {}, + stateInputIDToReset, }: PushRowWithModalProps) { const [isModalVisible, setIsModalVisible] = useState(false); @@ -58,16 +58,19 @@ function PushRowWithModal({ setIsModalVisible(true); }; - const handleOptionChange = (value: string) => { - onOptionChange(value); - onInputChange(value); + const handleOptionChange = (optionValue: string) => { + onInputChange(optionValue); + + if (stateInputIDToReset) { + onInputChange('', stateInputIDToReset); + } }; return ( <> { const originalMessage = parentReportAction && ReportActionsUtils.isMoneyRequestAction(parentReportAction) ? ReportActionsUtils.getOriginalMessage(parentReportAction) : undefined; @@ -307,9 +305,9 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals [transactionAmount, isSettled, isCancelled, isPolicyExpenseChat, isEmptyMerchant, transactionDate, readonly, hasErrors, hasViolations, translate, getViolationsForField], ); - const distanceRequestFields = canUseP2PDistanceRequests ? ( + const distanceRequestFields = ( <> - + - ) : ( - - - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute( - CONST.IOU.ACTION.EDIT, - iouType, - transaction?.transactionID ?? '-1', - report?.reportID ?? '-1', - Navigation.getReportRHPActiveRoute(), - ), - ) - } - /> - ); const isReceiptAllowed = !isPaidReport && !isInvoice; diff --git a/src/components/SelectionScreen.tsx b/src/components/SelectionScreen.tsx index 8382029bc12f..3538b04ed57f 100644 --- a/src/components/SelectionScreen.tsx +++ b/src/components/SelectionScreen.tsx @@ -93,6 +93,18 @@ type SelectionScreenProps = { /** Whether to update the focused index on a row select */ shouldUpdateFocusedIndex?: boolean; + + /** Whether to show the text input */ + shouldShowTextInput?: boolean; + + /** Label for the text input */ + textInputLabel?: string; + + /** Value for the text input */ + textInputValue?: string; + + /** Callback to fire when the text input changes */ + onChangeText?: (text: string) => void; }; function SelectionScreen({ @@ -117,6 +129,10 @@ function SelectionScreen({ onClose, shouldSingleExecuteRowSelect, headerTitleAlreadyTranslated, + textInputLabel, + textInputValue, + onChangeText, + shouldShowTextInput, shouldUpdateFocusedIndex = false, }: SelectionScreenProps) { const {translate} = useLocalize(); @@ -152,9 +168,13 @@ function SelectionScreen({ sections={sections} ListItem={listItem} showScrollIndicator + onChangeText={onChangeText} shouldShowTooltips={false} initiallyFocusedOptionKey={initiallyFocusedOptionKey} listEmptyContent={listEmptyContent} + textInputLabel={textInputLabel} + textInputValue={textInputValue} + shouldShowTextInput={shouldShowTextInput} listFooterContent={listFooterContent} sectionListStyle={!!sections.length && [styles.flexGrow0]} shouldSingleExecuteRowSelect={shouldSingleExecuteRowSelect} diff --git a/src/components/StatePicker/StateSelectorModal.tsx b/src/components/StatePicker/StateSelectorModal.tsx index 95c6067c95a2..0ce488c176ee 100644 --- a/src/components/StatePicker/StateSelectorModal.tsx +++ b/src/components/StatePicker/StateSelectorModal.tsx @@ -8,8 +8,8 @@ import RadioListItem from '@components/SelectionList/RadioListItem'; import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import searchCountryOptions from '@libs/searchCountryOptions'; -import type {CountryData} from '@libs/searchCountryOptions'; +import searchOptions from '@libs/searchOptions'; +import type {Option} from '@libs/searchOptions'; import StringUtils from '@libs/StringUtils'; import CONST from '@src/CONST'; @@ -29,7 +29,7 @@ type StateSelectorModalProps = { currentState: string; /** Function to call when the user selects a state */ - onStateSelected: (value: CountryData) => void; + onStateSelected: (value: Option) => void; /** Function to call when the user presses on the modal backdrop */ onBackdropPress?: () => void; @@ -56,7 +56,7 @@ function StateSelectorModal({isVisible, currentState, onStateSelected, onClose, [translate, currentState], ); - const searchResults = searchCountryOptions(debouncedSearchValue, countryStates); + const searchResults = searchOptions(debouncedSearchValue, countryStates); const headerMessage = debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : ''; const styles = useThemeStyles(); diff --git a/src/components/StatePicker/index.tsx b/src/components/StatePicker/index.tsx index ebcb156fd293..558db66a52ec 100644 --- a/src/components/StatePicker/index.tsx +++ b/src/components/StatePicker/index.tsx @@ -3,7 +3,7 @@ import React, {useState} from 'react'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import useLocalize from '@hooks/useLocalize'; import Navigation from '@libs/Navigation/Navigation'; -import type {CountryData} from '@libs/searchCountryOptions'; +import type {Option} from '@libs/searchOptions'; import CONST from '@src/CONST'; import StateSelectorModal from './StateSelectorModal'; @@ -28,7 +28,7 @@ function StatePicker({value, errorText, onInputChange = () => {}}: StatePickerPr setIsPickerVisible(false); }; - const updateInput = (item: CountryData) => { + const updateInput = (item: Option) => { onInputChange?.(item.value); hidePickerModal(); }; diff --git a/src/components/SubStepForms/AddressStep.tsx b/src/components/SubStepForms/AddressStep.tsx index 9a90d2fa7a42..86e6e328d226 100644 --- a/src/components/SubStepForms/AddressStep.tsx +++ b/src/components/SubStepForms/AddressStep.tsx @@ -1,7 +1,7 @@ -import React, {useCallback} from 'react'; +import React, {useCallback, useEffect, useRef} from 'react'; import {View} from 'react-native'; import FormProvider from '@components/Form/FormProvider'; -import type {FormInputErrors, FormOnyxKeys, FormOnyxValues, FormValue} from '@components/Form/types'; +import type {FormInputErrors, FormOnyxKeys, FormOnyxValues, FormRef, FormValue} from '@components/Form/types'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import type {SubStepProps} from '@hooks/useSubStep/types'; @@ -37,7 +37,7 @@ type AddressStepProps = SubStepProp /** Fields list of the form */ stepFields: Array>; - /* The IDs of the input fields */ + /** The IDs of the input fields */ inputFieldsIDs: AddressValues; /** The default values for the form */ @@ -45,6 +45,24 @@ type AddressStepProps = SubStepProp /** Should show help links */ shouldShowHelpLinks?: boolean; + + /** Indicates if country selector should be displayed */ + shouldDisplayCountrySelector?: boolean; + + /** Indicates if state selector should be displayed */ + shouldDisplayStateSelector?: boolean; + + /** Label for the state selector */ + stateSelectorLabel?: string; + + /** The title of the state selector modal */ + stateSelectorModalHeaderTitle?: string; + + /** The title of the state selector search input */ + stateSelectorSearchInputTitle?: string; + + /** Callback to be called when the country is changed */ + onCountryChange?: (country: unknown) => void; }; function AddressStep({ @@ -58,10 +76,23 @@ function AddressStep({ defaultValues, shouldShowHelpLinks, isEditing, + shouldDisplayCountrySelector = false, + shouldDisplayStateSelector = true, + stateSelectorLabel, + stateSelectorModalHeaderTitle, + stateSelectorSearchInputTitle, + onCountryChange, }: AddressStepProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); + const formRef = useRef(null); + + useEffect(() => { + // When stepFields change (e.g. country changes) we need to reset state errors manually + formRef.current?.resetFormFieldError(inputFieldsIDs.state); + }, [inputFieldsIDs.state, stepFields]); + const validate = useCallback( (values: FormOnyxValues): FormInputErrors => { const errors = ValidationUtils.getFieldRequiredErrors(values, stepFields); @@ -73,14 +104,14 @@ function AddressStep({ } const zipCode = values[inputFieldsIDs.zipCode as keyof typeof values]; - if (zipCode && !ValidationUtils.isValidZipCode(zipCode as string)) { + if (zipCode && (shouldDisplayCountrySelector ? !ValidationUtils.isValidZipCodeInternational(zipCode as string) : !ValidationUtils.isValidZipCode(zipCode as string))) { // @ts-expect-error type mismatch to be fixed errors[inputFieldsIDs.zipCode] = translate('bankAccount.error.zipCode'); } return errors; }, - [inputFieldsIDs.street, inputFieldsIDs.zipCode, stepFields, translate], + [inputFieldsIDs.street, inputFieldsIDs.zipCode, shouldDisplayCountrySelector, stepFields, translate], ); return ( @@ -90,6 +121,7 @@ function AddressStep({ validate={customValidate ?? validate} onSubmit={onSubmit} style={[styles.mh5, styles.flexGrow1]} + ref={formRef} > {formTitle} @@ -99,6 +131,12 @@ function AddressStep({ streetTranslationKey="common.streetAddress" defaultValues={defaultValues} shouldSaveDraft={!isEditing} + shouldDisplayStateSelector={shouldDisplayStateSelector} + shouldDisplayCountrySelector={shouldDisplayCountrySelector} + stateSelectorLabel={stateSelectorLabel} + stateSelectorModalHeaderTitle={stateSelectorModalHeaderTitle} + stateSelectorSearchInputTitle={stateSelectorSearchInputTitle} + onCountryChange={onCountryChange} /> {!!shouldShowHelpLinks && } diff --git a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx index 392e4b9176e6..425960078b0a 100644 --- a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx +++ b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx @@ -245,6 +245,7 @@ function BaseValidateCodeForm({ /> )} (null); @@ -67,7 +69,7 @@ function ValidateCodeActionModal({ onBackButtonPress={hide} /> - + {description} diff --git a/src/hooks/usePermissions.ts b/src/hooks/usePermissions.ts index 22200304fdd5..e60825b610e9 100644 --- a/src/hooks/usePermissions.ts +++ b/src/hooks/usePermissions.ts @@ -1,13 +1,12 @@ import {useContext, useMemo} from 'react'; import {BetasContext} from '@components/OnyxProvider'; import Permissions from '@libs/Permissions'; -import type {IOUType} from '@src/CONST'; type PermissionKey = keyof typeof Permissions; type UsePermissions = Partial>; let permissionKey: PermissionKey; -export default function usePermissions(iouType: IOUType | undefined = undefined): UsePermissions { +export default function usePermissions(): UsePermissions { const betas = useContext(BetasContext); return useMemo(() => { const permissions: UsePermissions = {}; @@ -16,10 +15,10 @@ export default function usePermissions(iouType: IOUType | undefined = undefined) if (betas) { const checkerFunction = Permissions[permissionKey]; - permissions[permissionKey] = checkerFunction(betas, iouType); + permissions[permissionKey] = checkerFunction(betas); } } return permissions; - }, [betas, iouType]); + }, [betas]); } diff --git a/src/languages/en.ts b/src/languages/en.ts index 38b11e9fea38..0f5b639dae5c 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -44,6 +44,7 @@ import type { ConfirmThatParams, ConnectionNameParams, ConnectionParams, + CurrencyCodeParams, CustomersOrJobsLabelParams, DateParams, DateShouldBeAfterParams, @@ -346,6 +347,7 @@ const translations = { pleaseSelectOne: 'Please select an option above.', invalidRateError: 'Please enter a valid rate.', lowRateError: 'Rate must be greater than 0.', + email: 'Please enter a valid email address.', }, comma: 'comma', semicolon: 'semicolon', @@ -1401,7 +1403,7 @@ const translations = { enableWalletToSendAndReceiveMoney: 'Enable your wallet to send and receive money with friends.', walletEnabledToSendAndReceiveMoney: 'Your wallet has been enabled to send and receive money with friends.', enableWallet: 'Enable wallet', - addBankAccountToSendAndReceive: 'Adding a bank account allows you to get paid back for expenses you submit to a workspace.', + addBankAccountToSendAndReceive: 'Add a bank account to get paid back for expenses you submit to a workspace.', addBankAccount: 'Add bank account', assignedCards: 'Assigned cards', assignedCardsDescription: 'These are cards assigned by a workspace admin to manage company spend.', @@ -1918,7 +1920,7 @@ const translations = { noBankAccountAvailable: "Sorry, there's no bank account available.", noBankAccountSelected: 'Please choose an account.', taxID: 'Please enter a valid tax ID number.', - website: 'Please enter a valid website using lower-case letters.', + website: 'Please enter a valid website.', zipCode: `Please enter a valid ZIP code using the format: ${CONST.COUNTRY_ZIP_REGEX_DATA.US.samples}.`, phoneNumber: 'Please enter a valid phone number.', companyName: 'Please enter a valid business name.', @@ -2169,6 +2171,33 @@ const translations = { listOfRestrictedBusinesses: 'list of restricted businesses', confirmCompanyIsNot: 'I confirm that this company is not on the', businessInfoTitle: 'Business info', + legalBusinessName: 'Legal business name', + whatsTheBusinessName: "What's the business name?", + whatsTheBusinessAddress: "What's the business address?", + whatsTheBusinessContactInformation: "What's the business contact information?", + whatsTheBusinessRegistrationNumber: "What's the business registration number?", + whatsThisNumber: "What's this number?", + whereWasTheBusinessIncorporated: 'Where was the business incorporated?', + whatTypeOfBusinessIsIt: 'What type of business is it?', + whatsTheBusinessAnnualPayment: "What's the business's annual payment volume?", + registrationNumber: 'Registration number', + businessAddress: 'Business address', + businessType: 'Business type', + incorporation: 'Incorporation', + incorporationCountry: 'Incorporation country', + incorporationTypeName: 'Incorporation type', + businessCategory: 'Business category', + annualPaymentVolume: 'Annual payment volume', + annualPaymentVolumeInCurrency: ({currencyCode}: CurrencyCodeParams) => `Annual payment volume in ${currencyCode}`, + selectIncorporationType: 'Select incorporation type', + selectBusinessCategory: 'Select business category', + selectAnnualPaymentVolume: 'Select annual payment volume', + selectIncorporationCountry: 'Select incorporation country', + selectIncorporationState: 'Select incorporation state', + findIncorporationType: 'Find incorporation type', + findBusinessCategory: 'Find business category', + findAnnualPaymentVolume: 'Find annual payment volume', + findIncorporationState: 'Find incorporation state', }, beneficialOwnerInfoStep: { doYouOwn25percent: 'Do you own 25% or more of', @@ -2337,6 +2366,7 @@ const translations = { displayedAs: 'Displayed as', plan: 'Plan', profile: 'Profile', + perDiem: 'Per diem', bankAccount: 'Bank account', connectBankAccount: 'Connect bank account', testTransactions: 'Test transactions', @@ -2401,6 +2431,25 @@ const translations = { } }, }, + perDiem: { + subtitle: 'Set per diem rates to control daily employee spend. ', + destination: 'Destination', + subrate: 'Subrate', + amount: 'Amount', + deleteRates: () => ({ + one: 'Delete rate', + other: 'Delete rates', + }), + deletePerDiemRate: 'Delete per diem rate', + areYouSureDelete: () => ({ + one: 'Are you sure you want to delete this rate?', + other: 'Are you sure you want to delete these rates?', + }), + emptyList: { + title: 'Per diem', + subtitle: 'Set per diem rates to control daily employee spend. Import rates from a spreadsheet to get started.', + }, + }, qbd: { exportOutOfPocketExpensesDescription: 'Set how out-of-pocket expenses export to QuickBooks Desktop.', exportOutOfPocketExpensesCheckToogle: 'Mark checks as “print later”', @@ -3291,6 +3340,10 @@ const translations = { title: 'Distance rates', subtitle: 'Add, update, and enforce rates.', }, + perDiem: { + title: 'Per diem', + subtitle: 'Set Per diem rates to control daily employee spend.', + }, expensifyCard: { title: 'Expensify Card', subtitle: 'Gain insights and control over spend.', @@ -4067,6 +4120,12 @@ const translations = { description: `Rules run in the background and keep your spend under control so you don't have to sweat the small stuff.\n\nRequire expense details like receipts and descriptions, set limits and defaults, and automate approvals and payments – all in one place.`, onlyAvailableOnPlan: 'Rules are only available on the Control plan, starting at ', }, + perDiem: { + title: 'Per diem', + description: + 'Per diem is a great way to keep your daily costs compliant and predictable whenever your employees travel. Enjoy features like custom rates, default categories, and more granular details like destinations and subrates.', + onlyAvailableOnPlan: 'Per diem are only available on the Control plan, starting at ', + }, pricing: { amount: '$9 ', perActiveMember: 'per active member per month.', @@ -4537,7 +4596,7 @@ const translations = { pressKit: 'Press Kit', support: 'Support', expensifyHelp: 'ExpensifyHelp', - community: 'Community', + terms: 'Terms of Service', privacy: 'Privacy', learnMore: 'Learn More', aboutExpensify: 'About Expensify', diff --git a/src/languages/es.ts b/src/languages/es.ts index 2ffc2bd21cca..2fff384c257b 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -42,6 +42,7 @@ import type { ConfirmThatParams, ConnectionNameParams, ConnectionParams, + CurrencyCodeParams, CustomersOrJobsLabelParams, DateParams, DateShouldBeAfterParams, @@ -336,6 +337,7 @@ const translations = { pleaseSelectOne: 'Seleccione una de las opciones.', invalidRateError: 'Por favor, introduce una tarifa válida.', lowRateError: 'La tarifa debe ser mayor que 0.', + email: 'Por favor, introduzca una dirección de correo electrónico válida.', }, comma: 'la coma', semicolon: 'el punto y coma', @@ -1401,7 +1403,7 @@ const translations = { enableWalletToSendAndReceiveMoney: 'Habilita tu Billetera Expensify para comenzar a enviar y recibir dinero con amigos.', walletEnabledToSendAndReceiveMoney: 'Tu billetera ha sido habilitada para enviar y recibir dinero con amigos.', enableWallet: 'Habilitar billetera', - addBankAccountToSendAndReceive: 'Agregar una cuenta bancaria te permite recibir reembolsos por los gastos que envíes a un espacio de trabajo.', + addBankAccountToSendAndReceive: 'Añade una cuenta bancaria para recibir reembolsos por los gastos que envíes a un espacio de trabajo.', addBankAccount: 'Añadir cuenta bancaria', assignedCards: 'Tarjetas asignadas', assignedCardsDescription: 'Son tarjetas asignadas por un administrador del espacio de trabajo para gestionar los gastos de la empresa.', @@ -1937,7 +1939,7 @@ const translations = { noBankAccountAvailable: 'Lo sentimos, no hay ninguna cuenta bancaria disponible.', noBankAccountSelected: 'Por favor, elige una cuenta bancaria.', taxID: 'Por favor, introduce un número de identificación fiscal válido.', - website: 'Por favor, introduce un sitio web válido. El sitio web debe estar en minúsculas.', + website: 'Por favor, introduce un sitio web válido.', zipCode: `Formato de código postal incorrecto. Formato aceptable: ${CONST.COUNTRY_ZIP_REGEX_DATA.US.samples}.`, phoneNumber: 'Por favor, introduce un teléfono válido.', companyName: 'Por favor, introduce un nombre comercial legal válido.', @@ -2192,6 +2194,33 @@ const translations = { listOfRestrictedBusinesses: 'lista de negocios restringidos', confirmCompanyIsNot: 'Confirmo que esta empresa no está en la', businessInfoTitle: 'Información del negocio', + legalBusinessName: 'Nombre legal de la empresa', + whatsTheBusinessName: '¿Cuál es el nombre de la empresa?', + whatsTheBusinessAddress: '¿Cuál es la dirección de la empresa?', + whatsTheBusinessContactInformation: '¿Cuál es la información de contacto de la empresa?', + whatsTheBusinessRegistrationNumber: '¿Cuál es el número de registro de la empresa?', + whatsThisNumber: '¿Qué es este número?', + whereWasTheBusinessIncorporated: '¿Dónde se constituyó la empresa?', + whatTypeOfBusinessIsIt: '¿Qué tipo de empresa es?', + whatsTheBusinessAnnualPayment: '¿Cuál es el volumen anual de pagos de la empresa?', + registrationNumber: 'Número de registro', + businessAddress: 'Dirección de la empresa', + businessType: 'Tipo de empresa', + incorporation: 'Constitución', + incorporationCountry: 'País de constitución', + incorporationTypeName: 'Tipo de constitución', + businessCategory: 'Categoría de la empresa', + annualPaymentVolume: 'Volumen anual de pagos', + annualPaymentVolumeInCurrency: ({currencyCode}: CurrencyCodeParams) => `Volumen anual de pagos en ${currencyCode}`, + selectIncorporationType: 'Seleccione tipo de constitución', + selectBusinessCategory: 'Seleccione categoría de la empresa', + selectAnnualPaymentVolume: 'Seleccione volumen anual de pagos', + selectIncorporationCountry: 'Seleccione país de constitución', + selectIncorporationState: 'Seleccione estado de constitución', + findIncorporationType: 'Buscar tipo de constitución', + findBusinessCategory: 'Buscar categoría de la empresa', + findAnnualPaymentVolume: 'Buscar volumen anual de pagos', + findIncorporationState: 'Buscar estado de constitución', }, beneficialOwnerInfoStep: { doYouOwn25percent: '¿Posees el 25% o más de', @@ -2358,6 +2387,7 @@ const translations = { rules: 'Reglas', plan: 'Plan', profile: 'Perfil', + perDiem: 'Per diem', bankAccount: 'Cuenta bancaria', displayedAs: 'Mostrado como', connectBankAccount: 'Conectar cuenta bancaria', @@ -2424,6 +2454,25 @@ const translations = { } }, }, + perDiem: { + subtitle: 'Establece las tasas per diem para controlar los gastos diarios de los empleados. ', + destination: 'Destino', + subrate: 'Subtasa', + amount: 'Cantidad', + deleteRates: () => ({ + one: 'Eliminar tasa', + other: 'Eliminar tasas', + }), + deletePerDiemRate: 'Eliminar tasa per diem', + areYouSureDelete: () => ({ + one: '¿Estás seguro de que quieres eliminar esta tasa?', + other: '¿Estás seguro de que quieres eliminar estas tasas?', + }), + emptyList: { + title: 'Per diem', + subtitle: 'Establece dietas per diem para controlar el gasto diario de los empleados. Importa las tarifas desde una hoja de cálculo para comenzar.', + }, + }, qbd: { exportOutOfPocketExpensesDescription: 'Establezca cómo se exportan los gastos de bolsillo a QuickBooks Desktop.', exportOutOfPocketExpensesCheckToogle: 'Marcar los cheques como “imprimir más tarde”', @@ -3332,6 +3381,10 @@ const translations = { title: 'Tasas de distancia', subtitle: 'Añade, actualiza y haz cumplir las tasas.', }, + perDiem: { + title: 'Per diem', + subtitle: 'Establece las tasas per diem para controlar los gastos diarios de los empleados.', + }, expensifyCard: { title: 'Tarjeta Expensify', subtitle: 'Obtén información y control sobre tus gastos.', @@ -4113,6 +4166,12 @@ const translations = { 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 Controlar, que comienza en ', }, + perDiem: { + title: 'Per diem', + description: + 'Las dietas per diem (ej.: $100 por día para comidas) son una excelente forma de mantener los gastos diarios predecibles y ajustados a las políticas de la empresa, especialmente si tus empleados viajan por negocios. Disfruta de funciones como tasas personalizadas, categorías por defecto y detalles más específicos como destinos y subtasas.', + onlyAvailableOnPlan: 'Las dietas per diem solo están disponibles en el plan Control, a partir de ', + }, note: { upgradeWorkspace: 'Mejore su espacio de trabajo para acceder a esta función, o', learnMore: 'más información', @@ -4586,7 +4645,7 @@ const translations = { pressKit: 'Kit de Prensa', support: 'Soporte', expensifyHelp: 'ExpensifyHelp', - community: 'Comunidad', + terms: 'Términos de Servicio', privacy: 'Privacidad', learnMore: 'Más Información', aboutExpensify: 'Acerca de Expensify', diff --git a/src/languages/params.ts b/src/languages/params.ts index e9f0c4370357..2d60c13c4dd0 100644 --- a/src/languages/params.ts +++ b/src/languages/params.ts @@ -547,6 +547,10 @@ type CompanyCardBankName = { bankName: string; }; +type CurrencyCodeParams = { + currencyCode: string; +}; + export type { AuthenticationErrorParams, ImportMembersSuccessfullDescriptionParams, @@ -746,4 +750,5 @@ export type { OptionalParam, AssignCardParams, ImportedTypesParams, + CurrencyCodeParams, }; diff --git a/src/libs/API/parameters/ApproveMoneyRequestParams.ts b/src/libs/API/parameters/ApproveMoneyRequestParams.ts index f6eb93270428..521226aeeff2 100644 --- a/src/libs/API/parameters/ApproveMoneyRequestParams.ts +++ b/src/libs/API/parameters/ApproveMoneyRequestParams.ts @@ -12,10 +12,6 @@ type ApproveMoneyRequestParams = { * }> */ optimisticHoldReportExpenseActionIDs?: string; - /** - * Call the optimized version of ApproveMoneyRequest - */ - v2?: boolean; }; export default ApproveMoneyRequestParams; diff --git a/src/libs/API/parameters/AssignCompanyCardParams.ts b/src/libs/API/parameters/AssignCompanyCardParams.ts index c4dcd7c628a0..782345686c62 100644 --- a/src/libs/API/parameters/AssignCompanyCardParams.ts +++ b/src/libs/API/parameters/AssignCompanyCardParams.ts @@ -1,6 +1,7 @@ type AssignCompanyCardParams = { policyID: string; bankName: string; + cardName: string; encryptedCardNumber: string; email: string; startDate: string; diff --git a/src/libs/API/parameters/OpenPolicyPerDiemRatesPageParams.ts b/src/libs/API/parameters/OpenPolicyPerDiemRatesPageParams.ts new file mode 100644 index 000000000000..de2fa3467027 --- /dev/null +++ b/src/libs/API/parameters/OpenPolicyPerDiemRatesPageParams.ts @@ -0,0 +1,5 @@ +type OpenPolicyPerDiemRatesPageParams = { + policyID: string; +}; + +export default OpenPolicyPerDiemRatesPageParams; diff --git a/src/libs/API/parameters/ReportVirtualExpensifyCardFraudParams.ts b/src/libs/API/parameters/ReportVirtualExpensifyCardFraudParams.ts index 350795d46355..ebb5abcb5d00 100644 --- a/src/libs/API/parameters/ReportVirtualExpensifyCardFraudParams.ts +++ b/src/libs/API/parameters/ReportVirtualExpensifyCardFraudParams.ts @@ -1,4 +1,5 @@ type ReportVirtualExpensifyCardFraudParams = { cardID: number; + validateCode: string; }; export default ReportVirtualExpensifyCardFraudParams; diff --git a/src/libs/API/parameters/RequestReplacementExpensifyCardParams.ts b/src/libs/API/parameters/RequestReplacementExpensifyCardParams.ts index bc86923a83a4..f8e62bd3bc6f 100644 --- a/src/libs/API/parameters/RequestReplacementExpensifyCardParams.ts +++ b/src/libs/API/parameters/RequestReplacementExpensifyCardParams.ts @@ -1,6 +1,7 @@ type RequestReplacementExpensifyCardParams = { cardID: number; reason: string; + validateCode: string; }; export default RequestReplacementExpensifyCardParams; diff --git a/src/libs/API/parameters/TogglePolicyPerDiemParams.ts b/src/libs/API/parameters/TogglePolicyPerDiemParams.ts new file mode 100644 index 000000000000..363020ec5c66 --- /dev/null +++ b/src/libs/API/parameters/TogglePolicyPerDiemParams.ts @@ -0,0 +1,7 @@ +type TogglePolicyPerDiemParams = { + policyID: string; + enabled: boolean; + customUnitID: string; +}; + +export default TogglePolicyPerDiemParams; diff --git a/src/libs/API/parameters/UnassignCompanyCard.ts b/src/libs/API/parameters/UnassignCompanyCard.ts index 10e04aa13f82..d433d9e537c3 100644 --- a/src/libs/API/parameters/UnassignCompanyCard.ts +++ b/src/libs/API/parameters/UnassignCompanyCard.ts @@ -1,6 +1,6 @@ type UnassignCompanyCard = { authToken?: string | null; - cardID: string; + cardID: number; }; export default UnassignCompanyCard; diff --git a/src/libs/API/parameters/UpdateCompanyCard.ts b/src/libs/API/parameters/UpdateCompanyCard.ts index 3d5eb3c580cb..113f0e1d7511 100644 --- a/src/libs/API/parameters/UpdateCompanyCard.ts +++ b/src/libs/API/parameters/UpdateCompanyCard.ts @@ -1,6 +1,6 @@ type UpdateCompanyCard = { authToken?: string | null; - cardID: string; + cardID: number; }; export default UpdateCompanyCard; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 26da6b2f6f03..fb5558fb0350 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -346,4 +346,6 @@ export type {default as UpdateInvoiceCompanyNameParams} from './UpdateInvoiceCom export type {default as UpdateInvoiceCompanyWebsiteParams} from './UpdateInvoiceCompanyWebsiteParams'; export type {default as UpdateQuickbooksDesktopExpensesExportDestinationTypeParams} from './UpdateQuickbooksDesktopExpensesExportDestinationTypeParams'; export type {default as UpdateQuickbooksDesktopCompanyCardExpenseAccountTypeParams} from './UpdateQuickbooksDesktopCompanyCardExpenseAccountTypeParams'; +export type {default as TogglePolicyPerDiemParams} from './TogglePolicyPerDiemParams'; +export type {default as OpenPolicyPerDiemRatesPageParams} from './OpenPolicyPerDiemRatesPageParams'; export type {default as TogglePlatformMuteParams} from './TogglePlatformMuteParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index bf3f749f5bac..b8b4bb749701 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -221,6 +221,7 @@ const WRITE_COMMANDS = { ENABLE_POLICY_WORKFLOWS: 'EnablePolicyWorkflows', ENABLE_POLICY_REPORT_FIELDS: 'EnablePolicyReportFields', ENABLE_POLICY_EXPENSIFY_CARDS: 'EnablePolicyExpensifyCards', + TOGGLE_POLICY_PER_DIEM: 'TogglePolicyPerDiem', ENABLE_POLICY_COMPANY_CARDS: 'EnablePolicyCompanyCards', ENABLE_POLICY_INVOICING: 'EnablePolicyInvoicing', SET_POLICY_RULES_ENABLED: 'SetPolicyRulesEnabled', @@ -661,6 +662,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.ENABLE_POLICY_WORKFLOWS]: Parameters.EnablePolicyWorkflowsParams; [WRITE_COMMANDS.ENABLE_POLICY_REPORT_FIELDS]: Parameters.EnablePolicyReportFieldsParams; [WRITE_COMMANDS.ENABLE_POLICY_EXPENSIFY_CARDS]: Parameters.EnablePolicyExpensifyCardsParams; + [WRITE_COMMANDS.TOGGLE_POLICY_PER_DIEM]: Parameters.TogglePolicyPerDiemParams; [WRITE_COMMANDS.ENABLE_POLICY_COMPANY_CARDS]: Parameters.EnablePolicyCompanyCardsParams; [WRITE_COMMANDS.ENABLE_POLICY_INVOICING]: Parameters.EnablePolicyInvoicingParams; [WRITE_COMMANDS.SET_POLICY_RULES_ENABLED]: Parameters.SetPolicyRulesEnabledParams; @@ -936,6 +938,7 @@ const READ_COMMANDS = { OPEN_DRAFT_WORKSPACE_REQUEST: 'OpenDraftWorkspaceRequest', OPEN_POLICY_WORKFLOWS_PAGE: 'OpenPolicyWorkflowsPage', OPEN_POLICY_DISTANCE_RATES_PAGE: 'OpenPolicyDistanceRatesPage', + OPEN_POLICY_PER_DIEM_RATES_PAGE: 'OpenPolicyPerDiemRatesPage', OPEN_POLICY_MORE_FEATURES_PAGE: 'OpenPolicyMoreFeaturesPage', OPEN_POLICY_ACCOUNTING_PAGE: 'OpenPolicyAccountingPage', OPEN_POLICY_PROFILE_PAGE: 'OpenPolicyProfilePage', @@ -993,6 +996,7 @@ type ReadCommandParameters = { [READ_COMMANDS.OPEN_DRAFT_WORKSPACE_REQUEST]: Parameters.OpenDraftWorkspaceRequestParams; [READ_COMMANDS.OPEN_POLICY_WORKFLOWS_PAGE]: Parameters.OpenPolicyWorkflowsPageParams; [READ_COMMANDS.OPEN_POLICY_DISTANCE_RATES_PAGE]: Parameters.OpenPolicyDistanceRatesPageParams; + [READ_COMMANDS.OPEN_POLICY_PER_DIEM_RATES_PAGE]: Parameters.OpenPolicyPerDiemRatesPageParams; [READ_COMMANDS.OPEN_POLICY_MORE_FEATURES_PAGE]: Parameters.OpenPolicyMoreFeaturesPageParams; [READ_COMMANDS.OPEN_POLICY_ACCOUNTING_PAGE]: Parameters.OpenPolicyAccountingPageParams; [READ_COMMANDS.OPEN_POLICY_EXPENSIFY_CARDS_PAGE]: Parameters.OpenPolicyExpensifyCardsPageParams; diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index 80b784a162cf..ad9bf7f4b90e 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -190,7 +190,7 @@ function getCompanyCardNumber(cardList: Record, lastFourPAN?: st return ''; } - return Object.keys(cardList).find((card) => card.endsWith(lastFourPAN)) ?? ''; + return Object.keys(cardList).find((card) => card.endsWith(lastFourPAN)) ?? maskCard(lastFourPAN); } function getCardFeedIcon(cardFeed: CompanyCardFeed | typeof CONST.EXPENSIFY_CARD.BANK): IconAsset { diff --git a/src/libs/CategoryUtils.ts b/src/libs/CategoryUtils.ts index 479ae557eab6..789026f91af6 100644 --- a/src/libs/CategoryUtils.ts +++ b/src/libs/CategoryUtils.ts @@ -24,9 +24,9 @@ function formatDefaultTaxRateText(translate: LocaleContextProps['translate'], ta return `${taxRateText}${suffix ? ` ${CONST.DOT_SEPARATOR} ${suffix}` : ``}`; } -function formatRequireReceiptsOverText(translate: LocaleContextProps['translate'], policy: Policy, categoryMaxExpenseAmountNoReceipt?: number | null) { - const isAlwaysSelected = categoryMaxExpenseAmountNoReceipt === 0; - const isNeverSelected = categoryMaxExpenseAmountNoReceipt === CONST.DISABLED_MAX_EXPENSE_VALUE; +function formatRequireReceiptsOverText(translate: LocaleContextProps['translate'], policy: Policy, categoryMaxAmountNoReceipt?: number | null) { + const isAlwaysSelected = categoryMaxAmountNoReceipt === 0; + const isNeverSelected = categoryMaxAmountNoReceipt === CONST.DISABLED_MAX_EXPENSE_VALUE; if (isAlwaysSelected) { return translate(`workspace.rules.categoryRules.requireReceiptsOverList.always`); diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index 286f952b3484..86e9c23af97b 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -50,6 +50,10 @@ function getMileageRates(policy: OnyxInputOrEntry, includeDisabledRates return; } + if (!distanceUnit.attributes) { + return; + } + mileageRates[rateID] = { rate: rate.rate, currency: rate.currency, @@ -79,7 +83,7 @@ function getDefaultMileageRate(policy: OnyxInputOrEntry): MileageRate | } const distanceUnit = PolicyUtils.getDistanceRateCustomUnit(policy); - if (!distanceUnit?.rates) { + if (!distanceUnit?.rates || !distanceUnit.attributes) { return; } const mileageRates = Object.values(getMileageRates(policy)); @@ -277,7 +281,7 @@ function convertToDistanceInMeters(distance: number, unit: Unit): number { /** * Returns custom unit rate ID for the distance transaction */ -function getCustomUnitRateID(reportID: string, shouldUseDefault?: boolean) { +function getCustomUnitRateID(reportID: string) { const allReports = ReportConnection.getAllReports(); const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`]; @@ -288,7 +292,7 @@ function getCustomUnitRateID(reportID: string, shouldUseDefault?: boolean) { const distanceUnit = Object.values(policy?.customUnits ?? {}).find((unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); const lastSelectedDistanceRateID = lastSelectedDistanceRates?.[policy?.id ?? '-1'] ?? '-1'; const lastSelectedDistanceRate = distanceUnit?.rates[lastSelectedDistanceRateID] ?? {}; - if (lastSelectedDistanceRate.enabled && lastSelectedDistanceRateID && !shouldUseDefault) { + if (lastSelectedDistanceRate.enabled && lastSelectedDistanceRateID) { customUnitRateID = lastSelectedDistanceRateID; } else { customUnitRateID = getDefaultMileageRate(policy)?.customUnitRateID ?? '-1'; diff --git a/src/libs/Navigation/AppNavigator/Navigators/FullScreenNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/FullScreenNavigator.tsx index 2f513fe804bb..4aac30587725 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/FullScreenNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/FullScreenNavigator.tsx @@ -29,6 +29,7 @@ const CENTRAL_PANE_WORKSPACE_SCREENS = { [SCREENS.WORKSPACE.REPORT_FIELDS]: () => require('../../../../pages/workspace/reportFields/WorkspaceReportFieldsPage').default, [SCREENS.WORKSPACE.EXPENSIFY_CARD]: () => require('../../../../pages/workspace/expensifyCard/WorkspaceExpensifyCardPage').default, [SCREENS.WORKSPACE.COMPANY_CARDS]: () => require('../../../../pages/workspace/companyCards/WorkspaceCompanyCardsPage').default, + [SCREENS.WORKSPACE.PER_DIEM]: () => require('../../../../pages/workspace/perDiem/WorkspacePerDiemPage').default, [SCREENS.WORKSPACE.DISTANCE_RATES]: () => require('../../../../pages/workspace/distanceRates/PolicyDistanceRatesPage').default, [SCREENS.WORKSPACE.RULES]: () => require('../../../../pages/workspace/rules/PolicyRulesPage').default, } satisfies Screens; diff --git a/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/CustomFullScreenRouter.tsx b/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/CustomFullScreenRouter.tsx index 18cb758c5703..a5746f6f8e81 100644 --- a/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/CustomFullScreenRouter.tsx +++ b/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/CustomFullScreenRouter.tsx @@ -1,6 +1,9 @@ import type {ParamListBase, PartialState, RouterConfigOptions, StackNavigationState} from '@react-navigation/native'; import {StackRouter} from '@react-navigation/native'; +import Onyx from 'react-native-onyx'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; +import * as PolicyUtils from '@libs/PolicyUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; import type {FullScreenNavigatorRouterOptions} from './types'; @@ -8,12 +11,29 @@ type StackState = StackNavigationState | PartialState state.routes.some((route) => route.name === screenName); +let isLoadingReportData = true; +Onyx.connect({ + key: ONYXKEYS.IS_LOADING_REPORT_DATA, + initWithStoredValues: false, + callback: (value) => (isLoadingReportData = value ?? false), +}); + function adaptStateIfNecessary(state: StackState) { const isNarrowLayout = getIsNarrowLayout(); const workspaceCentralPane = state.routes.at(-1); + const policyID = + workspaceCentralPane?.params && 'policyID' in workspaceCentralPane.params && typeof workspaceCentralPane.params.policyID === 'string' + ? workspaceCentralPane.params.policyID + : undefined; + const policy = PolicyUtils.getPolicy(policyID ?? ''); + const isPolicyAccessible = PolicyUtils.isPolicyAccessible(policy); // There should always be WORKSPACE.INITIAL screen in the state to make sure go back works properly if we deeplinkg to a subpage of settings. + // The only exception is when the workspace is invalid or inaccessible. if (!isAtLeastOneInState(state, SCREENS.WORKSPACE.INITIAL)) { + if (isNarrowLayout && !isLoadingReportData && !isPolicyAccessible) { + return; + } // @ts-expect-error Updating read only property // noinspection JSConstantReassignment state.stale = true; // eslint-disable-line diff --git a/src/libs/Navigation/Navigation.ts b/src/libs/Navigation/Navigation.ts index d5e9c5229a89..d54668bf3f69 100644 --- a/src/libs/Navigation/Navigation.ts +++ b/src/libs/Navigation/Navigation.ts @@ -12,6 +12,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {HybridAppRoute, Route} from '@src/ROUTES'; import ROUTES, {HYBRID_APP_ROUTES} from '@src/ROUTES'; import {PROTECTED_SCREENS} from '@src/SCREENS'; +import type {Screen} from '@src/SCREENS'; import type {Report} from '@src/types/onyx'; import originalCloseRHPFlow from './closeRHPFlow'; import originalDismissModal from './dismissModal'; @@ -418,6 +419,20 @@ function getTopMostCentralPaneRouteFromRootState() { return getTopmostCentralPaneRoute(navigationRef.getRootState() as State); } +function removeScreenFromNavigationState(screen: Screen) { + isNavigationReady().then(() => { + navigationRef.dispatch((state) => { + const routes = state.routes?.filter((item) => item.name !== screen); + + return CommonActions.reset({ + ...state, + routes, + index: routes.length < state.routes.length ? state.index - 1 : state.index, + }); + }); + }); +} + export default { setShouldPopAllStateOnUP, navigate, @@ -442,6 +457,7 @@ export default { closeRHPFlow, setNavigationActionToMicrotaskQueue, getTopMostCentralPaneRouteFromRootState, + removeScreenFromNavigationState, }; export {navigationRef}; diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 330d5f113503..7a5b31489764 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1418,6 +1418,9 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.COMPANY_CARDS]: { path: ROUTES.WORKSPACE_COMPANY_CARDS.route, }, + [SCREENS.WORKSPACE.PER_DIEM]: { + path: ROUTES.WORKSPACE_PER_DIEM.route, + }, [SCREENS.WORKSPACE.WORKFLOWS]: { path: ROUTES.WORKSPACE_WORKFLOWS.route, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 3eae46ac2855..ba859efff944 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1394,6 +1394,9 @@ type FullScreenNavigatorParamList = { [SCREENS.WORKSPACE.COMPANY_CARDS_ADD_NEW]: { policyID: string; }; + [SCREENS.WORKSPACE.PER_DIEM]: { + policyID: string; + }; [SCREENS.WORKSPACE.WORKFLOWS]: { policyID: string; }; diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts index 0853bd9c18ce..b0591d1ad42b 100644 --- a/src/libs/Permissions.ts +++ b/src/libs/Permissions.ts @@ -1,6 +1,5 @@ import type {OnyxEntry} from 'react-native-onyx'; import CONST from '@src/CONST'; -import type {IOUType} from '@src/CONST'; import type Beta from '@src/types/onyx/Beta'; function canUseAllBetas(betas: OnyxEntry): boolean { @@ -15,11 +14,6 @@ function canUseDupeDetection(betas: OnyxEntry): boolean { return !!betas?.includes(CONST.BETAS.DUPE_DETECTION) || canUseAllBetas(betas); } -function canUseP2PDistanceRequests(betas: OnyxEntry, iouType: IOUType | undefined): boolean { - // Allow using P2P distance request for TrackExpense outside of the beta, because that project doesn't want to be limited by the more cautious P2P distance beta - return !!betas?.includes(CONST.BETAS.P2P_DISTANCE_REQUESTS) || canUseAllBetas(betas) || iouType === CONST.IOU.TYPE.TRACK; -} - function canUseSpotnanaTravel(betas: OnyxEntry): boolean { return !!betas?.includes(CONST.BETAS.SPOTNANA_TRAVEL) || canUseAllBetas(betas); } @@ -52,7 +46,6 @@ export default { canUseDefaultRooms, canUseLinkPreviews, canUseDupeDetection, - canUseP2PDistanceRequests, canUseSpotnanaTravel, canUseNetSuiteUSATax, canUseCombinedTrackSubmit, diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index c596357585bc..3f7b6af9f951 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -136,6 +136,13 @@ function getDistanceRateCustomUnit(policy: OnyxEntry): CustomUnit | unde return Object.values(policy?.customUnits ?? {}).find((unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); } +/** + * Retrieves the per diem custom unit object for the given policy + */ +function getPerDiemCustomUnit(policy: OnyxEntry): CustomUnit | undefined { + return Object.values(policy?.customUnits ?? {}).find((unit) => unit.name === CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL); +} + /** * Retrieves custom unit rate object from the given customUnitRateID */ @@ -188,10 +195,11 @@ function getPolicyRole(policy: OnyxInputOrEntry, currentUserLogin: strin */ function shouldShowPolicy(policy: OnyxEntry, isOffline: boolean, currentUserLogin: string | undefined): boolean { return ( - !!policy && - (policy?.type !== CONST.POLICY.TYPE.PERSONAL || !!policy?.isJoinRequestPending) && - (isOffline || policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || Object.keys(policy.errors ?? {}).length > 0) && - !!getPolicyRole(policy, currentUserLogin) + !!policy?.isJoinRequestPending || + (!!policy && + policy?.type !== CONST.POLICY.TYPE.PERSONAL && + (isOffline || policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || Object.keys(policy.errors ?? {}).length > 0) && + !!getPolicyRole(policy, currentUserLogin)) ); } @@ -1064,6 +1072,10 @@ function getActivePolicy(): OnyxEntry { return getPolicy(activePolicyId); } +function isPolicyAccessible(policy: OnyxEntry): boolean { + return !isEmptyObject(policy) && (Object.keys(policy).length !== 1 || isEmptyObject(policy.errors)) && !!policy?.id; +} + export { canEditTaxRate, extractPolicyIDFromPath, @@ -1149,6 +1161,7 @@ export { getSageIntacctCreditCards, getSageIntacctBankAccounts, getDistanceRateCustomUnit, + getPerDiemCustomUnit, getDistanceRateCustomUnitRate, sortWorkspacesBySelected, removePendingFieldsFromCustomUnit, @@ -1181,6 +1194,7 @@ export { getNetSuiteImportCustomFieldLabel, getAllPoliciesLength, getActivePolicy, + isPolicyAccessible, }; export type {MemberEmailsToAccountIDs}; diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 6d08a128a253..99a97ea08672 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -1,3 +1,4 @@ +import lodashDeepClone from 'lodash/cloneDeep'; import lodashHas from 'lodash/has'; import lodashIsEqual from 'lodash/isEqual'; import lodashSet from 'lodash/set'; @@ -249,7 +250,7 @@ function areRequiredFieldsEmpty(transaction: OnyxEntry): boolean { */ function getUpdatedTransaction(transaction: Transaction, transactionChanges: TransactionChanges, isFromExpenseReport: boolean, shouldUpdateReceiptState = true): Transaction { // Only changing the first level fields so no need for deep clone now - const updatedTransaction = {...transaction}; + const updatedTransaction = lodashDeepClone(transaction); let shouldStopSmartscan = false; // The comment property does not have its modifiedComment counterpart @@ -301,7 +302,7 @@ function getUpdatedTransaction(transaction: Transaction, transactionChanges: Tra const conversionFactor = existingDistanceUnit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES ? CONST.CUSTOM_UNITS.MILES_TO_KILOMETERS : CONST.CUSTOM_UNITS.KILOMETERS_TO_MILES; const distance = NumberUtils.roundToTwoDecimalPlaces((transaction?.comment?.customUnit?.quantity ?? 0) * conversionFactor); lodashSet(updatedTransaction, 'comment.customUnit.quantity', distance); - lodashSet(updatedTransaction, 'pendingFields.waypoints', CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE); + lodashSet(updatedTransaction, 'pendingFields.merchant', CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE); } } @@ -341,6 +342,7 @@ function getUpdatedTransaction(transaction: Transaction, transactionChanges: Tra } updatedTransaction.pendingFields = { + ...(updatedTransaction?.pendingFields ?? {}), ...(Object.hasOwn(transactionChanges, 'comment') && {comment: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), ...(Object.hasOwn(transactionChanges, 'created') && {created: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), ...(Object.hasOwn(transactionChanges, 'amount') && {amount: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), diff --git a/src/libs/ValidationUtils.ts b/src/libs/ValidationUtils.ts index fbc1aefe30ce..0367325db6b1 100644 --- a/src/libs/ValidationUtils.ts +++ b/src/libs/ValidationUtils.ts @@ -246,8 +246,7 @@ function getDatePassedError(inputDate: string): string { * http/https/ftp URL scheme required. */ function isValidWebsite(url: string): boolean { - const isLowerCase = url === url.toLowerCase(); - return new RegExp(`^${Url.URL_REGEX_WITH_REQUIRED_PROTOCOL}$`, 'i').test(url) && isLowerCase; + return new RegExp(`^${Url.URL_REGEX_WITH_REQUIRED_PROTOCOL}$`, 'i').test(url); } /** Checks if the domain is public */ @@ -503,6 +502,33 @@ function isValidSubscriptionSize(subscriptionSize: string): boolean { return !Number.isNaN(parsedSubscriptionSize) && parsedSubscriptionSize > 0 && parsedSubscriptionSize <= CONST.SUBSCRIPTION_SIZE_LIMIT && Number.isInteger(parsedSubscriptionSize); } +/** + * Validates the given value if it is correct email address. + * @param email + */ +function isValidEmail(email: string): boolean { + return Str.isValidEmail(email); +} + +/** + * Validates the given value if it is correct phone number in E164 format (international standard). + * @param phoneNumber + */ +function isValidPhoneInternational(phoneNumber: string): boolean { + const phoneNumberWithCountryCode = LoginUtils.appendCountryCode(phoneNumber); + const parsedPhoneNumber = parsePhoneNumber(phoneNumberWithCountryCode); + + return parsedPhoneNumber.possible && Str.isValidE164Phone(parsedPhoneNumber.number?.e164 ?? ''); +} + +/** + * Validates the given value if it is correct zip code for international addresses. + * @param zipCode + */ +function isValidZipCodeInternational(zipCode: string): boolean { + return /^[a-z0-9][a-z0-9\- ]{0,10}[a-z0-9]$/.test(zipCode); +} + export { meetsMinimumAgeRequirement, meetsMaximumAgeRequirement, @@ -547,4 +573,7 @@ export { isValidSubscriptionSize, isExistingTaxCode, isPublicDomain, + isValidEmail, + isValidPhoneInternational, + isValidZipCodeInternational, }; diff --git a/src/libs/actions/Card.ts b/src/libs/actions/Card.ts index 3ffdd6778b12..b04a5e49bea5 100644 --- a/src/libs/actions/Card.ts +++ b/src/libs/actions/Card.ts @@ -37,7 +37,7 @@ type IssueNewCardFlowData = { data?: Partial; }; -function reportVirtualExpensifyCardFraud(card?: Card) { +function reportVirtualExpensifyCardFraud(card: Card, validateCode: string) { const cardID = card?.cardID ?? -1; const optimisticData: OnyxUpdate[] = [ { @@ -45,6 +45,7 @@ function reportVirtualExpensifyCardFraud(card?: Card) { key: ONYXKEYS.FORMS.REPORT_VIRTUAL_CARD_FRAUD, value: { isLoading: true, + errors: null, }, }, { @@ -105,6 +106,7 @@ function reportVirtualExpensifyCardFraud(card?: Card) { const parameters: ReportVirtualExpensifyCardFraudParams = { cardID, + validateCode, }; API.write(WRITE_COMMANDS.REPORT_VIRTUAL_EXPENSIFY_CARD_FRAUD, parameters, { @@ -119,7 +121,7 @@ function reportVirtualExpensifyCardFraud(card?: Card) { * @param cardID - id of the card that is going to be replaced * @param reason - reason for replacement */ -function requestReplacementExpensifyCard(cardID: number, reason: ReplacementReason) { +function requestReplacementExpensifyCard(cardID: number, reason: ReplacementReason, validateCode: string) { const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -154,6 +156,7 @@ function requestReplacementExpensifyCard(cardID: number, reason: ReplacementReas const parameters: RequestReplacementExpensifyCardParams = { cardID, reason, + validateCode, }; API.write(WRITE_COMMANDS.REQUEST_REPLACEMENT_EXPENSIFY_CARD, parameters, { diff --git a/src/libs/actions/CompanyCards.ts b/src/libs/actions/CompanyCards.ts index 2955a62f28c7..18779a284278 100644 --- a/src/libs/actions/CompanyCards.ts +++ b/src/libs/actions/CompanyCards.ts @@ -17,6 +17,7 @@ import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {Card} from '@src/types/onyx'; import type {AssignCard, AssignCardData} from '@src/types/onyx/AssignCard'; import type {AddNewCardFeedData, AddNewCardFeedStep, CompanyCardFeed} from '@src/types/onyx/CardFeeds'; import type {OnyxData} from '@src/types/onyx/Request'; @@ -183,7 +184,7 @@ function assignWorkspaceCompanyCard(policyID: string, data?: Partial [key, CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE])); + let pendingFields: OnyxTypes.Transaction['pendingFields'] = Object.fromEntries(Object.keys(transactionChanges).map((key) => [key, CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE])); const errorFields = Object.fromEntries(Object.keys(pendingFields).map((key) => [key, {[DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericEditFailureMessage')}])); const allReports = ReportConnection.getAllReports(); @@ -2519,6 +2519,13 @@ function getUpdateMoneyRequestParams( let updatedTransaction: OnyxEntry = transaction ? TransactionUtils.getUpdatedTransaction(transaction, transactionChanges, isFromExpenseReport) : undefined; const transactionDetails = ReportUtils.getTransactionDetails(updatedTransaction); + if (updatedTransaction?.pendingFields) { + pendingFields = { + ...pendingFields, + ...updatedTransaction?.pendingFields, + }; + } + if (transactionDetails?.waypoints) { // This needs to be a JSON string since we're sending this to the MapBox API transactionDetails.waypoints = JSON.stringify(transactionDetails.waypoints); @@ -4982,7 +4989,6 @@ function completeSplitBill(chatReportID: string, reportAction: OnyxTypes.ReportA const splitParticipants: Split[] = updatedTransaction?.comment?.splits ?? []; const amount = updatedTransaction?.modifiedAmount; const currency = updatedTransaction?.modifiedCurrency; - console.debug(updatedTransaction); // Exclude the current user when calculating the split amount, `calculateAmount` takes it into account const splitAmount = IOUUtils.calculateAmount(splitParticipants.length - 1, amount ?? 0, currency ?? '', false); @@ -7325,7 +7331,6 @@ function approveMoneyRequest(expenseReport: OnyxEntry, full?: optimisticHoldReportID, optimisticHoldActionID, optimisticHoldReportExpenseActionIDs, - v2: PolicyUtils.isControlOnAdvancedApprovalMode(PolicyUtils.getPolicy(expenseReport?.policyID)), }; API.write(WRITE_COMMANDS.APPROVE_MONEY_REQUEST, parameters, {optimisticData, successData, failureData}); diff --git a/src/libs/actions/Policy/Category.ts b/src/libs/actions/Policy/Category.ts index 41771ac5aa0e..dced49976c5a 100644 --- a/src/libs/actions/Policy/Category.ts +++ b/src/libs/actions/Policy/Category.ts @@ -390,8 +390,8 @@ function setPolicyCategoryDescriptionRequired(policyID: string, categoryName: st API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_DESCRIPTION_REQUIRED, parameters, onyxData); } -function setPolicyCategoryReceiptsRequired(policyID: string, categoryName: string, maxExpenseAmountNoReceipt: number) { - const originalMaxExpenseAmountNoReceipt = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]?.[categoryName]?.maxExpenseAmountNoReceipt; +function setPolicyCategoryReceiptsRequired(policyID: string, categoryName: string, maxAmountNoReceipt: number) { + const originalMaxAmountNoReceipt = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]?.[categoryName]?.maxAmountNoReceipt; const onyxData: OnyxData = { optimisticData: [ @@ -402,9 +402,9 @@ function setPolicyCategoryReceiptsRequired(policyID: string, categoryName: strin [categoryName]: { pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, pendingFields: { - maxExpenseAmountNoReceipt: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + maxAmountNoReceipt: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, }, - maxExpenseAmountNoReceipt, + maxAmountNoReceipt, }, }, }, @@ -417,9 +417,9 @@ function setPolicyCategoryReceiptsRequired(policyID: string, categoryName: strin [categoryName]: { pendingAction: null, pendingFields: { - maxExpenseAmountNoReceipt: null, + maxAmountNoReceipt: null, }, - maxExpenseAmountNoReceipt, + maxAmountNoReceipt, }, }, }, @@ -433,9 +433,9 @@ function setPolicyCategoryReceiptsRequired(policyID: string, categoryName: strin errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), pendingAction: null, pendingFields: { - maxExpenseAmountNoReceipt: null, + maxAmountNoReceipt: null, }, - maxExpenseAmountNoReceipt: originalMaxExpenseAmountNoReceipt, + maxAmountNoReceipt: originalMaxAmountNoReceipt, }, }, }, @@ -445,14 +445,14 @@ function setPolicyCategoryReceiptsRequired(policyID: string, categoryName: strin const parameters: SetPolicyCategoryReceiptsRequiredParams = { policyID, categoryName, - maxExpenseAmountNoReceipt, + maxExpenseAmountNoReceipt: maxAmountNoReceipt, }; API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_RECEIPTS_REQUIRED, parameters, onyxData); } function removePolicyCategoryReceiptsRequired(policyID: string, categoryName: string) { - const originalMaxExpenseAmountNoReceipt = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]?.[categoryName]?.maxExpenseAmountNoReceipt; + const originalMaxAmountNoReceipt = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]?.[categoryName]?.maxAmountNoReceipt; const onyxData: OnyxData = { optimisticData: [ @@ -463,9 +463,9 @@ function removePolicyCategoryReceiptsRequired(policyID: string, categoryName: st [categoryName]: { pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, pendingFields: { - maxExpenseAmountNoReceipt: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + maxAmountNoReceipt: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, }, - maxExpenseAmountNoReceipt: null, + maxAmountNoReceipt: null, }, }, }, @@ -478,9 +478,9 @@ function removePolicyCategoryReceiptsRequired(policyID: string, categoryName: st [categoryName]: { pendingAction: null, pendingFields: { - maxExpenseAmountNoReceipt: null, + maxAmountNoReceipt: null, }, - maxExpenseAmountNoReceipt: null, + maxAmountNoReceipt: null, }, }, }, @@ -494,9 +494,9 @@ function removePolicyCategoryReceiptsRequired(policyID: string, categoryName: st errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), pendingAction: null, pendingFields: { - maxExpenseAmountNoReceipt: null, + maxAmountNoReceipt: null, }, - maxExpenseAmountNoReceipt: originalMaxExpenseAmountNoReceipt, + maxAmountNoReceipt: originalMaxAmountNoReceipt, }, }, }, diff --git a/src/libs/actions/Policy/PerDiem.ts b/src/libs/actions/Policy/PerDiem.ts new file mode 100644 index 000000000000..2ce31fd4c921 --- /dev/null +++ b/src/libs/actions/Policy/PerDiem.ts @@ -0,0 +1,122 @@ +import type {NullishDeep, OnyxCollection} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; +import * as API from '@libs/API'; +import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import getIsNarrowLayout from '@libs/getIsNarrowLayout'; +import * as NumberUtils from '@libs/NumberUtils'; +import {navigateWhenEnableFeature} from '@libs/PolicyUtils'; +import * as ReportUtils from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, Report} from '@src/types/onyx'; +import type {OnyxData} from '@src/types/onyx/Request'; + +const allPolicies: OnyxCollection = {}; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.POLICY, + callback: (val, key) => { + if (!key) { + return; + } + if (val === null || val === undefined) { + // If we are deleting a policy, we have to check every report linked to that policy + // and unset the draft indicator (pencil icon) alongside removing any draft comments. Clearing these values will keep the newly archived chats from being displayed in the LHN. + // More info: https://github.com/Expensify/App/issues/14260 + const policyID = key.replace(ONYXKEYS.COLLECTION.POLICY, ''); + const policyReports = ReportUtils.getAllPolicyReports(policyID); + const cleanUpMergeQueries: Record<`${typeof ONYXKEYS.COLLECTION.REPORT}${string}`, NullishDeep> = {}; + const cleanUpSetQueries: Record<`${typeof ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${string}` | `${typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${string}`, null> = {}; + policyReports.forEach((policyReport) => { + if (!policyReport) { + return; + } + const {reportID} = policyReport; + cleanUpSetQueries[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`] = null; + cleanUpSetQueries[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`] = null; + }); + Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT, cleanUpMergeQueries); + Onyx.multiSet(cleanUpSetQueries); + delete allPolicies[key]; + return; + } + + allPolicies[key] = val; + }, +}); + +/** + * Returns a client generated 13 character hexadecimal value for a custom unit ID + */ +function generateCustomUnitID(): string { + return NumberUtils.generateHexadecimalValue(13); +} + +function enablePerDiem(policyID: string, enabled: boolean, customUnitID?: string) { + const doesCustomUnitExists = !!customUnitID; + const finalCustomUnitID = doesCustomUnitExists ? customUnitID : generateCustomUnitID(); + const optimisticCustomUnit = { + name: CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL, + customUnitID: finalCustomUnitID, + enabled: true, + defaultCategory: '', + rates: {}, + }; + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + arePerDiemRatesEnabled: enabled, + pendingFields: { + arePerDiemRatesEnabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + ...(doesCustomUnitExists ? {} : {customUnits: {[finalCustomUnitID]: optimisticCustomUnit}}), + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + arePerDiemRatesEnabled: null, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + arePerDiemRatesEnabled: !enabled, + pendingFields: { + arePerDiemRatesEnabled: null, + }, + }, + }, + ], + }; + + const parameters = {policyID, enabled, customUnitID: finalCustomUnitID}; + + API.write(WRITE_COMMANDS.TOGGLE_POLICY_PER_DIEM, parameters, onyxData); + + if (enabled && getIsNarrowLayout()) { + navigateWhenEnableFeature(policyID); + } +} + +function openPolicyPerDiemPage(policyID?: string) { + if (!policyID) { + return; + } + + const params = {policyID}; + + API.read(READ_COMMANDS.OPEN_POLICY_PER_DIEM_RATES_PAGE, params); +} + +export {enablePerDiem, openPolicyPerDiemPage}; diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 1dd6178d3159..d87f0321bab0 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -1508,7 +1508,7 @@ function generateCustomUnitID(): string { return NumberUtils.generateHexadecimalValue(13); } -function buildOptimisticCustomUnits(reportCurrency?: string): OptimisticCustomUnits { +function buildOptimisticDistanceRateCustomUnits(reportCurrency?: string): OptimisticCustomUnits { const currency = reportCurrency ?? allPersonalDetails?.[sessionAccountID]?.localCurrencyCode ?? CONST.CURRENCY.USD; const customUnitID = generateCustomUnitID(); const customUnitRateID = generateCustomUnitID(); @@ -1550,7 +1550,7 @@ function buildOptimisticCustomUnits(reportCurrency?: string): OptimisticCustomUn */ function createDraftInitialWorkspace(policyOwnerEmail = '', policyName = '', policyID = generatePolicyID(), makeMeAdmin = false) { const workspaceName = policyName || generateDefaultWorkspaceName(policyOwnerEmail); - const {customUnits, outputCurrency} = buildOptimisticCustomUnits(); + const {customUnits, outputCurrency} = buildOptimisticDistanceRateCustomUnits(); const optimisticData: OnyxUpdate[] = [ { @@ -1605,7 +1605,7 @@ function createDraftInitialWorkspace(policyOwnerEmail = '', policyName = '', pol function buildPolicyData(policyOwnerEmail = '', makeMeAdmin = false, policyName = '', policyID = generatePolicyID(), expenseReportId?: string, engagementChoice?: string) { const workspaceName = policyName || generateDefaultWorkspaceName(policyOwnerEmail); - const {customUnits, customUnitID, customUnitRateID, outputCurrency} = buildOptimisticCustomUnits(); + const {customUnits, customUnitID, customUnitRateID, outputCurrency} = buildOptimisticDistanceRateCustomUnits(); const { adminsChatReportID, @@ -1852,7 +1852,7 @@ function createWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policyName function createDraftWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policyName = '', policyID = generatePolicyID()): CreateWorkspaceParams { const workspaceName = policyName || generateDefaultWorkspaceName(policyOwnerEmail); - const {customUnits, customUnitID, customUnitRateID, outputCurrency} = buildOptimisticCustomUnits(); + const {customUnits, customUnitID, customUnitRateID, outputCurrency} = buildOptimisticDistanceRateCustomUnits(); const {expenseChatData, adminsChatReportID, adminsCreatedReportActionID, expenseChatReportID, expenseCreatedReportActionID} = ReportUtils.buildOptimisticWorkspaceChats( policyID, @@ -2144,7 +2144,7 @@ function createWorkspaceFromIOUPayment(iouReport: OnyxEntry): WorkspaceF const workspaceName = generateDefaultWorkspaceName(sessionEmail); const employeeAccountID = iouReport.ownerAccountID; const employeeEmail = iouReport.ownerEmail ?? ''; - const {customUnits, customUnitID, customUnitRateID} = buildOptimisticCustomUnits(iouReport.currency); + const {customUnits, customUnitID, customUnitRateID} = buildOptimisticDistanceRateCustomUnits(iouReport.currency); const oldPersonalPolicyID = iouReport.policyID; const iouReportID = iouReport.reportID; diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 9ea499283d70..e6a10d01b798 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -3676,6 +3676,9 @@ function completeOnboarding( }, []); const optimisticData: OnyxUpdate[] = [...tasksForOptimisticData]; + const lastVisibleActionCreated = + tasksData.at(-1)?.completedTaskReportAction?.created ?? tasksData.at(-1)?.taskReportAction.reportAction.created ?? videoCommentAction?.created ?? textCommentAction.created; + optimisticData.push( { onyxMethod: Onyx.METHOD.MERGE, @@ -3683,6 +3686,7 @@ function completeOnboarding( value: { lastMentionedTime: DateUtils.getDBTime(), hasOutstandingChildTask, + lastVisibleActionCreated, }, }, { @@ -3724,12 +3728,12 @@ function completeOnboarding( const {lastMessageText = '', lastMessageTranslationKey = ''} = ReportActionsUtils.getLastVisibleMessage(targetChatReportID); if (lastMessageText || lastMessageTranslationKey) { const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(targetChatReportID); - const lastVisibleActionCreated = lastVisibleAction?.created; + const prevLastVisibleActionCreated = lastVisibleAction?.created; const lastActorAccountID = lastVisibleAction?.actorAccountID; failureReport = { lastMessageTranslationKey, lastMessageText, - lastVisibleActionCreated, + lastVisibleActionCreated: prevLastVisibleActionCreated, lastActorAccountID, }; } diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index fe15515bcb4a..e7a3465f1d25 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -8,8 +8,9 @@ import * as API from '@libs/API'; import type {DismissViolationParams, GetRouteParams, MarkAsCashParams} from '@libs/API/parameters'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import * as CollectionUtils from '@libs/CollectionUtils'; +import * as NumberUtils from '@libs/NumberUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; -import {buildOptimisticDismissedViolationReportAction, buildOptimisticUnHoldReportAction, isCurrentUserSubmitter} from '@libs/ReportUtils'; +import {buildOptimisticDismissedViolationReportAction} from '@libs/ReportUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -352,11 +353,7 @@ function dismissDuplicateTransactionViolation(transactionIDs: string[], dissmiss const currentTransactionViolations = transactionIDs.map((id) => ({transactionID: id, violations: allTransactionViolation?.[id] ?? []})); const currentTransactions = transactionIDs.map((id) => allTransactions?.[id]); const transactionsReportActions = currentTransactions.map((transaction) => ReportActionsUtils.getIOUActionForReportID(transaction.reportID ?? '', transaction.transactionID ?? '')); - const isSubmitter = currentTransactions.every((transaction) => isCurrentUserSubmitter(transaction.reportID ?? '')); const optimisticDissmidedViolationReportActions = transactionsReportActions.map(() => { - if (isSubmitter) { - return buildOptimisticUnHoldReportAction(); - } return buildOptimisticDismissedViolationReportAction({reason: 'manual', violationName: CONST.VIOLATIONS.DUPLICATED_TRANSACTION}); }); @@ -427,16 +424,17 @@ function dismissDuplicateTransactionViolation(transactionIDs: string[], dissmiss onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${action?.childReportID ?? '-1'}`, value: { - [optimisticDissmidedViolationReportActions.at(index)?.reportActionID ?? '']: { - pendingAction: null, - }, + [optimisticDissmidedViolationReportActions.at(index)?.reportActionID ?? '']: null, }, })); - + // We are creating duplicate resolved report actions for each duplicate transactions and all the report actions + // should be correctly linked with their parent report but the BE is sometimes linking report actions to different + // parent reports than the one we set optimistically, resulting in duplicate report actions. Therefore, we send the BE + // random report action ids and onSuccessData we reset the report actions we added optimistically to avoid duplicate actions. const params: DismissViolationParams = { name: CONST.VIOLATIONS.DUPLICATED_TRANSACTION, transactionIDList: transactionIDs.join(','), - reportActionIDList: optimisticDissmidedViolationReportActions.map((action) => action.reportActionID).join(','), + reportActionIDList: optimisticDissmidedViolationReportActions.map(() => NumberUtils.rand64()).join(','), }; API.write(WRITE_COMMANDS.DISMISS_VIOLATION, params, { diff --git a/src/libs/searchCountryOptions.ts b/src/libs/searchOptions.ts similarity index 59% rename from src/libs/searchCountryOptions.ts rename to src/libs/searchOptions.ts index 953a5c81c77f..4c8021dffa10 100644 --- a/src/libs/searchCountryOptions.ts +++ b/src/libs/searchOptions.ts @@ -1,6 +1,6 @@ import StringUtils from './StringUtils'; -type CountryData = { +type Option = { value: string; keyForList: string; text: string; @@ -9,32 +9,32 @@ type CountryData = { }; /** - * Searches the countries/states data and returns sorted results based on the search query - * @param countriesData - An array of country data objects - * @returns An array of countries/states sorted based on the search query + * Searches the options and returns sorted results based on the search query + * @param options - An array of option objects + * @returns An array of options sorted based on the search query */ -function searchCountryOptions(searchValue: string, countriesData: CountryData[]): CountryData[] { +function searchOptions(searchValue: string, options: Option[]): Option[] { if (!searchValue) { - return countriesData; + return options; } const trimmedSearchValue = StringUtils.sanitizeString(searchValue); if (!trimmedSearchValue) { return []; } - const filteredData = countriesData.filter((country) => country.searchValue.includes(trimmedSearchValue)); + const filteredData = options.filter((option) => option.searchValue.includes(trimmedSearchValue)); const halfSorted = filteredData.sort((a, b) => { // Prioritize matches at the beginning of the string // e.g. For the search term "Bar" "Barbados" should be prioritized over Antigua & Barbuda // The first two characters are the country code, so we start at index 2 // and end at the length of the search term - const countryNameASubstring = a.searchValue.toLowerCase().substring(2, trimmedSearchValue.length + 2); - const countryNameBSubstring = b.searchValue.toLowerCase().substring(2, trimmedSearchValue.length + 2); - if (countryNameASubstring === trimmedSearchValue.toLowerCase()) { + const optionASubstring = a.searchValue.toLowerCase().substring(2, trimmedSearchValue.length + 2); + const optionBSubstring = b.searchValue.toLowerCase().substring(2, trimmedSearchValue.length + 2); + if (optionASubstring === trimmedSearchValue.toLowerCase()) { return -1; } - if (countryNameBSubstring === trimmedSearchValue.toLowerCase()) { + if (optionBSubstring === trimmedSearchValue.toLowerCase()) { return 1; } return 0; @@ -46,12 +46,12 @@ function searchCountryOptions(searchValue: string, countriesData: CountryData[]) // Diacritic detected, prioritize diacritic matches // We search for diacritic matches by using the unsanitized country name and search term fullSorted = halfSorted.sort((a, b) => { - const unsanitizedCountryNameA = a.text.toLowerCase(); - const unsanitizedCountryNameB = b.text.toLowerCase(); - if (unsanitizedCountryNameA.includes(unsanitizedSearchValue)) { + const unsanitizedOptionA = a.text.toLowerCase(); + const unsanitizedOptionB = b.text.toLowerCase(); + if (unsanitizedOptionA.includes(unsanitizedSearchValue)) { return -1; } - if (unsanitizedCountryNameB.includes(unsanitizedSearchValue)) { + if (unsanitizedOptionB.includes(unsanitizedSearchValue)) { return 1; } return 0; @@ -72,5 +72,5 @@ function searchCountryOptions(searchValue: string, countriesData: CountryData[]) return fullSorted; } -export default searchCountryOptions; -export type {CountryData}; +export default searchOptions; +export type {Option}; diff --git a/src/pages/ReimbursementAccount/AddressFormFields.tsx b/src/pages/ReimbursementAccount/AddressFormFields.tsx index a863d3cc5952..c095e439cbd8 100644 --- a/src/pages/ReimbursementAccount/AddressFormFields.tsx +++ b/src/pages/ReimbursementAccount/AddressFormFields.tsx @@ -1,9 +1,10 @@ -import React from 'react'; +import {CONST as COMMON_CONST} from 'expensify-common/dist/CONST'; +import React, {useState} from 'react'; import {View} from 'react-native'; +import type {StyleProp, ViewStyle} from 'react-native'; import AddressSearch from '@components/AddressSearch'; import InputWrapper from '@components/Form/InputWrapper'; -import type {State} from '@components/StateSelector'; -import StateSelector from '@components/StateSelector'; +import PushRowWithModal from '@components/PushRowWithModal'; import TextInput from '@components/TextInput'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -17,9 +18,6 @@ type AddressFormProps = { /** Translate key for Street name */ streetTranslationKey: TranslationPaths; - /** Callback fired when a field changes. Passes args as {[fieldName]: val} */ - onFieldChange?: (value: T) => void; - /** Default values */ defaultValues?: Address; @@ -34,14 +32,70 @@ type AddressFormProps = { /** Saves a draft of the input value when used in a form */ shouldSaveDraft?: boolean; + + /** Additional styles to apply to container */ + containerStyles?: StyleProp; + + /** Indicates if country selector should be displayed */ + shouldDisplayCountrySelector?: boolean; + + /** Indicates if state selector should be displayed */ + shouldDisplayStateSelector?: boolean; + + /** Label for the state selector */ + stateSelectorLabel?: string; + + /** The title of the state selector modal */ + stateSelectorModalHeaderTitle?: string; + + /** The title of the state selector search input */ + stateSelectorSearchInputTitle?: string; + + /** Callback to be called when the country is changed */ + onCountryChange?: (country: unknown) => void; }; -function AddressFormFields({shouldSaveDraft = false, defaultValues, values, errors, inputKeys, onFieldChange, streetTranslationKey}: AddressFormProps) { +const PROVINCES_LIST_OPTIONS = (Object.keys(COMMON_CONST.PROVINCES) as Array).reduce((acc, key) => { + acc[COMMON_CONST.PROVINCES[key].provinceISO] = COMMON_CONST.PROVINCES[key].provinceName; + return acc; +}, {} as Record); + +const STATES_LIST_OPTIONS = (Object.keys(COMMON_CONST.STATES) as Array).reduce((acc, key) => { + acc[COMMON_CONST.STATES[key].stateISO] = COMMON_CONST.STATES[key].stateName; + return acc; +}, {} as Record); + +function AddressFormFields({ + shouldSaveDraft = false, + defaultValues, + values, + errors, + inputKeys, + streetTranslationKey, + containerStyles, + shouldDisplayCountrySelector = false, + shouldDisplayStateSelector = true, + stateSelectorLabel, + stateSelectorModalHeaderTitle, + stateSelectorSearchInputTitle, + onCountryChange, +}: AddressFormProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); + const [countryInEditMode, setCountryInEditMode] = useState(defaultValues?.country ?? CONST.COUNTRY.US); + // When draft values are not being saved we need to relay on local state to determine the currently selected country + const currentlySelectedCountry = shouldSaveDraft ? defaultValues?.country : countryInEditMode; + + const handleCountryChange = (country: unknown) => { + if (typeof country === 'string' && country !== '') { + setCountryInEditMode(country); + } + onCountryChange?.(country); + }; + return ( - <> + onFieldChange?.({city: value})} errorText={errors?.city ? translate('bankAccount.error.addressCity') : ''} containerStyles={styles.mt6} /> - - onFieldChange?.({state: value})} - errorText={errors?.state ? translate('bankAccount.error.addressState') : ''} - /> - + {shouldDisplayStateSelector && ( + + + + )} onFieldChange?.({zipCode: value})} errorText={errors?.zipCode ? translate('bankAccount.error.zipCode') : ''} maxLength={CONST.BANK_ACCOUNT.MAX_LENGTH.ZIP_CODE} hint={translate('common.zipCodeExampleFormat', {zipSampleFormat: CONST.COUNTRY_ZIP_REGEX_DATA.US.samples})} containerStyles={styles.mt3} /> - + {shouldDisplayCountrySelector && ( + + + + )} + ); } diff --git a/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/BusinessInfo.tsx b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/BusinessInfo.tsx index 8d1781edefbd..61b42789daea 100644 --- a/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/BusinessInfo.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/BusinessInfo.tsx @@ -5,7 +5,14 @@ import useLocalize from '@hooks/useLocalize'; import useSubStep from '@hooks/useSubStep'; import type {SubStepProps} from '@hooks/useSubStep/types'; import CONST from '@src/CONST'; +import Address from './substeps/Address'; +import BusinessType from './substeps/BusinessType'; import Confirmation from './substeps/Confirmation'; +import ContactInformation from './substeps/ContactInformation'; +import IncorporationLocation from './substeps/IncorporationLocation'; +import Name from './substeps/Name'; +import PaymentVolume from './substeps/PaymentVolume'; +import RegistrationNumber from './substeps/RegistrationNumber'; type BusinessInfoProps = { /** Handles back button press */ @@ -15,7 +22,7 @@ type BusinessInfoProps = { onSubmit: () => void; }; -const bodyContent: Array> = [Confirmation]; +const bodyContent: Array> = [Name, Address, ContactInformation, RegistrationNumber, IncorporationLocation, BusinessType, PaymentVolume, Confirmation]; function BusinessInfo({onBackButtonPress, onSubmit}: BusinessInfoProps) { const {translate} = useLocalize(); diff --git a/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/mockedCorpayLists.ts b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/mockedCorpayLists.ts new file mode 100644 index 000000000000..3acde3dc6577 --- /dev/null +++ b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/mockedCorpayLists.ts @@ -0,0 +1,413 @@ +// TODO - Remove this file once GetCorpayOnboardingFields method is fully implemented. It should when we start work on https://github.com/Expensify/App/issues/50905 + +const annualVolumeRange = [ + { + id: '0', + name: 'Undefined', + stringValue: 'Undefined', + }, + { + id: '1', + name: 'LessThan25000', + stringValue: 'Less than 25000', + }, + { + id: '2', + name: 'TwentyFiveThousandToFiftyThousand', + stringValue: '25,000 - 50,000', + }, + { + id: '3', + name: 'FiftyThousandToSeventyFiveThousand', + stringValue: '50,000 – 75,000', + }, + { + id: '4', + name: 'SeventyFiveToOneHundredThousand', + stringValue: '75,000 – 100,000', + }, + { + id: '5', + name: 'OneHundredToOneHundredFiftyThousand', + stringValue: '100,000 – 150,000', + }, + { + id: '6', + name: 'OneHundredFiftyToTwoHundredThousand', + stringValue: '150,000 – 200,000', + }, + { + id: '7', + name: 'TwoHundredToTwoHundredFiftyThousand', + stringValue: '200,000 – 250,000', + }, + { + id: '8', + name: 'TwoHundredFiftyToThreeHundredThousand', + stringValue: '250,000 – 300,000', + }, + { + id: '9', + name: 'ThreeHundredToFourHundredThousand', + stringValue: '300,000 – 400,000', + }, + { + id: '10', + name: 'FourHundredToFiveHundredThousand', + stringValue: '400,000 – 500,000', + }, + { + id: '11', + name: 'FiveHundredToSevenHundredFiftyThousand', + stringValue: '500,000 – 750,000', + }, + { + id: '12', + name: 'SevenHundredFiftyThousandToOneMillion', + stringValue: '750,000 – 1 million', + }, + { + id: '13', + name: 'OneMillionToTwoMillion', + stringValue: '1 million – 2 million', + }, + { + id: '14', + name: 'TwoMillionToThreeMillion', + stringValue: '2 million – 3 million', + }, + { + id: '15', + name: 'ThreeMillionToFiveMillion', + stringValue: '3 million – 5 million', + }, + { + id: '16', + name: 'FiveMillionToSevenPointFiveMillion', + stringValue: '5 million – 7.5 million', + }, + { + id: '17', + name: 'SevenPointFiveMillionToTenMillion', + stringValue: '7.5 million – 10 million', + }, + { + id: '18', + name: 'GreaterThan10Million', + stringValue: 'Greater than 10 Million', + }, +]; + +// eslint-disable-next-line rulesdir/no-negated-variables +const applicantType = [ + { + id: '0', + name: 'Undefined', + stringValue: 'Undefined', + }, + { + id: '1', + name: 'Corporation', + stringValue: 'Corporation', + }, + { + id: '2', + name: 'Limited_Liability_Company', + stringValue: 'Limited Liability Company (e.g., LLC, LC)', + }, + { + id: '3', + name: 'Partnership', + stringValue: 'Partnership', + }, + { + id: '4', + name: 'Partnership_UK', + stringValue: 'Partnership UK', + }, + { + id: '5', + name: 'Unincorporated_Entity', + stringValue: 'Unincorporated Entity', + }, + { + id: '6', + name: 'Sole_Proprietorship_Sole_Trader', + stringValue: 'Sole Proprietorship/Sole Trader', + }, + { + id: '7', + name: 'Private_person_Entity', + stringValue: 'Private person/ Entity', + }, + { + id: '8', + name: 'Personal_Account', + stringValue: 'Personal Account', + }, + { + id: '9', + name: 'Financial_Institution', + stringValue: 'Financial Institution', + }, + { + id: '10', + name: 'Non_Profit', + stringValue: 'Not for Profit', + }, + { + id: '11', + name: 'Online_User_Verification', + stringValue: 'Online User Verification', + }, + { + id: '12', + name: 'Charitable_Organization', + stringValue: 'Charitable Organizationt', + }, + { + id: '13', + name: 'Trust', + stringValue: 'Trust', + }, +]; + +const natureOfBusiness = [ + { + id: '0', + name: 'Undefined', + stringValue: 'Undefined', + }, + { + id: '10', + name: 'Aerospace and defense', + stringValue: 'Aerospace and defense', + }, + { + id: '20', + name: 'Agriculture and agric-food', + stringValue: 'Agriculture and agric-food', + }, + { + id: '30', + name: 'Apparel / Clothing', + stringValue: 'Apparel / Clothing', + }, + { + id: '40', + name: 'Automotive / Trucking', + stringValue: 'Automotive / Trucking', + }, + { + id: '50', + name: 'Books / Magazines', + stringValue: 'Books / Magazines', + }, + { + id: '60', + name: 'Broadcasting', + stringValue: 'Broadcasting', + }, + { + id: '70', + name: 'Building products', + stringValue: 'Building products', + }, + { + id: '80', + name: 'Chemicals', + stringValue: 'Chemicals', + }, + { + id: '90', + name: 'Dairy', + stringValue: 'Dairy', + }, + { + id: '100', + name: 'E-business', + stringValue: 'E-business', + }, + { + id: '105', + name: 'Educational Institutes', + stringValue: 'Educational Institutes', + }, + { + id: '110', + name: 'Environment', + stringValue: 'Environment', + }, + { + id: '120', + name: 'Explosives', + stringValue: 'Explosives', + }, + { + id: '140', + name: 'Fisheries and oceans', + stringValue: 'Fisheries and oceans', + }, + { + id: '150', + name: 'Food / Beverage distribution', + stringValue: 'Food / Beverage distribution', + }, + { + id: '160', + name: 'Footwear', + stringValue: 'Footwear', + }, + { + id: '170', + name: 'Forest industries', + stringValue: 'Forest industries', + }, + { + id: '180', + name: 'Furniture', + stringValue: 'Furniture', + }, + { + id: '190', + name: 'Giftware and crafts', + stringValue: 'Giftware and crafts', + }, + { + id: '200', + name: 'Horticulture', + stringValue: 'Horticulture', + }, + { + id: '210', + name: 'Hydroelectric energy', + stringValue: 'Hydroelectric energy', + }, + { + id: '220', + name: 'Information and communication technologies', + stringValue: 'Information and communication technologies', + }, + { + id: '230', + name: 'Intelligent systems', + stringValue: 'Intelligent systems', + }, + { + id: '240', + name: 'Livestock', + stringValue: 'Livestock', + }, + { + id: '250', + name: 'Medical devices', + stringValue: 'Medical devices', + }, + { + id: '251', + name: 'Medical treatment', + stringValue: 'Medical treatment', + }, + { + id: '260', + name: 'Minerals, metals and mining', + stringValue: 'Minerals, metals and mining', + }, + { + id: '270', + name: 'Oil and gas', + stringValue: 'Oil and gas', + }, + { + id: '280', + name: 'Pharmaceuticals and biopharmaceuticals', + stringValue: 'Pharmaceuticals and biopharmaceuticals', + }, + { + id: '290', + name: 'Plastics', + stringValue: 'Plastics', + }, + { + id: '300', + name: 'Poultry and eggs', + stringValue: 'Poultry and eggs', + }, + { + id: '310', + name: 'Printing /Publishing', + stringValue: 'Printing /Publishing', + }, + { + id: '320', + name: 'Product design and development', + stringValue: 'Product design and development', + }, + { + id: '330', + name: 'Railway', + stringValue: 'Railway', + }, + { + id: '340', + name: 'Retail', + stringValue: 'Retail', + }, + { + id: '350', + name: 'Shipping and industrial marine', + stringValue: 'Shipping and industrial marine', + }, + { + id: '360', + name: 'Soil', + stringValue: 'Soil', + }, + { + id: '370', + name: 'Sound recording', + stringValue: 'Sound recording', + }, + { + id: '380', + name: 'Sporting goods', + stringValue: 'Sporting goods', + }, + { + id: '390', + name: 'Telecommunications equipment', + stringValue: 'Telecommunications equipment', + }, + { + id: '400', + name: 'Television', + stringValue: 'Television', + }, + { + id: '410', + name: 'Textiles', + stringValue: 'Textiles', + }, + { + id: '420', + name: 'Tourism', + stringValue: 'Tourism', + }, + { + id: '425', + name: 'Trademarks / Law', + stringValue: 'Trademarks / Law', + }, + { + id: '430', + name: 'Water supply', + stringValue: 'Water supply', + }, + { + id: '440', + name: 'Wholesale', + stringValue: 'Wholesale', + }, +]; + +export {annualVolumeRange, applicantType, natureOfBusiness}; diff --git a/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/substeps/Address.tsx b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/substeps/Address.tsx new file mode 100644 index 000000000000..cd9533b4d66f --- /dev/null +++ b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/substeps/Address.tsx @@ -0,0 +1,88 @@ +import React, {useMemo, useState} from 'react'; +import {useOnyx} from 'react-native-onyx'; +import AddressStep from '@components/SubStepForms/AddressStep'; +import useLocalize from '@hooks/useLocalize'; +import useReimbursementAccountStepFormSubmit from '@hooks/useReimbursementAccountStepFormSubmit'; +import type {SubStepProps} from '@hooks/useSubStep/types'; +import getSubstepValues from '@pages/ReimbursementAccount/utils/getSubstepValues'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; + +type AddressProps = SubStepProps; + +const {COMPANY_STREET, COMPANY_ZIP_CODE, COMPANY_STATE, COMPANY_CITY, COMPANY_COUNTRY} = INPUT_IDS.ADDITIONAL_DATA.CORPAY; + +const INPUT_KEYS = { + street: COMPANY_STREET, + city: COMPANY_CITY, + state: COMPANY_STATE, + zipCode: COMPANY_ZIP_CODE, + country: COMPANY_COUNTRY, +}; +const STEP_FIELDS = [COMPANY_STREET, COMPANY_CITY, COMPANY_STATE, COMPANY_ZIP_CODE, COMPANY_COUNTRY]; +const STEP_FIELDS_WITHOUT_STATE = [COMPANY_STREET, COMPANY_CITY, COMPANY_ZIP_CODE, COMPANY_COUNTRY]; + +function Address({onNext, onMove, isEditing}: AddressProps) { + const {translate} = useLocalize(); + + const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT); + const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT); + + const onyxValues = useMemo(() => getSubstepValues(INPUT_KEYS, reimbursementAccountDraft, reimbursementAccount), [reimbursementAccount, reimbursementAccountDraft]); + + const businessStepCountryDraftValue = onyxValues[COMPANY_COUNTRY]; + const countryStepCountryDraftValue = reimbursementAccountDraft?.[INPUT_IDS.ADDITIONAL_DATA.COUNTRY] ?? ''; + const countryInitialValue = + businessStepCountryDraftValue !== '' && businessStepCountryDraftValue !== countryStepCountryDraftValue ? businessStepCountryDraftValue : countryStepCountryDraftValue; + + const defaultValues = { + street: onyxValues[COMPANY_STREET] ?? '', + city: onyxValues[COMPANY_CITY] ?? '', + state: onyxValues[COMPANY_STATE] ?? '', + zipCode: onyxValues[COMPANY_ZIP_CODE] ?? '', + country: businessStepCountryDraftValue ?? countryInitialValue, + }; + + // Has to be stored in state and updated on country change due to the fact that we can't relay on onyxValues when user is editing the form (draft values are not being saved in that case) + const [shouldDisplayStateSelector, setShouldDisplayStateSelector] = useState( + defaultValues.country === CONST.COUNTRY.US || defaultValues.country === CONST.COUNTRY.CA || defaultValues.country === '', + ); + const stepFields = shouldDisplayStateSelector ? STEP_FIELDS : STEP_FIELDS_WITHOUT_STATE; + + const handleCountryChange = (country: unknown) => { + if (typeof country !== 'string' || country === '') { + return; + } + + setShouldDisplayStateSelector(country === CONST.COUNTRY.US || country === CONST.COUNTRY.CA); + }; + + const handleSubmit = useReimbursementAccountStepFormSubmit({ + fieldIds: stepFields, + onNext, + shouldSaveDraft: isEditing, + }); + + return ( + + isEditing={isEditing} + onNext={onNext} + onMove={onMove} + formID={ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM} + formTitle={translate('businessInfoStep.whatsTheBusinessAddress')} + formPOBoxDisclaimer={translate('common.noPO')} + onSubmit={handleSubmit} + stepFields={stepFields} + inputFieldsIDs={INPUT_KEYS} + defaultValues={defaultValues} + onCountryChange={handleCountryChange} + shouldDisplayStateSelector={shouldDisplayStateSelector} + shouldDisplayCountrySelector + /> + ); +} + +Address.displayName = 'Address'; + +export default Address; diff --git a/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/substeps/BusinessType.tsx b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/substeps/BusinessType.tsx new file mode 100644 index 000000000000..bd083c2dd535 --- /dev/null +++ b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/substeps/BusinessType.tsx @@ -0,0 +1,87 @@ +import React, {useCallback} from 'react'; +import {useOnyx} from 'react-native-onyx'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; +import PushRowWithModal from '@components/PushRowWithModal'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useReimbursementAccountStepFormSubmit from '@hooks/useReimbursementAccountStepFormSubmit'; +import type {SubStepProps} from '@hooks/useSubStep/types'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as ValidationUtils from '@libs/ValidationUtils'; +import {applicantType, natureOfBusiness} from '@pages/ReimbursementAccount/NonUSD/BusinessInfo/mockedCorpayLists'; +import ONYXKEYS from '@src/ONYXKEYS'; +import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; + +type BusinessTypeProps = SubStepProps; + +const {BUSINESS_CATEGORY, APPLICANT_TYPE_ID} = INPUT_IDS.ADDITIONAL_DATA.CORPAY; +const STEP_FIELDS = [BUSINESS_CATEGORY, APPLICANT_TYPE_ID]; + +const INCORPORATION_TYPE_LIST_OPTIONS = applicantType.reduce((accumulator, currentValue) => { + accumulator[currentValue.name] = currentValue.stringValue; + return accumulator; +}, {} as Record); +const BUSINESS_CATEGORY_LIST_OPTIONS = natureOfBusiness.reduce((accumulator, currentValue) => { + accumulator[currentValue.name] = currentValue.stringValue; + return accumulator; +}, {} as Record); + +function BusinessType({onNext, isEditing}: BusinessTypeProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + + const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT); + const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT); + + const incorporationTypeDefaultValue = reimbursementAccount?.achData?.additionalData?.corpay?.[APPLICANT_TYPE_ID] ?? reimbursementAccountDraft?.[APPLICANT_TYPE_ID] ?? ''; + const businessCategoryDefaultValue = reimbursementAccount?.achData?.additionalData?.corpay?.[BUSINESS_CATEGORY] ?? reimbursementAccountDraft?.[BUSINESS_CATEGORY] ?? ''; + + const validate = useCallback((values: FormOnyxValues): FormInputErrors => { + return ValidationUtils.getFieldRequiredErrors(values, STEP_FIELDS); + }, []); + + const handleSubmit = useReimbursementAccountStepFormSubmit({ + fieldIds: STEP_FIELDS, + onNext, + shouldSaveDraft: true, + }); + + return ( + + {translate('businessInfoStep.whatTypeOfBusinessIsIt')} + + + + ); +} + +BusinessType.displayName = 'BusinessType'; + +export default BusinessType; diff --git a/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/substeps/Confirmation.tsx b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/substeps/Confirmation.tsx index 9ff2b0e57de9..d0f26feccf0f 100644 --- a/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/substeps/Confirmation.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/substeps/Confirmation.tsx @@ -1,16 +1,58 @@ -import React from 'react'; +import React, {useMemo} from 'react'; import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import SafeAreaConsumer from '@components/SafeAreaConsumer'; import ScrollView from '@components/ScrollView'; +import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import type {SubStepProps} from '@hooks/useSubStep/types'; import useThemeStyles from '@hooks/useThemeStyles'; +import {annualVolumeRange, applicantType, natureOfBusiness} from '@pages/ReimbursementAccount/NonUSD/BusinessInfo/mockedCorpayLists'; +import getSubstepValues from '@pages/ReimbursementAccount/utils/getSubstepValues'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; -function Confirmation({onNext}: SubStepProps) { +const BUSINESS_INFO_STEP_KEYS = INPUT_IDS.ADDITIONAL_DATA.CORPAY; +const { + COMPANY_NAME, + BUSINESS_REGISTRATION_INCORPORATION_NUMBER, + COMPANY_COUNTRY, + COMPANY_STREET, + COMPANY_CITY, + COMPANY_STATE, + COMPANY_ZIP_CODE, + BUSINESS_CONTACT_NUMBER, + BUSINESS_CONFIRMATION_EMAIL, + FORMATION_INCORPORATION_COUNTRY_CODE, + ANNUAL_VOLUME, + APPLICANT_TYPE_ID, + BUSINESS_CATEGORY, +} = INPUT_IDS.ADDITIONAL_DATA.CORPAY; + +const displayStringValue = (list: Array<{id: string; name: string; stringValue: string}>, matchingName: string) => { + return list.find((item) => item.name === matchingName)?.stringValue ?? ''; +}; + +const displayAddress = (street: string, city: string, state: string, zipCode: string, country: string): string => { + return country === CONST.COUNTRY.US || country === CONST.COUNTRY.CA ? `${street}, ${city}, ${state}, ${zipCode}, ${country}` : `${street}, ${city}, ${zipCode}, ${country}`; +}; + +function Confirmation({onNext, onMove}: SubStepProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); + const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT); + const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT); + + const values = useMemo(() => getSubstepValues(BUSINESS_INFO_STEP_KEYS, reimbursementAccountDraft, reimbursementAccount), [reimbursementAccount, reimbursementAccountDraft]); + + const paymentVolume = useMemo(() => displayStringValue(annualVolumeRange, values[ANNUAL_VOLUME]), [values]); + const businessCategory = useMemo(() => displayStringValue(natureOfBusiness, values[BUSINESS_CATEGORY]), [values]); + const businessType = useMemo(() => displayStringValue(applicantType, values[APPLICANT_TYPE_ID]), [values]); + return ( {({safeAreaPaddingBottomStyle}) => ( @@ -18,6 +60,79 @@ function Confirmation({onNext}: SubStepProps) { style={styles.pt0} contentContainerStyle={[styles.flexGrow1, safeAreaPaddingBottomStyle]} > + {translate('businessInfoStep.letsDoubleCheck')} + { + onMove(0); + }} + /> + { + onMove(3); + }} + /> + { + onMove(1); + }} + /> + { + onMove(2); + }} + /> + { + onMove(2); + }} + /> + { + onMove(5); + }} + /> + { + onMove(4); + }} + /> + { + onMove(5); + }} + /> + { + onMove(6); + }} + />