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);
+ }}
+ />
): FormInputErrors => {
+ const errors = ValidationUtils.getFieldRequiredErrors(values, STEP_FIELDS);
+
+ if (values[BUSINESS_CONTACT_NUMBER] && !ValidationUtils.isValidPhoneInternational(values[BUSINESS_CONTACT_NUMBER])) {
+ errors[BUSINESS_CONTACT_NUMBER] = translate('common.error.phoneNumber');
+ }
+
+ if (values[BUSINESS_CONFIRMATION_EMAIL] && !ValidationUtils.isValidEmail(values[BUSINESS_CONFIRMATION_EMAIL])) {
+ errors[BUSINESS_CONFIRMATION_EMAIL] = translate('common.error.email');
+ }
+
+ return errors;
+ },
+ [translate],
+ );
+
+ const handleSubmit = useReimbursementAccountStepFormSubmit({
+ fieldIds: STEP_FIELDS,
+ onNext,
+ shouldSaveDraft: isEditing,
+ });
+
+ return (
+
+ {translate('businessInfoStep.whatsTheBusinessContactInformation')}
+
+
+
+ );
+}
+
+ContactInformation.displayName = 'ContactInformation';
+
+export default ContactInformation;
diff --git a/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/substeps/IncorporationLocation.tsx b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/substeps/IncorporationLocation.tsx
new file mode 100644
index 000000000000..1ccad754dd80
--- /dev/null
+++ b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/substeps/IncorporationLocation.tsx
@@ -0,0 +1,110 @@
+import {CONST as COMMON_CONST} from 'expensify-common/dist/CONST';
+import React, {useCallback, useMemo, useState} 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 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 IncorporationLocationProps = SubStepProps;
+
+const {FORMATION_INCORPORATION_COUNTRY_CODE, FORMATION_INCORPORATION_STATE, COMPANY_COUNTRY, COMPANY_STATE} = INPUT_IDS.ADDITIONAL_DATA.CORPAY;
+const STEP_FIELDS = [FORMATION_INCORPORATION_COUNTRY_CODE, FORMATION_INCORPORATION_STATE];
+
+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);
+
+const isCountryWithSelectableState = (countryCode: string) => countryCode === CONST.COUNTRY.US || countryCode === CONST.COUNTRY.CA;
+
+function IncorporationLocation({onNext, isEditing}: IncorporationLocationProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+
+ const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT);
+ const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT);
+ const onyxValues = useMemo(
+ () => getSubstepValues({FORMATION_INCORPORATION_COUNTRY_CODE, FORMATION_INCORPORATION_STATE, COMPANY_COUNTRY, COMPANY_STATE}, reimbursementAccountDraft, reimbursementAccount),
+ [reimbursementAccount, reimbursementAccountDraft],
+ );
+
+ const incorporationCountryInitialValue = onyxValues[FORMATION_INCORPORATION_COUNTRY_CODE] !== '' ? onyxValues[FORMATION_INCORPORATION_COUNTRY_CODE] : onyxValues[COMPANY_COUNTRY];
+ const businessAddressStateDefaultValue = isCountryWithSelectableState(onyxValues[COMPANY_COUNTRY]) ? onyxValues[COMPANY_STATE] : '';
+ const incorporationStateInitialValue = onyxValues[FORMATION_INCORPORATION_STATE] !== '' ? onyxValues[FORMATION_INCORPORATION_STATE] : businessAddressStateDefaultValue;
+
+ const [selectedCountry, setSelectedCountry] = useState(incorporationCountryInitialValue);
+ const shouldGatherState = isCountryWithSelectableState(selectedCountry);
+
+ const validate = useCallback(
+ (values: FormOnyxValues): FormInputErrors => {
+ return ValidationUtils.getFieldRequiredErrors(values, shouldGatherState ? STEP_FIELDS : [FORMATION_INCORPORATION_COUNTRY_CODE]);
+ },
+ [shouldGatherState],
+ );
+
+ const handleSubmit = useReimbursementAccountStepFormSubmit({
+ fieldIds: STEP_FIELDS,
+ onNext,
+ shouldSaveDraft: true,
+ });
+
+ const handleSelectingCountry = (country: unknown) => {
+ setSelectedCountry(typeof country === 'string' ? country : '');
+ };
+
+ return (
+
+ {translate('businessInfoStep.whereWasTheBusinessIncorporated')}
+ {shouldGatherState && (
+
+ )}
+
+
+ );
+}
+
+IncorporationLocation.displayName = 'IncorporationLocation';
+
+export default IncorporationLocation;
diff --git a/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/substeps/Name.tsx b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/substeps/Name.tsx
new file mode 100644
index 000000000000..2edf94637a81
--- /dev/null
+++ b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/substeps/Name.tsx
@@ -0,0 +1,74 @@
+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 Text from '@components/Text';
+import TextInput from '@components/TextInput';
+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 CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import INPUT_IDS from '@src/types/form/ReimbursementAccountForm';
+
+type NameProps = SubStepProps;
+
+const {COMPANY_NAME} = INPUT_IDS.ADDITIONAL_DATA.CORPAY;
+const STEP_FIELDS = [COMPANY_NAME];
+
+function Name({onNext, isEditing}: NameProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+
+ const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT);
+ const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT);
+ const defaultValue = reimbursementAccount?.achData?.additionalData?.corpay?.[COMPANY_NAME] ?? reimbursementAccountDraft?.[COMPANY_NAME] ?? '';
+
+ const validate = useCallback(
+ (values: FormOnyxValues): FormInputErrors => {
+ const errors = ValidationUtils.getFieldRequiredErrors(values, STEP_FIELDS);
+
+ if (values.companyName && !ValidationUtils.isValidCompanyName(values.companyName)) {
+ errors.companyName = translate('bankAccount.error.companyName');
+ }
+
+ return errors;
+ },
+ [translate],
+ );
+
+ const handleSubmit = useReimbursementAccountStepFormSubmit({
+ fieldIds: STEP_FIELDS,
+ onNext,
+ shouldSaveDraft: isEditing,
+ });
+
+ return (
+
+ {translate('businessInfoStep.whatsTheBusinessName')}
+
+
+ );
+}
+
+Name.displayName = 'Name';
+
+export default Name;
diff --git a/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/substeps/PaymentVolume.tsx b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/substeps/PaymentVolume.tsx
new file mode 100644
index 000000000000..9c1bc483c660
--- /dev/null
+++ b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/substeps/PaymentVolume.tsx
@@ -0,0 +1,72 @@
+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 {annualVolumeRange} from '@pages/ReimbursementAccount/NonUSD/BusinessInfo/mockedCorpayLists';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import INPUT_IDS from '@src/types/form/ReimbursementAccountForm';
+
+type PaymentVolumeProps = SubStepProps;
+
+const {ANNUAL_VOLUME} = INPUT_IDS.ADDITIONAL_DATA.CORPAY;
+const STEP_FIELDS = [ANNUAL_VOLUME];
+
+const ANNUAL_VOLUME_RANGE_LIST_OPTIONS = annualVolumeRange.reduce((accumulator, currentValue) => {
+ accumulator[currentValue.name] = currentValue.stringValue;
+ return accumulator;
+}, {} as Record);
+
+function PaymentVolume({onNext, isEditing}: PaymentVolumeProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+ const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT);
+ const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT);
+
+ const annualVolumeDefaultValue = reimbursementAccount?.achData?.additionalData?.corpay?.[ANNUAL_VOLUME] ?? reimbursementAccountDraft?.[ANNUAL_VOLUME] ?? '';
+
+ const validate = useCallback((values: FormOnyxValues): FormInputErrors => {
+ return ValidationUtils.getFieldRequiredErrors(values, STEP_FIELDS);
+ }, []);
+
+ const handleSubmit = useReimbursementAccountStepFormSubmit({
+ fieldIds: STEP_FIELDS,
+ onNext,
+ shouldSaveDraft: true,
+ });
+
+ return (
+
+ {translate('businessInfoStep.whatsTheBusinessAnnualPayment')}
+
+
+ );
+}
+
+PaymentVolume.displayName = 'PaymentVolume';
+
+export default PaymentVolume;
diff --git a/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/substeps/RegistrationNumber.tsx b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/substeps/RegistrationNumber.tsx
new file mode 100644
index 000000000000..c2e02688a23f
--- /dev/null
+++ b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/substeps/RegistrationNumber.tsx
@@ -0,0 +1,89 @@
+import React, {useCallback} from 'react';
+import {View} from 'react-native';
+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 Icon from '@components/Icon';
+import * as Expensicons from '@components/Icon/Expensicons';
+import Text from '@components/Text';
+import TextInput from '@components/TextInput';
+import TextLink from '@components/TextLink';
+import useLocalize from '@hooks/useLocalize';
+import useReimbursementAccountStepFormSubmit from '@hooks/useReimbursementAccountStepFormSubmit';
+import type {SubStepProps} from '@hooks/useSubStep/types';
+import useTheme from '@hooks/useTheme';
+import useThemeStyles from '@hooks/useThemeStyles';
+import * as ValidationUtils from '@libs/ValidationUtils';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import INPUT_IDS from '@src/types/form/ReimbursementAccountForm';
+
+type RegistrationNumberProps = SubStepProps;
+
+const {BUSINESS_REGISTRATION_INCORPORATION_NUMBER} = INPUT_IDS.ADDITIONAL_DATA.CORPAY;
+const STEP_FIELDS = [BUSINESS_REGISTRATION_INCORPORATION_NUMBER];
+
+function RegistrationNumber({onNext, isEditing}: RegistrationNumberProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+ const theme = useTheme();
+
+ const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT);
+ const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT);
+ const defaultValue =
+ reimbursementAccount?.achData?.additionalData?.corpay?.[BUSINESS_REGISTRATION_INCORPORATION_NUMBER] ?? reimbursementAccountDraft?.[BUSINESS_REGISTRATION_INCORPORATION_NUMBER] ?? '';
+
+ // TODO Validation for registration number depending on the country will be added in https://github.com/Expensify/App/issues/50905
+ const validate = useCallback((values: FormOnyxValues): FormInputErrors => {
+ return ValidationUtils.getFieldRequiredErrors(values, STEP_FIELDS);
+ }, []);
+
+ const handleSubmit = useReimbursementAccountStepFormSubmit({
+ fieldIds: STEP_FIELDS,
+ onNext,
+ shouldSaveDraft: isEditing,
+ });
+
+ return (
+
+ {translate('businessInfoStep.whatsTheBusinessRegistrationNumber')}
+
+
+
+
+
+ {translate('businessInfoStep.whatsThisNumber')}
+
+
+
+
+ );
+}
+
+RegistrationNumber.displayName = 'RegistrationNumber';
+
+export default RegistrationNumber;
diff --git a/src/pages/ReimbursementAccount/NonUSD/Country/substeps/Confirmation.tsx b/src/pages/ReimbursementAccount/NonUSD/Country/substeps/Confirmation.tsx
index 28a4485dacfb..4e52e8a86613 100644
--- a/src/pages/ReimbursementAccount/NonUSD/Country/substeps/Confirmation.tsx
+++ b/src/pages/ReimbursementAccount/NonUSD/Country/substeps/Confirmation.tsx
@@ -45,9 +45,8 @@ function Confirmation({onNext}: SubStepProps) {
Navigation.navigate(ROUTES.WORKSPACE_PROFILE.getRoute(policyID));
};
- const handleSelectingCountry = (country: string) => {
- FormActions.setDraftValues(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM, {[COUNTRY]: country});
- setSelectedCountry(country);
+ const handleSelectingCountry = (country: unknown) => {
+ setSelectedCountry(typeof country === 'string' ? country : '');
};
const validate = useCallback((values: FormOnyxValues): FormInputErrors => {
@@ -56,12 +55,16 @@ function Confirmation({onNext}: SubStepProps) {
useEffect(() => {
if (currency === CONST.CURRENCY.EUR) {
+ if (countryDefaultValue !== '') {
+ FormActions.setDraftValues(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM, {[COUNTRY]: countryDefaultValue});
+ setSelectedCountry(countryDefaultValue);
+ }
return;
}
FormActions.setDraftValues(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM, {[COUNTRY]: currencyMappedToCountry});
setSelectedCountry(currencyMappedToCountry);
- }, [currency, currencyMappedToCountry]);
+ }, [countryDefaultValue, currency, currencyMappedToCountry]);
return (
@@ -101,14 +104,14 @@ function Confirmation({onNext}: SubStepProps) {
diff --git a/src/pages/home/HeaderView.tsx b/src/pages/home/HeaderView.tsx
index 71051b0986c0..f3a98089b2b1 100644
--- a/src/pages/home/HeaderView.tsx
+++ b/src/pages/home/HeaderView.tsx
@@ -21,6 +21,7 @@ import Text from '@components/Text';
import Tooltip from '@components/Tooltip';
import useLocalize from '@hooks/useLocalize';
import usePolicy from '@hooks/usePolicy';
+import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import isReportOpenInRHP from '@libs/Navigation/isReportOpenInRHP';
@@ -64,6 +65,8 @@ const fallbackIcon: IconType = {
};
function HeaderView({report, parentReportAction, reportID, onNavigationMenuButtonClicked, shouldUseNarrowLayout = false}: HeaderViewProps) {
+ // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
+ const {isSmallScreenWidth} = useResponsiveLayout();
const [isDeleteTaskConfirmModalVisible, setIsDeleteTaskConfirmModalVisible] = React.useState(false);
const [invoiceReceiverPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.invoiceReceiver && 'policyID' in report.invoiceReceiver ? report.invoiceReceiver.policyID : -1}`);
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
@@ -145,7 +148,7 @@ function HeaderView({report, parentReportAction, reportID, onNavigationMenuButto
const isLoading = !report?.reportID || !title;
const isReportInRHP = isReportOpenInRHP(navigationRef?.getRootState());
- const shouldDisplaySearchRouter = !isReportInRHP;
+ const shouldDisplaySearchRouter = !isReportInRHP || isSmallScreenWidth;
const isChatUsedForOnboarding = ReportUtils.isChatUsedForOnboarding(report);
return (
diff --git a/src/pages/home/report/ReportActionItem.tsx b/src/pages/home/report/ReportActionItem.tsx
index a0e2f65a89a0..2953036f6af7 100644
--- a/src/pages/home/report/ReportActionItem.tsx
+++ b/src/pages/home/report/ReportActionItem.tsx
@@ -33,7 +33,6 @@ import {ShowContextMenuContext} from '@components/ShowContextMenuContext';
import Text from '@components/Text';
import UnreadActionIndicator from '@components/UnreadActionIndicator';
import useLocalize from '@hooks/useLocalize';
-import usePermissions from '@hooks/usePermissions';
import usePrevious from '@hooks/usePrevious';
import useReportScrollManager from '@hooks/useReportScrollManager';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
@@ -53,7 +52,6 @@ import * as ReportActionsUtils from '@libs/ReportActionsUtils';
import * as ReportUtils from '@libs/ReportUtils';
import SelectionScraper from '@libs/SelectionScraper';
import shouldRenderAddPaymentCard from '@libs/shouldRenderAppPaymentCard';
-import * as TransactionUtils from '@libs/TransactionUtils';
import {ReactionListContext} from '@pages/home/ReportScreenContext';
import * as BankAccounts from '@userActions/BankAccounts';
import * as EmojiPickerAction from '@userActions/EmojiPickerAction';
@@ -198,7 +196,6 @@ function ReportActionItem({
const downloadedPreviews = useRef([]);
const prevDraftMessage = usePrevious(draftMessage);
const [isUserValidated] = useOnyx(ONYXKEYS.USER, {selector: (user) => !!user?.validated});
- const {canUseP2PDistanceRequests} = usePermissions();
// The app would crash due to subscribing to the entire report collection if parentReportID is an empty string. So we should have a fallback ID here.
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID || -1}`);
@@ -421,18 +418,14 @@ function ReportActionItem({
if (ReportActionsUtils.isActionableTrackExpense(action)) {
const transactionID = ReportActionsUtils.getOriginalMessage(action)?.transactionID;
return [
- ...(!TransactionUtils.isDistanceRequest(TransactionUtils.getTransaction(transactionID ?? '-1')) || canUseP2PDistanceRequests
- ? [
- {
- text: 'actionableMentionTrackExpense.submit',
- key: `${action.reportActionID}-actionableMentionTrackExpense-submit`,
- onPress: () => {
- ReportUtils.createDraftTransactionAndNavigateToParticipantSelector(transactionID ?? '0', reportID, CONST.IOU.ACTION.SUBMIT, action.reportActionID);
- },
- isMediumSized: true,
- } as ActionableItem,
- ]
- : []),
+ {
+ text: 'actionableMentionTrackExpense.submit',
+ key: `${action.reportActionID}-actionableMentionTrackExpense-submit`,
+ onPress: () => {
+ ReportUtils.createDraftTransactionAndNavigateToParticipantSelector(transactionID ?? '0', reportID, CONST.IOU.ACTION.SUBMIT, action.reportActionID);
+ },
+ isMediumSized: true,
+ },
{
text: 'actionableMentionTrackExpense.categorize',
key: `${action.reportActionID}-actionableMentionTrackExpense-categorize`,
@@ -505,7 +498,7 @@ function ReportActionItem({
onPress: () => Report.resolveActionableMentionWhisper(reportID, action, CONST.REPORT.ACTIONABLE_MENTION_WHISPER_RESOLUTION.NOTHING),
},
];
- }, [action, isActionableWhisper, reportID, canUseP2PDistanceRequests]);
+ }, [action, isActionableWhisper, reportID]);
/**
* Get the content of ReportActionItem
diff --git a/src/pages/iou/request/IOURequestStartPage.tsx b/src/pages/iou/request/IOURequestStartPage.tsx
index 8a6ff75fee80..f095fac4d6b1 100644
--- a/src/pages/iou/request/IOURequestStartPage.tsx
+++ b/src/pages/iou/request/IOURequestStartPage.tsx
@@ -47,7 +47,7 @@ function IOURequestStartPage({
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${route?.params.transactionID || -1}`);
const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY);
- const {canUseP2PDistanceRequests, canUseCombinedTrackSubmit} = usePermissions(iouType);
+ const {canUseCombinedTrackSubmit} = usePermissions();
const tabTitles = {
[CONST.IOU.TYPE.REQUEST]: translate('iou.submitExpense'),
@@ -73,11 +73,6 @@ function IOURequestStartPage({
IOU.initMoneyRequest(reportID, policy, isFromGlobalCreate, transaction?.iouRequestType, transactionRequestType);
}, [transaction, policy, reportID, iouType, isFromGlobalCreate, transactionRequestType, isLoadingSelectedTab]);
- const isExpenseChat = ReportUtils.isPolicyExpenseChat(report);
- const isExpenseReport = ReportUtils.isExpenseReport(report);
- const shouldDisplayDistanceRequest =
- !!canUseCombinedTrackSubmit || !!canUseP2PDistanceRequests || isExpenseChat || isExpenseReport || (isFromGlobalCreate && iouType !== CONST.IOU.TYPE.SPLIT);
-
const navigateBack = () => {
Navigation.closeRHPFlow();
};
@@ -164,15 +159,13 @@ function IOURequestStartPage({
)}
- {shouldDisplayDistanceRequest && (
-
- {() => (
-
-
-
- )}
-
- )}
+
+ {() => (
+
+
+
+ )}
+
) : (
{
if (!areOptionsInitialized) {
@@ -168,7 +147,7 @@ function MoneyRequestParticipantsSelector({
const newOptions = OptionsListUtils.filterOptions(defaultOptions, debouncedSearchTerm, {
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
- canInviteUser: (canUseP2PDistanceRequests || iouRequestType !== CONST.IOU.REQUEST_TYPE.DISTANCE) && !isCategorizeOrShareAction,
+ canInviteUser: !isCategorizeOrShareAction,
selectedOptions: participants as Participant[],
excludeLogins: CONST.EXPENSIFY_EMAILS,
maxRecentReportsToShow: CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW,
@@ -176,7 +155,7 @@ function MoneyRequestParticipantsSelector({
preferRecentExpenseReports: action === CONST.IOU.ACTION.CREATE,
});
return newOptions;
- }, [areOptionsInitialized, defaultOptions, debouncedSearchTerm, participants, isPaidGroupPolicy, canUseP2PDistanceRequests, iouRequestType, isCategorizeOrShareAction, action]);
+ }, [areOptionsInitialized, defaultOptions, debouncedSearchTerm, participants, isPaidGroupPolicy, isCategorizeOrShareAction, action]);
/**
* Returns the sections needed for the OptionsSelector
@@ -327,10 +306,7 @@ function MoneyRequestParticipantsSelector({
const hasPolicyExpenseChatParticipant = participants.some((participant) => participant.isPolicyExpenseChat);
const shouldShowSplitBillErrorMessage = participants.length > 1 && hasPolicyExpenseChatParticipant;
- // canUseP2PDistanceRequests is true if the iouType is track expense, but we don't want to allow splitting distance with track expense yet
const isAllowedToSplit =
- // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
- (canUseP2PDistanceRequests || iouRequestType !== CONST.IOU.REQUEST_TYPE.DISTANCE) &&
![CONST.IOU.TYPE.PAY, CONST.IOU.TYPE.TRACK, CONST.IOU.TYPE.INVOICE].some((option) => option === iouType) &&
![CONST.IOU.ACTION.SHARE, CONST.IOU.ACTION.SUBMIT, CONST.IOU.ACTION.CATEGORIZE].some((option) => option === action);
@@ -483,8 +459,4 @@ function MoneyRequestParticipantsSelector({
MoneyRequestParticipantsSelector.displayName = 'MoneyTemporaryForRefactorRequestParticipantsSelector';
-export default memo(
- MoneyRequestParticipantsSelector,
- (prevProps, nextProps) =>
- lodashIsEqual(prevProps.participants, nextProps.participants) && prevProps.iouRequestType === nextProps.iouRequestType && prevProps.iouType === nextProps.iouType,
-);
+export default memo(MoneyRequestParticipantsSelector, (prevProps, nextProps) => lodashIsEqual(prevProps.participants, nextProps.participants) && prevProps.iouType === nextProps.iouType);
diff --git a/src/pages/iou/request/step/IOURequestStepCompanyInfo.tsx b/src/pages/iou/request/step/IOURequestStepCompanyInfo.tsx
index 1c2f14dbbb2b..d6cb1e71407f 100644
--- a/src/pages/iou/request/step/IOURequestStepCompanyInfo.tsx
+++ b/src/pages/iou/request/step/IOURequestStepCompanyInfo.tsx
@@ -56,7 +56,7 @@ function IOURequestStepCompanyInfo({route, report, transaction}: IOURequestStepC
if (!ValidationUtils.isValidWebsite(companyWebsite)) {
errors.companyWebsite = translate('bankAccount.error.website');
} else {
- const domain = Url.extractUrlDomain(values.companyWebsite);
+ const domain = Url.extractUrlDomain(companyWebsite);
if (!domain || !Str.isValidDomainName(domain)) {
errors.companyWebsite = translate('iou.invalidDomainError');
diff --git a/src/pages/iou/request/step/IOURequestStepParticipants.tsx b/src/pages/iou/request/step/IOURequestStepParticipants.tsx
index 65e041180408..c956acadb7b0 100644
--- a/src/pages/iou/request/step/IOURequestStepParticipants.tsx
+++ b/src/pages/iou/request/step/IOURequestStepParticipants.tsx
@@ -3,7 +3,6 @@ import React, {useCallback, useEffect, useMemo, useRef} from 'react';
import {useOnyx} from 'react-native-onyx';
import FormHelpMessage from '@components/FormHelpMessage';
import useLocalize from '@hooks/useLocalize';
-import usePermissions from '@hooks/usePermissions';
import useThemeStyles from '@hooks/useThemeStyles';
import {READ_COMMANDS} from '@libs/API/types';
import DistanceRequestUtils from '@libs/DistanceRequestUtils';
@@ -38,7 +37,6 @@ function IOURequestStepParticipants({
const {translate} = useLocalize();
const styles = useThemeStyles();
const isFocused = useIsFocused();
- const {canUseP2PDistanceRequests} = usePermissions(iouType);
const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID ?? -1}`);
// We need to set selectedReportID if user has navigated back from confirmation page and navigates to confirmation page with already selected participant
@@ -91,7 +89,7 @@ function IOURequestStepParticipants({
HttpUtils.cancelPendingRequests(READ_COMMANDS.SEARCH_FOR_REPORTS);
const firstParticipantReportID = val.at(0)?.reportID ?? '';
- const rateID = DistanceRequestUtils.getCustomUnitRateID(firstParticipantReportID, !canUseP2PDistanceRequests);
+ const rateID = DistanceRequestUtils.getCustomUnitRateID(firstParticipantReportID);
const isInvoice = iouType === CONST.IOU.TYPE.INVOICE && ReportUtils.isInvoiceRoomWithID(firstParticipantReportID);
numberOfParticipants.current = val.length;
@@ -108,7 +106,7 @@ function IOURequestStepParticipants({
// When a participant is selected, the reportID needs to be saved because that's the reportID that will be used in the confirmation step.
selectedReportID.current = firstParticipantReportID || reportID;
},
- [iouType, reportID, transactionID, canUseP2PDistanceRequests],
+ [iouType, reportID, transactionID],
);
const goToNextStep = useCallback(() => {
@@ -154,7 +152,7 @@ function IOURequestStepParticipants({
return;
}
- const rateID = DistanceRequestUtils.getCustomUnitRateID(selfDMReportID, !canUseP2PDistanceRequests);
+ const rateID = DistanceRequestUtils.getCustomUnitRateID(selfDMReportID);
IOU.setCustomUnitRateID(transactionID, rateID);
IOU.setMoneyRequestParticipantsFromReport(transactionID, ReportUtils.getReport(selfDMReportID));
const iouConfirmationPageRoute = ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(action, CONST.IOU.TYPE.TRACK, transactionID, selfDMReportID);
@@ -192,7 +190,6 @@ function IOURequestStepParticipants({
onFinish={goToNextStep}
onTrackExpensePress={trackExpense}
iouType={iouType}
- iouRequestType={iouRequestType}
action={action}
shouldDisplayTrackExpenseButton={shouldDisplayTrackExpenseButton}
/>
diff --git a/src/pages/iou/request/step/IOURequestStepScan/NavigationAwareCamera/WebCamera.tsx b/src/pages/iou/request/step/IOURequestStepScan/NavigationAwareCamera/WebCamera.tsx
index cc2b0f164a78..db8c1656b3f8 100644
--- a/src/pages/iou/request/step/IOURequestStepScan/NavigationAwareCamera/WebCamera.tsx
+++ b/src/pages/iou/request/step/IOURequestStepScan/NavigationAwareCamera/WebCamera.tsx
@@ -1,16 +1,14 @@
+import {useIsFocused} from '@react-navigation/native';
import React from 'react';
import type {ForwardedRef} from 'react';
import {View} from 'react-native';
import type {Camera} from 'react-native-vision-camera';
import Webcam from 'react-webcam';
-import useTabNavigatorFocus from '@hooks/useTabNavigatorFocus';
import type {NavigationAwareCameraProps} from './types';
// Wraps a camera that will only be active when the tab is focused or as soon as it starts to become focused.
function WebCamera({torchOn, onTorchAvailability, cameraTabIndex, ...props}: NavigationAwareCameraProps, ref: ForwardedRef) {
- const shouldShowCamera = useTabNavigatorFocus({
- tabIndex: cameraTabIndex,
- });
+ const shouldShowCamera = useIsFocused();
if (!shouldShowCamera) {
return null;
diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.tsx
index 46f2fe6262ec..ecf84c877496 100644
--- a/src/pages/iou/request/step/IOURequestStepScan/index.tsx
+++ b/src/pages/iou/request/step/IOURequestStepScan/index.tsx
@@ -1,3 +1,4 @@
+import {useIsFocused} from '@react-navigation/native';
import {Str} from 'expensify-common';
import React, {useCallback, useContext, useEffect, useMemo, useReducer, useRef, useState} from 'react';
import {ActivityIndicator, PanResponder, PixelRatio, View} from 'react-native';
@@ -24,7 +25,6 @@ import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalD
import useLocalize from '@hooks/useLocalize';
import usePolicy from '@hooks/usePolicy';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
-import useTabNavigatorFocus from '@hooks/useTabNavigatorFocus';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import * as Browser from '@libs/Browser';
@@ -93,7 +93,7 @@ function IOURequestStepScan({
const [videoConstraints, setVideoConstraints] = useState();
const tabIndex = 1;
- const isTabActive = useTabNavigatorFocus({tabIndex});
+ const isTabActive = useIsFocused();
const isEditing = action === CONST.IOU.ACTION.EDIT;
const defaultTaxCode = TransactionUtils.getDefaultTaxCode(policy, transaction);
diff --git a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx
index dc21701f65fe..24325e15beca 100644
--- a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx
+++ b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx
@@ -270,7 +270,6 @@ function ContactMethodDetailsPage({route}: ContactMethodDetailsPageProps) {
}}
sendValidateCode={() => User.requestContactMethodValidateCode(contactMethod)}
description={translate('contacts.enterMagicCode', {contactMethod})}
- footer={() => getMenuItems()}
/>
{!isValidateCodeActionModalVisible && getMenuItems()}
diff --git a/src/pages/settings/Profile/PersonalDetails/CountrySelectionPage.tsx b/src/pages/settings/Profile/PersonalDetails/CountrySelectionPage.tsx
index a8afffe32d33..375e3d2fb8c4 100644
--- a/src/pages/settings/Profile/PersonalDetails/CountrySelectionPage.tsx
+++ b/src/pages/settings/Profile/PersonalDetails/CountrySelectionPage.tsx
@@ -7,8 +7,8 @@ import RadioListItem from '@components/SelectionList/RadioListItem';
import useLocalize from '@hooks/useLocalize';
import Navigation from '@libs/Navigation/Navigation';
import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
-import type {CountryData} from '@libs/searchCountryOptions';
-import searchCountryOptions from '@libs/searchCountryOptions';
+import type {Option} from '@libs/searchOptions';
+import searchOptions from '@libs/searchOptions';
import StringUtils from '@libs/StringUtils';
import {appendParam} from '@libs/Url';
import CONST from '@src/CONST';
@@ -38,11 +38,11 @@ function CountrySelectionPage({route, navigation}: CountrySelectionPageProps) {
[translate, currentCountry],
);
- const searchResults = searchCountryOptions(searchValue, countries);
+ const searchResults = searchOptions(searchValue, countries);
const headerMessage = searchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : '';
const selectCountry = useCallback(
- (option: CountryData) => {
+ (option: Option) => {
const backTo = route.params.backTo ?? '';
// Check the navigation state and "backTo" parameter to decide navigation behavior
if (navigation.getState().routes.length === 1 && !backTo) {
diff --git a/src/pages/settings/Profile/PersonalDetails/StateSelectionPage.tsx b/src/pages/settings/Profile/PersonalDetails/StateSelectionPage.tsx
index 2a42f1922bf8..85bf9333588d 100644
--- a/src/pages/settings/Profile/PersonalDetails/StateSelectionPage.tsx
+++ b/src/pages/settings/Profile/PersonalDetails/StateSelectionPage.tsx
@@ -9,8 +9,8 @@ import SelectionList from '@components/SelectionList';
import RadioListItem from '@components/SelectionList/RadioListItem';
import useLocalize from '@hooks/useLocalize';
import Navigation from '@libs/Navigation/Navigation';
-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 {appendParam} from '@libs/Url';
import type {Route} from '@src/ROUTES';
@@ -50,11 +50,11 @@ function StateSelectionPage() {
[translate, currentState],
);
- const searchResults = searchCountryOptions(searchValue, countryStates);
+ const searchResults = searchOptions(searchValue, countryStates);
const headerMessage = searchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : '';
const selectCountryState = useCallback(
- (option: CountryData) => {
+ (option: Option) => {
const backTo = params?.backTo ?? '';
// Determine navigation action based on "backTo" presence and route stack length.
diff --git a/src/pages/settings/Security/TwoFactorAuth/Steps/CodesStep.tsx b/src/pages/settings/Security/TwoFactorAuth/Steps/CodesStep.tsx
index 6ae779fce480..0e0d1919423d 100644
--- a/src/pages/settings/Security/TwoFactorAuth/Steps/CodesStep.tsx
+++ b/src/pages/settings/Security/TwoFactorAuth/Steps/CodesStep.tsx
@@ -1,6 +1,4 @@
-import type {RouteProp} from '@react-navigation/native';
-import {useRoute} from '@react-navigation/native';
-import React, {useEffect, useState} from 'react';
+import React, {useEffect, useMemo, useState} from 'react';
import {ActivityIndicator, View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
import Button from '@components/Button';
@@ -12,22 +10,23 @@ import PressableWithDelayToggle from '@components/Pressable/PressableWithDelayTo
import ScrollView from '@components/ScrollView';
import Section from '@components/Section';
import Text from '@components/Text';
-import ValidateAccountMessage from '@components/ValidateAccountMessage';
+import ValidateCodeActionModal from '@components/ValidateCodeActionModal';
import useLocalize from '@hooks/useLocalize';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import Clipboard from '@libs/Clipboard';
+import * as ErrorUtils from '@libs/ErrorUtils';
import localFileDownload from '@libs/localFileDownload';
-import type {BackToParams, SettingsNavigatorParamList} from '@libs/Navigation/types';
+import type {BackToParams} from '@libs/Navigation/types';
import StepWrapper from '@pages/settings/Security/TwoFactorAuth/StepWrapper/StepWrapper';
import useTwoFactorAuthContext from '@pages/settings/Security/TwoFactorAuth/TwoFactorAuthContext/useTwoFactorAuth';
import * as Session from '@userActions/Session';
import * as TwoFactorAuthActions from '@userActions/TwoFactorAuthActions';
+import * as User from '@userActions/User';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-import ROUTES from '@src/ROUTES';
-import type SCREENS from '@src/SCREENS';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
type CodesStepProps = BackToParams;
@@ -42,9 +41,14 @@ function CodesStep({backTo}: CodesStepProps) {
const [account] = useOnyx(ONYXKEYS.ACCOUNT);
const [user] = useOnyx(ONYXKEYS.USER);
+ const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST);
const isUserValidated = user?.validated;
- const route = useRoute>();
+ const contactMethod = account?.primaryLogin ?? '';
+
+ const loginData = useMemo(() => loginList?.[contactMethod], [loginList, contactMethod]);
+ const validateLoginError = ErrorUtils.getEarliestErrorField(loginData, 'validateLogin');
+ const hasMagicCodeBeenSent = !!loginData?.validateCodeSent;
const {setStep} = useTwoFactorAuthContext();
@@ -135,7 +139,6 @@ function CodesStep({backTo}: CodesStepProps) {
)}
- {!isUserValidated && }
{!!error && (
+ User.requestValidateCodeAction()}
+ handleSubmitForm={(validateCode) => User.validateSecondaryLogin(loginList, contactMethod, validateCode)}
+ validateError={!isEmptyObject(validateLoginError) ? validateLoginError : ErrorUtils.getLatestErrorField(loginData, 'validateCodeSent')}
+ clearError={() => User.clearContactMethodErrors(contactMethod, !isEmptyObject(validateLoginError) ? 'validateLogin' : 'validateCodeSent')}
+ onModalHide={() => {}}
+ onClose={() => TwoFactorAuthActions.quitAndNavigateBack(backTo)}
+ />
);
diff --git a/src/pages/settings/Troubleshoot/TroubleshootPage.tsx b/src/pages/settings/Troubleshoot/TroubleshootPage.tsx
index 69293fe894d4..bd0ce596c733 100644
--- a/src/pages/settings/Troubleshoot/TroubleshootPage.tsx
+++ b/src/pages/settings/Troubleshoot/TroubleshootPage.tsx
@@ -11,6 +11,7 @@ import * as Illustrations from '@components/Icon/Illustrations';
import ImportOnyxState from '@components/ImportOnyxState';
import LottieAnimations from '@components/LottieAnimations';
import MenuItemList from '@components/MenuItemList';
+import {useOptionsList} from '@components/OptionListContextProvider';
import ScreenWrapper from '@components/ScreenWrapper';
import ScrollView from '@components/ScrollView';
import Section from '@components/Section';
@@ -51,6 +52,7 @@ function TroubleshootPage() {
const [isLoading, setIsLoading] = useState(false);
const [shouldStoreLogs] = useOnyx(ONYXKEYS.SHOULD_STORE_LOGS);
const [shouldMaskOnyxState = true] = useOnyx(ONYXKEYS.SHOULD_MASK_ONYX_STATE);
+ const {resetOptions} = useOptionsList({shouldInitialize: false});
const exportOnyxState = useCallback(() => {
ExportOnyxState.readFromOnyxDatabase().then((value: Record) => {
@@ -160,6 +162,7 @@ function TroubleshootPage() {
isVisible={isConfirmationModalVisible}
onConfirm={() => {
setIsConfirmationModalVisible(false);
+ resetOptions();
clearOnyxAndResetApp();
}}
onCancel={() => setIsConfirmationModalVisible(false)}
diff --git a/src/pages/settings/Wallet/ReportCardLostPage.tsx b/src/pages/settings/Wallet/ReportCardLostPage.tsx
index 35b5bc9cd19a..ec74ea400a13 100644
--- a/src/pages/settings/Wallet/ReportCardLostPage.tsx
+++ b/src/pages/settings/Wallet/ReportCardLostPage.tsx
@@ -1,18 +1,20 @@
import type {StackScreenProps} from '@react-navigation/stack';
-import React, {useEffect, useState} from 'react';
+import React, {useCallback, useEffect, useState} from 'react';
import {View} from 'react-native';
-import type {OnyxEntry} from 'react-native-onyx';
-import {withOnyx} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
import ScreenWrapper from '@components/ScreenWrapper';
import SingleOptionSelector from '@components/SingleOptionSelector';
import Text from '@components/Text';
+import ValidateCodeActionModal from '@components/ValidateCodeActionModal';
import useLocalize from '@hooks/useLocalize';
import usePrevious from '@hooks/usePrevious';
import useStyledSafeAreaInsets from '@hooks/useStyledSafeAreaInsets';
import useThemeStyles from '@hooks/useThemeStyles';
+import {requestValidateCodeAction} from '@libs/actions/User';
+import * as ErrorUtils from '@libs/ErrorUtils';
import Navigation from '@libs/Navigation/Navigation';
import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
@@ -24,8 +26,6 @@ import type {TranslationPaths} from '@src/languages/types';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
-import type {ReportPhysicalCardForm} from '@src/types/form';
-import type {Card, PrivatePersonalDetails} from '@src/types/onyx';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
const OPTIONS_KEYS = {
@@ -50,49 +50,32 @@ const OPTIONS: Option[] = [
},
];
-type ReportCardLostPageOnyxProps = {
- /** Onyx form data */
- formData: OnyxEntry;
-
- /** User's private personal details */
- privatePersonalDetails: OnyxEntry;
-
- /** User's cards list */
- cardList: OnyxEntry>;
-};
-
-type ReportCardLostPageProps = ReportCardLostPageOnyxProps & StackScreenProps;
+type ReportCardLostPageProps = StackScreenProps;
function ReportCardLostPage({
- privatePersonalDetails = {
- addresses: [
- {
- street: '',
- street2: '',
- city: '',
- state: '',
- zip: '',
- country: '',
- },
- ],
- },
- cardList = {},
route: {
params: {cardID = ''},
},
- formData,
}: ReportCardLostPageProps) {
const styles = useThemeStyles();
- const physicalCard = cardList?.[cardID];
-
const {translate} = useLocalize();
+ const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST);
+ const [account] = useOnyx(ONYXKEYS.ACCOUNT);
+ const [formData] = useOnyx(ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM);
+ const [cardList] = useOnyx(ONYXKEYS.CARD_LIST);
+ const [privatePersonalDetails] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS);
+
const [reason, setReason] = useState();
const [isReasonConfirmed, setIsReasonConfirmed] = useState(false);
const [shouldShowAddressError, setShouldShowAddressError] = useState(false);
const [shouldShowReasonError, setShouldShowReasonError] = useState(false);
+ const physicalCard = cardList?.[cardID];
+ const validateError = ErrorUtils.getLatestErrorMessageField(physicalCard);
+ const [isValidateCodeActionModalVisible, setIsValidateCodeActionModalVisible] = useState(false);
+
const prevIsLoading = usePrevious(formData?.isLoading);
const {paddingBottom} = useStyledSafeAreaInsets();
@@ -115,6 +98,16 @@ function ReportCardLostPage({
FormActions.setErrors(ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM, physicalCard?.errors ?? {});
}, [formData?.isLoading, physicalCard?.errors]);
+ const handleValidateCodeEntered = useCallback(
+ (validateCode: string) => {
+ if (!physicalCard) {
+ return;
+ }
+ CardActions.requestReplacementExpensifyCard(physicalCard.cardID, reason?.key as ReplacementReason, validateCode);
+ },
+ [physicalCard, reason?.key],
+ );
+
if (isEmptyObject(physicalCard)) {
return ;
}
@@ -135,8 +128,17 @@ function ReportCardLostPage({
setShouldShowAddressError(true);
return;
}
+ setIsValidateCodeActionModalVisible(true);
+ };
+
+ const sendValidateCode = () => {
+ const primaryLogin = account?.primaryLogin ?? '';
+
+ if (loginList?.[primaryLogin]?.validateCodeSent) {
+ return;
+ }
- CardActions.requestReplacementExpensifyCard(physicalCard.cardID, reason?.key as ReplacementReason);
+ requestValidateCodeAction();
};
const handleOptionSelect = (option: Option) => {
@@ -189,6 +191,18 @@ function ReportCardLostPage({
isLoading={formData?.isLoading}
buttonText={isDamaged ? translate('reportCardLostOrDamaged.shipNewCardButton') : translate('reportCardLostOrDamaged.deactivateCardButton')}
/>
+ {
+ CardActions.clearCardListErrors(physicalCard.cardID);
+ }}
+ onClose={() => setIsValidateCodeActionModalVisible(false)}
+ isVisible={isValidateCodeActionModalVisible}
+ title={translate('cardPage.validateCardTitle')}
+ description={translate('cardPage.enterMagicCode', {contactMethod: account?.primaryLogin ?? ''})}
+ />
>
) : (
<>
@@ -215,14 +229,4 @@ function ReportCardLostPage({
ReportCardLostPage.displayName = 'ReportCardLostPage';
-export default withOnyx({
- privatePersonalDetails: {
- key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS,
- },
- cardList: {
- key: ONYXKEYS.CARD_LIST,
- },
- formData: {
- key: ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM,
- },
-})(ReportCardLostPage);
+export default ReportCardLostPage;
diff --git a/src/pages/settings/Wallet/ReportVirtualCardFraudPage.tsx b/src/pages/settings/Wallet/ReportVirtualCardFraudPage.tsx
index 104ed9607184..b1673a8bd51c 100644
--- a/src/pages/settings/Wallet/ReportVirtualCardFraudPage.tsx
+++ b/src/pages/settings/Wallet/ReportVirtualCardFraudPage.tsx
@@ -1,14 +1,16 @@
import type {StackScreenProps} from '@react-navigation/stack';
-import React, {useCallback, useEffect} from 'react';
-import {InteractionManager, View} from 'react-native';
+import React, {useCallback, useEffect, useState} from 'react';
+import {View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import Text from '@components/Text';
+import ValidateCodeActionModal from '@components/ValidateCodeActionModal';
import useLocalize from '@hooks/useLocalize';
import usePrevious from '@hooks/usePrevious';
import useThemeStyles from '@hooks/useThemeStyles';
+import {requestValidateCodeAction} from '@libs/actions/User';
import * as ErrorUtils from '@libs/ErrorUtils';
import Navigation from '@libs/Navigation/Navigation';
import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
@@ -28,20 +30,20 @@ function ReportVirtualCardFraudPage({
}: ReportVirtualCardFraudPageProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
+ const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST);
+ const [account] = useOnyx(ONYXKEYS.ACCOUNT);
const [cardList] = useOnyx(ONYXKEYS.CARD_LIST);
const [formData] = useOnyx(ONYXKEYS.FORMS.REPORT_VIRTUAL_CARD_FRAUD);
+ const primaryLogin = account?.primaryLogin ?? '';
+ const loginData = loginList?.[primaryLogin];
const virtualCard = cardList?.[cardID];
const virtualCardError = ErrorUtils.getLatestErrorMessage(virtualCard);
+ const validateError = ErrorUtils.getLatestErrorMessageField(virtualCard);
- const prevIsLoading = usePrevious(formData?.isLoading);
+ const [isValidateCodeActionModalVisible, setIsValidateCodeActionModalVisible] = useState(false);
- const submit = useCallback(() => {
- Navigation.dismissModal();
- InteractionManager.runAfterInteractions(() => {
- Card.reportVirtualExpensifyCardFraud(virtualCard);
- });
- }, [virtualCard]);
+ const prevIsLoading = usePrevious(formData?.isLoading);
useEffect(() => {
if (!prevIsLoading || formData?.isLoading) {
@@ -54,6 +56,28 @@ function ReportVirtualCardFraudPage({
Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARD.getRoute(cardID));
}, [cardID, formData?.isLoading, prevIsLoading, virtualCard?.errors]);
+ const handleValidateCodeEntered = useCallback(
+ (validateCode: string) => {
+ if (!virtualCard) {
+ return;
+ }
+ Card.reportVirtualExpensifyCardFraud(virtualCard, validateCode);
+ },
+ [virtualCard],
+ );
+
+ const sendValidateCode = () => {
+ if (loginData?.validateCodeSent) {
+ return;
+ }
+
+ requestValidateCodeAction();
+ };
+
+ const handleSubmit = useCallback(() => {
+ setIsValidateCodeActionModalVisible(true);
+ }, [setIsValidateCodeActionModalVisible]);
+
if (isEmptyObject(virtualCard)) {
return ;
}
@@ -68,12 +92,25 @@ function ReportVirtualCardFraudPage({
{translate('reportFraudPage.description')}
+ {
+ Card.clearCardListErrors(virtualCard.cardID);
+ }}
+ onClose={() => setIsValidateCodeActionModalVisible(false)}
+ isVisible={isValidateCodeActionModalVisible}
+ title={translate('cardPage.validateCardTitle')}
+ description={translate('cardPage.enterMagicCode', {contactMethod: account?.primaryLogin ?? ''})}
+ hasMagicCodeBeenSent={!!loginData?.validateCodeSent}
+ />
);
diff --git a/src/pages/signin/SignInPageLayout/Footer.tsx b/src/pages/signin/SignInPageLayout/Footer.tsx
index 314d91256e80..2f0efc862a91 100644
--- a/src/pages/signin/SignInPageLayout/Footer.tsx
+++ b/src/pages/signin/SignInPageLayout/Footer.tsx
@@ -93,8 +93,8 @@ const columns = ({navigateFocus = () => {}}: Pick)
translationPath: 'footer.expensifyHelp',
},
{
- href: CONST.FOOTER.COMMUNITY_URL,
- translationPath: 'footer.community',
+ href: CONST.FOOTER.TERMS_URL,
+ translationPath: 'footer.terms',
},
{
href: CONST.FOOTER.PRIVACY_URL,
diff --git a/src/pages/workspace/AccessOrNotFoundWrapper.tsx b/src/pages/workspace/AccessOrNotFoundWrapper.tsx
index 45bce8c2d1ba..9bda7f3972f9 100644
--- a/src/pages/workspace/AccessOrNotFoundWrapper.tsx
+++ b/src/pages/workspace/AccessOrNotFoundWrapper.tsx
@@ -17,6 +17,7 @@ import type {IOUType} from '@src/CONST';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
+import SCREENS from '@src/SCREENS';
import type * as OnyxTypes from '@src/types/onyx';
import type {PolicyFeatureName} from '@src/types/onyx/Policy';
import callOrReturn from '@src/types/utils/callOrReturn';
@@ -152,7 +153,7 @@ function AccessOrNotFoundWrapper({
return acc && accessFunction(policy, login, report, allPolicies ?? null, iouType);
}, true);
- const isPolicyNotAccessible = isEmptyObject(policy) || (Object.keys(policy).length === 1 && !isEmptyObject(policy.errors)) || !policy?.id;
+ const isPolicyNotAccessible = !PolicyUtils.isPolicyAccessible(policy);
const shouldShowNotFoundPage = (!isMoneyRequest && !isFromGlobalCreate && isPolicyNotAccessible) || !isPageAccessible || !isPolicyFeatureEnabled || shouldBeBlocked;
// We only update the feature state if it isn't pending.
@@ -165,6 +166,14 @@ function AccessOrNotFoundWrapper({
setIsPolicyFeatureEnabled(isFeatureEnabled);
}, [pendingField, isOffline, isFeatureEnabled]);
+ useEffect(() => {
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ if (isLoadingReportData || !isPolicyNotAccessible) {
+ return;
+ }
+ Navigation.removeScreenFromNavigationState(SCREENS.WORKSPACE.INITIAL);
+ }, [isLoadingReportData, isPolicyNotAccessible]);
+
if (shouldShowFullScreenLoadingIndicator) {
return ;
}
diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx
index f9d1fedb91c1..971cc064f9a5 100644
--- a/src/pages/workspace/WorkspaceInitialPage.tsx
+++ b/src/pages/workspace/WorkspaceInitialPage.tsx
@@ -62,7 +62,8 @@ type WorkspaceMenuItem = {
| typeof SCREENS.WORKSPACE.EXPENSIFY_CARD
| typeof SCREENS.WORKSPACE.COMPANY_CARDS
| typeof SCREENS.WORKSPACE.REPORT_FIELDS
- | typeof SCREENS.WORKSPACE.RULES;
+ | typeof SCREENS.WORKSPACE.RULES
+ | typeof SCREENS.WORKSPACE.PER_DIEM;
badgeText?: string;
};
@@ -110,6 +111,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac
[CONST.POLICY.MORE_FEATURES.ARE_REPORT_FIELDS_ENABLED]: policy?.areReportFieldsEnabled,
[CONST.POLICY.MORE_FEATURES.ARE_RULES_ENABLED]: policy?.areRulesEnabled,
[CONST.POLICY.MORE_FEATURES.ARE_INVOICES_ENABLED]: policy?.areInvoicesEnabled,
+ [CONST.POLICY.MORE_FEATURES.ARE_PER_DIEM_RATES_ENABLED]: policy?.arePerDiemRatesEnabled,
}),
[policy],
) as PolicyFeatureStates;
@@ -224,6 +226,15 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac
});
}
+ if (featureStates?.[CONST.POLICY.MORE_FEATURES.ARE_PER_DIEM_RATES_ENABLED]) {
+ protectedCollectPolicyMenuItems.push({
+ translationKey: 'workspace.common.perDiem',
+ icon: Expensicons.CalendarSolid,
+ action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_PER_DIEM.getRoute(policyID)))),
+ routeName: SCREENS.WORKSPACE.PER_DIEM,
+ });
+ }
+
if (featureStates?.[CONST.POLICY.MORE_FEATURES.ARE_WORKFLOWS_ENABLED]) {
protectedCollectPolicyMenuItems.push({
translationKey: 'workspace.common.workflows',
diff --git a/src/pages/workspace/WorkspaceInvitePage.tsx b/src/pages/workspace/WorkspaceInvitePage.tsx
index ad48d15aa9df..3e63ae7cbe79 100644
--- a/src/pages/workspace/WorkspaceInvitePage.tsx
+++ b/src/pages/workspace/WorkspaceInvitePage.tsx
@@ -1,8 +1,8 @@
+import {useNavigation} from '@react-navigation/native';
import type {StackScreenProps} from '@react-navigation/stack';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import type {SectionListData} from 'react-native';
-import {useOnyx, withOnyx} from 'react-native-onyx';
-import type {OnyxEntry} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import {useOptionsList} from '@components/OptionListContextProvider';
@@ -34,7 +34,7 @@ import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
-import type {Beta, InvitedEmailsToAccountIDs} from '@src/types/onyx';
+import type {InvitedEmailsToAccountIDs} from '@src/types/onyx';
import type {Errors} from '@src/types/onyx/OnyxCommon';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import AccessOrNotFoundWrapper from './AccessOrNotFoundWrapper';
@@ -43,22 +43,12 @@ import type {WithPolicyAndFullscreenLoadingProps} from './withPolicyAndFullscree
type MembersSection = SectionListData>;
-type WorkspaceInvitePageOnyxProps = {
- /** Beta features list */
- betas: OnyxEntry;
+type WorkspaceInvitePageProps = WithPolicyAndFullscreenLoadingProps & WithNavigationTransitionEndProps & StackScreenProps;
- /** An object containing the accountID for every invited user email */
- invitedEmailsToAccountIDsDraft: OnyxEntry;
-};
-
-type WorkspaceInvitePageProps = WithPolicyAndFullscreenLoadingProps &
- WithNavigationTransitionEndProps &
- WorkspaceInvitePageOnyxProps &
- StackScreenProps;
-
-function WorkspaceInvitePage({route, betas, invitedEmailsToAccountIDsDraft, policy}: WorkspaceInvitePageProps) {
+function WorkspaceInvitePage({route, policy}: WorkspaceInvitePageProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
+ const navigation = useNavigation();
const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState('');
const [selectedOptions, setSelectedOptions] = useState([]);
const [personalDetails, setPersonalDetails] = useState([]);
@@ -66,6 +56,8 @@ function WorkspaceInvitePage({route, betas, invitedEmailsToAccountIDsDraft, poli
const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false);
const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false});
const firstRenderRef = useRef(true);
+ const [betas] = useOnyx(ONYXKEYS.BETAS);
+ const [invitedEmailsToAccountIDsDraft] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${route.params.policyID.toString()}`);
const openWorkspaceInvitePage = () => {
const policyMemberEmailsToAccountIDs = PolicyUtils.getMemberAccountIDsForWorkspace(policy?.employeeList);
@@ -76,11 +68,12 @@ function WorkspaceInvitePage({route, betas, invitedEmailsToAccountIDsDraft, poli
});
useEffect(() => {
- return () => {
+ const unsubscribe = navigation.addListener('beforeRemove', () => {
Member.setWorkspaceInviteMembersDraft(route.params.policyID, {});
- };
- // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
- }, [route.params.policyID]);
+ });
+
+ return unsubscribe;
+ }, [navigation, route.params.policyID]);
useEffect(() => {
Policy.clearErrors(route.params.policyID);
@@ -354,15 +347,4 @@ function WorkspaceInvitePage({route, betas, invitedEmailsToAccountIDsDraft, poli
WorkspaceInvitePage.displayName = 'WorkspaceInvitePage';
-export default withNavigationTransitionEnd(
- withPolicyAndFullscreenLoading(
- withOnyx({
- betas: {
- key: ONYXKEYS.BETAS,
- },
- invitedEmailsToAccountIDsDraft: {
- key: ({route}) => `${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${route.params.policyID.toString()}`,
- },
- })(WorkspaceInvitePage),
- ),
-);
+export default withNavigationTransitionEnd(withPolicyAndFullscreenLoading(WorkspaceInvitePage));
diff --git a/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx b/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx
index 11acbda1ebbe..4eb1f752d176 100644
--- a/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx
+++ b/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx
@@ -12,15 +12,17 @@ import Section from '@components/Section';
import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
+import usePermissions from '@hooks/usePermissions';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';
import * as CardUtils from '@libs/CardUtils';
import * as ErrorUtils from '@libs/ErrorUtils';
import Navigation from '@libs/Navigation/Navigation';
import type {FullScreenNavigatorParamList} from '@libs/Navigation/types';
-import {isControlPolicy} from '@libs/PolicyUtils';
+import {getPerDiemCustomUnit, isControlPolicy} from '@libs/PolicyUtils';
import * as Category from '@userActions/Policy/Category';
import * as DistanceRate from '@userActions/Policy/DistanceRate';
+import * as PerDiem from '@userActions/Policy/PerDiem';
import * as Policy from '@userActions/Policy/Policy';
import * as Tag from '@userActions/Policy/Tag';
import * as Report from '@userActions/Report';
@@ -62,6 +64,7 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro
const styles = useThemeStyles();
const {shouldUseNarrowLayout} = useResponsiveLayout();
const {translate} = useLocalize();
+ const {canUsePerDiem} = usePermissions();
const hasAccountingConnection = !isEmptyObject(policy?.connections);
const isAccountingEnabled = !!policy?.areConnectionsEnabled || !isEmptyObject(policy?.connections);
const isSyncTaxEnabled =
@@ -78,6 +81,8 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro
const [isDisableExpensifyCardWarningModalOpen, setIsDisableExpensifyCardWarningModalOpen] = useState(false);
const [isDisableCompanyCardsWarningModalOpen, setIsDisableCompanyCardsWarningModalOpen] = useState(false);
+ const perDiemCustomUnit = getPerDiemCustomUnit(policy);
+
const onDisabledOrganizeSwitchPress = useCallback(() => {
if (!hasAccountingConnection) {
return;
@@ -140,6 +145,26 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro
},
});
+ if (canUsePerDiem) {
+ spendItems.push({
+ icon: Illustrations.PerDiem,
+ titleTranslationKey: 'workspace.moreFeatures.perDiem.title',
+ subtitleTranslationKey: 'workspace.moreFeatures.perDiem.subtitle',
+ isActive: policy?.arePerDiemRatesEnabled ?? false,
+ pendingAction: policy?.pendingFields?.arePerDiemRatesEnabled,
+ action: (isEnabled: boolean) => {
+ if (!policyID) {
+ return;
+ }
+ if (isEnabled && !isControlPolicy(policy)) {
+ Navigation.navigate(ROUTES.WORKSPACE_UPGRADE.getRoute(policyID, CONST.UPGRADE_FEATURE_INTRO_MAPPING.perDiem.alias, ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID)));
+ return;
+ }
+ PerDiem.enablePerDiem(policyID, isEnabled, perDiemCustomUnit?.customUnitID);
+ },
+ });
+ }
+
const manageItems: Item[] = [
{
icon: Illustrations.Workflows,
diff --git a/src/pages/workspace/categories/CategoryRequireReceiptsOverPage.tsx b/src/pages/workspace/categories/CategoryRequireReceiptsOverPage.tsx
index 7d526ede42b5..d24cda38c461 100644
--- a/src/pages/workspace/categories/CategoryRequireReceiptsOverPage.tsx
+++ b/src/pages/workspace/categories/CategoryRequireReceiptsOverPage.tsx
@@ -43,8 +43,8 @@ function CategoryRequireReceiptsOverPage({
const policy = usePolicy(policyID);
const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`);
- const isAlwaysSelected = policyCategories?.[categoryName]?.maxExpenseAmountNoReceipt === 0;
- const isNeverSelected = policyCategories?.[categoryName]?.maxExpenseAmountNoReceipt === CONST.DISABLED_MAX_EXPENSE_VALUE;
+ const isAlwaysSelected = policyCategories?.[categoryName]?.maxAmountNoReceipt === 0;
+ const isNeverSelected = policyCategories?.[categoryName]?.maxAmountNoReceipt === CONST.DISABLED_MAX_EXPENSE_VALUE;
const maxExpenseAmountToDisplay = policy?.maxExpenseAmount === CONST.DISABLED_MAX_EXPENSE_VALUE ? 0 : policy?.maxExpenseAmount;
const requireReceiptsOverListData = [
diff --git a/src/pages/workspace/categories/CategorySettingsPage.tsx b/src/pages/workspace/categories/CategorySettingsPage.tsx
index 499a47c01abe..9bc7bf615d96 100644
--- a/src/pages/workspace/categories/CategorySettingsPage.tsx
+++ b/src/pages/workspace/categories/CategorySettingsPage.tsx
@@ -102,8 +102,8 @@ function CategorySettingsPage({
if (!policy) {
return '';
}
- return CategoryUtils.formatRequireReceiptsOverText(translate, policy, policyCategory?.maxExpenseAmountNoReceipt);
- }, [policy, policyCategory?.maxExpenseAmountNoReceipt, translate]);
+ return CategoryUtils.formatRequireReceiptsOverText(translate, policy, policyCategory?.maxAmountNoReceipt);
+ }, [policy, policyCategory?.maxAmountNoReceipt, translate]);
if (!policyCategory) {
return ;
@@ -308,7 +308,7 @@ function CategorySettingsPage({
shouldShowRightIcon
/>
-
+
= CONST.STANDARD_LIST_ITEM_LIMIT;
+
+ const searchedListOptions = useMemo(() => {
+ return exportMenuItem?.data.filter((option) => option.value.toLowerCase().includes(searchText));
+ }, [exportMenuItem?.data, searchText]);
const listEmptyContent = useMemo(
() => (
@@ -87,14 +93,18 @@ function WorkspaceCompanyCardAccountSelectCardPage({route}: WorkspaceCompanyCard
}
featureName={CONST.POLICY.MORE_FEATURES.ARE_COMPANY_CARDS_ENABLED}
displayName={WorkspaceCompanyCardAccountSelectCardPage.displayName}
- sections={[{data: exportMenuItem?.data ?? []}]}
+ sections={[{data: searchedListOptions ?? []}]}
listItem={RadioListItem}
+ textInputLabel={translate('common.search')}
+ textInputValue={searchText}
+ onChangeText={setSearchText}
onSelectRow={updateExportAccount}
initiallyFocusedOptionKey={exportMenuItem?.data?.find((mode) => mode.isSelected)?.keyForList}
onBackButtonPress={() => Navigation.goBack(ROUTES.WORKSPACE_COMPANY_CARD_DETAILS.getRoute(policyID, cardID, bank))}
headerTitleAlreadyTranslated={exportMenuItem?.description}
listEmptyContent={listEmptyContent}
connectionName={connectedIntegration}
+ shouldShowTextInput={shouldShowTextInput}
/>
);
}
diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardDetailsPage.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardDetailsPage.tsx
index c5fc6e5e6eea..b7f27baa42d4 100644
--- a/src/pages/workspace/companyCards/WorkspaceCompanyCardDetailsPage.tsx
+++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardDetailsPage.tsx
@@ -14,6 +14,7 @@ import OfflineWithFeedback from '@components/OfflineWithFeedback';
import ScreenWrapper from '@components/ScreenWrapper';
import ScrollView from '@components/ScrollView';
import useLocalize from '@hooks/useLocalize';
+import useNetwork from '@hooks/useNetwork';
import usePolicy from '@hooks/usePolicy';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -48,6 +49,7 @@ function WorkspaceCompanyCardDetailsPage({route}: WorkspaceCompanyCardDetailsPag
const {translate} = useLocalize();
const styles = useThemeStyles();
const theme = useTheme();
+ const {isOffline} = useNetwork();
const accountingIntegrations = Object.values(CONST.POLICY.CONNECTIONS.NAME);
const connectedIntegration = getConnectedIntegration(policy, accountingIntegrations) ?? connectionSyncProgress?.connectionName;
@@ -62,7 +64,7 @@ function WorkspaceCompanyCardDetailsPage({route}: WorkspaceCompanyCardDetailsPag
const unassignCard = () => {
setIsUnassignModalVisible(false);
- CompanyCards.unassignWorkspaceCompanyCard(workspaceAccountID, cardID, bank);
+ CompanyCards.unassignWorkspaceCompanyCard(workspaceAccountID, bank, card);
Navigation.goBack();
};
@@ -168,6 +170,7 @@ function WorkspaceCompanyCardDetailsPage({route}: WorkspaceCompanyCardDetailsPag
>
- );
- }
-
if (sortedCards.length === 0) {
return ;
}
diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsListHeaderButtons.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsListHeaderButtons.tsx
index 1d5f2bd6dde5..9c09abd5646f 100644
--- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsListHeaderButtons.tsx
+++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsListHeaderButtons.tsx
@@ -41,7 +41,7 @@ function WorkspaceCompanyCardsListHeaderButtons({policyID, selectedFeed}: Worksp
const formattedFeedName = translate('workspace.companyCards.feedName', {feedName});
const isCustomFeed =
CONST.COMPANY_CARD.FEED_BANK_NAME.MASTER_CARD === selectedFeed || CONST.COMPANY_CARD.FEED_BANK_NAME.VISA === selectedFeed || CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX === selectedFeed;
- const currentFeedData = cardFeeds?.settings?.companyCards?.[selectedFeed] ?? {pending: true, errors: {}};
+ const currentFeedData = cardFeeds?.settings?.companyCards?.[selectedFeed] ?? cardFeeds?.settings?.oAuthAccountDetails?.[selectedFeed] ?? {pending: true, errors: {}};
return (
Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS_ASSIGN_CARD.getRoute(policyID, selectedFeed))}
icon={Expensicons.Plus}
diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx
index 22a03f7db07f..dcd30a24212e 100644
--- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx
+++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx
@@ -37,8 +37,8 @@ function WorkspaceCompanyCardPage({route}: WorkspaceCompanyCardPageProps) {
const isLoading = !cardFeeds || !!(cardFeeds.isLoading && !cardFeeds.settings);
const companyCards = CardUtils.removeExpensifyCardFromCompanyCards(cardFeeds?.settings?.companyCards);
- const selectedCompanyCard = companyCards[selectedFeed ?? ''] ?? null;
- const isNoFeed = isEmptyObject(companyCards) && !selectedCompanyCard;
+ const selectedCompanyCard = companyCards[selectedFeed ?? ''] ?? cardFeeds?.settings?.oAuthAccountDetails?.[selectedFeed ?? ''] ?? null;
+ const isNoFeed = isEmptyObject(companyCards) && isEmptyObject(cardFeeds?.settings?.oAuthAccountDetails) && !selectedCompanyCard;
const isPending = !!selectedCompanyCard?.pending;
const isFeedAdded = !isPending && !isNoFeed;
diff --git a/src/pages/workspace/companyCards/addNew/BankConnection/index.native.tsx b/src/pages/workspace/companyCards/addNew/BankConnection/index.native.tsx
index b08d73f8d83e..b465704a7995 100644
--- a/src/pages/workspace/companyCards/addNew/BankConnection/index.native.tsx
+++ b/src/pages/workspace/companyCards/addNew/BankConnection/index.native.tsx
@@ -8,10 +8,15 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton';
import Modal from '@components/Modal';
import useLocalize from '@hooks/useLocalize';
import getUAForWebView from '@libs/getUAForWebView';
+import Navigation from '@libs/Navigation/Navigation';
+import * as PolicyUtils from '@libs/PolicyUtils';
+import * as Card from '@userActions/Card';
import * as CompanyCards from '@userActions/CompanyCards';
import getCompanyCardBankConnection from '@userActions/getCompanyCardBankConnection';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
type BankConnectionStepProps = {
policyID?: string;
@@ -26,6 +31,11 @@ function BankConnection({policyID}: BankConnectionStepProps) {
const [addNewCard] = useOnyx(ONYXKEYS.ADD_NEW_COMPANY_CARD);
const bankName: ValueOf | undefined = addNewCard?.data?.selectedBank;
const url = getCompanyCardBankConnection(policyID, bankName);
+ const workspaceAccountID = PolicyUtils.getWorkspaceAccountID(policyID ?? '-1');
+ const [cardFeeds] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`);
+ const bankKey = Object.keys(CONST.COMPANY_CARDS.BANKS).find((value) => CONST.COMPANY_CARDS.BANKS?.[value as keyof typeof CONST.COMPANY_CARDS.BANKS] === bankName);
+ const feedName = bankKey && bankKey !== CONST.COMPANY_CARDS.BANKS.OTHER ? CONST.COMPANY_CARD.FEED_BANK_NAME?.[bankKey as keyof typeof CONST.COMPANY_CARD.FEED_BANK_NAME] : undefined;
+ const connectedBank = feedName ? cardFeeds?.settings?.oAuthAccountDetails?.[feedName] : undefined;
const renderLoading = () => ;
@@ -46,6 +56,16 @@ function BankConnection({policyID}: BankConnectionStepProps) {
setWebViewOpen(true);
}, []);
+ useEffect(() => {
+ if (!url) {
+ return;
+ }
+ if (feedName && connectedBank && !isEmptyObject(connectedBank)) {
+ Card.updateSelectedFeed(feedName, policyID ?? '-1');
+ Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(policyID ?? '-1'));
+ }
+ }, [connectedBank, feedName, policyID, url]);
+
return (
| undefined = addNewCard?.data?.selectedBank;
+ const workspaceAccountID = PolicyUtils.getWorkspaceAccountID(policyID ?? '-1');
+ const [cardFeeds] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`);
+ const bankKey = Object.keys(CONST.COMPANY_CARDS.BANKS).find((value) => CONST.COMPANY_CARDS.BANKS?.[value as keyof typeof CONST.COMPANY_CARDS.BANKS] === bankName);
+ const feedName = bankKey && bankKey !== CONST.COMPANY_CARDS.BANKS.OTHER ? CONST.COMPANY_CARD.FEED_BANK_NAME?.[bankKey as keyof typeof CONST.COMPANY_CARD.FEED_BANK_NAME] : undefined;
+ const connectedBank = feedName ? cardFeeds?.settings?.oAuthAccountDetails?.[feedName] : undefined;
+
const currentUrl = getCurrentUrl();
const isBankConnectionCompleteRoute = currentUrl.includes(ROUTES.BANK_CONNECTION_COMPLETE);
const url = getCompanyCardBankConnection(policyID, bankName);
@@ -63,12 +73,18 @@ function BankConnection({policyID}: BankConnectionStepProps) {
if (!url) {
return;
}
+ if (feedName && connectedBank && !isEmptyObject(connectedBank)) {
+ customWindow?.close();
+ Card.updateSelectedFeed(feedName, policyID ?? '-1');
+ Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(policyID ?? '-1'));
+ return;
+ }
if (isBankConnectionCompleteRoute) {
customWindow?.close();
return;
}
customWindow = openBankConnection(url);
- }, [isBankConnectionCompleteRoute, url]);
+ }, [connectedBank, feedName, isBankConnectionCompleteRoute, policyID, url]);
return (
diff --git a/src/pages/workspace/companyCards/assignCard/AssignCardFeedPage.tsx b/src/pages/workspace/companyCards/assignCard/AssignCardFeedPage.tsx
index ad3589651b3a..20c51b882054 100644
--- a/src/pages/workspace/companyCards/assignCard/AssignCardFeedPage.tsx
+++ b/src/pages/workspace/companyCards/assignCard/AssignCardFeedPage.tsx
@@ -9,6 +9,7 @@ import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type SCREENS from '@src/SCREENS';
import AssigneeStep from './AssigneeStep';
+import CardNameStep from './CardNameStep';
import CardSelectionStep from './CardSelectionStep';
import ConfirmationStep from './ConfirmationStep';
import TransactionStartDateStep from './TransactionStartDateStep';
@@ -39,6 +40,8 @@ function AssignCardFeedPage({route, policy}: AssignCardFeedPageProps) {
);
case CONST.COMPANY_CARD.STEP.TRANSACTION_START_DATE:
return ;
+ case CONST.COMPANY_CARD.STEP.CARD_NAME:
+ return ;
case CONST.COMPANY_CARD.STEP.CONFIRMATION:
return (
) => {
+ CompanyCards.setAssignCardStepAndData({
+ currentStep: CONST.COMPANY_CARD.STEP.CONFIRMATION,
+ data: {
+ cardName: values.name,
+ },
+ isEditing: false,
+ });
+ };
+
+ const validate = (values: FormOnyxValues): FormInputErrors => {
+ const errors = ValidationUtils.getFieldRequiredErrors(values, [INPUT_IDS.NAME]);
+ const length = values.name.length;
+
+ if (length > CONST.STANDARD_LENGTH_LIMIT) {
+ ErrorUtils.addErrorMessage(errors, INPUT_IDS.NAME, translate('common.error.characterLimitExceedCounter', {length, limit: CONST.STANDARD_LENGTH_LIMIT}));
+ }
+
+ return errors;
+ };
+
+ return (
+
+
+ CompanyCards.setAssignCardStepAndData({currentStep: CONST.COMPANY_CARD.STEP.CONFIRMATION, isEditing: false})}
+ />
+ {translate('workspace.moreFeatures.companyCards.giveItNameInstruction')}
+
+
+
+
+
+ );
+}
+
+CardNameStep.displayName = 'CardNameStep';
+
+export default CardNameStep;
diff --git a/src/pages/workspace/companyCards/assignCard/CardSelectionStep.tsx b/src/pages/workspace/companyCards/assignCard/CardSelectionStep.tsx
index 84e7ea5723a7..9795adb3fa0d 100644
--- a/src/pages/workspace/companyCards/assignCard/CardSelectionStep.tsx
+++ b/src/pages/workspace/companyCards/assignCard/CardSelectionStep.tsx
@@ -88,14 +88,14 @@ function CardSelectionStep({feed, policyID}: CardSelectionStepProps) {
return;
}
- const cardName =
+ const cardNumber =
Object.entries(filteredCardList)
.find(([, encryptedCardNumber]) => encryptedCardNumber === cardSelected)
?.at(0) ?? '';
CompanyCards.setAssignCardStepAndData({
currentStep: isEditing ? CONST.COMPANY_CARD.STEP.CONFIRMATION : CONST.COMPANY_CARD.STEP.TRANSACTION_START_DATE,
- data: {encryptedCardNumber: cardSelected, cardName: accountCardList?.length > 0 ? cardSelected : cardName},
+ data: {encryptedCardNumber: cardSelected, cardNumber: accountCardList?.length > 0 ? cardSelected : cardNumber},
isEditing: false,
});
};
diff --git a/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx b/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx
index 01933f827362..6d8c00504998 100644
--- a/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx
+++ b/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx
@@ -75,7 +75,7 @@ function ConfirmationStep({policyID, backTo}: ConfirmationStepProps) {
/>
editStep(CONST.COMPANY_CARD.STEP.CARD)}
/>
@@ -85,6 +85,12 @@ function ConfirmationStep({policyID, backTo}: ConfirmationStepProps) {
shouldShowRightIcon
onPress={() => editStep(CONST.COMPANY_CARD.STEP.TRANSACTION_START_DATE)}
/>
+ editStep(CONST.COMPANY_CARD.STEP.CARD_NAME)}
+ />
{
- if (!customUnit) {
+ if (!customUnit || !customUnit.attributes) {
return;
}
const attributes = {...customUnit?.attributes, taxEnabled: isOn};
diff --git a/src/pages/workspace/expensifyCard/WorkspaceCardListHeader.tsx b/src/pages/workspace/expensifyCard/WorkspaceCardListHeader.tsx
index fb818b5fb383..a0231f20641b 100644
--- a/src/pages/workspace/expensifyCard/WorkspaceCardListHeader.tsx
+++ b/src/pages/workspace/expensifyCard/WorkspaceCardListHeader.tsx
@@ -54,7 +54,17 @@ function WorkspaceCardListHeader({policyID}: WorkspaceCardListHeaderProps) {
{translate('workspace.expensifyCard.name')}
-
+ {!shouldUseNarrowLayout && (
+
+
+ {translate('common.type')}
+
+
+ )}
+
PersonalDetailsUtils.getDisplayNameOrDefault(cardholder), [cardholder]);
-
+ const cardType = isVirtual ? translate('workspace.expensifyCard.virtual') : translate('workspace.expensifyCard.physical');
return (
@@ -58,16 +62,49 @@ function WorkspaceCardListRow({limit, cardholder, lastFourPAN, name, currency}:
-
+ {!shouldUseNarrowLayout && (
+
+
+ {cardType}
+
+
+ )}
+
{lastFourPAN}
-
-
+
+
+ {CurrencyUtils.convertToDisplayString(limit, currency)}
+
+ {shouldUseNarrowLayout && (
+
+
+ {cardType}
+
+
+ )}
);
diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx
index 8709b2864fda..dede1ad95ec0 100644
--- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx
+++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx
@@ -100,6 +100,7 @@ function WorkspaceExpensifyCardListPage({route, cardsList}: WorkspaceExpensifyCa
limit={item.nameValuePairs?.unapprovedExpenseLimit ?? 0}
name={item.nameValuePairs?.cardTitle ?? ''}
currency={policyCurrency}
+ isVirtual={!!item.nameValuePairs?.isVirtual}
/>
diff --git a/src/pages/workspace/invoices/WorkspaceInvoicingDetailsWebsite.tsx b/src/pages/workspace/invoices/WorkspaceInvoicingDetailsWebsite.tsx
index e9c5d8e35187..aead9b03f49a 100644
--- a/src/pages/workspace/invoices/WorkspaceInvoicingDetailsWebsite.tsx
+++ b/src/pages/workspace/invoices/WorkspaceInvoicingDetailsWebsite.tsx
@@ -1,6 +1,6 @@
import type {StackScreenProps} from '@react-navigation/stack';
import {Str} from 'expensify-common';
-import React, {useMemo} from 'react';
+import React from 'react';
import {useOnyx} from 'react-native-onyx';
import FormProvider from '@components/Form/FormProvider';
import InputWrapper from '@components/Form/InputWrapper';
@@ -11,7 +11,6 @@ import TextInput from '@components/TextInput';
import useAutoFocusInput from '@hooks/useAutoFocusInput';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
-import {getDefaultCompanyWebsite} from '@libs/BankAccountUtils';
import * as Url from '@libs/Url';
import * as ValidationUtils from '@libs/ValidationUtils';
import Navigation from '@navigation/Navigation';
@@ -32,9 +31,6 @@ function WorkspaceInvoicingDetailsWebsite({route}: WorkspaceInvoicingDetailsWebs
const {inputCallbackRef} = useAutoFocusInput();
const styles = useThemeStyles();
const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`);
- const [session] = useOnyx(ONYXKEYS.SESSION);
- const [user] = useOnyx(ONYXKEYS.USER);
- const defaultWebsiteExample = useMemo(() => getDefaultCompanyWebsite(session, user), [session, user]);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const submit = (values: FormOnyxValues) => {
@@ -49,10 +45,11 @@ function WorkspaceInvoicingDetailsWebsite({route}: WorkspaceInvoicingDetailsWebs
const errors = ValidationUtils.getFieldRequiredErrors(values, [INPUT_IDS.COMPANY_WEBSITE]);
if (values.companyWebsite) {
- if (!ValidationUtils.isValidWebsite(Str.sanitizeURL(values.companyWebsite, CONST.COMPANY_WEBSITE_DEFAULT_SCHEME))) {
+ const companyWebsite = Str.sanitizeURL(values.companyWebsite, CONST.COMPANY_WEBSITE_DEFAULT_SCHEME);
+ if (!ValidationUtils.isValidWebsite(companyWebsite)) {
errors.companyWebsite = translate('bankAccount.error.website');
} else {
- const domain = Url.extractUrlDomain(values.companyWebsite);
+ const domain = Url.extractUrlDomain(companyWebsite);
if (!domain || !Str.isValidDomainName(domain)) {
errors.companyWebsite = translate('iou.invalidDomainError');
@@ -91,7 +88,7 @@ function WorkspaceInvoicingDetailsWebsite({route}: WorkspaceInvoicingDetailsWebs
label={translate('workspace.invoices.companyWebsite')}
accessibilityLabel={translate('workspace.invoices.companyWebsite')}
role={CONST.ROLE.PRESENTATION}
- defaultValue={policy?.invoice?.companyWebsite ?? defaultWebsiteExample}
+ defaultValue={policy?.invoice?.companyWebsite}
ref={inputCallbackRef}
inputMode={CONST.INPUT_MODE.URL}
/>
diff --git a/src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx b/src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx
new file mode 100644
index 000000000000..9430cfd911b5
--- /dev/null
+++ b/src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx
@@ -0,0 +1,452 @@
+import {useFocusEffect, useIsFocused} from '@react-navigation/native';
+import type {StackScreenProps} from '@react-navigation/stack';
+import lodashSortBy from 'lodash/sortBy';
+import React, {useCallback, useEffect, useMemo, useState} from 'react';
+import {ActivityIndicator, View} from 'react-native';
+import Button from '@components/Button';
+import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu';
+import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types';
+import ConfirmModal from '@components/ConfirmModal';
+import EmptyStateComponent from '@components/EmptyStateComponent';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import * as Expensicons from '@components/Icon/Expensicons';
+import * as Illustrations from '@components/Icon/Illustrations';
+import LottieAnimations from '@components/LottieAnimations';
+import ScreenWrapper from '@components/ScreenWrapper';
+import TableListItem from '@components/SelectionList/TableListItem';
+import type {ListItem} from '@components/SelectionList/types';
+import SelectionListWithModal from '@components/SelectionListWithModal';
+import TableListItemSkeleton from '@components/Skeletons/TableRowSkeleton';
+import Text from '@components/Text';
+import TextLink from '@components/TextLink';
+import useLocalize from '@hooks/useLocalize';
+import useMobileSelectionMode from '@hooks/useMobileSelectionMode';
+import useNetwork from '@hooks/useNetwork';
+import usePolicy from '@hooks/usePolicy';
+import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useTheme from '@hooks/useTheme';
+import useThemeStyles from '@hooks/useThemeStyles';
+import useWindowDimensions from '@hooks/useWindowDimensions';
+import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode';
+import * as CurrencyUtils from '@libs/CurrencyUtils';
+import * as DeviceCapabilities from '@libs/DeviceCapabilities';
+import localeCompare from '@libs/LocaleCompare';
+import Navigation from '@libs/Navigation/Navigation';
+import type {FullScreenNavigatorParamList} from '@libs/Navigation/types';
+import {getPerDiemCustomUnit} from '@libs/PolicyUtils';
+import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper';
+import * as Link from '@userActions/Link';
+import * as Modal from '@userActions/Modal';
+import * as PerDiem from '@userActions/Policy/PerDiem';
+import CONST from '@src/CONST';
+// import ROUTES from '@src/ROUTES';
+import type SCREENS from '@src/SCREENS';
+import type {PendingAction} from '@src/types/onyx/OnyxCommon';
+import type {Rate} from '@src/types/onyx/Policy';
+import type DeepValueOf from '@src/types/utils/DeepValueOf';
+
+type PolicyOption = ListItem & {
+ /** subRateID is used as a key for identification of the entry */
+ subRateID: string;
+
+ /** rateID is used as a key for identification of the entry */
+ rateID: string;
+};
+
+type SubRateData = {
+ pendingAction?: PendingAction;
+ destination: string;
+ subRateName: string;
+ rate: number;
+ currency: string;
+ rateID: string;
+ subRateID: string;
+};
+
+function getSubRatesData(customUnitRates: Rate[]) {
+ const subRatesData: SubRateData[] = [];
+ for (const rate of customUnitRates) {
+ const subRates = rate.subRates;
+ if (subRates) {
+ for (const subRate of subRates) {
+ subRatesData.push({
+ pendingAction: rate.pendingAction,
+ destination: rate.name ?? '',
+ subRateName: subRate.name,
+ rate: subRate.rate,
+ currency: rate.currency ?? CONST.CURRENCY.USD,
+ rateID: rate.customUnitRateID ?? '',
+ subRateID: subRate.id,
+ });
+ }
+ }
+ }
+ return subRatesData;
+}
+
+function generateSingleSubRateData(customUnitRates: Rate[], rateID: string, subRateID: string) {
+ const selectedRate = customUnitRates.find((rate) => rate.customUnitRateID === rateID);
+ if (!selectedRate) {
+ return null;
+ }
+ const selectedSubRate = selectedRate.subRates?.find((subRate) => subRate.id === subRateID);
+ if (!selectedSubRate) {
+ return null;
+ }
+ return {
+ pendingAction: selectedRate.pendingAction,
+ destination: selectedRate.name ?? '',
+ subRateName: selectedSubRate.name,
+ rate: selectedSubRate.rate,
+ currency: selectedRate.currency ?? CONST.CURRENCY.USD,
+ rateID: selectedRate.customUnitRateID ?? '',
+ subRateID: selectedSubRate.id,
+ };
+}
+
+type WorkspacePerDiemPageProps = StackScreenProps;
+
+function WorkspacePerDiemPage({route}: WorkspacePerDiemPageProps) {
+ const {shouldUseNarrowLayout} = useResponsiveLayout();
+ const {windowWidth} = useWindowDimensions();
+ const styles = useThemeStyles();
+ const theme = useTheme();
+ const {translate} = useLocalize();
+ const [isOfflineModalVisible, setIsOfflineModalVisible] = useState(false);
+ const [selectedPerDiem, setSelectedPerDiem] = useState([]);
+ const [deletePerDiemConfirmModalVisible, setDeletePerDiemConfirmModalVisible] = useState(false);
+ const isFocused = useIsFocused();
+ const policyID = route.params.policyID ?? '-1';
+ const backTo = route.params?.backTo;
+ const policy = usePolicy(policyID);
+ const {selectionMode} = useMobileSelectionMode();
+
+ const customUnit = getPerDiemCustomUnit(policy);
+ const customUnitRates: Record = useMemo(() => customUnit?.rates ?? {}, [customUnit]);
+
+ const allRatesArray = Object.values(customUnitRates);
+
+ const allSubRates = getSubRatesData(allRatesArray);
+
+ // Filter out rates that will be deleted
+ const allSelectableSubRates = useMemo(() => allSubRates.filter((subRate) => subRate.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE), [allSubRates]);
+
+ const canSelectMultiple = shouldUseNarrowLayout ? selectionMode?.isEnabled : true;
+
+ const fetchPerDiem = useCallback(() => {
+ PerDiem.openPolicyPerDiemPage(policyID);
+ }, [policyID]);
+
+ const {isOffline} = useNetwork({onReconnect: fetchPerDiem});
+
+ useFocusEffect(
+ useCallback(() => {
+ fetchPerDiem();
+ }, [fetchPerDiem]),
+ );
+
+ useEffect(() => {
+ if (isFocused) {
+ return;
+ }
+ setSelectedPerDiem([]);
+ }, [isFocused]);
+
+ const subRatesList = useMemo(
+ () =>
+ (lodashSortBy(allSubRates, 'destination', localeCompare) as SubRateData[]).map((value) => {
+ const isDisabled = value.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE;
+ return {
+ text: value.destination,
+ subRateID: value.subRateID,
+ rateID: value.rateID,
+ keyForList: value.subRateID,
+ isSelected: selectedPerDiem.find((rate) => rate.subRateID === value.subRateID) !== undefined && canSelectMultiple,
+ isDisabled,
+ pendingAction: value.pendingAction,
+ rightElement: (
+ <>
+
+ {value.subRateName}
+
+
+
+ {CurrencyUtils.convertAmountToDisplayString(value.rate, value.currency)}
+
+
+ >
+ ),
+ };
+ }),
+ [allSubRates, selectedPerDiem, canSelectMultiple, styles.flex1, styles.alignItemsStart, styles.textSupporting, styles.label, styles.alignSelfEnd, styles.pl2],
+ );
+
+ const toggleSubRate = (subRate: PolicyOption) => {
+ if (selectedPerDiem.find((selectedSubRate) => selectedSubRate.subRateID === subRate.subRateID) !== undefined) {
+ setSelectedPerDiem((prev) => prev.filter((selectedSubRate) => selectedSubRate.subRateID !== subRate.subRateID));
+ } else {
+ const subRateData = generateSingleSubRateData(allRatesArray, subRate.rateID, subRate.subRateID);
+ if (!subRateData) {
+ return;
+ }
+ setSelectedPerDiem((prev) => [...prev, subRateData]);
+ }
+ };
+
+ const toggleAllSubRates = () => {
+ if (selectedPerDiem.length === allSelectableSubRates.length) {
+ setSelectedPerDiem([]);
+ } else {
+ setSelectedPerDiem([...allSelectableSubRates]);
+ }
+ };
+
+ const getCustomListHeader = () => (
+
+
+ {translate('workspace.perDiem.destination')}
+
+
+ {translate('workspace.perDiem.subrate')}
+
+
+ {translate('workspace.perDiem.amount')}
+
+
+ );
+
+ const openSettings = () => {
+ // TODO: Uncomment this when the import feature is ready
+ // Navigation.navigate(ROUTES.WORKSPACE_PER_DIEM_RATES_SETTINGS.getRoute(policyID));
+ };
+
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const openSubRateDetails = (rate: PolicyOption) => {
+ // TODO: Uncomment this when the import feature is ready
+ // Navigation.navigate(ROUTES.WORKSPACE_PER_DIEM_RATE_DETAILS.getRoute(policyID, rate.rateID, rate.subRateID));
+ };
+
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const dismissError = (item: PolicyOption) => {
+ // TODO: Implement this when the editing feature is ready
+ };
+
+ const handleDeletePerDiemRates = () => {
+ setSelectedPerDiem([]);
+ setDeletePerDiemConfirmModalVisible(false);
+ };
+
+ const getHeaderButtons = () => {
+ const options: Array>> = [];
+
+ if (shouldUseNarrowLayout ? canSelectMultiple : selectedPerDiem.length > 0) {
+ options.push({
+ icon: Expensicons.Trashcan,
+ text: translate('workspace.perDiem.deleteRates', {count: selectedPerDiem.length}),
+ value: CONST.POLICY.BULK_ACTION_TYPES.DELETE,
+ onSelected: () => setDeletePerDiemConfirmModalVisible(true),
+ });
+
+ return (
+ null}
+ shouldAlwaysShowDropdownMenu
+ pressOnEnter
+ buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM}
+ customText={translate('workspace.common.selected', {count: selectedPerDiem.length})}
+ options={options}
+ isSplitButton={false}
+ style={[shouldUseNarrowLayout && styles.flexGrow1, shouldUseNarrowLayout && styles.mb3]}
+ isDisabled={!selectedPerDiem.length}
+ />
+ );
+ }
+
+ return (
+
+
+
+ );
+ };
+
+ const isLoading = !isOffline && customUnit === undefined;
+
+ useEffect(() => {
+ if (selectionMode?.isEnabled) {
+ return;
+ }
+
+ setSelectedPerDiem([]);
+ }, [setSelectedPerDiem, selectionMode?.isEnabled]);
+
+ const hasVisibleSubRates = subRatesList.some((subRate) => subRate.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || isOffline);
+
+ const getHeaderText = () => (
+
+
+ {translate('workspace.perDiem.subtitle')}
+ Link.openExternalLink(CONST.DEEP_DIVE_PER_DIEM)}
+ >
+ {translate('workspace.common.learnMore')}
+
+
+
+ );
+
+ const threeDotsMenuItems = useMemo(() => {
+ const menuItems = [
+ {
+ icon: Expensicons.Table,
+ text: translate('spreadsheet.importSpreadsheet'),
+ // eslint-disable-next-line rulesdir/prefer-early-return
+ onSelected: () => {
+ if (isOffline) {
+ Modal.close(() => setIsOfflineModalVisible(true));
+ // eslint-disable-next-line no-useless-return
+ return;
+ }
+ // TODO: Uncomment this when the import feature is ready
+ // Navigation.navigate(ROUTES.WORKSPACE_PER_DIEM_IMPORT.getRoute(policyID));
+ },
+ },
+ {
+ icon: Expensicons.Download,
+ text: translate('spreadsheet.downloadCSV'),
+ // eslint-disable-next-line rulesdir/prefer-early-return
+ onSelected: () => {
+ if (isOffline) {
+ Modal.close(() => setIsOfflineModalVisible(true));
+ // eslint-disable-next-line no-useless-return
+ return;
+ }
+ // Category.downloadPerDiemCSV(policyID);
+ },
+ },
+ ];
+
+ return menuItems;
+ }, [translate, isOffline]);
+
+ const selectionModeHeader = selectionMode?.isEnabled && shouldUseNarrowLayout;
+
+ return (
+
+
+ {
+ if (selectionMode?.isEnabled) {
+ setSelectedPerDiem([]);
+ turnOffMobileSelectionMode();
+ return;
+ }
+ Navigation.goBack(backTo);
+ }}
+ shouldShowThreeDotsButton
+ threeDotsMenuItems={threeDotsMenuItems}
+ threeDotsAnchorPosition={styles.threeDotsPopoverOffsetNoCloseButton(windowWidth)}
+ >
+ {!shouldUseNarrowLayout && getHeaderButtons()}
+
+ setDeletePerDiemConfirmModalVisible(false)}
+ title={translate('workspace.perDiem.deletePerDiemRate')}
+ prompt={translate('workspace.perDiem.areYouSureDelete', {count: selectedPerDiem.length})}
+ confirmText={translate('common.delete')}
+ cancelText={translate('common.cancel')}
+ danger
+ />
+ {shouldUseNarrowLayout && {getHeaderButtons()} }
+ {(!shouldUseNarrowLayout || !hasVisibleSubRates || isLoading) && getHeaderText()}
+ {isLoading && (
+
+ )}
+
+ {!hasVisibleSubRates && !isLoading && (
+ {
+ if (isOffline) {
+ setIsOfflineModalVisible(true);
+ // eslint-disable-next-line no-useless-return
+ return;
+ }
+ // TODO: Uncomment this when the import feature is ready
+ // Navigation.navigate(ROUTES.WORKSPACE_PER_DIEM_IMPORT.getRoute(policyID));
+ },
+ success: true,
+ },
+ ]}
+ />
+ )}
+ {hasVisibleSubRates && !isLoading && (
+ item && toggleSubRate(item)}
+ sections={[{data: subRatesList, isDisabled: false}]}
+ onCheckboxPress={toggleSubRate}
+ onSelectRow={openSubRateDetails}
+ shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()}
+ onSelectAll={toggleAllSubRates}
+ ListItem={TableListItem}
+ onDismissError={dismissError}
+ customListHeader={getCustomListHeader()}
+ listHeaderWrapperStyle={[styles.ph9, styles.pv3, styles.pb5]}
+ listHeaderContent={shouldUseNarrowLayout ? getHeaderText() : null}
+ showScrollIndicator={false}
+ />
+ )}
+
+ setIsOfflineModalVisible(false)}
+ title={translate('common.youAppearToBeOffline')}
+ prompt={translate('common.thisFeatureRequiresInternet')}
+ confirmText={translate('common.buttonConfirm')}
+ shouldShowCancelButton={false}
+ />
+
+
+ );
+}
+
+WorkspacePerDiemPage.displayName = 'WorkspacePerDiemPage';
+
+export default WorkspacePerDiemPage;
diff --git a/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx b/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx
index 2bec17e0c580..d5ad6e7b4d1c 100644
--- a/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx
+++ b/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx
@@ -11,6 +11,7 @@ import Navigation from '@libs/Navigation/Navigation';
import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
import * as PolicyUtils from '@libs/PolicyUtils';
import NotFoundPage from '@pages/ErrorPage/NotFoundPage';
+import * as PerDiem from '@userActions/Policy/PerDiem';
import CONST from '@src/CONST';
import * as Policy from '@src/libs/actions/Policy/Policy';
import ROUTES from '@src/ROUTES';
@@ -32,6 +33,8 @@ function WorkspaceUpgradePage({route}: WorkspaceUpgradePageProps) {
const canPerformUpgrade = !!feature && !!policy && PolicyUtils.isPolicyAdmin(policy);
const isUpgraded = React.useMemo(() => PolicyUtils.isControlPolicy(policy), [policy]);
+ const perDiemCustomUnit = PolicyUtils.getPerDiemCustomUnit(policy);
+
const goBack = useCallback(() => {
if (!feature) {
return;
@@ -46,6 +49,7 @@ function WorkspaceUpgradePage({route}: WorkspaceUpgradePageProps) {
case CONST.UPGRADE_FEATURE_INTRO_MAPPING.reportFields.id:
case CONST.UPGRADE_FEATURE_INTRO_MAPPING.rules.id:
case CONST.UPGRADE_FEATURE_INTRO_MAPPING.companyCards.id:
+ case CONST.UPGRADE_FEATURE_INTRO_MAPPING.perDiem.id:
Navigation.dismissModal();
return Navigation.navigate(ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID));
default:
@@ -76,9 +80,12 @@ function WorkspaceUpgradePage({route}: WorkspaceUpgradePageProps) {
case CONST.UPGRADE_FEATURE_INTRO_MAPPING.companyCards.id:
Policy.enableCompanyCards(policyID, true, true);
break;
+ case CONST.UPGRADE_FEATURE_INTRO_MAPPING.perDiem.id:
+ PerDiem.enablePerDiem(policyID, true, perDiemCustomUnit?.customUnitID);
+ break;
default:
}
- }, [feature, policyID]);
+ }, [feature, perDiemCustomUnit?.customUnitID, policyID]);
useEffect(() => {
const unsubscribeListener = navigation.addListener('blur', () => {
diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts
index 070e78265ff2..02778b4ca351 100644
--- a/src/styles/utils/index.ts
+++ b/src/styles/utils/index.ts
@@ -490,14 +490,14 @@ function getWidthAndHeightStyle(width: number, height?: number): Pick {
+function getIconWidthAndHeightStyle(small: boolean, medium: boolean, large: boolean, width: number, height: number, isButtonIcon: boolean): Pick {
switch (true) {
case small:
- return {width: hasText ? variables.iconSizeExtraSmall : variables.iconSizeSmall, height: hasText ? variables.iconSizeExtraSmall : variables?.iconSizeSmall};
+ return {width: isButtonIcon ? variables.iconSizeExtraSmall : variables.iconSizeSmall, height: isButtonIcon ? variables.iconSizeExtraSmall : variables?.iconSizeSmall};
case medium:
- return {width: hasText ? variables.iconSizeSmall : variables.iconSizeNormal, height: hasText ? variables.iconSizeSmall : variables.iconSizeNormal};
+ return {width: isButtonIcon ? variables.iconSizeSmall : variables.iconSizeNormal, height: isButtonIcon ? variables.iconSizeSmall : variables.iconSizeNormal};
case large:
- return {width: hasText ? variables.iconSizeNormal : variables.iconSizeLarge, height: hasText ? variables.iconSizeNormal : variables.iconSizeLarge};
+ return {width: isButtonIcon ? variables.iconSizeNormal : variables.iconSizeLarge, height: isButtonIcon ? variables.iconSizeNormal : variables.iconSizeLarge};
default: {
return {width, height};
}
diff --git a/src/types/form/ReimbursementAccountForm.ts b/src/types/form/ReimbursementAccountForm.ts
index 1d480b993e6a..2554a28eef54 100644
--- a/src/types/form/ReimbursementAccountForm.ts
+++ b/src/types/form/ReimbursementAccountForm.ts
@@ -53,6 +53,22 @@ const INPUT_IDS = {
AMOUNT3: 'amount3',
ADDITIONAL_DATA: {
COUNTRY: 'country',
+ CORPAY: {
+ COMPANY_NAME: 'companyName',
+ COMPANY_STREET: 'companyStreet',
+ COMPANY_CITY: 'companyCity',
+ COMPANY_STATE: 'companyState',
+ COMPANY_ZIP_CODE: 'companyZipCode',
+ COMPANY_COUNTRY: 'companyCountry',
+ BUSINESS_CONTACT_NUMBER: 'businessContactNumber',
+ BUSINESS_CONFIRMATION_EMAIL: 'businessConfirmationEmail',
+ BUSINESS_REGISTRATION_INCORPORATION_NUMBER: 'businessRegistrationIncorporationNumber',
+ FORMATION_INCORPORATION_STATE: 'formationIncorporationState',
+ FORMATION_INCORPORATION_COUNTRY_CODE: 'formationIncorporationCountryCode',
+ BUSINESS_CATEGORY: 'natureOfBusiness',
+ APPLICANT_TYPE_ID: 'applicantTypeID',
+ ANNUAL_VOLUME: 'annualVolume',
+ },
},
} as const;
@@ -129,6 +145,48 @@ type ReimbursementAccountProps = {
type NonUSDReimbursementAccountAdditionalProps = {
/** Country of the reimbursement account */
[INPUT_IDS.ADDITIONAL_DATA.COUNTRY]: Country | '';
+
+ /** Company name */
+ [INPUT_IDS.ADDITIONAL_DATA.CORPAY.COMPANY_NAME]: string;
+
+ /** Company street */
+ [INPUT_IDS.ADDITIONAL_DATA.CORPAY.COMPANY_STREET]: string;
+
+ /** Company city */
+ [INPUT_IDS.ADDITIONAL_DATA.CORPAY.COMPANY_CITY]: string;
+
+ /** Company state */
+ [INPUT_IDS.ADDITIONAL_DATA.CORPAY.COMPANY_STATE]: string;
+
+ /** Company zip code */
+ [INPUT_IDS.ADDITIONAL_DATA.CORPAY.COMPANY_ZIP_CODE]: string;
+
+ /** Company country */
+ [INPUT_IDS.ADDITIONAL_DATA.CORPAY.COMPANY_COUNTRY]: Country | '';
+
+ /** Company contact number */
+ [INPUT_IDS.ADDITIONAL_DATA.CORPAY.BUSINESS_CONTACT_NUMBER]: string;
+
+ /** Company email */
+ [INPUT_IDS.ADDITIONAL_DATA.CORPAY.BUSINESS_CONFIRMATION_EMAIL]: string;
+
+ /** Company registration number */
+ [INPUT_IDS.ADDITIONAL_DATA.CORPAY.BUSINESS_REGISTRATION_INCORPORATION_NUMBER]: string;
+
+ /** Company incorporation country */
+ [INPUT_IDS.ADDITIONAL_DATA.CORPAY.FORMATION_INCORPORATION_COUNTRY_CODE]: string;
+
+ /** Company incorporation state */
+ [INPUT_IDS.ADDITIONAL_DATA.CORPAY.FORMATION_INCORPORATION_STATE]: string;
+
+ /** Company business category */
+ [INPUT_IDS.ADDITIONAL_DATA.CORPAY.BUSINESS_CATEGORY]: string;
+
+ /** Company applicant type ID */
+ [INPUT_IDS.ADDITIONAL_DATA.CORPAY.APPLICANT_TYPE_ID]: string;
+
+ /** Company annual volume */
+ [INPUT_IDS.ADDITIONAL_DATA.CORPAY.ANNUAL_VOLUME]: string;
};
type ReimbursementAccountForm = ReimbursementAccountFormExtraProps &
diff --git a/src/types/onyx/AssignCard.ts b/src/types/onyx/AssignCard.ts
index 690900fe5f74..3e69de72541f 100644
--- a/src/types/onyx/AssignCard.ts
+++ b/src/types/onyx/AssignCard.ts
@@ -9,9 +9,12 @@ type AssignCardData = {
/** The email address of the assignee */
email: string;
- /** Number of the selected card */
+ /** Encrypted number of the selected card */
encryptedCardNumber: string;
+ /** Number of the selected card */
+ cardNumber: string;
+
/** The name of the feed */
bankName: string;
diff --git a/src/types/onyx/CardFeeds.ts b/src/types/onyx/CardFeeds.ts
index ce06ed076a15..8ffb1258689d 100644
--- a/src/types/onyx/CardFeeds.ts
+++ b/src/types/onyx/CardFeeds.ts
@@ -61,6 +61,12 @@ type CardFeeds = {
/** Expiration number */
expiration: number;
+
+ /** Whether any actions are pending */
+ pending?: boolean;
+
+ /** Broken connection errors */
+ errors?: OnyxCommon.Errors;
}
>;
};
diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts
index ecc5bd1f6606..1057e21d394f 100644
--- a/src/types/onyx/Policy.ts
+++ b/src/types/onyx/Policy.ts
@@ -17,22 +17,34 @@ type TaxRateAttributes = {
taxRateExternalID?: string;
};
-/** Model of policy distance rate */
+/** Model of policy subrate */
+type Subrate = {
+ /** Generated ID to identify the subrate */
+ id: string;
+
+ /** Name of the subrate */
+ name: string;
+
+ /** Amount to be reimbursed per unit */
+ rate: number;
+};
+
+/** Model of policy rate */
type Rate = OnyxCommon.OnyxValueWithOfflineFeedback<
{
- /** Name of the distance rate */
+ /** Name of the rate */
name?: string;
- /** Amount to be reimbursed per distance unit travelled */
+ /** Amount to be reimbursed per unit */
rate?: number;
- /** Currency used to pay the distance rate */
+ /** Currency used to pay the rate */
currency?: string;
- /** Generated ID to identify the distance rate */
+ /** Generated ID to identify the rate */
customUnitRateID?: string;
- /** Whether this distance rate is currently enabled */
+ /** Whether this rate is currently enabled */
enabled?: boolean;
/** Error messages to show in UI */
@@ -43,6 +55,9 @@ type Rate = OnyxCommon.OnyxValueWithOfflineFeedback<
/** Tax rate attributes of the policy */
attributes?: TaxRateAttributes;
+
+ /** Subrates of the given rate */
+ subRates?: Subrate[];
},
keyof TaxRateAttributes
>;
@@ -66,7 +81,7 @@ type CustomUnit = OnyxCommon.OnyxValueWithOfflineFeedback<
customUnitID: string;
/** Contains custom attributes like unit, for this custom unit */
- attributes: Attributes;
+ attributes?: Attributes;
/** Distance rates using this custom unit */
rates: Record;
@@ -1755,6 +1770,9 @@ type Policy = OnyxCommon.OnyxValueWithOfflineFeedback<
/** Whether the Distance Rates feature is enabled */
areDistanceRatesEnabled?: boolean;
+ /** Whether the Per diem rates feature is enabled */
+ arePerDiemRatesEnabled?: boolean;
+
/** Whether the Expensify Card feature is enabled */
areExpensifyCardsEnabled?: boolean;
diff --git a/src/types/onyx/PolicyCategory.ts b/src/types/onyx/PolicyCategory.ts
index ed2638f95491..90aa92ca9367 100644
--- a/src/types/onyx/PolicyCategory.ts
+++ b/src/types/onyx/PolicyCategory.ts
@@ -47,7 +47,7 @@ type PolicyCategory = OnyxCommon.OnyxValueWithOfflineFeedback<{
expenseLimitType?: PolicyCategoryExpenseLimitType;
/** Max expense amount with no receipt violation */
- maxExpenseAmountNoReceipt?: number | null;
+ maxAmountNoReceipt?: number | null;
}>;
/** Record of policy categories, indexed by their name */
diff --git a/src/types/onyx/ReimbursementAccount.ts b/src/types/onyx/ReimbursementAccount.ts
index ad348c5ad390..383820fc1ffe 100644
--- a/src/types/onyx/ReimbursementAccount.ts
+++ b/src/types/onyx/ReimbursementAccount.ts
@@ -12,10 +12,58 @@ type BankAccountStep = ValueOf;
/** Substeps to setup a reimbursement bank account */
type BankAccountSubStep = ValueOf;
+/** Modal of Corpay data */
+type Corpay = {
+ /** Company name */
+ [INPUT_IDS.ADDITIONAL_DATA.CORPAY.COMPANY_NAME]: string;
+
+ /** Company street */
+ [INPUT_IDS.ADDITIONAL_DATA.CORPAY.COMPANY_STREET]: string;
+
+ /** Company city */
+ [INPUT_IDS.ADDITIONAL_DATA.CORPAY.COMPANY_CITY]: string;
+
+ /** Company state */
+ [INPUT_IDS.ADDITIONAL_DATA.CORPAY.COMPANY_STATE]: string;
+
+ /** Company zip code */
+ [INPUT_IDS.ADDITIONAL_DATA.CORPAY.COMPANY_ZIP_CODE]: string;
+
+ /** Company country */
+ [INPUT_IDS.ADDITIONAL_DATA.CORPAY.COMPANY_COUNTRY]: Country | '';
+
+ /** Company contact number */
+ [INPUT_IDS.ADDITIONAL_DATA.CORPAY.BUSINESS_CONTACT_NUMBER]: string;
+
+ /** Company email */
+ [INPUT_IDS.ADDITIONAL_DATA.CORPAY.BUSINESS_CONFIRMATION_EMAIL]: string;
+
+ /** Company registration number */
+ [INPUT_IDS.ADDITIONAL_DATA.CORPAY.BUSINESS_REGISTRATION_INCORPORATION_NUMBER]: string;
+
+ /** Company incorporation country */
+ [INPUT_IDS.ADDITIONAL_DATA.CORPAY.FORMATION_INCORPORATION_COUNTRY_CODE]: string;
+
+ /** Company incorporation state */
+ [INPUT_IDS.ADDITIONAL_DATA.CORPAY.FORMATION_INCORPORATION_STATE]: string;
+
+ /** Company business category */
+ [INPUT_IDS.ADDITIONAL_DATA.CORPAY.BUSINESS_CATEGORY]: string;
+
+ /** Company applicant type ID */
+ [INPUT_IDS.ADDITIONAL_DATA.CORPAY.APPLICANT_TYPE_ID]: string;
+
+ /** Company annual volume */
+ [INPUT_IDS.ADDITIONAL_DATA.CORPAY.ANNUAL_VOLUME]: string;
+};
+
/** Additional data where details of the non-USD reimbursements account are stored */
type AdditionalData = {
/** Country of the reimbursement account */
[INPUT_IDS.ADDITIONAL_DATA.COUNTRY]: Country | '';
+
+ /** Details required by Corpay */
+ corpay: Corpay;
};
/** Model of ACH data */
diff --git a/tests/perf-test/SearchRouter.perf-test.tsx b/tests/perf-test/SearchRouter.perf-test.tsx
index 4154e80ab6b8..8ae16c3b8a1c 100644
--- a/tests/perf-test/SearchRouter.perf-test.tsx
+++ b/tests/perf-test/SearchRouter.perf-test.tsx
@@ -143,7 +143,7 @@ function SearchRouterInputWrapper() {
function SearchRouterWrapperWithCachedOptions() {
return (
- ({options: mockedOptions, initializeOptions: () => {}, areOptionsInitialized: true}), [])}>
+ ({options: mockedOptions, initializeOptions: () => {}, resetOptions: () => {}, areOptionsInitialized: true}), [])}>
diff --git a/tests/unit/searchCountryOptionsTest.ts b/tests/unit/searchCountryOptionsTest.ts
index 40481e929d7f..a8a3d4e49410 100644
--- a/tests/unit/searchCountryOptionsTest.ts
+++ b/tests/unit/searchCountryOptionsTest.ts
@@ -1,4 +1,4 @@
-import searchCountryOptions from '@libs/searchCountryOptions';
+import searchOptions from '@libs/searchOptions';
describe('searchCountryOptions', () => {
test('when the search term is a country code, the country with that code should be prioritized', () => {
@@ -49,7 +49,7 @@ describe('searchCountryOptions', () => {
searchValue: 'auaustralia',
},
];
- const actual = searchCountryOptions(searchValue, countriesData);
+ const actual = searchOptions(searchValue, countriesData);
expect(actual).toEqual(expected);
});
test('when the search term contains diacritics the country names that exactly match should be prioritized', () => {
@@ -93,7 +93,7 @@ describe('searchCountryOptions', () => {
searchValue: 'alalbania',
},
];
- const actual = searchCountryOptions(searchValue, countriesData);
+ const actual = searchOptions(searchValue, countriesData);
expect(actual).toEqual(expected);
});
test('when the search term contains diacritics the country names that exactly match should be prioritized, test case #2', () => {
@@ -144,7 +144,7 @@ describe('searchCountryOptions', () => {
searchValue: 'usunitedstates',
},
];
- const actual = searchCountryOptions(searchValue, countriesData);
+ const actual = searchOptions(searchValue, countriesData);
expect(actual).toEqual(expected);
});
test('when the search term contains no diacritics, countries with diacritics should still be searched by their sanitized names', () => {
@@ -188,7 +188,7 @@ describe('searchCountryOptions', () => {
searchValue: 'axalandislands',
},
];
- const actual = searchCountryOptions(searchValue, countriesData);
+ const actual = searchOptions(searchValue, countriesData);
expect(actual).toEqual(expected);
});
test('when a search term exactly matches the beginning of a countries name, that country should be prioritized', () => {
@@ -239,7 +239,7 @@ describe('searchCountryOptions', () => {
searchValue: 'agantiguaandbarbuda',
},
];
- const actual = searchCountryOptions(searchValue, countriesData);
+ const actual = searchOptions(searchValue, countriesData);
expect(actual).toEqual(expected);
});
test('when the search term is empty, all countries should be returned', () => {
@@ -275,7 +275,7 @@ describe('searchCountryOptions', () => {
},
];
const expected = countriesData;
- const actual = searchCountryOptions(searchValue, countriesData);
+ const actual = searchOptions(searchValue, countriesData);
expect(actual).toEqual(expected);
});
});