diff --git a/.env.example b/.env.example
index c4adc4f98b65..2bdda890b2ef 100644
--- a/.env.example
+++ b/.env.example
@@ -14,6 +14,7 @@ ONYX_METRICS=false
USE_THIRD_PARTY_SCRIPTS=false
EXPENSIFY_ACCOUNT_ID_ACCOUNTING=-1
+EXPENSIFY_ACCOUNT_ID_ACCOUNTS_PAYABLE=-1
EXPENSIFY_ACCOUNT_ID_ADMIN=-1
EXPENSIFY_ACCOUNT_ID_BILLS=-1
EXPENSIFY_ACCOUNT_ID_CHRONOS=-1
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 069b3fafba09..b672c601e341 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 1009006400
- versionName "9.0.64-0"
+ versionCode 1009006404
+ versionName "9.0.64-4"
// 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/contributingGuides/PERFORMANCE_METRICS.md b/contributingGuides/PERFORMANCE_METRICS.md
index ecebbaae4e0e..9e942f21d918 100644
--- a/contributingGuides/PERFORMANCE_METRICS.md
+++ b/contributingGuides/PERFORMANCE_METRICS.md
@@ -14,7 +14,7 @@ Project is using Firebase for tracking these metrics. However, not all of them a
| `js_loaded` | ✅ | The time it takes for the JavaScript bundle to load.
**Platforms:** Android, iOS | **Android:** Starts in the `onCreate` method.
**iOS:** Starts in the AppDelegate's `didFinishLaunchingWithOptions` method. | Stops at the first render of the app via native module on the JS side. |
| `_app_in_foreground` | ✅ | The time when the app is running in the foreground and available to the user.
**Platforms:** Android, iOS | **Android:** Starts when the first activity to reach the foreground has its `onResume()` method called.
**iOS:** Starts when the application receives the `UIApplicationDidBecomeActiveNotification` notification. | **Android:** Stops when the last activity to leave the foreground has its `onStop()` method called.
**iOS:** Stops when it receives the `UIApplicationWillResignActiveNotification` notification. |
| `_app_in_background` | ✅ | Time when the app is running in the background.
**Platforms:** Android, iOS | **Android:** Starts when the last activity to leave the foreground has its `onStop()` method called.
**iOS:** Starts when the application receives the `UIApplicationWillResignActiveNotification` notification. | **Android:** Stops when the first activity to reach the foreground has its `onResume()` method called.
**iOS:** Stops when it receives the `UIApplicationDidBecomeActiveNotification` notification. |
-| `sidebar_loaded` | ❌ | Time taken for the Sidebar to load.
**Platforms:** All | Starts when the Sidebar is mounted. | Stops when the LHN finishes laying out. |
+| `sidebar_loaded` | ✅ | Time taken for the Sidebar to load.
**Platforms:** All | Starts when the Sidebar is mounted. | Stops when the LHN finishes laying out. |
| `calc_most_recent_last_modified_action` | ✅ | Time taken to find the most recently modified report action or report.
**Platforms:** All | Starts when the app reconnects to the network | Ends when the app reconnects to the network and the most recent report action or report is found. |
| `open_search` | ✅ | Time taken to open up the Search Router.
**Platforms:** All | Starts when the Search Router icon in LHN is pressed. | Stops when the list of available options finishes laying out. |
| `load_search_options` | ✅ | Time taken to generate the list of options used in the Search Router.
**Platforms:** All | Starts when the `getSearchOptions` function is called. | Stops when the list of available options is generated. |
diff --git a/contributingGuides/STYLE.md b/contributingGuides/STYLE.md
index e6660d848129..c7f05e661bd2 100644
--- a/contributingGuides/STYLE.md
+++ b/contributingGuides/STYLE.md
@@ -477,20 +477,68 @@ if (ref.current && 'getBoundingClientRect' in ref.current) {
### Default value for inexistent IDs
- Use `'-1'` or `-1` when there is a possibility that the ID property of an Onyx value could be `null` or `undefined`.
+Use `CONST.DEFAULT_NUMBER_ID` when there is a possibility that the number ID property of an Onyx value could be `null` or `undefined`. **Do not default string IDs to any value!**
+
+> Why? The default number ID (currently set to `0`, which matches the backend’s default) is a falsy value. This makes it compatible with conditions that check if an ID is set, such as `if (!ownerAccountID) {`. Since it’s stored as a constant, it can easily be changed across the codebase if needed.
+>
+> However, defaulting string IDs to `'0'` breaks such conditions because `'0'` is a truthy value in JavaScript. Defaulting to `''` avoids this issue, but it can cause crashes or bugs if the ID is passed to Onyx. This is because `''` could accidentally subscribe to an entire Onyx collection instead of a single record.
+>
+> To address both problems, string IDs **should not have a default value**. This approach allows conditions like `if (!policyID) {` to work correctly, as `undefined` is a falsy value. At the same time, it prevents Onyx bugs: if `policyID` is used to subscribe to a specific Onyx record, a `policy_undefined` key will be used, and Onyx won’t return any records.
+>
+> In case you are confused or find a situation where you can't apply the rules mentioned above, please raise your question in the [`#expensify-open-source`](https://expensify.slack.com/archives/C01GTK53T8Q) Slack channel.
``` ts
// BAD
-const foo = report?.reportID ?? '';
-const bar = report?.reportID ?? '0';
-
-report ? report.reportID : '0';
-report ? report.reportID : '';
+const accountID = report?.ownerAccountID ?? -1;
+const policyID = report?.policyID ?? '-1';
+const managerID = report ? report.managerID : 0;
// GOOD
-const foo = report?.reportID ?? '-1';
+const accountID = report?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID;
+const policyID = report?.policyID;
+const managerID = report ? report.managerID : CONST.DEFAULT_NUMBER_ID;
+```
+
+Here are some common cases you may face when fixing your code to remove the old/bad default values.
+
+#### **Case 1**: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
+
+```diff
+-Report.getNewerActions(newestActionCurrentReport?.reportID ?? '-1', newestActionCurrentReport?.reportActionID ?? '-1');
++Report.getNewerActions(newestActionCurrentReport?.reportID, newestActionCurrentReport?.reportActionID);
+```
+
+> error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'. Type 'undefined' is not assignable to type 'string'.
+
+We need to change `Report.getNewerActions()` arguments to allow `undefined`. By doing that we could add a condition that return early if one of the parameters are falsy, preventing the code (which is expecting defined IDs) from executing.
+
+```diff
+-function getNewerActions(reportID: string, reportActionID: string) {
++function getNewerActions(reportID: string | undefined, reportActionID: string | undefined) {
++ if (!reportID || !reportActionID) {
++ return;
++ }
+```
+
+#### **Case 2**: Type 'undefined' cannot be used as an index type.
+
+```diff
+function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = false, updatedTransaction, isFromReviewDuplicates = false}: MoneyRequestViewProps) {
+ const [parentReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, {
+ canEvict: false,
+ });
+- const parentReportAction = parentReportActions?.[report?.parentReportActionID ?? '-1'];
++ const parentReportAction = parentReportActions?.[report?.parentReportActionID];
+```
+
+> error TS2538: Type 'undefined' cannot be used as an index type.
+
+This error is inside a component, so we can't simply use early return conditions here. Instead, we can check if `report?.parentReportActionID` is defined, if it is we can safely use it to find the record inside `parentReportActions`. If it's not defined, we just return `undefined`.
-report ? report.reportID : '-1';
+```diff
+function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = false, updatedTransaction, isFromReviewDuplicates = false}: MoneyRequestViewProps) {
+- const parentReportAction = parentReportActions?.[report?.parentReportActionID ?? '-1'];
++ const parentReportAction = report?.parentReportActionID ? parentReportActions?.[report.parentReportActionID] : undefined;
```
### Extract complex types
diff --git a/docs/articles/expensify-classic/connections/netsuite/Netsuite-Troubleshooting.md b/docs/articles/expensify-classic/connections/netsuite/Netsuite-Troubleshooting.md
index 01aa21a28b80..c2dbb969d007 100644
--- a/docs/articles/expensify-classic/connections/netsuite/Netsuite-Troubleshooting.md
+++ b/docs/articles/expensify-classic/connections/netsuite/Netsuite-Troubleshooting.md
@@ -2,8 +2,6 @@
title: Netsuite Troubleshooting
description: Troubleshoot common NetSuite sync and export errors.
---
-
-# Overview of NetSuite Troubleshooting
Synchronizing and exporting data between Expensify and NetSuite can streamline your financial processes, but occasionally, users may encounter errors that prevent a smooth integration. These errors often arise from discrepancies in settings, missing data, or configuration issues within NetSuite or Expensify.
This troubleshooting guide aims to help you identify and resolve common sync and export errors, ensuring a seamless connection between your financial management systems. By following the step-by-step solutions provided for each specific error, you can quickly address issues and maintain accurate and efficient expense reporting and data management.
@@ -30,7 +28,7 @@ When exporting as a Vendor Bill, we pull from the vendor record, not the employe
**Journal Entries and Expense Reports:**
If you see this error when exporting a Journal Entry or Expense Report, it might be because the report submitter doesn’t have default settings for Departments, Classes, or Locations.
-**To fix this:**
+**To resolve:**
1. Go to **Lists > Employees** in NetSuite.
2. Click **"Edit"** next to the employee's name who submitted the report.
3. Scroll down to the **Classification** section.
@@ -41,28 +39,29 @@ If you see this error when exporting a Journal Entry or Expense Report, it might
# ExpensiError NS0012: Currency Does Not Exist In NetSuite
-**Scenario One:** When dealing with foreign transactions, Expensify sends the conversion rate and currency of the original expense to NetSuite. If the currency isn't listed in your NetSuite subsidiary, you'll see an error message saying the currency does not exist in NetSuite.
+## Scenario One
+When dealing with foreign transactions, Expensify sends the conversion rate and currency of the original expense to NetSuite. If the currency isn't listed in your NetSuite subsidiary, you'll see an error message saying the currency does not exist in NetSuite.
-**To fix this:**
+**To resolve:**
1. Ensure the currency in Expensify matches what's in your NetSuite subsidiary.
2. If you see an error saying 'The currency X does not exist in NetSuite', re-sync your connection to NetSuite through the workspace admin section in Expensify.
3. Try exporting again.
-**Scenario Two:** This error can happen if you’re using a non-OneWorld NetSuite instance and exporting a currency other than EUR, GBP, USD, or CAD.
+## Scenario Two
+This error can happen if you’re using a non-OneWorld NetSuite instance and exporting a currency other than EUR, GBP, USD, or CAD.
-**To fix this:**
+**To resolve:**
1. Head to NetSuite.
2. Go to **Setup > Enable Features**.
3. Check the **Multiple Currencies** box.
Once you've done this, you can add the offending currency by searching **New Currencies** in the NetSuite global search.
-
# ExpensiError NS0021: Invalid tax code reference key
This error usually indicates an issue with the Tax Group settings in NetSuite, which can arise from several sources.
-#### Tax Group to Tax Code Mapping
+## Tax Group to Tax Code Mapping
If a Tax Code on Sales Transactions is mapped to a Tax Group, an error will occur. To fix this, the Tax Code must be mapped to a Tax Code on Purchase Transactions instead.
To verify if a Tax Code is for Sales or Purchase transactions, view the relevant Tax Code(s).
@@ -78,9 +77,7 @@ Tax Groups can represent different types of taxes. For compatibility with Expens
#### Enable Tax Groups
Some subsidiaries require you to enable Tax Groups. Go to **Set Up Taxes** for the subsidiary's country and ensure the Tax Code lists include both Tax Codes and Tax Groups.
-
# ExpensiError NS0023: Employee Does Not Exist in NetSuite (Invalid Employee)
-
This can happen if the employee’s subsidiary in NetSuite doesn’t match what’s listed in Expensify.
## How to Fix ExpensiError NS0023
@@ -100,13 +97,10 @@ This can happen if the employee’s subsidiary in NetSuite doesn’t match what
- If exporting as Journal Entries, ensure the currency for the NetSuite employee record, NetSuite subsidiary, and Expensify policy all match.
- In NetSuite, go to the **Human Resources** tab > **Expense Report Currencies**, and add the subsidiary/policy currency if necessary.
-
# ExpensiError NS0024: Invalid Customer or Project Tag
-
Employees must be listed as a resource on the customer/project in NetSuite to be able to apply it to an expense. If that isn’t set up in NetSuite, you can run into this error.
## How to Fix ExpensiError NS0024
-
1. **Ensure Employee Access:**
- In NetSuite, go to **Lists > Relationships > Customer/Projects**.
- Click **Edit** next to the desired Customer/Project.
@@ -124,9 +118,7 @@ Employees must be listed as a resource on the customer/project in NetSuite to be
- Go to **Settings > Workspaces > Group > [Workspace Name] > Connections > NetSuite > Configure > Advanced**.
- Enable **Cross-Subsidiary Customers/Projects** to remove the requirement for the employee's subsidiary and the customer's subsidiary to match.
-
# ExpensiError NS0034: This record already exists
-
This error occurs when the report in question was already exported to NetSuite.
## How to fix ExpensiError NS0034
@@ -141,9 +133,7 @@ This error occurs when the report in question was already exported to NetSuite.
5. **Re-export the Report from Expensify to NetSuite:**
- After deleting the report in NetSuite, re-export it from Expensify to NetSuite.
-
# ExpensiError NS0046: Billable Expenses Not Coded with a NetSuite Customer or Billable Project
-
NetSuite requires billable expenses to be assigned to a Customer or a Project that is configured as billable to a Customer. If this is not set up correctly in NetSuite, this error can occur.
## How to Fix ExpensiError NS0046
@@ -160,10 +150,8 @@ NetSuite requires billable expenses to be assigned to a Customer or a Project th
- Verify that there are no violations and that a value has been applied to the field.
5. Make any necessary adjustments to the billable expenses and try the export again.
-
# ExpensiError NS0059: A credit card account has not been selected for corporate card expenses.
-
-**To resolve this error:**
+**To resolve:**
1. Log into NetSuite as an admin.
2. Type "Page: Subsidiaries" in the global search box and select the subsidiary you will export to.
3. Under the Preferences tab of the subsidiary, locate the field: Default Account for Corporate Card Expenses.
@@ -179,9 +167,7 @@ NetSuite requires billable expenses to be assigned to a Customer or a Project th
For accounts without subsidiaries (non-OneWorld accounts), the default field is in your accounting preferences.
-
# ExpensiError NS0085: Expense Does Not Have Appropriate Permissions for Settings an Exchange Rate in NetSuite
-
This error occurs when the exchange rate settings in NetSuite aren't updated correctly.
## How to Fix ExpensiError NS0085
@@ -203,7 +189,6 @@ This error occurs when the exchange rate settings in NetSuite aren't updated cor
# ExpensiError NS0079: The Transaction Date is Not Within the Date Range of Your Accounting Period
-
The transaction date you specified is not within the date range of your accounting period. When the posting period settings in NetSuite are not configured to allow a transaction date outside the posting period, you can't export a report to the next open period, which is why you’ll run into this error.
## How to Fix ExpensiError NS0079
@@ -211,7 +196,7 @@ The transaction date you specified is not within the date range of your accounti
2. Under the General Ledger section, ensure the field Allow Transaction Date Outside of the Posting Period is set to Warn.
3. Then, choose whether to export your reports to the First Open Period or the Current Period.
-Additionally, ensure the Export to Next Open Period feature is enabled within Expensify:
+**Additionally, ensure the Export to Next Open Period feature is enabled within Expensify:**
1. Navigate to **Settings > Workspaces > Group > [Workspace Name] > Connections > Configure**.
2. Open the **Advanced tab**.
3. Confirm that the setting for **Export to Next Open Period** is enabled.
@@ -220,7 +205,6 @@ If any configuration settings are updated on the NetSuite connection, be sure to
# ExpensiError NS0055: The Vendor You are Trying to Export to Does Not Have Access to the Currency X
-
This error occurs when a vendor tied to a report in Expensify does not have access to a currency on the report in NetSuite. The vendor used in NetSuite depends on the type of expenses on the report you're exporting.
- For **reimbursable** (out-of-pocket) expenses, this is the report's submitter (the employee who submitted the report).
- For **non-reimbursable** (e.g., company card) expenses, this is the default vendor set via Settings > Workspaces > Group > [Workspace Name] > Connections > NetSuite > Configure.
@@ -246,13 +230,13 @@ To fix this, the vendor needs to be given access to the applicable currency:
5. Sync the NetSuite connection under **Settings > Workspaces > Group > [Workspace Name] > Connections > Sync Now**.
6. Export the report(s) again.
-#### For reports with Expensify Card expenses
+## ExpensiError NS0068: Reports with Expensify Card expenses
Expensify Card expenses export as Journal Entries. If you encounter this error when exporting a report with Expensify Card non-reimbursable expenses, ensure the field Created From has the Show checkbox checked for Journal Entries in NetSuite.
# ExpensiError NS0037: You do not have permission to set a value for element - “Receipt URL”
-**To resolve this error:**
+**To resolve:**
1. In NetSuite, go to Customization > Forms > Transaction Forms.
2. Search for the form type that the report is being exported as in NetSuite (Expense Report, Journal Entry, or Vendor Bill).
3. Click Edit next to the form that has the Preferred checkbox checked.
@@ -270,14 +254,12 @@ Expensify Card expenses export as Journal Entries. If you encounter this error w
# ExpensiError NS0042: Error creating vendor - this entity already exists
-
This error occurs when a vendor record already exists in NetSuite, but Expensify is still attempting to create a new one. This typically means that Expensify cannot find the existing vendor during export.
- The vendor record already exists in NetSuite, but there may be discrepancies preventing Expensify from recognizing it.
- The email on the NetSuite vendor record does not match the email of the report submitter in Expensify.
- The vendor record might not be associated with the correct subsidiary in NetSuite.
## How to Fix ExpensiError NS0042
-
Follow these steps to resolve the issue:
1. **Check Email Matching:**
- Ensure the email on the NetSuite vendor record matches the email of the report submitter in Expensify.
@@ -299,7 +281,6 @@ Follow these steps to resolve the issue:
# ExpensiError NS0109: Failed to login to NetSuite, please verify your credentials
-
This error indicates a problem with the tokens created for the connection between Expensify and NetSuite. The error message will say, "Login Error. Please check your credentials."
## How to Fix ExpensiError NS0109
@@ -308,7 +289,6 @@ This error indicates a problem with the tokens created for the connection betwee
# ExpensiError NS0123 Login Error: Please make sure that the Expensify integration is enabled
-
This error indicates that the Expensify integration is not enabled in NetSuite.
## How to Fix ExpensiError NS0123
@@ -321,10 +301,9 @@ This error indicates that the Expensify integration is not enabled in NetSuite.
Once the Expensify integration is enabled, try syncing the NetSuite connection again.
-
# ExpensiError NS0045: Expenses Not Categorized with a NetSuite Account
-**To resolve this error:**
+**To resolve:**
1. Log into NetSuite
2. Do a global search for the missing record.
- Ensure the expense category is active and correctly named.
@@ -335,7 +314,6 @@ Once the Expensify integration is enabled, try syncing the NetSuite connection a
# ExpensiError NS0061: Please Enter Value(s) for: Tax Code
-
This error typically occurs when attempting to export expense reports to a Canadian subsidiary in NetSuite for the first time and/or if your subsidiary in NetSuite has Tax enabled.
## How to Fix ExpensiError NS0061
@@ -348,12 +326,10 @@ To fix this, you need to enable Tax in the NetSuite configuration settings.
**Note:** Expenses created before Tax was enabled might need to have the newly imported taxes applied to them retroactively to be exported.
-
# Error creating employee: Your current role does not have permission to access this record.
-
This error indicates that the credentials or role used to connect NetSuite to Expensify do not have the necessary permissions within NetSuite. You can find setup instructions for configuring permissions in NetSuite [here](https://help.expensify.com/articles/expensify-classic/connections/netsuite/Connect-To-NetSuite#step-3-add-expensify-integration-role-to-a-user).
-**To resolve this error:**
+**To resolve:**
1. If permissions are configured correctly, confirm the report submitter exists in the subsidiary set on the workspace and that their Expensify email address matches the email on the NetSuite Employee Record.
2. If the above is true, try toggling off "Automatically create vendors/employees" under the Advanced tab of the NetSuite configuration window.
- Head to **Settings > Workspaces > Group > Workspace Name > Connections > NetSuite > Configure**
@@ -363,10 +339,9 @@ This error indicates that the credentials or role used to connect NetSuite to Ex
4. Export the report again.
# Elimination Settings for X Do Not Match
-
This error occurs when an Intercompany Payable account is set as the default in the Default Payable Account field in the NetSuite subsidiary preferences, and the Accounting Approval option is enabled for Expense Reports.
-**To resolve this error:**
+**To resolve:**
Set the Default Payable Account for Expense Reports on each subsidiary in NetSuite to ensure the correct payable account is active.
1. Navigate to Subsidiaries:
- Go to Setup > Company > Subsidiaries.
@@ -378,23 +353,21 @@ Set the Default Payable Account for Expense Reports on each subsidiary in NetSui
Repeat these steps for each subsidiary to ensure the settings are correct, and then sync Expensify to NetSuite to update the connection.
-# Why are reports exporting as `Accounting Approved` instead of `Paid in Full`?
+
+{% include faq-begin.md %}
+## Why are reports exporting as `Accounting Approved` instead of `Paid in Full`?
**This can occur for two reasons:**
- Missing Locations, Classes, or Departments in the Bill Payment Form
- Incorrect Settings in Expensify Workspace Configuration
-## Missing Locations, Classes, or Departments in Bill Payment Form
-
-If locations, classes, or departments are required in your accounting classifications but are not marked as 'Show' on the preferred bill payment form, this error can occur, and you will need to update the bill payment form in NetSuite:
+**Missing Locations, Classes, or Departments in Bill Payment Form:** If locations, classes, or departments are required in your accounting classifications but are not marked as 'Show' on the preferred bill payment form, this error can occur, and you will need to update the bill payment form in NetSuite:
1. Go to Customization > Forms > Transaction Forms.
2. Find your preferred (checkmarked) Bill Payment form.
3. Click Edit or Customize.
4. Under the Screen Fields > Main tab, check 'Show' near the department, class, and location options.
-## Incorrect Settings in Expensify Workspace Configuration
-
-To fix this, you'll want to confirm the NetSuite connection settings are set up correctly in Expensify:
+**Incorrect Settings in Expensify Workspace Configuration:** To fix this, you'll want to confirm the NetSuite connection settings are set up correctly in Expensify:
1. Head to **Settings > Workspaces > Group > Workspace Name > Connections > NetSuite > Configure > Advanced**
2. **Ensure the following settings are correct:**
- Sync Reimbursed Reports: Enabled and payment account chosen.
@@ -410,9 +383,7 @@ To fix this, you'll want to confirm the NetSuite connection settings are set up
Following these steps will help ensure that reports are exported as "Paid in Full" instead of "Accounting Approved."
-
-# Why are reports exporting as `Pending Approval`?
-
+## Why are reports exporting as `Pending Approval`?
If reports are exporting as "Pending Approval" instead of "Approved," you'll need to adjust the approval preferences in NetSuite.
**Exporting as Journal Entries/Vendor Bills:**
@@ -426,8 +397,7 @@ If reports are exporting as "Pending Approval" instead of "Approved," you'll nee
1. In NetSuite, navigate to Setup > Company > Enable Features.
2. On the "Employee" tab, uncheck "Approval Routing" to remove the approval requirement for Expense Reports created in NetSuite. Please note that this setting also applies to purchase orders.
-
-# How do I Change the Default Payable Account for Reimbursable Expenses in NetSuite?
+## How do I Change the Default Payable Account for Reimbursable Expenses in NetSuite?
NetSuite is set up with a default payable account that is credited each time reimbursable expenses are exported as Expense Reports to NetSuite (once approved by the supervisor and accounting). If you need to change this to credit a different account, follow the below steps:
@@ -445,7 +415,7 @@ NetSuite is set up with a default payable account that is credited each time rei
4. Click Save.
-# Why are my Company Card Expenses Exporting to the Wrong Account in NetSuite?
+## Why are my Company Card Expenses Exporting to the Wrong Account in NetSuite?
If your company card transactions are exporting to the wrong account in your accounting system, there are a couple of factors to check:
1. **Verify Card Mapping:**
@@ -462,3 +432,4 @@ Even if an expense was paid with the company card, it is considered a 'cash' exp
Less commonly, the issue may occur if the company card has been added to the user's personal settings. Expenses imported from a card linked at the individual account level will have a plain card icon.
+{% include faq-end.md %}
diff --git a/docs/articles/expensify-classic/spending-insights/Custom-Templates.md b/docs/articles/expensify-classic/spending-insights/Custom-Templates.md
deleted file mode 100644
index 75d436471dbf..000000000000
--- a/docs/articles/expensify-classic/spending-insights/Custom-Templates.md
+++ /dev/null
@@ -1,207 +0,0 @@
----
-title: Custom Templates
-description: Custom Templates
----
-# Overview
-
-If you don't have a direct connection to your accounting system, as long as the system accepts a CSV file, you can easily export your expense data for upload to the system. Custom templates are great if you want to analyze the data in your favorite spreadsheet program.
-
-# How to use custom templates
-If you are a Group workspace admin you can create a custom template that will be available to all Workspace Admins on the workspace from **Settings > Workspaces > Group > _Workspace Name_ > Export Formats**.
-
-If you are using a free account you can create a custom template from **Settings > Account > Preferences > CSV Export Formats**.
-
-You can use your custom templates from the **Reports** page.
-1. Select the checkbox next to the report you’d like to export
-3. Click **Export to** at the top of the page
-4. Select your template from the dropdown
-
-# Formulas
-## Report level
-
-| Formula | Description |
-| -- | -- |
-| **Report title** | **the title of the report the expense is part of** |
-| {report:title} | would output "Expense Expenses to 2019-11-05" assuming that is the report's title.|
-| **Report ID** | **number is a unique number per report and can be used to identify specific reports**|
-| {report:id} | would output R00I7J3xs5fn assuming that is the report's ID.|
-| **Old Report ID** | **a unique number per report and can be used to identify specific reports as well. Every report has both an ID and an old ID - they're simply different ways of showing the same information in either [base10](https://community.expensify.com/home/leaving?allowTrusted=1&target=https%3A%2F%2Fwww.twinkl.co.uk%2Fteaching-wiki%2Fbase-10) or base62.** |
-| {report:oldID} | would output R3513250790654885 assuming that is the report's old ID.|
-| **Reimbursement ID** | **the unique number for a report that's been reimbursed via ACH in Expensify. The reimbursement ID is searchable on the Reports page and is found on your bank statement in the line-item detail for the reimbursed amount.**|
-| {report:reimbursementid} | would output 123456789109876 assuming that is the ID on the line-item detail for the reimbursed amount in your business bank account.|
-| **Report Total** | **the total amount of the expense report.**|
-| {report:total} | would output $325.34 assuming that is the report's total.|
-| **Type** | **is the type of report (either Expense Report, Invoice or Bill)**|
-| {report:type} | would output "Expense Report" assuming that is the report's type|
-| **Reimbursable Total** | **is the total amount that is reimbursable on the report.**|
-| {report:reimbursable} | would output $143.43 assuming the report's reimbursable total was 143.43 US Dollars.|
-| **Currency** | **is the currency to which all expenses on the report are being converted.**|
-| {report:currency} | would output USD assuming that the report total was calculated in US Dollars|
-|| Note - Currency accepts an optional three character currency code or NONE. If you want to do any math operations on the report total, you should use {report:total:nosymbol} to avoid an error. Please see Expense:Amount for more information on currencies.|
-| **Report Field** | **formula will output the value for a given Report Field which is created in the workspace settings.**|
-| {field:Employee ID} | would output 12456 , assuming "Employee ID" is the name of the Report Field and "123456" is the value of that field on the report.|
-| **Created date** | **the expense report was originally created by the user.**|
-| {report:created} | would output 2010-09-15 12:00:00 assuming the expense report was created on September 15th, 2010 at noon.|
-| {report:created:yyyy-MM-dd} | would output 2010-09-15 assuming the expense report was created on September 15, 2010.|
-| | Note - All Date Formulas accept an optional format string. The default if one is not provided is yyyy-MM-dd hh:mm:ss. For a full breakdown, check out the Date Formatting [here](https://community.expensify.com/discussion/5799/deep-dive-date-formating-for-formulas/p1?new=1).|
-| **StartDate** | **is the date of the earliest expense on the report.**|
-| {report:startdate} | would output 2010-09-15 assuming that is the date of the earliest expense on the report.|
-| **EndDate**| **is the date of the last expense on the report.**|
-| {report:enddate} | would output 2010-09-26 assuming that is the date of the last expense on the report.|
-| **Scheduled Submit Dates** | **the start and end dates of the Scheduled Submit reporting cycle.**|
-| {report:autoReporting:start} | would output 2010-09-15 assuming that is the start date of the automatic reporting cycle, when the automatic reporting frequency is not set to daily.|
-| {report:autoReporting:end} | would output 2010-09-26 assuming that is the end date of the automatic reporting cycle, when the automatic reporting frequency is not set to daily.|
-| **Submission Date** | **is the date that the report was submitted.**|
-| {report:submit:date} | would output 1986-09-15 12:00:00 assuming that the report was submitted on September 15, 1986, at noon.|
-| {report:submit:date:yyyy-MM-dd} | would output 1986-09-15 assuming that the report was submitted on September 15, 1986.|
-| | Note - All Date Formulas accept an optional format string. The default if one is not provided is yyyy-MM-dd hh:mm:ss. For a full breakdown, check out the Date Formatting |
-| **Approval Date** | **the date the report was approved. This formula can be used for export templates, but not for report titles.**|
-| {report:approve:date} | would output 2011-09-25 12:00:00 assuming that the report was approved on September 25, 2011, at noon.|
-| {report:approve:date:yyyy-MM-dd} | would output 2011-09-25 assuming that the report was approved on September 25, 2011.|
-| **Reimbursement Date** | **the date an expense report was reimbursed. This formula can be used for export templates, but not for report titles.**|
-| {report:achreimburse} | would output 2011-09-25 assuming that is the date the report was reimbursed via ACH Direct Deposit.|
-| {report:manualreimburse} | would output 2011-09-25 assuming that is the date the report was marked as reimbursed. |
-| **Export Date** | **is the date when the report is exported. This formula can be used for export templates, but not for report titles.**|
-| {report:dateexported} | would output 2013-09-15 12:00 assuming that the report was exported on September 15, 2013, at noon.|
-| {report:dateexported:yyyy-MM-dd} | would output 2013-09-15 assuming that the report was exported on September 15, 2013.|
-| **Expenses Count** | **is the number of total expenses on the report of this specific expense.**|
-| {report:expensescount} | would output 10 assuming that there were 10 expenses on the given report for this expense.|
-| **Workspace Name** | **is the name of the workspace applied to the report.**|
-| {report:policyname} | would output Sales assuming that the given report was under a workspace named Sales.|
-| **Status** | **is the current state of the report when it was exported**.|
-| {report:status} | would output Approved assuming that the report has been approved and not yet reimbursed.|
-| **Custom Fields** | |
-| {report:submit:from:customfield1} | would output the custom field 1 entry associated with the user who submitted the report. If John Smith’s Custom Field 1 contains 100, then this formula would output 100.|
-| {report:submit:from:customfield2} | would output the custom field 2 entry associated with the user who submitted the report. If John Smith’s Custom Field 2 contains 1234, then this formula would output 1234. |
-| **To** | **is the email address of the last person who the report was submitted to.**|
-| {report:submit:to} | would output alice@email.com if they are the current approver|
-| {report:submit:to:email\|frontPart} | would output alice.|
-| **Current user** | **To export the email of the currently logged in Expensify user**|
-| {user:email} | would output bob@example.com assuming that is the currently logged in Expensify user's email.|
-| **Submitter** | **"Sally Ride" with email "sride@email.com" is the submitter for the following examples**|
-| {report:submit:from:email}| sride@email.com|
-| {report:submit:from}| Sally Ride|
-| {report:submit:from:firstname}| Sally|
-| {report:submit:from:lastname}| Ride|
-| {report:submit:from:fullname}| Sally Ride |
-| | Note - If user's name is blank, then {report:submit:from} and {report:submit:from:email\|frontPart} will print the user's whole email.|
-
-`{report:submit:from:email|frontPart}` sride
-
-`{report:submit:from:email|domain}` email.com
-
-`{user:email|frontPart}` would output bob assuming that is the currently logged in Expensify user's email.
-
-## Expense level
-
-| Formula | Description |
-| -- | -- |
-| **Merchant** | **Merchant of the expense** |
-| {expense:merchant} | would output Sharons Coffee Shop and Grill assuming the expense is from Sharons Coffee Shop |
-| {expense:distance:count} | would output the total miles/kilometers of the expense.|
-| {expense:distance:rate} | would output the monetary rate allowed per mile/kilometer. |
-| {expense:distance:unit} | would output either mi or km depending on which unit is applied in the workspace settings. |
-| **Date** | **Related to the date listed on the expense** |
-| {expense:created:yyyy-MM-dd} | would output 2019-11-05 assuming the expense was created on November 5th, 2019 |
-| {expense:posted:yyyy-MM-dd} | would output 2023-07-24 assuming the expense was posted on July 24th, 2023 |
-| **Tax** | **The tax type and amount applied to the expense line item** |
-| {expense:tax:field} | would output VAT assuming this is the name of the tax field.|
-| {expense:tax:ratename} | would output the name of the tax rate that was used (ex: Standard). This will show custom if the chosen tax amount is manually entered and not chosen from the list of given options.|
-| {expense:tax:amount} | would output $2.00 assuming that is the amount of the tax on the expense.|
-| {expense:tax:percentage} | would output 20% assuming this is the amount of tax that was applied to the subtotal.|
-| {expense:tax:net} | would output $18.66 assuming this is the amount of the expense before tax was applied.|
-| {expense:tax:code} | would output the tax code that was set in the workspace settings.|
-| **Expense Amount** | **Related to the currency type and amount of the expense** |
-| {expense:amount} | would output $3.95 assuming the expense was for three dollars and ninety-five cents|
-| {expense:amount:isk} | would output Íkr3.95 assuming the expense was for 3.95 Icelandic króna.|
-| {expense:amount:nosymbol} | would output 3.95. Notice that there is no currency symbol in front of the expense amount because we designated none.|
-| {expense:exchrate} | would output the currency conversion rate used to convert the expense amount|
-| | Add an optional extra input that is either a three-letter currency code or nosymbol to denote the output's currency. The default if one isn't provided is USD.|
-| {expense:amount:originalcurrency} | This gives the amount of the expense in the currency in which it occurred before currency conversion |
-| {expense:amount:originalcurrency:nosymbol} | will export the expense in its original currency without the currency symbol. |
-| {expense:amount:negsign} | displays negative expenses with a minus sign in front rather wrapped in parenthesis. It would output -$3.95 assuming the expense was already a negative expense for three dollars and ninety-five cents. This formula does not convert a positive expense to a negative value.|
-| {expense:amount:unformatted} | displays expense amounts without commas. This removes commas from expenses that have an amount of more than 1000. It would output $10000 assuming the expense was for ten thousand dollars.|
-| {expense:debitamount} | displays the amount of the expense if the expense is positive. Nothing will be displayed in this column if the expense is negative. It would output $3.95 assuming the expense was for three dollars and ninety-five cents.|
-| {expense:creditamount} | displays the amount of the expense if the expense is negative. Nothing will be displayed in this column if the expense is positive. It would output -$3.95 assuming the expense was for negative three dollars and ninety-five cents.|
-| **For expenses imported via CDF/VCF feed only** ||
-| {expense:purchaseamount} | is the amount of the original purchase in the currency it was purchased in. Control plan users only.|
-| {expense:purchaseamount} | would output Irk 3.95 assuming the expense was for 3.95 Icelandic krónur, no matter what currency your bank has translated it to.|
-| {expense:purchasecurrency} | would output Irk assuming the expense was incurred in Icelandic krónur (before your bank converted it back to your home currency)|
-| **Original Amount** | **when import with a connected bank**|
-| {expense:originalamount} | is the amount of the expense imported from your bank or credit card feed. It would output $3.95 assuming the expense equated to $3.95 and you use US-based bank. You may add an optional extra input that is either a three-letter currency code or NONE to denote the output's currency.|
-| **Category** | **The category of the expense** |
-| {expense:category} | would output Employee Moral assuming that is the expenses' category.|
-| {expense:category:glcode} | would output the category gl code of the category selected.|
-| {expense:category:payrollcode} | outputs the payroll code information entered for the category that is applied to the expense. If the payroll code for the Mileage category was 39847, this would output simply 39847.|
-| **Attendees** | **Persons listed as attendees on the expense**|
-| {expense:attendees} | would output the name or email address entered in the Attendee field within the expense (ex. guest@domain.com). |
-| {expense:attendees:count} | would output the number of attendees that were added to the expense (ex. 2).8. Attendees - persons listed as attendees on the expense.|
-| **Tags** | Tags of the expense - in this example the name of the tag is "Department" |
-| {expense:tag} | would output Henry at Example Co. assuming that is the expenses' tag. |
-| **Multiple Tags** | Tags for companies that have multiple tags setup. |
-| {expense:tag:ntag-1} | outputs the first tag on the expense, if one is selected |
-| {expense:tag:ntag-3} | outputs the third tag on the expense, if one is selected |
-| **Description** | The description on the expense |
-| {expense:comment} |would output "office lunch" assuming that is the expenses' description.|
-| **Receipt** | |
-| {expense:receipt:type} | would output eReceipt if the receipt is an Expensify Guaranteed eReceipt.|
-| {expense:receipt:url} | would output a link to the receipt image page that anyone with access to the receipt in Expensify could view.|
-| {expense:receipt:url:direct} | would show the direct receipt image url for download. |
-| {expense:mcc} | would output 3351 assuming that is the expenses' MCC (Merchant Category Code of the expense).|
-| | Note, we only have the MCC for expenses that are automatically imported or imported from an OFX/QFX file. For those we don't have an MCC for the output would be (an empty string).|
-| **Card name/number expense type** | |
-| {expense:card} | Manual/Cash Expenses — would output Cash assuming the expense was manually entered using either the website or the mobile app.|
-| {expense:card} | Bank Card Expenses — would output user@company.com – 1234 assuming the expense was imported from a credit card feed.|
-| | Note - If you do not have access to the card that the expense was created on 'Unknown' will be displayed. If cards are assigned to users under Domain, then you'll need to be a Domain Admin to export the card number.|
-| **Expense ID** | |
-| {expense:id} | would output the unique number associated with each individual expense "4294967579".|
-| **Reimbursable state** | |
-| {expense:reimbursable} | would output "yes" or "no" depending on whether the expense is reimbursable or not.|
-| **Billable state** | |
-| {expense:billable} | would output "yes" or "no" depending on whether the expense is billable or not.
-| **Expense Number** | **is the ordinal number of the expense on its expense report.**|
-| {report:expense:number} | would output 2 assuming that the given expense was the second expense on its report.|
-| **GL codes** | |
-| {expense:category:glcode} | would output the GL code associated with the category of the expense. If the GL code for Meals is 45256 this would output simply 45256.|
-| {expense:tag:glcode} | would output the GL code associated with the tag of the expense. If the GL code for Client X is 08294 this would output simply 08294.|
-| {expense:tag:ntag-3:glcode} | would output the GL code associated with the third tag the user chooses. This is only for companies that have multiple tags setup.|
-
-## Date formats
-
-| Formula | Description |
-| -- | -- |
-| M/dd/yyyy | 5/23/2019|
-|MMMM dd, yyyy| May 23, 2019|
-|dd MMM yyyy| 23 May 2019|
-|yyyy/MM/dd| 2019/05/23|
-|dd MMM yyyy| 23 May 2019|
-|yyyy/MM/dd| 2019/05/23|
-|MMMM, yyyy| May, 2019|
-|yy/MM/dd| 19/05/23|
-|dd/MM/yy| 23/05/19|
-|yyyy| 2019|
-
-## Math formulas
-
-| Formula | Description |
-| -- | -- |
-| * | Multiplication {math: 3 * 4} output 12|
-| / | Division {math: 3 / 4 }output 0.75|
-| + | Addition {math: 3 + 4 }output |
-| - | Subtraction {math: 3 - 4 }output -1|
-| ^ | Exponent {math: 3 ^ 4 } output 81|
-| sqrt | The square root of a number. {sqrt:64} output 8|
-|| Note - You can also combine the value of any two numeric fields. For example, you can use {math: {expense:tag:glcode} + {expense:category:glcode}} to add the value of the Tag GL code with the Category GL code.|
-
-## Substring formulas
-This formula will output a subset of the string in question. It is important to remember that the count starts at 0 not 1.
-
-`{expense:merchant|substr:0:4}` would output "Star" for a merchant named Starbucks. This is because we are telling it to start at position 0 and be of 4 character length.
-
-`{expense:merchant|substr:4:5}` would output "bucks" for a merchant named Starbucks. This is because we are telling it to start at position 4 and be of 5 character length.
-
-# FAQs
-
-**Can I export one line per report?**
-
-No, the custom template always exports one line per expense. At the moment it is not possible to create a template that will export one line per report.
diff --git a/docs/articles/expensify-classic/spending-insights/Default-Export-Templates.md b/docs/articles/expensify-classic/spending-insights/Default-Export-Templates.md
deleted file mode 100644
index b89dca85df04..000000000000
--- a/docs/articles/expensify-classic/spending-insights/Default-Export-Templates.md
+++ /dev/null
@@ -1,31 +0,0 @@
----
-title: Default Export Templates
-description: Default Export Templates
----
-# Overview
-Use default export templates for exporting report data to a CSV format, for data analysis, or uploading to an accounting software.
-Below is a breakdown of the available default templates.
-# How to use default export templates
-- **All Data - Expense Level Export** - This export prints a line for each expense with all of the data associated with the expenses. This is useful if you want to see all of the data stored in Expensify for each expense.
-- **All Data - Report Level Export** - This export prints a line per report, giving a summary of the report data.
-- **Basic Export** - A simpler expense level export without as much detail. This exports the data visible on the PDF of the report. Basics such as date, amount, merchant, category, tag, reimbursable state, description, receipt URL, and original expense currency and amount.
-- **Canadian Multiple Tax Export** - Exports a line per expense with all available info on the taxes applied to the expenses on your report(s). This is useful if you need to see the tax spend.
-- **Category Export** - Exports category names with the total amount attributed to each category on the report. While you can also access this information on the Insights page, it can be convenient to export to a CSV to run further analysis in your favorite spreadsheet program.
-- **Per Diem Export** - This exports basic expense details only for the per diem expenses on the report. Useful for reviewing employee Per Diem spend.
-- **Tag Export** - Exports tag names into columns with the total amount attributed to each tag on the report.
-
-# How to export using a default template
-1. Navigate to your Reports page
-2. Select the reports you want to export (you can use the filters to help you find the reports you’re after)
-3. Click the **Export to** in the top right corner
-4. Select the export template you’d like to use
-
-{% include faq-begin.md %}
-## Why are my numbers exporting in a weird format?
-Do your numbers look something like this: 1.7976931348623157e+308? This means that your spreadsheet program is formatting long numbers in an exponential or scientific format. If that happens, you can correct it by changing the data to Plain Text or a Number in your spreadsheet program.
-## Why are my leading zeros missing?
-Is the export showing “1” instead of “01”? This means that your spreadsheet program is cutting off the leading zero. This is a common issue with viewing exported data in Excel. Unfortunately, we don’t have a good solution for this. We recommend checking your spreadsheet program’s help documents for suggestions for formatting.
-## I want a report that is not in the default list, how can I build that?
-For a guide on building your own custom template check out Exports > Custom Exports in the Help pages!
-
-{% include faq-end.md %}
diff --git a/docs/articles/expensify-classic/spending-insights/Export-Expenses-And-Reports.md b/docs/articles/expensify-classic/spending-insights/Export-Expenses-And-Reports.md
new file mode 100644
index 000000000000..eac2723e5c9c
--- /dev/null
+++ b/docs/articles/expensify-classic/spending-insights/Export-Expenses-And-Reports.md
@@ -0,0 +1,294 @@
+---
+title: Export Expenses and Reports
+description: How to export expenses and reports using custom reports, PDF files, CSVs, and more
+---
+
+There are several methods you can use to export your expenses and reports, including:
+- Export as a PDF
+- Export as a CSV or to an accounting integration
+- Export using a default or custom export template
+
+# Export PDF
+
+1. Click the **Reports** tab.
+2. Open a report.
+3. Click **Details** in the top right of the report.
+4. Click the download icon.
+
+The PDF will be downloaded with all expenses, any attached receipts, and all report notes.
+
+# Export CSV or apply a template
+
+1. Click either the **Expenses** or **Reports** tab.
+2. On the left hand side, select the expenses/reports you’d like to export.
+3. Click **Export to** at the top right of the page.
+4. Choose the desired export option. You can use one of the default templates below, or you can create your own template. *Note: The default templates and the option to export to a connected accounting package are only available on the Reports page.*
+ - **All Data - Expense Level Export**: Prints a line for each expense with all of the data associated with the expenses. This is useful if you want to see all of the data stored in Expensify for each expense.
+ - **All Data - Report Level Export**: Prints a line per report, giving a summary of the report data.
+ - **Basic Export**: A simpler expense-level export of the data visible on the PDF report. Includes basics such as date, amount, merchant, category, tag, reimbursable state, description, receipt URL, and original expense currency and amount.
+ - **Canadian Multiple Tax Export**: Exports a line per expense with all available information on the taxes applied to the expenses on your report(s). This is useful if you need to see the tax spend.
+ - **Category Export**: Exports category names with the total amount attributed to each category on the report. While you can also access this information on the Insights page, it can be convenient to export to a CSV to run further analysis in your favorite spreadsheet program.
+ - **Per Diem Export**: Exports basic expense details for only the per diem expenses on the report. Useful for reviewing employee Per Diem spend.
+ - **Tag Export**: Exports tag names into columns with the total amount attributed to each tag on the report.
+
+# Create custom export templates
+
+If you don't have a direct connection to your accounting system, you can export your expense data to the system for upload as long as the system accepts a CSV file. You can then analyze the data in your favorite spreadsheet program.
+
+Custom export templates can be created and made available to all Workspace Admins for your workspace, or you can create a template that is just for your own use.
+
+## For a workspace
+
+{% include info.html %}
+Must be a Group Workspace Admin to complete this process.
+{% include end-info.html %}
+
+1. Hover over **Settings** and click **Workspaces**.
+2. Select the desired workspace.
+3. Click the **Export Formats** tab on the left.
+4. Click **New Export Format**.
+5. Enter a name for the export format.
+6. Select the format type (e.g., CSV, XLS for Excel, or CSV without BOM for MS Access)
+7. Enter a name and formula for each column (formulas provided below).
+8. Scroll below all of the columns and, if needed:
+ - Click **Add Column** to add a new column.
+ - Drag and drop the columns into a different order.
+ - Hover over a column and click the red X in the right corner to delete it.
+9. Check the Example Output at the bottom and click **Save Export Format** when all the columns are complete.
+
+## For personal use
+
+1. Hover over **Settings** and click **Account**.
+2. Click **Preferences**.
+3. Under CSV Export Formats, click **New Export Format**.
+4. Enter a name for the export format.
+5. Select the format type (e.g., CSV, XLS for Excel, or CSV without BOM for MS Access)
+6. Enter a name and formula for each column (formulas provided below).
+7. Scroll below all of the columns and, if needed:
+ - Click **Add Column** to add a new column.
+ - Drag and drop the columns into a different order.
+ - Hover over a column and click the red X in the right corner to delete it.
+8. Check the Example Output at the bottom and click **Save Export Format** when all the columns are complete.
+
+## Formulas
+
+Enter any of the following formulas into the Formula field for each column. Be sure to also include both brackets around the formula as shown in the table below.
+
+### Report level
+
+| Formula | Description |
+| -- | -- |
+| Report title | The title of the report the expense is part of. |
+| {report:title} | Would output "Expense Expenses to 2019-11-05" assuming that is the report's title.|
+| Report ID | Number is a unique number per report and can be used to identify specific reports.|
+| {report:id} | Would output R00I7J3xs5fn assuming that is the report's ID.|
+| Old Report ID | A unique number per report and can be used to identify specific reports as well. Every report has both an ID and an old ID - they're simply different ways of showing the same information in either [base10](https://community.expensify.com/home/leaving?allowTrusted=1&target=https%3A%2F%2Fwww.twinkl.co.uk%2Fteaching-wiki%2Fbase-10) or base62. |
+| {report:oldID} | Would output R3513250790654885 assuming that is the report's old ID.|
+| Reimbursement ID | The unique number for a report that's been reimbursed via ACH in Expensify. The reimbursement ID is searchable on the Reports page and is found on your bank statement in the line-item detail for the reimbursed amount.|
+| {report:reimbursementid} | Would output 123456789109876 assuming that is the ID on the line-item detail for the reimbursed amount in your business bank account.|
+| Report Total | The total amount of the expense report.|
+| {report:total} | Would output $325.34 assuming that is the report's total.|
+| Type | Is the type of report (either Expense Report, Invoice or Bill)|
+| {report:type} | Would output "Expense Report" assuming that is the report's type.|
+| Reimbursable Total | Is the total amount that is reimbursable on the report.|
+| {report:reimbursable} | Would output $143.43 assuming the report's reimbursable total was 143.43 US Dollars.|
+| Currency | Is the currency to which all expenses on the report are being converted.|
+| {report:currency} | Would output USD assuming that the report total was calculated in US dollars.|
+|| Note - Currency accepts an optional three character currency code or NONE. If you want to do any math operations on the report total, you should use {report:total:nosymbol} to avoid an error. Please see Expense:Amount for more information on currencies.|
+| Report Field | Formula will output the value for a given Report Field which is created in the workspace settings.|
+| {field:Employee ID} | Would output 12456 , assuming "Employee ID" is the name of the Report Field and "123456" is the value of that field on the report.|
+| Created date | The expense report was originally created by the user.|
+| {report:created} | Would output 2010-09-15 12:00:00 assuming the expense report was created on September 15th, 2010 at noon.|
+| {report:created:yyyy-MM-dd} | Would output 2010-09-15 assuming the expense report was created on September 15, 2010.|
+| | Note - All Date Formulas accept an optional format string. The default if one is not provided is yyyy-MM-dd hh:mm:ss. For a full breakdown, check out the Date Formatting [here](https://community.expensify.com/discussion/5799/deep-dive-date-formating-for-formulas/p1?new=1).|
+| StartDate | Is the date of the earliest expense on the report.|
+| {report:startdate} | Would output 2010-09-15 assuming that is the date of the earliest expense on the report.|
+| EndDate| Is the date of the last expense on the report.|
+| {report:enddate} | Would output 2010-09-26 assuming that is the date of the last expense on the report.|
+| Scheduled Submit Dates | The start and end dates of the Scheduled Submit reporting cycle.|
+| {report:autoReporting:start} | Would output 2010-09-15 assuming that is the start date of the automatic reporting cycle, when the automatic reporting frequency is not set to daily.|
+| {report:autoReporting:end} | Would output 2010-09-26 assuming that is the end date of the automatic reporting cycle, when the automatic reporting frequency is not set to daily.|
+| Submission Date | Is the date that the report was submitted.|
+| {report:submit:date} | Would output 1986-09-15 12:00:00 assuming that the report was submitted on September 15, 1986, at noon.|
+| {report:submit:date:yyyy-MM-dd} | Would output 1986-09-15 assuming that the report was submitted on September 15, 1986.|
+| | Note - All Date Formulas accept an optional format string. The default if one is not provided is yyyy-MM-dd hh:mm:ss. For a full breakdown, check out the Date Formatting.|
+| Approval Date | The date the report was approved. This formula can be used for export templates, but not for report titles.|
+| {report:approve:date} | Would output 2011-09-25 12:00:00 assuming that the report was approved on September 25, 2011, at noon.|
+| {report:approve:date:yyyy-MM-dd} | Would output 2011-09-25 assuming that the report was approved on September 25, 2011.|
+| Reimbursement Date | The date an expense report was reimbursed. This formula can be used for export templates, but not for report titles.|
+| {report:achreimburse} | Would output 2011-09-25 assuming that is the date the report was reimbursed via ACH Direct Deposit.|
+| {report:manualreimburse} | Would output 2011-09-25 assuming that is the date the report was marked as reimbursed. |
+| Export Date | Is the date when the report is exported. This formula can be used for export templates, but not for report titles.|
+| {report:dateexported} | Would output 2013-09-15 12:00 assuming that the report was exported on September 15, 2013, at noon.|
+| {report:dateexported:yyyy-MM-dd} | Would output 2013-09-15 assuming that the report was exported on September 15, 2013.|
+| Expenses Count | Is the number of total expenses on the report of this specific expense.|
+| {report:expensescount} | Would output 10 assuming that there were 10 expenses on the given report for this expense.|
+| Workspace Name | Is the name of the workspace applied to the report.|
+| {report:policyname} | Would output Sales assuming that the given report was under a workspace named Sales.|
+| Status | Is the current state of the report when it was exported.|
+| {report:status} | Would output Approved assuming that the report has been approved and not yet reimbursed.|
+| Custom Fields | |
+| {report:submit:from:customfield1} | Would output the custom field 1 entry associated with the user who submitted the report. If John Smith’s Custom Field 1 contains 100, then this formula would output 100.|
+| {report:submit:from:customfield2} | Would output the custom field 2 entry associated with the user who submitted the report. If John Smith’s Custom Field 2 contains 1234, then this formula would output 1234. |
+| To | Is the email address of the last person who the report was submitted to.|
+| {report:submit:to} | Would output alice@email.com if they are the current approver.|
+| {report:submit:to:email\|frontPart} | Would output alice.|
+| Current user | To export the email of the currently logged in Expensify user.|
+| {user:email} | Would output bob@example.com assuming that is the currently logged in Expensify user's email.|
+| Submitter | "Sally Ride" with email "sride@email.com" is the submitter for the following examples.|
+| {report:submit:from:email}| sride@email.com|
+| {report:submit:from}| Sally Ride|
+| {report:submit:from:firstname}| Sally|
+| {report:submit:from:lastname}| Ride|
+| {report:submit:from:fullname}| Sally Ride |
+| | Note - If user's name is blank, then {report:submit:from} and {report:submit:from:email\|frontPart} will print the user's whole email.|
+
+`{report:submit:from:email|frontPart}` sride
+
+`{report:submit:from:email|domain}` email.com
+
+`{user:email|frontPart}` would output bob assuming that is the currently logged in Expensify user's email.
+
+### Expense level
+
+| Formula | Description |
+| -- | -- |
+| Merchant | Merchant of the expense |
+| {expense:merchant} | Would output Sharons Coffee Shop and Grill assuming the expense is from Sharons Coffee Shop. |
+| {expense:distance:count} | Would output the total miles/kilometers of the expense.|
+| {expense:distance:rate} | Would output the monetary rate allowed per mile/kilometer. |
+| {expense:distance:unit} | Would output either mi or km depending on which unit is applied in the workspace settings. |
+| Date | Related to the date listed on the expense |
+| {expense:created:yyyy-MM-dd} | Would output 2019-11-05 assuming the expense was created on November 5th, 2019. |
+| {expense:posted:yyyy-MM-dd} | Would output 2023-07-24 assuming the expense was posted on July 24th, 2023. |
+| Tax | The tax type and amount applied to the expense line item. |
+| {expense:tax:field} | Would output VAT assuming this is the name of the tax field.|
+| {expense:tax:ratename} | Would output the name of the tax rate that was used (ex: Standard). This will show custom if the chosen tax amount is manually entered and not chosen from the list of given options.|
+| {expense:tax:amount} | Would output $2.00 assuming that is the amount of the tax on the expense.|
+| {expense:tax:percentage} | Would output 20% assuming this is the amount of tax that was applied to the subtotal.|
+| {expense:tax:net} | would output $18.66 assuming this is the amount of the expense before tax was applied.|
+| {expense:tax:code} | would output the tax code that was set in the workspace settings.|
+| Expense Amount | Related to the currency type and amount of the expense. |
+| {expense:amount} | Would output $3.95 assuming the expense was for three dollars and ninety-five cents.|
+| {expense:amount:isk} | Would output Íkr3.95 assuming the expense was for 3.95 Icelandic króna.|
+| {expense:amount:nosymbol} | Would output 3.95. Notice that there is no currency symbol in front of the expense amount because we designated none.|
+| {expense:exchrate} | Would output the currency conversion rate used to convert the expense amount|
+| | Add an optional extra input that is either a three-letter currency code or nosymbol to denote the output's currency. The default if one isn't provided is USD.|
+| {expense:amount:originalcurrency} | This gives the amount of the expense in the currency in which it occurred before currency conversion |
+| {expense:amount:originalcurrency:nosymbol} | Will export the expense in its original currency without the currency symbol. |
+| {expense:amount:negsign} | displays negative expenses with a minus sign in front rather wrapped in parenthesis. It would output -$3.95 assuming the expense was already a negative expense for three dollars and ninety-five cents. This formula does not convert a positive expense to a negative value.|
+| {expense:amount:unformatted} | Displays expense amounts without commas. This removes commas from expenses that have an amount of more than 1000. It would output $10000 assuming the expense was for ten thousand dollars.|
+| {expense:debitamount} | Displays the amount of the expense if the expense is positive. Nothing will be displayed in this column if the expense is negative. It would output $3.95 assuming the expense was for three dollars and ninety-five cents.|
+| {expense:creditamount} | Displays the amount of the expense if the expense is negative. Nothing will be displayed in this column if the expense is positive. It would output -$3.95 assuming the expense was for negative three dollars and ninety-five cents.|
+| For expenses imported via CDF/VCF feed only ||
+| {expense:purchaseamount} | Is the amount of the original purchase in the currency it was purchased in. Control plan users only.|
+| {expense:purchaseamount} | Would output Irk 3.95 assuming the expense was for 3.95 Icelandic krónur, no matter what currency your bank has translated it to.|
+| {expense:purchasecurrency} | Would output Irk assuming the expense was incurred in Icelandic krónur (before your bank converted it back to your home currency).|
+| Original Amount | When import with a connected bank.|
+| {expense:originalamount} | Is the amount of the expense imported from your bank or credit card feed. It would output $3.95 assuming the expense equated to $3.95 and you use US-based bank. You may add an optional extra input that is either a three-letter currency code or NONE to denote the output's currency.|
+| Category | The category of the expense. |
+| {expense:category} | Would output Employee Moral assuming that is the expenses' category.|
+| {expense:category:glcode} | Would output the category gl code of the category selected.|
+| {expense:category:payrollcode} | Outputs the payroll code information entered for the category that is applied to the expense. If the payroll code for the Mileage category was 39847, this would output simply 39847.|
+| Attendees | Persons listed as attendees on the expense.|
+| {expense:attendees} | Would output the name or email address entered in the Attendee field within the expense (ex. guest@domain.com). |
+| {expense:attendees:count} | Would output the number of attendees that were added to the expense (ex. 2).8. Attendees - persons listed as attendees on the expense.|
+| Tags | Tags of the expense - in this example the name of the tag is "Department." |
+| {expense:tag} | Would output Henry at Example Co. assuming that is the expenses' tag. |
+| Multiple Tags | Tags for companies that have multiple tags setup. |
+| {expense:tag:ntag-1} | Outputs the first tag on the expense, if one is selected. |
+| {expense:tag:ntag-3} | Outputs the third tag on the expense, if one is selected. |
+| Description | The description on the expense. |
+| {expense:comment} | Would output "office lunch" assuming that is the expenses' description.|
+| Receipt | |
+| {expense:receipt:type} | Would output eReceipt if the receipt is an Expensify Guaranteed eReceipt.|
+| {expense:receipt:url} | Would output a link to the receipt image page that anyone with access to the receipt in Expensify could view.|
+| {expense:receipt:url:direct} | Would show the direct receipt image url for download. |
+| {expense:mcc} | Would output 3351 assuming that is the expenses' MCC (Merchant Category Code of the expense).|
+| | Note, we only have the MCC for expenses that are automatically imported or imported from an OFX/QFX file. For those we don't have an MCC for the output would be (an empty string).|
+| Card name/number expense type | |
+| {expense:card} | Manual/Cash Expenses — would output Cash assuming the expense was manually entered using either the website or the mobile app.|
+| {expense:card} | Bank Card Expenses — would output user@company.com – 1234 assuming the expense was imported from a credit card feed.|
+| | Note - If you do not have access to the card that the expense was created on 'Unknown' will be displayed. If cards are assigned to users under Domain, then you'll need to be a Domain Admin to export the card number.|
+| Expense ID | |
+| {expense:id} | Would output the unique number associated with each individual expense "4294967579".|
+| Reimbursable state | |
+| {expense:reimbursable} | Would output "yes" or "no" depending on whether the expense is reimbursable or not.|
+| Billable state | |
+| {expense:billable} | Would output "yes" or "no" depending on whether the expense is billable or not.
+| Expense Number | Is the ordinal number of the expense on its expense report.|
+| {report:expense:number} | Would output 2 assuming that the given expense was the second expense on its report.|
+| GL codes | |
+| {expense:category:glcode} | Would output the GL code associated with the category of the expense. If the GL code for Meals is 45256 this would output simply 45256.|
+| {expense:tag:glcode} | Would output the GL code associated with the tag of the expense. If the GL code for Client X is 08294 this would output simply 08294.|
+| {expense:tag:ntag-3:glcode} | Would output the GL code associated with the third tag the user chooses. This is only for companies that have multiple tags setup.|
+
+### Date formats
+
+| Formula | Description |
+| -- | -- |
+| M/dd/yyyy | 5/23/2019|
+|MMMM dd, yyyy| May 23, 2019|
+|dd MMM yyyy| 23 May 2019|
+|yyyy/MM/dd| 2019/05/23|
+|dd MMM yyyy| 23 May 2019|
+|yyyy/MM/dd| 2019/05/23|
+|MMMM, yyyy| May, 2019|
+|yy/MM/dd| 19/05/23|
+|dd/MM/yy| 23/05/19|
+|yyyy| 2019|
+
+### Math formulas
+
+| Formula | Description |
+| -- | -- |
+| * | Multiplication {math: 3 * 4} output 12|
+| / | Division {math: 3 / 4 }output 0.75|
+| + | Addition {math: 3 + 4 }output |
+| - | Subtraction {math: 3 - 4 }output -1|
+| ^ | Exponent {math: 3 ^ 4 } output 81|
+| sqrt | The square root of a number. {sqrt:64} output 8|
+|| Note - You can also combine the value of any two numeric fields. For example, you can use {math: {expense:tag:glcode} + {expense:category:glcode}} to add the value of the Tag GL code with the Category GL code.|
+
+### Substring formulas
+
+This formula will output a subset of the string in question. It is important to remember that the count starts at 0 not 1.
+
+`{expense:merchant|substr:0:4}` would output "Star" for a merchant named Starbucks. This is because we are telling it to start at position 0 and be of 4 character length.
+
+`{expense:merchant|substr:4:5}` would output "bucks" for a merchant named Starbucks. This is because we are telling it to start at position 4 and be of 5 character length.
+
+{% include faq-begin.md %}
+
+**Can I export one line per report?**
+
+No, the custom template always exports one line per *expense*. At the moment, it is not possible to create a template that will export one line per report.
+
+**How do I print a report?**
+
+1. Click the **Reports** tab.
+2. Open a report.
+3. Click **Details** in the top right of the report.
+4. Click the Print icon.
+
+**Why isn’t my report exporting?**
+
+Big reports with a lot of expenses may cause the PDF download to fail due to images with large resolutions. In that case, try breaking the report into multiple smaller reports. A report must have at least one expense to be exported or saved as a PDF.
+
+**Can I download multiple PDFs at once?**
+
+No, you can’t download multiple reports as PDFs at the same time. If you’d like to export multiple reports, an alternative to consider is the CSV export option.
+
+**The data exported to Excel is showing incorrectly. How can I fix this?**
+
+When opening a CSV file export from Expensify in Excel, it’ll automatically register report IDs and transaction IDs as numbers and assign the number format to the report ID column. If a number is greater than a certain length, Excel will contract the number and display it in exponential form. To prevent this, the number needs to be imported as text, which can be done by opening Excel and clicking File > Import > select your CSV file. Follow the prompts, then on step 3, set the report ID/transactionID column to import as Text.
+
+**Why are my numbers exporting in a weird format?**
+
+Do your numbers look something like this: 1.7976931348623157e+308? This means that your spreadsheet program is formatting long numbers in an exponential or scientific format. If that happens, you can correct it by changing the data to Plain Text or a Number in your spreadsheet program.
+
+**Why are my leading zeros missing?**
+
+Is the export showing “1” instead of “01”? This means that your spreadsheet program is cutting off the leading zero. This is a common issue with viewing exported data in Excel. Unfortunately, we don’t have a good solution for this. We recommend checking your spreadsheet program’s help documents for formatting suggestions.
+
+{% include faq-end.md %}
diff --git a/docs/articles/expensify-classic/spending-insights/Other-Export-Options.md b/docs/articles/expensify-classic/spending-insights/Other-Export-Options.md
deleted file mode 100644
index 9d752dec3eb9..000000000000
--- a/docs/articles/expensify-classic/spending-insights/Other-Export-Options.md
+++ /dev/null
@@ -1,41 +0,0 @@
----
-title: Other Export Options
-description: Other Export Options
----
-
-# Overview
-Here’s a quick look at how to export your expense and report data into a spreadsheet, accounting package, or PDF. We’ll also show you how to print out your reports in a few easy steps.
-
-# How to export expenses and reports to a CSV or accounting package
-From the **Expenses** page, you can export individual expenses into a CSV. From the Reports page, you can export entire reports into a CSV or connected accounting package. Here’s how to do both:
-
-1. Go to either the Expenses or Reports page
-2. On the left hand side, select the expenses/reports you’d like to export
-3. Click **Export to** at the top right of the page
-4. Choose the desired export option
-
-You can use one of the [default templates](https://help.expensify.com/articles/expensify-classic/insights-and-custom-reporting/Default-Export-Templates) or [create your own template](https://help.expensify.com/articles/expensify-classic/insights-and-custom-reporting/Custom-Templates). The default templates and the option to export to a connected accounting package are only available on the **Reports** page. Visit the specific help page for your accounting package to learn more about how to get this set up.
-
-# How to export a report as a PDF
-1. Go to the **Reports** page
-2. Click into a report
-3. Click on **Details** in the top right of the report
-4. Click the **download icon** to generate a PDF
-
-The PDF will include all expenses, any attached receipts, and all report notes.
-
-# How to print a report
-1. Go to the Reports page
-2. Click into a report
-3. Click on **Details** in the top right of the report
-4. Click the **print icon**
-
-{% include faq-begin.md %}
-## Why isn’t my report exporting?
-Big reports with lots of expenses may cause the PDF download to fail due to images with large resolutions. In that case, try breaking the report into multiple smaller reports. Also, please note that a report must have at least one expense to be exported or saved as a PDF.
-## Can I download multiple PDFs at once?
-No, you can’t download multiple reports as PDFs at the same time. If you’d like to export multiple reports, an alternative to consider is the CSV export option.
-## The data exported to Excel is showing incorrectly. How can I fix this?
-When opening a CSV file export from Expensify in Excel, it’ll automatically register report IDs and transaction IDs as numbers and assign the number format to the report ID column. If a number is greater than a certain length, Excel will contract the number and display it in exponential form. To prevent this, the number needs to be imported as text, which can be done by opening Excel and clicking File > Import > select your CSV file > follow the prompts and on step 3 set the report ID/transactionID column to import as Text.
-
-{% include faq-end.md %}
diff --git a/docs/articles/expensify-classic/travel/Book-with-Expensify-Travel.md b/docs/articles/expensify-classic/travel/Book-with-Expensify-Travel.md
index 5d25670ac5ab..f48d069e21dc 100644
--- a/docs/articles/expensify-classic/travel/Book-with-Expensify-Travel.md
+++ b/docs/articles/expensify-classic/travel/Book-with-Expensify-Travel.md
@@ -1,8 +1,7 @@
---
title: Book with Expensify Travel
-description: Book flights, hotels, cars, trains, and more with Expensify Travel
+description: How to book flights, hotels, cars, trains, and more with Expensify Travel
---
-
Expensify Travel allows members to search and book flights, hotels, cars, and trains globally at the most competitive rates available.
@@ -38,52 +37,6 @@ The traveler is emailed an itinerary of the booking. Additionally,
The travel itinerary is also emailed to the traveler’s [copilots](https://help.expensify.com/articles/expensify-classic/copilots-and-delegates/Assign-or-remove-a-Copilot), if applicable.
{% include end-info.html %}
-
+# Edit or cancel travel arrangements
-
-Expensify Travel allows members to search and book flights, hotels, cars, and trains globally at the most competitive rates available.
-
-With Expensify Travel, you can:
-- Search and book travel arrangements all in one place
-- Book travel for yourself or for someone else
-- Get real-time support by chat or phone
-- Manage all your T&E expenses in Expensify
-- Create specific rules for booking travel
-- Enable approvals for out-of-policy trips
-- Book with any credit card on the market
-- Book with the Expensify Card to get cash back and automatically reconcile transactions
-
-There is a flat fee of $15 per trip booked. A single trip can include multiple bookings, such as a flight, a hotel, and a car rental.
-
-# Book travel
-
-{% include selector.html values="desktop, mobile" %}
-
-{% include option.html value="desktop" %}
-1. Click the + icon in the bottom left menu and select **Book travel**.
-2. Click **Book or manage travel**.
-3. Agree to the terms and conditions and click **Continue**.
-4. Use the icons at the top to select the type of travel arrangement you want to book: flights, hotels, cars, or trains.
-5. Enter the travel information relevant to the travel arrangement selected (for example, the destination, dates of travel, etc.).
-6. Select all the details for the arrangement you want to book.
-7. Review the booking details and click **Book Flight / Book Hotel / Book Car / Book Rail** to complete the booking.
-{% include end-option.html %}
-
-{% include option.html value="mobile" %}
-1. Tap the + icon in the bottom menu and select **Book travel**.
-2. Tap **Book or manage travel**.
-3. Agree to the terms and conditions and tap **Continue**.
-4. Use the icons at the top to select the type of travel arrangement you want to book: flights, hotels, cars, or trains.
-5. Enter the travel information relevant to the travel arrangement selected (for example, the destination, dates of travel, etc.).
-6. Select all the details for the arrangement you want to book.
-7. Review the booking details and click **Book Flight / Book Hotel / Book Car / Book Rail** to complete the booking.
-{% include end-option.html %}
-
-{% include end-selector.html %}
-
-The traveler is emailed an itinerary of the booking. Additionally,
-- Their travel details are added to a Trip chat room under their primary workspace.
-- An expense report for the trip is created.
-- If booked with an Expensify Card, the trip is automatically reconciled.
-
-
+Click **Get Support** on your emailed travel itinerary for real-time help with the booking. Any modifications, exchanges, or voidings made to a trip via support will incur a $25 booking change fee.
diff --git a/docs/articles/expensify-classic/travel/Edit-or-cancel-travel-arrangements.md b/docs/articles/expensify-classic/travel/Edit-or-cancel-travel-arrangements.md
deleted file mode 100644
index 7dc71c3220ca..000000000000
--- a/docs/articles/expensify-classic/travel/Edit-or-cancel-travel-arrangements.md
+++ /dev/null
@@ -1,28 +0,0 @@
----
-title: Edit or cancel travel arrangements
-description: Modify travel arrangements booked with Expensify Travel
----
-
-
-Click **Get Support** on your emailed travel itinerary for real-time help with the booking. Any modifications, exchanges, or voidings made to a trip via support will incur a $25 booking change fee.
-
-
-
-
-
-You can review your travel arrangements any time by opening the Trip chat in your inbox. For example, if you booked a flight to San Francisco, a “Trip to San Francisco” chat will be automatically added to your chat inbox.
-
-To edit or cancel a travel arrangement,
-1. Click your profile image or icon in the bottom left menu.
-2. Scroll down and click **Workspaces** in the left menu.
-3. Select the workspace the travel is booked under.
-4. Tap into the booking to see more details.
-5. Click **Trip Support**.
-
-If there is an unexpected change to the itinerary (for example, a flight cancellation), Expensify’s travel partner **Spotnana** will reach out to the traveler to provide updates on those changes.
-
-{% include info.html %}
-You can click **Get Support** on your emailed travel itinerary for real-time help with the booking. Any modifications, exchanges, or voidings made to a trip via support will incur a $25 booking change fee.
-{% include end-info.html %}
-
-
diff --git a/docs/articles/new-expensify/expenses-&-payments/Pay-an-expense.md b/docs/articles/new-expensify/expenses-&-payments/Pay-an-expense.md
new file mode 100644
index 000000000000..5d2b634e8032
--- /dev/null
+++ b/docs/articles/new-expensify/expenses-&-payments/Pay-an-expense.md
@@ -0,0 +1,65 @@
+---
+title: Pay Expenses
+description: Pay workspace expenses or expenses submitted by friends and family
+---
+
+
+# Pay expenses submitted to a workspace
+
+To pay expenses within Expensify, you’ll need to set up your [business bank account](https://help.expensify.com/articles/new-expensify/expenses-&-payments/Connect-a-Business-Bank-Account).
+The submitter must also connect a [personal bank account](https://help.expensify.com/articles/new-expensify/expenses-&-payments/Connect-a-Personal-Bank-Account) to receive the payment.
+
+To pay an expense,
+{% include selector.html values="desktop, mobile" %}
+{% include option.html value="desktop" %}
+1. You will receive an email and in-app notification prompting you to review and **Pay** the expense. If your default contact method is a phone number, you'll receive a text.
+2. Click the **Pay** button on the notification to be directed to New Expensify.
+3. Select a payment option.
+- **Pay with Expensify** to pay the total expense within Expensify. Follow the prompt to pay with a [business bank account](https://help.expensify.com/articles/new-expensify/expenses-&-payments/Connect-a-Business-Bank-Account).
+- **Pay Elsewhere** to pay outside Expensify.
+{% include end-option.html %}
+
+{% include option.html value="mobile" %}
+1. When an employee sends you an expense, you will receive an email and in-app notification prompting you to review and **Pay** the expense. If your default contact method is a phone number, you'll receive a text.
+2. Tap the **Pay** button on the notification to be directed to New Expensify.
+3. Select a payment option.
+- **Pay with Expensify** to pay the total expense within Expensify. Follow the prompt to pay with a [business bank account](https://help.expensify.com/articles/new-expensify/expenses-&-payments/Connect-a-Business-Bank-Account).
+- **Pay Elsewhere** to pay outside Expensify.
+{% include end-option.html %}
+{% include end-selector.html %}
+
+# Pay back friends and family
+
+You'll need to [set up your wallet](https://help.expensify.com/articles/new-expensify/expenses-&-payments/Set-up-your-wallet) to send and receive personal payments within Expensify. The wallet is currently available to customers in the US-only.
+
+To pay an expense,
+
+{% include selector.html values="desktop, mobile" %}
+{% include option.html value="desktop" %}
+1. You will receive an email or in-app notification when an individual sends you an expense. If your default contact method is a phone number, you'll receive a text.
+2. Click the **Pay** button to be directed to New Expensify.
+3. Review the expense details and click **Pay**.
+4. Select a payment option.
+- **Pay with Expensify** to pay the expense with your connected Wallet.
+- **Pay Elsewhere** to pay outside Expensify.
+{% include end-option.html %}
+
+{% include option.html value="mobile" %}
+1. You will receive an email or in-app notification when an individual sends you an expense. If your default contact method is a phone number, you'll receive a text.
+2. Tap the **Pay** button to be directed to New Expensify.
+3. Review the expense details and tap **Pay**.
+4. Select a payment option.
+- **Pay with Expensify** to pay the expense with your connected Wallet.
+- **Pay Elsewhere** to pay outside Expensify.
+{% include end-option.html %}
+{% include end-selector.html %}
+
+{% include faq-begin.md %}
+
+**Can I pay someone in another currency?**
+
+While you can record your expenses in different currencies, Expensify is configured to pay a U.S. personal or business bank account.
+
+{% include faq-end.md %}
+
+
diff --git a/docs/articles/new-expensify/expenses-&-payments/Pay-an-invoice.md b/docs/articles/new-expensify/expenses-&-payments/Pay-an-invoice.md
index 615fac731c41..4be5f9d739b5 100644
--- a/docs/articles/new-expensify/expenses-&-payments/Pay-an-invoice.md
+++ b/docs/articles/new-expensify/expenses-&-payments/Pay-an-invoice.md
@@ -10,7 +10,7 @@ Anyone who receives an Expensify invoice can pay it using Expensify—even if th
You'll receive an automated email or text notification when an invoice is sent to you for payment.
-To pay an invoice,
+# Pay an invoice
{% include selector.html values="desktop, mobile" %}
diff --git a/docs/redirects.csv b/docs/redirects.csv
index 4a08a683d08e..5c83d510ccb8 100644
--- a/docs/redirects.csv
+++ b/docs/redirects.csv
@@ -598,4 +598,8 @@ 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
\ No newline at end of file
+https://help.expensify.com/articles/new-expensify/expenses-&-payments/Approve-and-pay-expenses,https://help.expensify.com/articles/new-expensify/expenses-&-payments/Approve-expenses
+https://help.expensify.com/articles/expensify-classic/spending-insights/Custom-Templates,https://help.expensify.com/articles/expensify-classic/spending-insights/Export-Expenses-And-Reports/
+https://help.expensify.com/articles/expensify-classic/spending-insights/Default-Export-Templates,https://help.expensify.com/articles/expensify-classic/spending-insights/Export-Expenses-And-Reports/
+https://help.expensify.com/articles/expensify-classic/spending-insights/Other-Export-Options,https://help.expensify.com/articles/expensify-classic/spending-insights/Export-Expenses-And-Reports/
+https://help.expensify.com/articles/expensify-classic/travel/Edit-or-cancel-travel-arrangements,https://help.expensify.com/articles/expensify-classic/travel/Book-with-Expensify-Travel
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 20c407dd5930..08ab0c7aba57 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -40,7 +40,7 @@
CFBundleVersion
- 9.0.64.0
+ 9.0.64.4FullStoryOrgId
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index e8bfec4a0629..e3b4f59ce64d 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -19,6 +19,6 @@
CFBundleSignature????CFBundleVersion
- 9.0.64.0
+ 9.0.64.4
diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist
index 86ad689e275f..7ecf35bd30d0 100644
--- a/ios/NotificationServiceExtension/Info.plist
+++ b/ios/NotificationServiceExtension/Info.plist
@@ -13,7 +13,7 @@
CFBundleShortVersionString9.0.64CFBundleVersion
- 9.0.64.0
+ 9.0.64.4NSExtensionNSExtensionPointIdentifier
diff --git a/package-lock.json b/package-lock.json
index 22a03bc5e464..445e4328e7ef 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "9.0.64-0",
+ "version": "9.0.64-4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "9.0.64-0",
+ "version": "9.0.64-4",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
diff --git a/package.json b/package.json
index d984af3f9431..4f66ca5d13dd 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "9.0.64-0",
+ "version": "9.0.64-4",
"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.",
diff --git a/patches/recyclerlistview+4.2.1.patch b/patches/recyclerlistview+4.2.1.patch
new file mode 100644
index 000000000000..bc68489246cd
--- /dev/null
+++ b/patches/recyclerlistview+4.2.1.patch
@@ -0,0 +1,12 @@
+diff --git a/node_modules/recyclerlistview/dist/reactnative/core/RecyclerListView.js b/node_modules/recyclerlistview/dist/reactnative/core/RecyclerListView.js
+index 3ca4550..753c2f7 100644
+--- a/node_modules/recyclerlistview/dist/reactnative/core/RecyclerListView.js
++++ b/node_modules/recyclerlistview/dist/reactnative/core/RecyclerListView.js
+@@ -251,6 +251,7 @@ var RecyclerListView = /** @class */ (function (_super) {
+ this._virtualRenderer.setOptimizeForAnimations(false);
+ };
+ RecyclerListView.prototype.componentDidMount = function () {
++ this._isMounted = true;
+ if (this._initComplete) {
+ this._processInitialOffset();
+ this._processOnEndReached();
diff --git a/src/CONST.ts b/src/CONST.ts
index f43c86e94b4f..06feb863a80a 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -620,6 +620,31 @@ const CONST = {
AGREEMENTS: 'AgreementsStep',
FINISH: 'FinishStep',
},
+ BENEFICIAL_OWNER_INFO_STEP: {
+ SUBSTEP: {
+ IS_USER_BENEFICIAL_OWNER: 1,
+ IS_ANYONE_ELSE_BENEFICIAL_OWNER: 2,
+ BENEFICIAL_OWNER_DETAILS_FORM: 3,
+ ARE_THERE_MORE_BENEFICIAL_OWNERS: 4,
+ OWNERSHIP_CHART: 5,
+ BENEFICIAL_OWNERS_LIST: 6,
+ },
+ BENEFICIAL_OWNER_DATA: {
+ BENEFICIAL_OWNER_KEYS: 'beneficialOwnerKeys',
+ PREFIX: 'beneficialOwner',
+ FIRST_NAME: 'firstName',
+ LAST_NAME: 'lastName',
+ OWNERSHIP_PERCENTAGE: 'ownershipPercentage',
+ DOB: 'dob',
+ SSN_LAST_4: 'ssnLast4',
+ STREET: 'street',
+ CITY: 'city',
+ STATE: 'state',
+ ZIP_CODE: 'zipCode',
+ COUNTRY: 'country',
+ },
+ CURRENT_USER_KEY: 'currentUser',
+ },
STEP_NAMES: ['1', '2', '3', '4', '5', '6'],
STEP_HEADER_HEIGHT: 40,
SIGNER_INFO_STEP: {
@@ -850,6 +875,7 @@ const CONST = {
CLOUDFRONT_URL,
EMPTY_ARRAY,
EMPTY_OBJECT,
+ DEFAULT_NUMBER_ID: 0,
USE_EXPENSIFY_URL,
EXPENSIFY_URL,
GOOGLE_MEET_URL_ANDROID: 'https://meet.google.com',
@@ -1620,6 +1646,7 @@ const CONST = {
EXPENSIFY_MERCHANT: 'Expensify, Inc.',
EMAIL: {
ACCOUNTING: 'accounting@expensify.com',
+ ACCOUNTS_PAYABLE: 'accountspayable@expensify.com',
ADMIN: 'admin@expensify.com',
BILLS: 'bills@expensify.com',
CHRONOS: 'chronos@expensify.com',
@@ -2082,6 +2109,7 @@ const CONST = {
ACCOUNT_ID: {
ACCOUNTING: Number(Config?.EXPENSIFY_ACCOUNT_ID_ACCOUNTING ?? 9645353),
+ ACCOUNTS_PAYABLE: Number(Config?.EXPENSIFY_ACCOUNT_ID_ACCOUNTS_PAYABLE ?? 10903701),
ADMIN: Number(Config?.EXPENSIFY_ACCOUNT_ID_ADMIN ?? -1),
BILLS: Number(Config?.EXPENSIFY_ACCOUNT_ID_BILLS ?? 1371),
CHRONOS: Number(Config?.EXPENSIFY_ACCOUNT_ID_CHRONOS ?? 10027416),
@@ -3026,6 +3054,7 @@ const CONST = {
get EXPENSIFY_EMAILS() {
return [
this.EMAIL.ACCOUNTING,
+ this.EMAIL.ACCOUNTS_PAYABLE,
this.EMAIL.ADMIN,
this.EMAIL.BILLS,
this.EMAIL.CHRONOS,
@@ -3046,6 +3075,7 @@ const CONST = {
get EXPENSIFY_ACCOUNT_IDS() {
return [
this.ACCOUNT_ID.ACCOUNTING,
+ this.ACCOUNT_ID.ACCOUNTS_PAYABLE,
this.ACCOUNT_ID.ADMIN,
this.ACCOUNT_ID.BILLS,
this.ACCOUNT_ID.CHRONOS,
@@ -4821,7 +4851,6 @@ const CONST = {
WELCOME_VIDEO_URL: `${CLOUDFRONT_URL}/videos/intro-1280.mp4`,
- ONBOARDING_INTRODUCTION: 'Let’s get you set up 🔧',
ONBOARDING_CHOICES: {...onboardingChoices},
SELECTABLE_ONBOARDING_CHOICES: {...selectableOnboardingChoices},
COMBINED_TRACK_SUBMIT_ONBOARDING_CHOICES: {...combinedTrackSubmitOnboardingChoices},
@@ -5005,7 +5034,7 @@ const CONST = {
'\n' +
`Here’s how to connect to ${integrationName}:\n` +
'\n' +
- '1. Click your profile photo.\n' +
+ '1. Click the settings tab.\n' +
'2. Go to Workspaces.\n' +
'3. Select your workspace.\n' +
'4. Click Accounting.\n' +
diff --git a/src/components/Attachments/AttachmentCarousel/index.tsx b/src/components/Attachments/AttachmentCarousel/index.tsx
index ce160edefd89..560edcbbf099 100644
--- a/src/components/Attachments/AttachmentCarousel/index.tsx
+++ b/src/components/Attachments/AttachmentCarousel/index.tsx
@@ -3,7 +3,7 @@ import type {MutableRefObject} from 'react';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import type {ListRenderItemInfo} from 'react-native';
import {Keyboard, PixelRatio, View} from 'react-native';
-import type {GestureType} from 'react-native-gesture-handler';
+import type {ComposedGesture, GestureType} from 'react-native-gesture-handler';
import {Gesture, GestureDetector} from 'react-native-gesture-handler';
import {useOnyx} from 'react-native-onyx';
import Animated, {scrollTo, useAnimatedRef, useSharedValue} from 'react-native-reanimated';
@@ -38,6 +38,19 @@ const viewabilityConfig = {
const MIN_FLING_VELOCITY = 500;
+type DeviceAwareGestureDetectorProps = {
+ canUseTouchScreen: boolean;
+ gesture: ComposedGesture | GestureType;
+ children: React.ReactNode;
+};
+
+function DeviceAwareGestureDetector({canUseTouchScreen, gesture, children}: DeviceAwareGestureDetectorProps) {
+ // Don't render GestureDetector on non-touchable devices to prevent unexpected pointer event capture.
+ // This issue is left out on touchable devices since finger touch works fine.
+ // See: https://github.com/Expensify/App/issues/51246
+ return canUseTouchScreen ? {children} : children;
+}
+
function AttachmentCarousel({report, source, onNavigate, setDownloadButtonVisibility, type, accountID, onClose, attachmentLink}: AttachmentCarouselProps) {
const theme = useTheme();
const {translate} = useLocalize();
@@ -70,7 +83,7 @@ function AttachmentCarousel({report, source, onNavigate, setDownloadButtonVisibi
setShouldShowArrows(true);
}, [canUseTouchScreen, page, setShouldShowArrows]);
- const compareImage = useCallback((attachment: Attachment) => attachment.source === source && attachment.attachmentLink === attachmentLink, [attachmentLink, source]);
+ const compareImage = useCallback((attachment: Attachment) => attachment.source === source && (!attachmentLink || attachment.attachmentLink === attachmentLink), [attachmentLink, source]);
useEffect(() => {
const parentReportAction = report.parentReportActionID && parentReportActions ? parentReportActions[report.parentReportActionID] : undefined;
@@ -290,7 +303,10 @@ function AttachmentCarousel({report, source, onNavigate, setDownloadButtonVisibi
cancelAutoHideArrow={cancelAutoHideArrows}
/>
-
+
-
+
diff --git a/src/components/InitialURLContextProvider.tsx b/src/components/InitialURLContextProvider.tsx
index 8042786ff8af..adf361a2573d 100644
--- a/src/components/InitialURLContextProvider.tsx
+++ b/src/components/InitialURLContextProvider.tsx
@@ -1,10 +1,8 @@
import React, {createContext, useEffect, useMemo, useState} from 'react';
import type {ReactNode} from 'react';
import {Linking} from 'react-native';
-import {useOnyx} from 'react-native-onyx';
import {signInAfterTransitionFromOldDot} from '@libs/actions/Session';
import CONST from '@src/CONST';
-import ONYXKEYS from '@src/ONYXKEYS';
import type {Route} from '@src/ROUTES';
import {useSplashScreenStateContext} from '@src/SplashScreenStateContext';
@@ -30,13 +28,10 @@ type InitialURLContextProviderProps = {
function InitialURLContextProvider({children, url}: InitialURLContextProviderProps) {
const [initialURL, setInitialURL] = useState();
const {setSplashScreenState} = useSplashScreenStateContext();
- const [initialLastUpdateIDAppliedToClient, metadata] = useOnyx(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT);
+
useEffect(() => {
- if (metadata.status !== 'loaded') {
- return;
- }
if (url) {
- signInAfterTransitionFromOldDot(url, initialLastUpdateIDAppliedToClient).then((route) => {
+ signInAfterTransitionFromOldDot(url).then((route) => {
setInitialURL(route);
setSplashScreenState(CONST.BOOT_SPLASH_STATE.READY_TO_BE_HIDDEN);
});
@@ -45,7 +40,7 @@ function InitialURLContextProvider({children, url}: InitialURLContextProviderPro
Linking.getInitialURL().then((initURL) => {
setInitialURL(initURL as Route);
});
- }, [initialLastUpdateIDAppliedToClient, metadata.status, setSplashScreenState, url]);
+ }, [setSplashScreenState, url]);
const initialUrlContext = useMemo(
() => ({
@@ -61,5 +56,4 @@ function InitialURLContextProvider({children, url}: InitialURLContextProviderPro
InitialURLContextProvider.displayName = 'InitialURLContextProvider';
export default InitialURLContextProvider;
-
export {InitialURLContext};
diff --git a/src/components/LocationPermissionModal/index.android.tsx b/src/components/LocationPermissionModal/index.android.tsx
index 30896cf37084..6e4e6877c540 100644
--- a/src/components/LocationPermissionModal/index.android.tsx
+++ b/src/components/LocationPermissionModal/index.android.tsx
@@ -50,7 +50,7 @@ function LocationPermissionModal({startPermissionFlow, resetPermissionFlow, onDe
setHasError(true);
return;
} else {
- onDeny(status);
+ onDeny();
}
setShowModal(false);
setHasError(false);
@@ -58,7 +58,7 @@ function LocationPermissionModal({startPermissionFlow, resetPermissionFlow, onDe
});
const skipLocationPermission = () => {
- onDeny(RESULTS.DENIED);
+ onDeny();
setShowModal(false);
setHasError(false);
};
diff --git a/src/components/LocationPermissionModal/index.tsx b/src/components/LocationPermissionModal/index.tsx
index 0e500a9b7cc4..45e3f5b22d1b 100644
--- a/src/components/LocationPermissionModal/index.tsx
+++ b/src/components/LocationPermissionModal/index.tsx
@@ -1,4 +1,4 @@
-import React, {useEffect, useState} from 'react';
+import React, {useEffect, useMemo, useState} from 'react';
import {Linking} from 'react-native';
import {RESULTS} from 'react-native-permissions';
import ConfirmModal from '@components/ConfirmModal';
@@ -39,10 +39,10 @@ function LocationPermissionModal({startPermissionFlow, resetPermissionFlow, onDe
if (hasError) {
if (Linking.openSettings) {
Linking.openSettings();
+ } else {
+ onDeny?.();
}
setShowModal(false);
- setHasError(false);
- resetPermissionFlow();
return;
}
cb();
@@ -54,7 +54,7 @@ function LocationPermissionModal({startPermissionFlow, resetPermissionFlow, onDe
if (status === RESULTS.GRANTED || status === RESULTS.LIMITED) {
onGrant();
} else {
- onDeny(status);
+ onDeny();
}
})
.finally(() => {
@@ -64,7 +64,7 @@ function LocationPermissionModal({startPermissionFlow, resetPermissionFlow, onDe
});
const skipLocationPermission = () => {
- onDeny(RESULTS.DENIED);
+ onDeny();
setShowModal(false);
setHasError(false);
};
@@ -81,15 +81,22 @@ function LocationPermissionModal({startPermissionFlow, resetPermissionFlow, onDe
setShowModal(false);
resetPermissionFlow();
};
+
+ const locationErrorMessage = useMemo(() => (isWeb ? 'receipt.allowLocationFromSetting' : 'receipt.locationErrorMessage'), [isWeb]);
+
return (
{
+ setHasError(false);
+ resetPermissionFlow();
+ }}
isVisible={showModal}
onConfirm={grantLocationPermission}
onCancel={skipLocationPermission}
onBackdropPress={closeModal}
confirmText={getConfirmText()}
cancelText={translate('common.notNow')}
- prompt={translate(hasError ? 'receipt.locationErrorMessage' : 'receipt.locationAccessMessage')}
promptStyles={[styles.textLabelSupportingEmptyValue, styles.mb4]}
title={translate(hasError ? 'receipt.locationErrorTitle' : 'receipt.locationAccessTitle')}
titleContainerStyles={[styles.mt2, styles.mb0]}
@@ -100,6 +107,7 @@ function LocationPermissionModal({startPermissionFlow, resetPermissionFlow, onDe
iconHeight={120}
shouldCenterIcon
shouldReverseStackedButtons
+ prompt={translate(hasError ? locationErrorMessage : 'receipt.locationAccessMessage')}
/>
);
}
diff --git a/src/components/LocationPermissionModal/types.ts b/src/components/LocationPermissionModal/types.ts
index ec603bfdb8c1..eb18e1d71c13 100644
--- a/src/components/LocationPermissionModal/types.ts
+++ b/src/components/LocationPermissionModal/types.ts
@@ -1,11 +1,9 @@
-import type {PermissionStatus} from 'react-native-permissions';
-
type LocationPermissionModalProps = {
/** A callback to call when the permission has been granted */
onGrant: () => void;
/** A callback to call when the permission has been denied */
- onDeny: (permission: PermissionStatus) => void;
+ onDeny: () => void;
/** Should start the permission flow? */
startPermissionFlow: boolean;
diff --git a/src/components/ParentNavigationSubtitle.tsx b/src/components/ParentNavigationSubtitle.tsx
index 997106f3e649..11b361642c6e 100644
--- a/src/components/ParentNavigationSubtitle.tsx
+++ b/src/components/ParentNavigationSubtitle.tsx
@@ -52,7 +52,7 @@ function ParentNavigationSubtitle({parentNavigationSubtitleData, parentReportAct
style={pressableStyles}
>
{!!reportName && (
diff --git a/src/components/ProcessMoneyReportHoldMenu.tsx b/src/components/ProcessMoneyReportHoldMenu.tsx
index f1a72cc7fb8e..246a57dccaf2 100644
--- a/src/components/ProcessMoneyReportHoldMenu.tsx
+++ b/src/components/ProcessMoneyReportHoldMenu.tsx
@@ -67,6 +67,9 @@ function ProcessMoneyReportHoldMenu({
const onSubmit = (full: boolean) => {
if (isApprove) {
+ if (startAnimation) {
+ startAnimation();
+ }
IOU.approveMoneyRequest(moneyRequestReport, full);
if (!full && isLinkedTransactionHeld(Navigation.getTopmostReportActionId() ?? '-1', moneyRequestReport?.reportID ?? '')) {
Navigation.goBack(ROUTES.REPORT_WITH_ID.getRoute(moneyRequestReport?.reportID ?? ''));
diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx
index bbceeb18bdcd..27bffd47571b 100644
--- a/src/components/ReportActionItem/ReportPreview.tsx
+++ b/src/components/ReportActionItem/ReportPreview.tsx
@@ -121,6 +121,7 @@ function ReportPreview({
);
const [isPaidAnimationRunning, setIsPaidAnimationRunning] = useState(false);
+ const [isApprovedAnimationRunning, setIsApprovedAnimationRunning] = useState(false);
const [isHoldMenuVisible, setIsHoldMenuVisible] = useState(false);
const [requestType, setRequestType] = useState();
const {nonHeldAmount, fullAmount, hasValidNonHeldAmount} = ReportUtils.getNonHeldAndFullAmount(iouReport, policy);
@@ -140,12 +141,18 @@ function ReportPreview({
}));
const checkMarkScale = useSharedValue(iouSettled ? 1 : 0);
+ const isApproved = ReportUtils.isReportApproved(iouReport, action);
+ const thumbsUpScale = useSharedValue(isApproved ? 1 : 0.25);
+ const thumbsUpStyle = useAnimatedStyle(() => ({
+ ...styles.defaultCheckmarkWrapper,
+ transform: [{scale: thumbsUpScale.value}],
+ }));
+
const moneyRequestComment = action?.childLastMoneyRequestComment ?? '';
const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(chatReport);
const isInvoiceRoom = ReportUtils.isInvoiceRoom(chatReport);
const isOpenExpenseReport = isPolicyExpenseChat && ReportUtils.isOpenExpenseReport(iouReport);
- const isApproved = ReportUtils.isReportApproved(iouReport, action);
const canAllowSettlement = ReportUtils.hasUpdatedTotal(iouReport, policy);
const numberOfRequests = allTransactions.length;
const transactionsWithReceipts = ReportUtils.getTransactionsWithReceipts(iouReportID);
@@ -196,11 +203,19 @@ function ReportPreview({
const {isDelegateAccessRestricted, delegatorEmail} = useDelegateUserDetails();
const [isNoDelegateAccessMenuVisible, setIsNoDelegateAccessMenuVisible] = useState(false);
- const stopAnimation = useCallback(() => setIsPaidAnimationRunning(false), []);
+ const stopAnimation = useCallback(() => {
+ setIsPaidAnimationRunning(false);
+ setIsApprovedAnimationRunning(false);
+ }, []);
+
const startAnimation = useCallback(() => {
setIsPaidAnimationRunning(true);
HapticFeedback.longPress();
}, []);
+ const startApprovedAnimation = useCallback(() => {
+ setIsApprovedAnimationRunning(true);
+ HapticFeedback.longPress();
+ }, []);
const confirmPayment = useCallback(
(type: PaymentMethodType | undefined, payAsBusiness?: boolean) => {
if (!type) {
@@ -232,6 +247,8 @@ function ReportPreview({
} else if (ReportUtils.hasHeldExpenses(iouReport?.reportID)) {
setIsHoldMenuVisible(true);
} else {
+ setIsApprovedAnimationRunning(true);
+ HapticFeedback.longPress();
IOU.approveMoneyRequest(iouReport, true);
}
};
@@ -330,14 +347,16 @@ function ReportPreview({
const bankAccountRoute = ReportUtils.getBankAccountRoute(chatReport);
const getCanIOUBePaid = useCallback(
- (onlyShowPayElsewhere = false) => IOU.canIOUBePaid(iouReport, chatReport, policy, transactionViolations, allTransactions, onlyShowPayElsewhere),
+ (onlyShowPayElsewhere = false, shouldCheckApprovedState = true) =>
+ IOU.canIOUBePaid(iouReport, chatReport, policy, transactionViolations, allTransactions, onlyShowPayElsewhere, shouldCheckApprovedState),
[iouReport, chatReport, policy, allTransactions, transactionViolations],
);
const canIOUBePaid = useMemo(() => getCanIOUBePaid(), [getCanIOUBePaid]);
+ const canIOUBePaidAndApproved = useMemo(() => getCanIOUBePaid(false, false), [getCanIOUBePaid]);
const onlyShowPayElsewhere = useMemo(() => !canIOUBePaid && getCanIOUBePaid(true), [canIOUBePaid, getCanIOUBePaid]);
const shouldShowPayButton = isPaidAnimationRunning || canIOUBePaid || onlyShowPayElsewhere;
- const shouldShowApproveButton = useMemo(() => IOU.canApproveIOU(iouReport, policy, transactionViolations), [iouReport, policy, transactionViolations]);
+ const shouldShowApproveButton = useMemo(() => IOU.canApproveIOU(iouReport, policy, transactionViolations), [iouReport, policy, transactionViolations]) || isApprovedAnimationRunning;
const shouldDisableApproveButton = shouldShowApproveButton && !ReportUtils.isAllowedToApproveExpenseReport(iouReport);
@@ -424,7 +443,7 @@ function ReportPreview({
const shouldShowExportIntegrationButton = !shouldShowPayButton && !shouldShowSubmitButton && connectedIntegration && isAdmin && ReportUtils.canBeExported(iouReport);
useEffect(() => {
- if (!isPaidAnimationRunning) {
+ if (!isPaidAnimationRunning || isApprovedAnimationRunning) {
return;
}
@@ -450,6 +469,14 @@ function ReportPreview({
}
}, [isPaidAnimationRunning, iouSettled, checkMarkScale]);
+ useEffect(() => {
+ if (!isApproved) {
+ return;
+ }
+
+ thumbsUpScale.value = withSpring(1, {duration: 200});
+ }, [isApproved, thumbsUpScale]);
+
return (
- {previewMessage}
+ {previewMessage}
{shouldShowRBR && (
)}
+ {isApproved && (
+
+
+
+ )}
{shouldShowSubtitle && !!supportText && (
@@ -538,6 +573,8 @@ function ReportPreview({
{
+ if (requestType === CONST.IOU.REPORT_ACTION_TYPE.APPROVE) {
+ startApprovedAnimation();
+ } else {
+ startAnimation();
+ }
+ }}
/>
)}
diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx
index 4c57c0d1f63f..b05d34b2351b 100644
--- a/src/components/Search/SearchRouter/SearchRouter.tsx
+++ b/src/components/Search/SearchRouter/SearchRouter.tsx
@@ -1,7 +1,9 @@
import {useNavigationState} from '@react-navigation/native';
import {Str} from 'expensify-common';
+import isEmpty from 'lodash/isEmpty';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {View} from 'react-native';
+import type {TextInputProps} from 'react-native';
import {useOnyx} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
@@ -46,9 +48,10 @@ import type {AutocompleteItemData} from './SearchRouterList';
type SearchRouterProps = {
onRouterClose: () => void;
+ shouldHideInputCaret?: TextInputProps['caretHidden'];
};
-function SearchRouter({onRouterClose}: SearchRouterProps) {
+function SearchRouter({onRouterClose, shouldHideInputCaret}: SearchRouterProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const [betas] = useOnyx(ONYXKEYS.BETAS);
@@ -321,6 +324,7 @@ function SearchRouter({onRouterClose}: SearchRouterProps) {
],
);
+ const prevUserQueryRef = useRef(null);
useEffect(() => {
Report.searchInServer(debouncedInputValue.trim());
}, [debouncedInputValue]);
@@ -338,11 +342,14 @@ function SearchRouter({onRouterClose}: SearchRouterProps) {
const updatedSubstitutionsMap = getUpdatedSubstitutionsMap(userQuery, autocompleteSubstitutions);
setAutocompleteSubstitutions(updatedSubstitutionsMap);
- if (newUserQuery) {
+ if (newUserQuery || !isEmpty(prevUserQueryRef.current)) {
listRef.current?.updateAndScrollToFocusedIndex(0);
} else {
listRef.current?.updateAndScrollToFocusedIndex(-1);
}
+
+ // Store the previous newUserQuery
+ prevUserQueryRef.current = newUserQuery;
},
[autocompleteSubstitutions, autocompleteSuggestions, setTextInputValue, updateAutocomplete],
);
@@ -396,6 +403,7 @@ function SearchRouter({onRouterClose}: SearchRouterProps) {
onSubmit={() => {
onSearchSubmit(textInputValue);
}}
+ caretHidden={shouldHideInputCaret}
routerListRef={listRef}
shouldShowOfflineMessage
wrapperStyle={[styles.border, styles.alignItemsCenter]}
diff --git a/src/components/Search/SearchRouter/SearchRouterInput.tsx b/src/components/Search/SearchRouter/SearchRouterInput.tsx
index c66437f63bdd..6b99588a21df 100644
--- a/src/components/Search/SearchRouter/SearchRouterInput.tsx
+++ b/src/components/Search/SearchRouter/SearchRouterInput.tsx
@@ -1,6 +1,6 @@
import type {ReactNode, RefObject} from 'react';
import React, {useState} from 'react';
-import type {StyleProp, ViewStyle} from 'react-native';
+import type {StyleProp, TextInputProps, ViewStyle} from 'react-native';
import {View} from 'react-native';
import FormHelpMessage from '@components/FormHelpMessage';
import type {SelectionListHandle} from '@components/SelectionList/types';
@@ -34,9 +34,6 @@ type SearchRouterInputProps = {
/** Whether the offline message should be shown */
shouldShowOfflineMessage?: boolean;
- /** Whether the input should be focused */
- autoFocus?: boolean;
-
/** Any additional styles to apply */
wrapperStyle?: StyleProp;
@@ -51,7 +48,7 @@ type SearchRouterInputProps = {
/** Whether the search reports API call is running */
isSearchingForReports?: boolean;
-};
+} & Pick;
function SearchRouterInput({
value,
@@ -62,6 +59,7 @@ function SearchRouterInput({
disabled = false,
shouldShowOfflineMessage = false,
autoFocus = true,
+ caretHidden = false,
wrapperStyle,
wrapperFocusedStyle,
outerWrapperStyle,
@@ -86,6 +84,7 @@ function SearchRouterInput({
onChangeText={updateSearch}
autoFocus={autoFocus}
shouldDelayFocus={shouldDelayFocus}
+ caretHidden={caretHidden}
loadingSpinnerStyle={[styles.mt0, styles.mr2]}
role={CONST.ROLE.PRESENTATION}
placeholder={translate('search.searchPlaceholder')}
diff --git a/src/components/Search/SearchRouter/SearchRouterModal.tsx b/src/components/Search/SearchRouter/SearchRouterModal.tsx
index 356dcb70f199..340b46cbf173 100644
--- a/src/components/Search/SearchRouter/SearchRouterModal.tsx
+++ b/src/components/Search/SearchRouter/SearchRouterModal.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, {useState} from 'react';
import FocusTrapForModal from '@components/FocusTrap/FocusTrapForModal';
import Modal from '@components/Modal';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
@@ -7,10 +7,15 @@ import CONST from '@src/CONST';
import SearchRouter from './SearchRouter';
import {useSearchRouterContext} from './SearchRouterContext';
+const isMobileSafari = Browser.isMobileSafari();
+
function SearchRouterModal() {
const {shouldUseNarrowLayout} = useResponsiveLayout();
const {isSearchRouterDisplayed, closeSearchRouter} = useSearchRouterContext();
+ // On mWeb Safari, the input caret stuck for a moment while the modal is animating. So, we hide the caret until the animation is done.
+ const [shouldHideInputCaret, setShouldHideInputCaret] = useState(isMobileSafari);
+
const modalType = shouldUseNarrowLayout ? CONST.MODAL.MODAL_TYPE.CENTERED_SWIPABLE_TO_RIGHT : CONST.MODAL.MODAL_TYPE.POPOVER;
return (
@@ -22,10 +27,15 @@ function SearchRouterModal() {
propagateSwipe
shouldHandleNavigationBack={Browser.isMobileChrome()}
onClose={closeSearchRouter}
+ onModalHide={() => setShouldHideInputCaret(isMobileSafari)}
+ onModalShow={() => setShouldHideInputCaret(false)}
>
{isSearchRouterDisplayed && (
-
+
)}
diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx
index b7bef18896d1..66d648f1b472 100644
--- a/src/components/SelectionList/BaseSelectionList.tsx
+++ b/src/components/SelectionList/BaseSelectionList.tsx
@@ -645,10 +645,13 @@ function BaseSelectionList(
) {
return;
}
- // Remove the focus if the search input is empty or selected options length is changed (and allOptions length remains the same)
+ // Remove the focus if the search input is empty and prev search input not empty or selected options length is changed (and allOptions length remains the same)
// else focus on the first non disabled item
const newSelectedIndex =
- textInputValue === '' || (flattenedSections.selectedOptions.length !== prevSelectedOptionsLength && prevAllOptionsLength === flattenedSections.allOptions.length) ? -1 : 0;
+ (isEmpty(prevTextInputValue) && textInputValue === '') ||
+ (flattenedSections.selectedOptions.length !== prevSelectedOptionsLength && prevAllOptionsLength === flattenedSections.allOptions.length)
+ ? -1
+ : 0;
// reseting the currrent page to 1 when the user types something
setCurrentPage(1);
diff --git a/src/components/SettlementButton/AnimatedSettlementButton.tsx b/src/components/SettlementButton/AnimatedSettlementButton.tsx
index 5de528d741a2..7e42c8cdc45c 100644
--- a/src/components/SettlementButton/AnimatedSettlementButton.tsx
+++ b/src/components/SettlementButton/AnimatedSettlementButton.tsx
@@ -11,9 +11,18 @@ import type SettlementButtonProps from './types';
type AnimatedSettlementButtonProps = SettlementButtonProps & {
isPaidAnimationRunning: boolean;
onAnimationFinish: () => void;
+ isApprovedAnimationRunning: boolean;
+ canIOUBePaid: boolean;
};
-function AnimatedSettlementButton({isPaidAnimationRunning, onAnimationFinish, isDisabled, ...settlementButtonProps}: AnimatedSettlementButtonProps) {
+function AnimatedSettlementButton({
+ isPaidAnimationRunning,
+ onAnimationFinish,
+ isApprovedAnimationRunning,
+ isDisabled,
+ canIOUBePaid,
+ ...settlementButtonProps
+}: AnimatedSettlementButtonProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const buttonScale = useSharedValue(1);
@@ -38,12 +47,13 @@ function AnimatedSettlementButton({isPaidAnimationRunning, onAnimationFinish, is
overflow: 'hidden',
marginTop: buttonMarginTop.value,
}));
- const buttonDisabledStyle = isPaidAnimationRunning
- ? {
- opacity: 1,
- ...styles.cursorDefault,
- }
- : undefined;
+ const buttonDisabledStyle =
+ isPaidAnimationRunning || isApprovedAnimationRunning
+ ? {
+ opacity: 1,
+ ...styles.cursorDefault,
+ }
+ : undefined;
const resetAnimation = useCallback(() => {
// eslint-disable-next-line react-compiler/react-compiler
@@ -56,7 +66,7 @@ function AnimatedSettlementButton({isPaidAnimationRunning, onAnimationFinish, is
}, [buttonScale, buttonOpacity, paymentCompleteTextScale, paymentCompleteTextOpacity, height, buttonMarginTop, styles.expenseAndReportPreviewTextButtonContainer.gap]);
useEffect(() => {
- if (!isPaidAnimationRunning) {
+ if (!isApprovedAnimationRunning && !isPaidAnimationRunning) {
resetAnimation();
return;
}
@@ -67,13 +77,30 @@ function AnimatedSettlementButton({isPaidAnimationRunning, onAnimationFinish, is
// Wait for the above animation + 1s delay before hiding the component
const totalDelay = CONST.ANIMATION_PAID_DURATION + CONST.ANIMATION_PAID_BUTTON_HIDE_DELAY;
+ const willShowPaymentButton = canIOUBePaid && isApprovedAnimationRunning;
height.value = withDelay(
totalDelay,
- withTiming(0, {duration: CONST.ANIMATION_PAID_DURATION}, () => runOnJS(onAnimationFinish)()),
+ withTiming(willShowPaymentButton ? variables.componentSizeNormal : 0, {duration: CONST.ANIMATION_PAID_DURATION}, () => runOnJS(onAnimationFinish)()),
+ );
+ buttonMarginTop.value = withDelay(
+ totalDelay,
+ withTiming(willShowPaymentButton ? styles.expenseAndReportPreviewTextButtonContainer.gap : 0, {duration: CONST.ANIMATION_PAID_DURATION}),
);
- buttonMarginTop.value = withDelay(totalDelay, withTiming(0, {duration: CONST.ANIMATION_PAID_DURATION}));
paymentCompleteTextOpacity.value = withDelay(totalDelay, withTiming(0, {duration: CONST.ANIMATION_PAID_DURATION}));
- }, [isPaidAnimationRunning, onAnimationFinish, buttonOpacity, buttonScale, height, paymentCompleteTextOpacity, paymentCompleteTextScale, buttonMarginTop, resetAnimation]);
+ }, [
+ isPaidAnimationRunning,
+ isApprovedAnimationRunning,
+ onAnimationFinish,
+ buttonOpacity,
+ buttonScale,
+ height,
+ paymentCompleteTextOpacity,
+ paymentCompleteTextScale,
+ buttonMarginTop,
+ resetAnimation,
+ canIOUBePaid,
+ styles.expenseAndReportPreviewTextButtonContainer.gap,
+ ]);
return (
@@ -82,11 +109,16 @@ function AnimatedSettlementButton({isPaidAnimationRunning, onAnimationFinish, is
{translate('iou.paymentComplete')}
)}
+ {isApprovedAnimationRunning && (
+
+ {translate('iou.approved')}
+
+ )}
diff --git a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx
index cd80330b08ef..930d4139d388 100644
--- a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx
+++ b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx
@@ -179,7 +179,7 @@ function BaseValidateCodeForm({
setValidateCode(text);
setFormError({});
- if (validateError) {
+ if (!isEmptyObject(validateError)) {
clearError();
User.clearValidateCodeActionError('actionVerified');
}
diff --git a/src/components/ValidateCodeActionModal/index.tsx b/src/components/ValidateCodeActionModal/index.tsx
index f715fd8ef136..470d846ccc76 100644
--- a/src/components/ValidateCodeActionModal/index.tsx
+++ b/src/components/ValidateCodeActionModal/index.tsx
@@ -7,6 +7,7 @@ import ScreenWrapper from '@components/ScreenWrapper';
import Text from '@components/Text';
import useSafePaddingBottomStyle from '@hooks/useSafePaddingBottomStyle';
import useThemeStyles from '@hooks/useThemeStyles';
+import Navigation from '@libs/Navigation/Navigation';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {ValidateCodeActionModalProps} from './type';
@@ -56,6 +57,7 @@ function ValidateCodeActionModal({
isVisible={isVisible}
onClose={hide}
onModalHide={onModalHide ?? hide}
+ onBackdropPress={() => Navigation.dismissModal()}
hideModalContentWhileAnimating
useNativeDriver
shouldUseModalPaddingStyle={false}
diff --git a/src/components/VideoPlayer/BaseVideoPlayer.tsx b/src/components/VideoPlayer/BaseVideoPlayer.tsx
index 012537b75108..b1a829b4cc4b 100644
--- a/src/components/VideoPlayer/BaseVideoPlayer.tsx
+++ b/src/components/VideoPlayer/BaseVideoPlayer.tsx
@@ -70,6 +70,7 @@ function BaseVideoPlayer({
const [position, setPosition] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [isLoading, setIsLoading] = useState(true);
+ const [isEnded, setIsEnded] = useState(false);
const [isBuffering, setIsBuffering] = useState(true);
// we add "#t=0.001" at the end of the URL to skip first milisecond of the video and always be able to show proper video preview when video is paused at the beginning
const [sourceURL] = useState(VideoUtils.addSkipTimeTagToURL(url.includes('blob:') || url.includes('file:///') ? url : addEncryptedAuthTokenToURL(url), 0.001));
@@ -95,6 +96,7 @@ function BaseVideoPlayer({
const shouldUseNewRate = typeof source === 'number' || !source || source.uri !== sourceURL;
const togglePlayCurrentVideo = useCallback(() => {
+ setIsEnded(false);
videoResumeTryNumberRef.current = 0;
if (!isCurrentlyURLSet) {
updateCurrentlyPlayingURL(url);
@@ -106,9 +108,12 @@ function BaseVideoPlayer({
}, [isCurrentlyURLSet, isPlaying, pauseVideo, playVideo, updateCurrentlyPlayingURL, url, videoResumeTryNumberRef]);
const hideControl = useCallback(() => {
+ if (isEnded) {
+ return;
+ }
// eslint-disable-next-line react-compiler/react-compiler
controlsOpacity.value = withTiming(0, {duration: 500}, () => runOnJS(setControlStatusState)(CONST.VIDEO_PLAYER.CONTROLS_STATUS.HIDE));
- }, [controlsOpacity]);
+ }, [controlsOpacity, isEnded]);
const debouncedHideControl = useMemo(() => debounce(hideControl, 1500), [hideControl]);
useEffect(() => {
@@ -198,6 +203,13 @@ function BaseVideoPlayer({
onPlaybackStatusUpdate?.(status);
return;
}
+ if (status.didJustFinish) {
+ setIsEnded(status.didJustFinish && !status.isLooping);
+ setControlStatusState(CONST.VIDEO_PLAYER.CONTROLS_STATUS.SHOW);
+ controlsOpacity.value = 1;
+ } else if (status.isPlaying && isEnded) {
+ setIsEnded(false);
+ }
if (prevIsMutedRef.current && prevVolumeRef.current === 0 && !status.isMuted) {
updateVolume(0.25);
@@ -213,7 +225,7 @@ function BaseVideoPlayer({
const currentDuration = status.durationMillis || videoDuration * 1000;
const currentPositon = status.positionMillis || 0;
- if (shouldReplayVideo(status, isVideoPlaying, currentDuration, currentPositon)) {
+ if (shouldReplayVideo(status, isVideoPlaying, currentDuration, currentPositon) && !isEnded) {
videoPlayerRef.current?.setStatusAsync({positionMillis: 0, shouldPlay: true});
}
@@ -228,7 +240,7 @@ function BaseVideoPlayer({
onPlaybackStatusUpdate?.(status);
},
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- we don't want to trigger this when isPlaying changes because isPlaying is only used inside shouldReplayVideo
- [onPlaybackStatusUpdate, preventPausingWhenExitingFullscreen, videoDuration],
+ [onPlaybackStatusUpdate, preventPausingWhenExitingFullscreen, videoDuration, isEnded],
);
const handleFullscreenUpdate = useCallback(
@@ -456,7 +468,7 @@ function BaseVideoPlayer({
{((isLoading && !isOffline) || (isBuffering && !isPlaying)) && }
{isLoading && (isOffline || !isBuffering) && }
- {controlStatusState !== CONST.VIDEO_PLAYER.CONTROLS_STATUS.HIDE && !isLoading && (isPopoverVisible || isHovered || canUseTouchScreen) && (
+ {controlStatusState !== CONST.VIDEO_PLAYER.CONTROLS_STATUS.HIDE && !isLoading && (isPopoverVisible || isHovered || canUseTouchScreen || isEnded) && (
`Do you own 25% or more of ${companyName}`,
+ doesAnyoneOwn: ({companyName}: CompanyNameParams) => `Does any individuals own 25% or more of ${companyName}`,
+ regulationsRequire: 'Regulations require us to verify the identity of any individual who owns more than 25% of the business.',
+ legalFirstName: 'Legal first name',
+ legalLastName: 'Legal last name',
+ whatsTheOwnersName: "What's the owner's legal name?",
+ whatsYourName: "What's your legal name?",
+ whatPercentage: 'What percentage of the business belongs to the owner?',
+ whatsYoursPercentage: 'What percentage of the business do you own?',
+ ownership: 'Ownership',
+ whatsTheOwnersDOB: "What's the owner's date of birth?",
+ whatsYourDOB: "What's your date of birth?",
+ whatsTheOwnersAddress: "What's the owner's address?",
+ whatsYourAddress: "What's your address?",
+ whatAreTheLast: "What are the last 4 digits of the owner's Social Security Number?",
+ whatsYourLast: 'What are the last 4 digits of your Social Security Number?',
+ dontWorry: "Don't worry, we don't do any personal credit checks!",
+ last4: 'Last 4 of SSN',
+ whyDoWeAsk: 'Why do we ask for this?',
+ letsDoubleCheck: 'Let’s double check that everything looks right.',
+ legalName: 'Legal name',
+ ownershipPercentage: 'Ownership percentage',
+ areThereOther: ({companyName}: CompanyNameParams) => `Are there other individuals who own 25% or more of ${companyName}`,
+ owners: 'Owners',
+ addCertified: 'Add a certified org chart that shows the beneficial owners',
+ regulationRequiresChart: 'Regulation requires us to collect a certified copy of the ownership chart that shows every individual or entity who owns 25% or more of the business.',
+ uploadEntity: 'Upload entity ownership chart',
+ noteEntity: 'Note: Entity ownership chart must be signed by your accountant, legal counsel, or notarized.',
+ certified: 'Certified entity ownership chart',
+ selectCountry: 'Select country',
+ findCountry: 'Find country',
+ address: 'Address',
+ },
validationStep: {
headerTitle: 'Validate bank account',
buttonText: 'Finish setup',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 98cf332ed0f9..91557c9defbf 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -789,6 +789,7 @@ const translations = {
locationAccessMessage: 'El acceso a la ubicación nos ayuda a mantener tu zona horaria y moneda precisas dondequiera que vayas.',
locationErrorTitle: 'Permitir acceso a la ubicación',
locationErrorMessage: 'El acceso a la ubicación nos ayuda a mantener tu zona horaria y moneda precisas dondequiera que vayas.',
+ allowLocationFromSetting: `El acceso a la ubicación nos ayuda a mantener tu zona horaria y moneda precisas dondequiera que estés. Por favor, permite el acceso a la ubicación en la configuración de permisos de tu dispositivo.`,
cameraErrorMessage: 'Se ha producido un error al hacer una foto. Por favor, inténtalo de nuevo.',
dropTitle: 'Suéltalo',
dropMessage: 'Suelta tu archivo aquí',
@@ -1975,6 +1976,7 @@ const translations = {
noDefaultDepositAccountOrDebitCardAvailable: 'Por favor, añade una cuenta bancaria para depósitos o una tarjeta de débito.',
validationAmounts: 'Los importes de validación que introduciste son incorrectos. Por favor, comprueba tu cuenta bancaria e inténtalo de nuevo.',
fullName: 'Please enter a valid full name.',
+ ownershipPercentage: 'Por favor, ingrese un número de porcentaje válido.',
},
},
addPersonalBankAccountPage: {
@@ -2254,6 +2256,43 @@ const translations = {
byAddingThisBankAccount: 'Al añadir esta cuenta bancaria, confirmas que has leído, comprendido y aceptado',
owners: 'Dueños',
},
+ ownershipInfoStep: {
+ ownerInfo: 'Información del propietario',
+ businessOwner: 'Propietario del negocio',
+ signerInfo: 'Información del firmante',
+ doYouOwn: ({companyName}: CompanyNameParams) => `¿Posee el 25% o más de ${companyName}?`,
+ doesAnyoneOwn: ({companyName}: CompanyNameParams) => `¿Alguien posee el 25% o más de ${companyName}?`,
+ regulationsRequire: 'Las regulaciones requieren que verifiquemos la identidad de cualquier persona que posea más del 25% del negocio.',
+ legalFirstName: 'Nombre legal',
+ legalLastName: 'Apellido legal',
+ whatsTheOwnersName: '¿Cuál es el nombre legal del propietario?',
+ whatsYourName: '¿Cuál es su nombre legal?',
+ whatPercentage: '¿Qué porcentaje del negocio pertenece al propietario?',
+ whatsYoursPercentage: '¿Qué porcentaje del negocio posee?',
+ ownership: 'Propiedad',
+ whatsTheOwnersDOB: '¿Cuál es la fecha de nacimiento del propietario?',
+ whatsYourDOB: '¿Cuál es su fecha de nacimiento?',
+ whatsTheOwnersAddress: '¿Cuál es la dirección del propietario?',
+ whatsYourAddress: '¿Cuál es su dirección?',
+ whatAreTheLast: '¿Cuáles son los últimos 4 dígitos del número de seguro social del propietario?',
+ whatsYourLast: '¿Cuáles son los últimos 4 dígitos de su número de seguro social?',
+ dontWorry: 'No se preocupe, ¡no realizamos ninguna verificación de crédito personal!',
+ last4: 'Últimos 4 del SSN',
+ whyDoWeAsk: '¿Por qué solicitamos esto?',
+ letsDoubleCheck: 'Verifiquemos que todo esté correcto.',
+ legalName: 'Nombre legal',
+ ownershipPercentage: 'Porcentaje de propiedad',
+ areThereOther: ({companyName}: CompanyNameParams) => `¿Hay otras personas que posean el 25% o más de ${companyName}?`,
+ owners: 'Propietarios',
+ addCertified: 'Agregue un organigrama certificado que muestre los propietarios beneficiarios',
+ regulationRequiresChart: 'La regulación nos exige recopilar una copia certificada del organigrama que muestre a cada persona o entidad que posea el 25% o más del negocio.',
+ uploadEntity: 'Subir organigrama de propiedad de la entidad',
+ noteEntity: 'Nota: El organigrama de propiedad de la entidad debe estar firmado por su contador, asesor legal o notariado.',
+ certified: 'Organigrama certificado de propiedad de la entidad',
+ selectCountry: 'Seleccionar país',
+ findCountry: 'Buscar país',
+ address: 'Dirección',
+ },
validationStep: {
headerTitle: 'Validar cuenta bancaria',
buttonText: 'Finalizar configuración',
diff --git a/src/libs/API/parameters/CategorizeTrackedExpenseParams.ts b/src/libs/API/parameters/CategorizeTrackedExpenseParams.ts
index d999f96fb505..78eb0adecc5e 100644
--- a/src/libs/API/parameters/CategorizeTrackedExpenseParams.ts
+++ b/src/libs/API/parameters/CategorizeTrackedExpenseParams.ts
@@ -20,8 +20,6 @@ type CategorizeTrackedExpenseParams = {
taxCode: string;
taxAmount: number;
billable?: boolean;
- waypoints?: string;
- customUnitRateID?: string;
};
export default CategorizeTrackedExpenseParams;
diff --git a/src/libs/API/parameters/ShareTrackedExpenseParams.ts b/src/libs/API/parameters/ShareTrackedExpenseParams.ts
index c89c0d400e72..cee4bc40d9ac 100644
--- a/src/libs/API/parameters/ShareTrackedExpenseParams.ts
+++ b/src/libs/API/parameters/ShareTrackedExpenseParams.ts
@@ -20,8 +20,6 @@ type ShareTrackedExpenseParams = {
taxCode: string;
taxAmount: number;
billable?: boolean;
- customUnitRateID?: string;
- waypoints?: string;
};
export default ShareTrackedExpenseParams;
diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts
index d56477c3f148..77aeb8e0ecc3 100644
--- a/src/libs/CardUtils.ts
+++ b/src/libs/CardUtils.ts
@@ -135,15 +135,26 @@ function maskCard(lastFour = ''): string {
* Converts given 'X' to '•' for the entire card string.
*
* @param cardName - card name with XXXX in the middle.
+ * @param feed - card feed.
* @returns - The masked card string.
*/
-function maskCardNumber(cardName: string): string {
+function maskCardNumber(cardName: string, feed: string | undefined): string {
if (!cardName || cardName === '') {
return '';
}
const hasSpace = /\s/.test(cardName);
const maskedString = cardName.replace(/X/g, '•');
- return hasSpace ? cardName : maskedString.replace(/(.{4})/g, '$1 ').trim();
+ const isAmexBank = [CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX, CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX_DIRECT].some((value) => value === feed);
+
+ if (hasSpace) {
+ return cardName;
+ }
+
+ if (isAmexBank && maskedString.length === 15) {
+ return maskedString.replace(/(.{4})(.{6})(.{5})/, '$1 $2 $3');
+ }
+
+ return maskedString.replace(/(.{4})/g, '$1 ').trim();
}
/**
diff --git a/src/libs/EmojiUtils.ts b/src/libs/EmojiUtils.ts
index 7c042bbefe67..f9fb5f226280 100644
--- a/src/libs/EmojiUtils.ts
+++ b/src/libs/EmojiUtils.ts
@@ -1,4 +1,5 @@
import {Str} from 'expensify-common';
+import lodashSortBy from 'lodash/sortBy';
import Onyx from 'react-native-onyx';
import type {OnyxEntry} from 'react-native-onyx';
import * as Emojis from '@assets/emojis';
@@ -23,6 +24,8 @@ const findEmojiByName = (name: string): Emoji => Emojis.emojiNameTable[name];
const findEmojiByCode = (code: string): Emoji => Emojis.emojiCodeTableWithSkinTones[code];
+const sortByName = (emoji: Emoji, emojiData: RegExpMatchArray) => !emoji.name.includes(emojiData[0].toLowerCase().slice(1));
+
let frequentlyUsedEmojis: FrequentlyUsedEmoji[] = [];
Onyx.connect({
key: ONYXKEYS.FREQUENTLY_USED_EMOJIS,
@@ -424,7 +427,7 @@ function suggestEmojis(text: string, lang: Locale, limit: number = CONST.AUTO_CO
for (const node of nodes) {
if (node.metaData?.code && !matching.find((obj) => obj.name === node.name)) {
if (matching.length === limit) {
- return matching;
+ return lodashSortBy(matching, (emoji) => sortByName(emoji, emojiData));
}
matching.push({code: node.metaData.code, name: node.name, types: node.metaData.types});
}
@@ -434,7 +437,7 @@ function suggestEmojis(text: string, lang: Locale, limit: number = CONST.AUTO_CO
}
for (const suggestion of suggestions) {
if (matching.length === limit) {
- return matching;
+ return lodashSortBy(matching, (emoji) => sortByName(emoji, emojiData));
}
if (!matching.find((obj) => obj.name === suggestion.name)) {
@@ -442,7 +445,7 @@ function suggestEmojis(text: string, lang: Locale, limit: number = CONST.AUTO_CO
}
}
}
- return matching;
+ return lodashSortBy(matching, (emoji) => sortByName(emoji, emojiData));
}
/**
diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx
index 0923287d6cdb..137debc3b35a 100644
--- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx
+++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx
@@ -1,3 +1,4 @@
+import {findFocusedRoute} from '@react-navigation/native';
import React, {memo, useEffect, useMemo, useRef, useState} from 'react';
import {View} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
@@ -22,9 +23,10 @@ import KeyboardShortcut from '@libs/KeyboardShortcut';
import Log from '@libs/Log';
import getCurrentUrl from '@libs/Navigation/currentUrl';
import getOnboardingModalScreenOptions from '@libs/Navigation/getOnboardingModalScreenOptions';
-import Navigation from '@libs/Navigation/Navigation';
+import Navigation, {navigationRef} from '@libs/Navigation/Navigation';
import shouldOpenOnAdminRoom from '@libs/Navigation/shouldOpenOnAdminRoom';
import type {AuthScreensParamList, CentralPaneName, CentralPaneScreensParamList} from '@libs/Navigation/types';
+import {isOnboardingFlowName} from '@libs/NavigationUtils';
import NetworkConnection from '@libs/NetworkConnection';
import onyxSubscribe from '@libs/onyxSubscribe';
import * as Pusher from '@libs/Pusher/pusher';
@@ -348,6 +350,11 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie
searchShortcutConfig.shortcutKey,
() => {
Session.checkIfActionIsAllowed(() => {
+ const state = navigationRef.getRootState();
+ const currentFocusedRoute = findFocusedRoute(state);
+ if (isOnboardingFlowName(currentFocusedRoute?.name)) {
+ return;
+ }
toggleSearchRouter();
})();
},
diff --git a/src/libs/ValidationUtils.ts b/src/libs/ValidationUtils.ts
index 0367325db6b1..f42c0252644d 100644
--- a/src/libs/ValidationUtils.ts
+++ b/src/libs/ValidationUtils.ts
@@ -529,6 +529,31 @@ function isValidZipCodeInternational(zipCode: string): boolean {
return /^[a-z0-9][a-z0-9\- ]{0,10}[a-z0-9]$/.test(zipCode);
}
+/**
+ * Validates the given value if it is correct ownership percentage
+ * @param value
+ * @param totalOwnedPercentage
+ * @param ownerBeingModifiedID
+ */
+function isValidOwnershipPercentage(value: string, totalOwnedPercentage: Record, ownerBeingModifiedID: string): boolean {
+ const parsedValue = Number(value);
+ const isValidNumber = !Number.isNaN(parsedValue) && parsedValue >= 25 && parsedValue <= 100;
+
+ let totalOwnedPercentageSum = 0;
+ const totalOwnedPercentageKeys = Object.keys(totalOwnedPercentage);
+ totalOwnedPercentageKeys.forEach((key) => {
+ if (key === ownerBeingModifiedID) {
+ return;
+ }
+
+ totalOwnedPercentageSum += totalOwnedPercentage[key];
+ });
+
+ const isTotalSumValid = totalOwnedPercentageSum + parsedValue <= 100;
+
+ return isValidNumber && isTotalSumValid;
+}
+
export {
meetsMinimumAgeRequirement,
meetsMaximumAgeRequirement,
@@ -576,4 +601,5 @@ export {
isValidEmail,
isValidPhoneInternational,
isValidZipCodeInternational,
+ isValidOwnershipPercentage,
};
diff --git a/src/libs/actions/App.ts b/src/libs/actions/App.ts
index f778405ee6e8..f1f46aee0a93 100644
--- a/src/libs/actions/App.ts
+++ b/src/libs/actions/App.ts
@@ -180,6 +180,7 @@ function setSidebarLoaded() {
Onyx.set(ONYXKEYS.IS_SIDEBAR_LOADED, true);
Performance.markEnd(CONST.TIMING.SIDEBAR_LOADED);
+ Timing.end(CONST.TIMING.SIDEBAR_LOADED);
}
let appState: AppStateStatus;
diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts
index 7ac367e3b559..8856c6b775ae 100644
--- a/src/libs/actions/IOU.ts
+++ b/src/libs/actions/IOU.ts
@@ -1,6 +1,7 @@
-import {format} from 'date-fns';
-import {fastMerge, Str} from 'expensify-common';
-import {InteractionManager} from 'react-native';
+import { format } from 'date-fns';
+import { fastMerge, Str } from 'expensify-common';
+import { InteractionManager } from 'react-native';
+
import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxInputValue, OnyxUpdate} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
import type {PartialDeep, SetRequired, ValueOf} from 'type-fest';
@@ -3389,8 +3390,6 @@ function categorizeTrackedExpense(
billable?: boolean,
receipt?: Receipt,
createdWorkspaceParams?: CreateWorkspaceParams,
- waypoints?: string,
- customUnitRateID?: string,
) {
const {optimisticData, successData, failureData} = onyxData ?? {};
@@ -3437,8 +3436,6 @@ function categorizeTrackedExpense(
policyExpenseCreatedReportActionID: createdWorkspaceParams?.expenseCreatedReportActionID,
adminsChatReportID: createdWorkspaceParams?.adminsChatReportID,
adminsCreatedReportActionID: createdWorkspaceParams?.adminsCreatedReportActionID,
- waypoints,
- customUnitRateID,
};
API.write(WRITE_COMMANDS.CATEGORIZE_TRACKED_EXPENSE, parameters, {optimisticData, successData, failureData});
@@ -3474,8 +3471,6 @@ function shareTrackedExpense(
billable?: boolean,
receipt?: Receipt,
createdWorkspaceParams?: CreateWorkspaceParams,
- waypoints?: string,
- customUnitRateID?: string,
) {
const {optimisticData, successData, failureData} = onyxData ?? {};
@@ -3522,8 +3517,6 @@ function shareTrackedExpense(
policyExpenseCreatedReportActionID: createdWorkspaceParams?.expenseCreatedReportActionID,
adminsChatReportID: createdWorkspaceParams?.adminsChatReportID,
adminsCreatedReportActionID: createdWorkspaceParams?.adminsCreatedReportActionID,
- waypoints,
- customUnitRateID,
};
API.write(WRITE_COMMANDS.SHARE_TRACKED_EXPENSE, parameters, {optimisticData, successData, failureData});
@@ -3827,8 +3820,6 @@ function trackExpense(
value: recentServerValidatedWaypoints,
});
- const waypoints = validWaypoints ? JSON.stringify(sanitizeRecentWaypoints(validWaypoints)) : undefined;
-
switch (action) {
case CONST.IOU.ACTION.CATEGORIZE: {
if (!linkedTrackedExpenseReportAction || !actionableWhisperReportActionID || !linkedTrackedExpenseReportID) {
@@ -3859,8 +3850,6 @@ function trackExpense(
billable,
trackedReceipt,
createdWorkspaceParams,
- waypoints,
- customUnitRateID,
);
break;
}
@@ -3892,8 +3881,6 @@ function trackExpense(
billable,
trackedReceipt,
createdWorkspaceParams,
- waypoints,
- customUnitRateID,
);
break;
}
@@ -3922,7 +3909,7 @@ function trackExpense(
receiptGpsPoints: gpsPoints ? JSON.stringify(gpsPoints) : undefined,
transactionThreadReportID: transactionThreadReportID ?? '-1',
createdReportActionIDForThread: createdReportActionIDForThread ?? '-1',
- waypoints,
+ waypoints: validWaypoints ? JSON.stringify(sanitizeRecentWaypoints(validWaypoints)) : undefined,
customUnitRateID,
};
if (actionableWhisperReportActionIDParam) {
@@ -6787,6 +6774,7 @@ function canIOUBePaid(
violations: OnyxCollection,
transactions?: OnyxTypes.Transaction[],
onlyShowPayElsewhere = false,
+ shouldCheckApprovedState = true,
) {
const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(chatReport);
const reportNameValuePairs = ReportUtils.getReportNameValuePairs(chatReport?.reportID);
@@ -6841,7 +6829,7 @@ function canIOUBePaid(
reimbursableSpend !== 0 &&
!isChatReportArchived &&
!isAutoReimbursable &&
- !shouldBeApproved &&
+ (!shouldBeApproved || !shouldCheckApprovedState) &&
!hasViolations &&
!isPayAtEndExpenseReport
);
diff --git a/src/libs/actions/Link.ts b/src/libs/actions/Link.ts
index 4cda676d89e8..0250ea7b84a1 100644
--- a/src/libs/actions/Link.ts
+++ b/src/libs/actions/Link.ts
@@ -65,13 +65,13 @@ function buildOldDotURL(url: string, shortLivedAuthToken?: string): Promise openExternalLink(oldDotURL));
+ buildOldDotURL(url).then((oldDotURL) => openExternalLink(oldDotURL, undefined, shouldOpenInSameTab));
return;
}
@@ -82,6 +82,8 @@ function openOldDotLink(url: string) {
.then((response) => (response ? buildOldDotURL(url, response.shortLivedAuthToken) : buildOldDotURL(url)))
.catch(() => buildOldDotURL(url)),
(oldDotURL) => oldDotURL,
+ undefined,
+ shouldOpenInSameTab,
);
}
diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts
index 32c0a40876d7..81f7116b3da7 100644
--- a/src/libs/actions/Report.ts
+++ b/src/libs/actions/Report.ts
@@ -3498,15 +3498,6 @@ function prepareOnboardingOptimisticData(
const targetChatReport = ReportUtils.getChatByParticipants([actorAccountID, currentUserAccountID]);
const {reportID: targetChatReportID = '', policyID: targetChatPolicyID = ''} = targetChatReport ?? {};
- // Introductory message
- const introductionComment = ReportUtils.buildOptimisticAddCommentReportAction(CONST.ONBOARDING_INTRODUCTION, undefined, actorAccountID);
- const introductionCommentAction: OptimisticAddCommentReportAction = introductionComment.reportAction;
- const introductionMessage: AddCommentOrAttachementParams = {
- reportID: targetChatReportID,
- reportActionID: introductionCommentAction.reportActionID,
- reportComment: introductionComment.commentText,
- };
-
// Text message
const textComment = ReportUtils.buildOptimisticAddCommentReportAction(data.message, undefined, actorAccountID, 1);
const textCommentAction: OptimisticAddCommentReportAction = textComment.reportAction;
@@ -3753,7 +3744,6 @@ function prepareOnboardingOptimisticData(
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetChatReportID}`,
value: {
- [introductionCommentAction.reportActionID]: introductionCommentAction as ReportAction,
[textCommentAction.reportActionID]: textCommentAction as ReportAction,
},
},
@@ -3776,7 +3766,6 @@ function prepareOnboardingOptimisticData(
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetChatReportID}`,
value: {
- [introductionCommentAction.reportActionID]: {pendingAction: null},
[textCommentAction.reportActionID]: {pendingAction: null},
},
});
@@ -3811,9 +3800,6 @@ function prepareOnboardingOptimisticData(
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetChatReportID}`,
value: {
- [introductionCommentAction.reportActionID]: {
- errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('report.genericAddCommentFailureMessage'),
- } as ReportAction,
[textCommentAction.reportActionID]: {
errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('report.genericAddCommentFailureMessage'),
} as ReportAction,
@@ -3866,10 +3852,7 @@ function prepareOnboardingOptimisticData(
});
}
- const guidedSetupData: GuidedSetupData = [
- {type: 'message', ...introductionMessage},
- {type: 'message', ...textMessage},
- ];
+ const guidedSetupData: GuidedSetupData = [{type: 'message', ...textMessage}];
if ('video' in data && data.video && videoCommentAction && videoMessage) {
optimisticData.push({
diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts
index bb5bca7374b7..af7da709d456 100644
--- a/src/libs/actions/Session/index.ts
+++ b/src/libs/actions/Session/index.ts
@@ -481,7 +481,7 @@ function signUpUser() {
API.write(WRITE_COMMANDS.SIGN_UP_USER, params, {optimisticData, successData, failureData});
}
-function signInAfterTransitionFromOldDot(transitionURL: string, lastUpdateId?: number) {
+function signInAfterTransitionFromOldDot(transitionURL: string) {
const [route, queryParams] = transitionURL.split('?');
const {
@@ -528,14 +528,7 @@ function signInAfterTransitionFromOldDot(transitionURL: string, lastUpdateId?: n
[ONYXKEYS.NVP_TRYNEWDOT]: {classicRedirect: {completedHybridAppOnboarding: completedHybridAppOnboarding === 'true'}},
}),
)
- .then(() => {
- if (clearOnyxOnStart === 'true') {
- // We clear Onyx when this flag is set to true so we have to download all data
- App.openApp();
- } else {
- App.reconnectApp(lastUpdateId);
- }
- })
+ .then(App.openApp)
.catch((error) => {
Log.hmmm('[HybridApp] Initialization of HybridApp has failed. Forcing transition', {error});
})
diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts
index f3b8b1a15c28..20bca969468a 100644
--- a/src/libs/actions/User.ts
+++ b/src/libs/actions/User.ts
@@ -583,7 +583,7 @@ function validateLogin(accountID: number, validateCode: string) {
/**
* Validates a secondary login / contact method
*/
-function validateSecondaryLogin(loginList: OnyxEntry, contactMethod: string, validateCode: string) {
+function validateSecondaryLogin(loginList: OnyxEntry, contactMethod: string, validateCode: string, shouldResetActionCode?: boolean) {
const optimisticData: OnyxUpdate[] = [
{
onyxMethod: Onyx.METHOD.MERGE,
@@ -729,6 +729,19 @@ function validateSecondaryLogin(loginList: OnyxEntry, contactMethod:
},
];
+ // Sometimes we will also need to reset the validateCodeSent of ONYXKEYS.VALIDATE_ACTION_CODE in order to receive the magic code next time we open the ValidateCodeActionModal.
+ if (shouldResetActionCode) {
+ const optimisticResetActionCode = {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.VALIDATE_ACTION_CODE,
+ value: {
+ validateCodeSent: null,
+ },
+ };
+ successData.push(optimisticResetActionCode);
+ failureData.push(optimisticResetActionCode);
+ }
+
const parameters: ValidateSecondaryLoginParams = {partnerUserID: contactMethod, validateCode};
API.write(WRITE_COMMANDS.VALIDATE_SECONDARY_LOGIN, parameters, {optimisticData, successData, failureData});
diff --git a/src/libs/asyncOpenURL/index.website.ts b/src/libs/asyncOpenURL/index.website.ts
index 4f6d95b76b8b..ba7da73616c2 100644
--- a/src/libs/asyncOpenURL/index.website.ts
+++ b/src/libs/asyncOpenURL/index.website.ts
@@ -1,22 +1,26 @@
import {Linking} from 'react-native';
+import type {Linking as LinkingWeb} from 'react-native-web';
+import getPlatform from '@libs/getPlatform';
import Log from '@libs/Log';
+import CONST from '@src/CONST';
import type AsyncOpenURL from './types';
/**
* Prevents Safari from blocking pop-up window when opened within async call.
* @param shouldSkipCustomSafariLogic When true, we will use `Linking.openURL` even if the browser is Safari.
*/
-const asyncOpenURL: AsyncOpenURL = (promise, url, shouldSkipCustomSafariLogic) => {
+const asyncOpenURL: AsyncOpenURL = (promise, url, shouldSkipCustomSafariLogic, shouldOpenInSameTab) => {
if (!url) {
return;
}
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
+ const canOpenURLInSameTab = getPlatform() === CONST.PLATFORM.WEB;
- if (!isSafari || shouldSkipCustomSafariLogic) {
+ if (!isSafari || !!shouldSkipCustomSafariLogic || !!shouldOpenInSameTab) {
promise
.then((params) => {
- Linking.openURL(typeof url === 'string' ? url : url(params));
+ (Linking.openURL as LinkingWeb['openURL'])(typeof url === 'string' ? url : url(params), shouldOpenInSameTab && canOpenURLInSameTab ? '_self' : undefined);
})
.catch(() => {
Log.warn('[asyncOpenURL] error occured while opening URL', {url});
diff --git a/src/libs/asyncOpenURL/types.ts b/src/libs/asyncOpenURL/types.ts
index bf24756b0cc2..320e2606222e 100644
--- a/src/libs/asyncOpenURL/types.ts
+++ b/src/libs/asyncOpenURL/types.ts
@@ -1,3 +1,3 @@
-type AsyncOpenURL = (promise: Promise, url: string | ((params: T) => string), shouldSkipCustomSafariLogic?: boolean) => void;
+type AsyncOpenURL = (promise: Promise, url: string | ((params: T) => string), shouldSkipCustomSafariLogic?: boolean, shouldOpenInSameTab?: boolean) => void;
export default AsyncOpenURL;
diff --git a/src/libs/onboardingSelectors.ts b/src/libs/onboardingSelectors.ts
index bdbb3ff142f4..35b1f1b4208c 100644
--- a/src/libs/onboardingSelectors.ts
+++ b/src/libs/onboardingSelectors.ts
@@ -15,6 +15,10 @@ function hasCompletedGuidedSetupFlowSelector(onboarding: OnyxValue void;
-};
-
-function BeneficialOwnerCheckUBO({title, onSelectedValue, defaultValue}: BeneficialOwnerCheckUBOProps) {
- const {translate} = useLocalize();
- const styles = useThemeStyles();
-
- return (
-
- );
-}
-
-BeneficialOwnerCheckUBO.displayName = 'BeneficialOwnerCheckUBO';
-
-export default BeneficialOwnerCheckUBO;
diff --git a/src/pages/ReimbursementAccount/BeneficialOwnersStep.tsx b/src/pages/ReimbursementAccount/BeneficialOwnersStep.tsx
index 6d70e966fa2d..6fd3bcb09f80 100644
--- a/src/pages/ReimbursementAccount/BeneficialOwnersStep.tsx
+++ b/src/pages/ReimbursementAccount/BeneficialOwnersStep.tsx
@@ -2,14 +2,15 @@ import {Str} from 'expensify-common';
import React, {useState} from 'react';
import {useOnyx} from 'react-native-onyx';
import InteractiveStepWrapper from '@components/InteractiveStepWrapper';
+import YesNoStep from '@components/SubStepForms/YesNoStep';
import useLocalize from '@hooks/useLocalize';
import useSubStep from '@hooks/useSubStep';
import type {SubStepProps} from '@hooks/useSubStep/types';
+import useThemeStyles from '@hooks/useThemeStyles';
import * as BankAccounts from '@userActions/BankAccounts';
import * as FormActions from '@userActions/FormActions';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-import BeneficialOwnerCheckUBO from './BeneficialOwnerInfo/substeps/BeneficialOwnerCheckUBO';
import AddressUBO from './BeneficialOwnerInfo/substeps/BeneficialOwnerDetailsFormSubsteps/AddressUBO';
import ConfirmationUBO from './BeneficialOwnerInfo/substeps/BeneficialOwnerDetailsFormSubsteps/ConfirmationUBO';
import DateOfBirthUBO from './BeneficialOwnerInfo/substeps/BeneficialOwnerDetailsFormSubsteps/DateOfBirthUBO';
@@ -30,6 +31,7 @@ const bodyContent: Array> = [Le
function BeneficialOwnersStep({onBackButtonPress}: BeneficialOwnersStepProps) {
const {translate} = useLocalize();
+ const styles = useThemeStyles();
const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT);
const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT);
@@ -214,16 +216,20 @@ function BeneficialOwnersStep({onBackButtonPress}: BeneficialOwnersStepProps) {
stepNames={CONST.BANK_ACCOUNT.STEP_NAMES}
>
{currentUBOSubstep === SUBSTEP.IS_USER_UBO && (
-
)}
{currentUBOSubstep === SUBSTEP.IS_ANYONE_ELSE_UBO && (
-
@@ -240,8 +246,10 @@ function BeneficialOwnersStep({onBackButtonPress}: BeneficialOwnersStepProps) {
)}
{currentUBOSubstep === SUBSTEP.ARE_THERE_MORE_UBOS && (
-
diff --git a/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Address.tsx b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Address.tsx
new file mode 100644
index 000000000000..1629b90a5308
--- /dev/null
+++ b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/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 CONST from '@src/CONST';
+import type {Country} from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+
+type NameProps = SubStepProps & {isUserEnteringHisOwnData: boolean; ownerBeingModifiedID: string};
+
+const {STREET, CITY, STATE, ZIP_CODE, COUNTRY, PREFIX} = CONST.NON_USD_BANK_ACCOUNT.BENEFICIAL_OWNER_INFO_STEP.BENEFICIAL_OWNER_DATA;
+
+function Address({onNext, isEditing, onMove, isUserEnteringHisOwnData, ownerBeingModifiedID}: NameProps) {
+ const {translate} = useLocalize();
+ const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT);
+
+ const countryInputKey: `beneficialOwner_${string}_${string}` = `${PREFIX}_${ownerBeingModifiedID}_${COUNTRY}`;
+ const inputKeys = {
+ street: `${PREFIX}_${ownerBeingModifiedID}_${STREET}`,
+ city: `${PREFIX}_${ownerBeingModifiedID}_${CITY}`,
+ state: `${PREFIX}_${ownerBeingModifiedID}_${STATE}`,
+ zipCode: `${PREFIX}_${ownerBeingModifiedID}_${ZIP_CODE}`,
+ country: countryInputKey,
+ } as const;
+
+ const defaultValues = {
+ street: reimbursementAccountDraft?.[inputKeys.street] ?? '',
+ city: reimbursementAccountDraft?.[inputKeys.city] ?? '',
+ state: reimbursementAccountDraft?.[inputKeys.state] ?? '',
+ zipCode: reimbursementAccountDraft?.[inputKeys.zipCode] ?? '',
+ country: (reimbursementAccountDraft?.[inputKeys.country] ?? '') as Country | '',
+ };
+
+ const formTitle = translate(isUserEnteringHisOwnData ? 'ownershipInfoStep.whatsYourAddress' : 'ownershipInfoStep.whatsTheOwnersAddress');
+
+ // 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 stepFieldsWithState = useMemo(
+ () => [inputKeys.street, inputKeys.city, inputKeys.state, inputKeys.zipCode, countryInputKey],
+ [countryInputKey, inputKeys.city, inputKeys.state, inputKeys.street, inputKeys.zipCode],
+ );
+ const stepFieldsWithoutState = useMemo(
+ () => [inputKeys.street, inputKeys.city, inputKeys.zipCode, countryInputKey],
+ [countryInputKey, inputKeys.city, inputKeys.street, inputKeys.zipCode],
+ );
+
+ const stepFields = shouldDisplayStateSelector ? stepFieldsWithState : stepFieldsWithoutState;
+
+ 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={formTitle}
+ formPOBoxDisclaimer={translate('common.noPO')}
+ onSubmit={handleSubmit}
+ stepFields={stepFields}
+ inputFieldsIDs={inputKeys}
+ defaultValues={defaultValues}
+ onCountryChange={handleCountryChange}
+ shouldDisplayStateSelector={shouldDisplayStateSelector}
+ shouldDisplayCountrySelector
+ />
+ );
+}
+
+Address.displayName = 'Address';
+
+export default Address;
diff --git a/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Confirmation.tsx b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Confirmation.tsx
new file mode 100644
index 000000000000..6b880e8b3ad1
--- /dev/null
+++ b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Confirmation.tsx
@@ -0,0 +1,89 @@
+import React 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 getValuesForBeneficialOwner from '@pages/ReimbursementAccount/NonUSD/utils/getValuesForBeneficialOwner';
+import ONYXKEYS from '@src/ONYXKEYS';
+
+type ConfirmationProps = SubStepProps & {ownerBeingModifiedID: string};
+
+function Confirmation({onNext, onMove, ownerBeingModifiedID}: ConfirmationProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+
+ const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT);
+ const values = getValuesForBeneficialOwner(ownerBeingModifiedID, reimbursementAccountDraft);
+
+ return (
+
+ {({safeAreaPaddingBottomStyle}) => (
+
+ {translate('ownershipInfoStep.letsDoubleCheck')}
+ {
+ onMove(0);
+ }}
+ />
+ {
+ onMove(1);
+ }}
+ />
+ {
+ onMove(2);
+ }}
+ />
+ {
+ onMove(4);
+ }}
+ />
+ {
+ onMove(3);
+ }}
+ />
+
+
+
+
+ )}
+
+ );
+}
+
+Confirmation.displayName = 'Confirmation';
+
+export default Confirmation;
diff --git a/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/DateOfBirth.tsx b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/DateOfBirth.tsx
new file mode 100644
index 000000000000..a75daf469c41
--- /dev/null
+++ b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/DateOfBirth.tsx
@@ -0,0 +1,45 @@
+import React from 'react';
+import {useOnyx} from 'react-native-onyx';
+import DateOfBirthStep from '@components/SubStepForms/DateOfBirthStep';
+import useLocalize from '@hooks/useLocalize';
+import useReimbursementAccountStepFormSubmit from '@hooks/useReimbursementAccountStepFormSubmit';
+import type {SubStepProps} from '@hooks/useSubStep/types';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+
+type DateOfBirthProps = SubStepProps & {isUserEnteringHisOwnData: boolean; ownerBeingModifiedID: string};
+
+const {DOB, PREFIX} = CONST.NON_USD_BANK_ACCOUNT.BENEFICIAL_OWNER_INFO_STEP.BENEFICIAL_OWNER_DATA;
+
+function DateOfBirth({onNext, isEditing, onMove, isUserEnteringHisOwnData, ownerBeingModifiedID}: DateOfBirthProps) {
+ const {translate} = useLocalize();
+ const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT);
+
+ const dobInputID = `${PREFIX}_${ownerBeingModifiedID}_${DOB}` as const;
+ const dobDefaultValue = reimbursementAccountDraft?.[dobInputID] ?? '';
+ const formTitle = translate(isUserEnteringHisOwnData ? 'ownershipInfoStep.whatsYourDOB' : 'ownershipInfoStep.whatsTheOwnersDOB');
+
+ const handleSubmit = useReimbursementAccountStepFormSubmit({
+ fieldIds: [dobInputID],
+ onNext,
+ shouldSaveDraft: isEditing,
+ });
+
+ return (
+
+ isEditing={isEditing}
+ onNext={onNext}
+ onMove={onMove}
+ formID={ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM}
+ formTitle={formTitle}
+ onSubmit={handleSubmit}
+ stepFields={[dobInputID]}
+ dobInputID={dobInputID}
+ dobDefaultValue={dobDefaultValue}
+ />
+ );
+}
+
+DateOfBirth.displayName = 'DateOfBirth';
+
+export default DateOfBirth;
diff --git a/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Last4SSN.tsx b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Last4SSN.tsx
new file mode 100644
index 000000000000..1ba2cac6ee27
--- /dev/null
+++ b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Last4SSN.tsx
@@ -0,0 +1,65 @@
+import React, {useCallback} from 'react';
+import {useOnyx} from 'react-native-onyx';
+import type {FormInputErrors, FormOnyxValues} from '@components/Form/types';
+import SingleFieldStep from '@components/SubStepForms/SingleFieldStep';
+import useLocalize from '@hooks/useLocalize';
+import useReimbursementAccountStepFormSubmit from '@hooks/useReimbursementAccountStepFormSubmit';
+import type {SubStepProps} from '@hooks/useSubStep/types';
+import * as ValidationUtils from '@libs/ValidationUtils';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+
+type Last4SSNProps = SubStepProps & {isUserEnteringHisOwnData: boolean; ownerBeingModifiedID: string};
+
+const {SSN_LAST_4, PREFIX} = CONST.NON_USD_BANK_ACCOUNT.BENEFICIAL_OWNER_INFO_STEP.BENEFICIAL_OWNER_DATA;
+
+function Last4SSN({onNext, isEditing, onMove, isUserEnteringHisOwnData, ownerBeingModifiedID}: Last4SSNProps) {
+ const {translate} = useLocalize();
+ const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT);
+
+ const last4SSNInputID = `${PREFIX}_${ownerBeingModifiedID}_${SSN_LAST_4}` as const;
+ const defaultLast4SSN = reimbursementAccountDraft?.[last4SSNInputID] ?? '';
+ const formTitle = translate(isUserEnteringHisOwnData ? 'ownershipInfoStep.whatsYourLast' : 'ownershipInfoStep.whatAreTheLast');
+
+ const validate = useCallback(
+ (values: FormOnyxValues): FormInputErrors => {
+ const errors = ValidationUtils.getFieldRequiredErrors(values, [last4SSNInputID]);
+
+ if (values[last4SSNInputID] && !ValidationUtils.isValidSSNLastFour(values[last4SSNInputID])) {
+ errors[last4SSNInputID] = translate('bankAccount.error.ssnLast4');
+ }
+
+ return errors;
+ },
+ [last4SSNInputID, translate],
+ );
+
+ const handleSubmit = useReimbursementAccountStepFormSubmit({
+ fieldIds: [last4SSNInputID],
+ onNext,
+ shouldSaveDraft: isEditing,
+ });
+
+ return (
+
+ isEditing={isEditing}
+ onNext={onNext}
+ onMove={onMove}
+ formID={ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM}
+ formTitle={formTitle}
+ formDisclaimer={translate('beneficialOwnerInfoStep.dontWorry')}
+ validate={validate}
+ onSubmit={handleSubmit}
+ inputId={last4SSNInputID}
+ inputLabel={translate('ownershipInfoStep.last4')}
+ inputMode={CONST.INPUT_MODE.NUMERIC}
+ defaultValue={defaultLast4SSN}
+ shouldShowHelpLinks={false}
+ maxLength={CONST.BANK_ACCOUNT.MAX_LENGTH.SSN}
+ />
+ );
+}
+
+Last4SSN.displayName = 'Last4SSN';
+
+export default Last4SSN;
diff --git a/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Name.tsx b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Name.tsx
new file mode 100644
index 000000000000..847d6b99a7e2
--- /dev/null
+++ b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Name.tsx
@@ -0,0 +1,52 @@
+import React, {useMemo} from 'react';
+import {useOnyx} from 'react-native-onyx';
+import FullNameStep from '@components/SubStepForms/FullNameStep';
+import useLocalize from '@hooks/useLocalize';
+import useReimbursementAccountStepFormSubmit from '@hooks/useReimbursementAccountStepFormSubmit';
+import type {SubStepProps} from '@hooks/useSubStep/types';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+
+type NameProps = SubStepProps & {isUserEnteringHisOwnData: boolean; ownerBeingModifiedID: string};
+
+const {FIRST_NAME, LAST_NAME, PREFIX} = CONST.NON_USD_BANK_ACCOUNT.BENEFICIAL_OWNER_INFO_STEP.BENEFICIAL_OWNER_DATA;
+
+function Name({onNext, isEditing, onMove, isUserEnteringHisOwnData, ownerBeingModifiedID}: NameProps) {
+ const {translate} = useLocalize();
+ const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT);
+
+ const firstNameInputID = `${PREFIX}_${ownerBeingModifiedID}_${FIRST_NAME}` as const;
+ const lastNameInputID = `${PREFIX}_${ownerBeingModifiedID}_${LAST_NAME}` as const;
+ const stepFields = useMemo(() => [firstNameInputID, lastNameInputID], [firstNameInputID, lastNameInputID]);
+ const formTitle = translate(isUserEnteringHisOwnData ? 'ownershipInfoStep.whatsYourName' : 'ownershipInfoStep.whatsTheOwnersName');
+ const defaultValues = {
+ firstName: reimbursementAccountDraft?.[firstNameInputID] ?? '',
+ lastName: reimbursementAccountDraft?.[lastNameInputID] ?? '',
+ };
+
+ const handleSubmit = useReimbursementAccountStepFormSubmit({
+ fieldIds: stepFields,
+ onNext,
+ shouldSaveDraft: isEditing,
+ });
+
+ return (
+
+ isEditing={isEditing}
+ onNext={onNext}
+ onMove={onMove}
+ formID={ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM}
+ formTitle={formTitle}
+ onSubmit={handleSubmit}
+ stepFields={stepFields}
+ firstNameInputID={firstNameInputID}
+ lastNameInputID={lastNameInputID}
+ defaultValues={defaultValues}
+ shouldShowHelpLinks={false}
+ />
+ );
+}
+
+Name.displayName = 'Name';
+
+export default Name;
diff --git a/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/OwnershipPercentage.tsx b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/OwnershipPercentage.tsx
new file mode 100644
index 000000000000..351a6c8ec048
--- /dev/null
+++ b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/OwnershipPercentage.tsx
@@ -0,0 +1,73 @@
+import React, {useCallback} from 'react';
+import {useOnyx} from 'react-native-onyx';
+import type {FormInputErrors, FormOnyxValues} from '@components/Form/types';
+import SingleFieldStep from '@components/SubStepForms/SingleFieldStep';
+import useLocalize from '@hooks/useLocalize';
+import useReimbursementAccountStepFormSubmit from '@hooks/useReimbursementAccountStepFormSubmit';
+import type {SubStepProps} from '@hooks/useSubStep/types';
+import * as ValidationUtils from '@libs/ValidationUtils';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+
+type OwnershipPercentageProps = SubStepProps & {
+ isUserEnteringHisOwnData: boolean;
+ ownerBeingModifiedID: string;
+ totalOwnedPercentage: Record;
+ setTotalOwnedPercentage: (ownedPercentage: Record) => void;
+};
+
+const {OWNERSHIP_PERCENTAGE, PREFIX} = CONST.NON_USD_BANK_ACCOUNT.BENEFICIAL_OWNER_INFO_STEP.BENEFICIAL_OWNER_DATA;
+
+function OwnershipPercentage({onNext, isEditing, onMove, isUserEnteringHisOwnData, ownerBeingModifiedID, totalOwnedPercentage, setTotalOwnedPercentage}: OwnershipPercentageProps) {
+ const {translate} = useLocalize();
+ const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT);
+
+ const ownershipPercentageInputID = `${PREFIX}_${ownerBeingModifiedID}_${OWNERSHIP_PERCENTAGE}` as const;
+ const defaultOwnershipPercentage = reimbursementAccountDraft?.[ownershipPercentageInputID] ?? '';
+ const formTitle = translate(isUserEnteringHisOwnData ? 'ownershipInfoStep.whatsYoursPercentage' : 'ownershipInfoStep.whatPercentage');
+
+ const validate = useCallback(
+ (values: FormOnyxValues): FormInputErrors => {
+ const errors = ValidationUtils.getFieldRequiredErrors(values, [ownershipPercentageInputID]);
+
+ if (values[ownershipPercentageInputID] && !ValidationUtils.isValidOwnershipPercentage(values[ownershipPercentageInputID], totalOwnedPercentage, ownerBeingModifiedID)) {
+ errors[ownershipPercentageInputID] = translate('bankAccount.error.ownershipPercentage');
+ }
+
+ setTotalOwnedPercentage({
+ ...totalOwnedPercentage,
+ [ownerBeingModifiedID]: Number(values[ownershipPercentageInputID]),
+ });
+
+ return errors;
+ },
+ [ownerBeingModifiedID, ownershipPercentageInputID, setTotalOwnedPercentage, totalOwnedPercentage, translate],
+ );
+
+ const handleSubmit = useReimbursementAccountStepFormSubmit({
+ fieldIds: [ownershipPercentageInputID],
+ onNext,
+ shouldSaveDraft: isEditing,
+ });
+
+ return (
+
+ isEditing={isEditing}
+ onNext={onNext}
+ onMove={onMove}
+ formID={ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM}
+ formTitle={formTitle}
+ validate={validate}
+ onSubmit={handleSubmit}
+ inputId={ownershipPercentageInputID}
+ inputLabel={translate('ownershipInfoStep.ownership')}
+ inputMode={CONST.INPUT_MODE.NUMERIC}
+ defaultValue={defaultOwnershipPercentage}
+ shouldShowHelpLinks={false}
+ />
+ );
+}
+
+OwnershipPercentage.displayName = 'OwnershipPercentage';
+
+export default OwnershipPercentage;
diff --git a/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerInfo.tsx b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerInfo.tsx
index 477bab90af45..49ad1de6b56f 100644
--- a/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerInfo.tsx
+++ b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerInfo.tsx
@@ -1,10 +1,24 @@
-import React from 'react';
-import {View} from 'react-native';
-import Button from '@components/Button';
+import {Str} from 'expensify-common';
+import type {ComponentType} from 'react';
+import React, {useState} from 'react';
+import {useOnyx} from 'react-native-onyx';
import InteractiveStepWrapper from '@components/InteractiveStepWrapper';
+import YesNoStep from '@components/SubStepForms/YesNoStep';
import useLocalize from '@hooks/useLocalize';
-import useThemeStyles from '@hooks/useThemeStyles';
+import useSubStep from '@hooks/useSubStep';
+import type {SubStepProps} from '@hooks/useSubStep/types';
+import * as FormActions from '@userActions/FormActions';
import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import INPUT_IDS from '@src/types/form/ReimbursementAccountForm';
+import Address from './BeneficialOwnerDetailsFormSubSteps/Address';
+import Confirmation from './BeneficialOwnerDetailsFormSubSteps/Confirmation';
+import DateOfBirth from './BeneficialOwnerDetailsFormSubSteps/DateOfBirth';
+import Last4SSN from './BeneficialOwnerDetailsFormSubSteps/Last4SSN';
+import Name from './BeneficialOwnerDetailsFormSubSteps/Name';
+import OwnershipPercentage from './BeneficialOwnerDetailsFormSubSteps/OwnershipPercentage';
+import BeneficialOwnersList from './BeneficialOwnersList';
+import UploadOwnershipChart from './UploadOwnershipChart';
type BeneficialOwnerInfoProps = {
/** Handles back button press */
@@ -14,27 +28,327 @@ type BeneficialOwnerInfoProps = {
onSubmit: () => void;
};
+const {OWNS_MORE_THAN_25_PERCENT, ANY_INDIVIDUAL_OWN_25_PERCENT_OR_MORE, BENEFICIAL_OWNERS, COMPANY_NAME, ENTITY_CHART} = INPUT_IDS.ADDITIONAL_DATA.CORPAY;
+const {FIRST_NAME, LAST_NAME, OWNERSHIP_PERCENTAGE, DOB, SSN_LAST_4, STREET, CITY, STATE, ZIP_CODE, COUNTRY, PREFIX} =
+ CONST.NON_USD_BANK_ACCOUNT.BENEFICIAL_OWNER_INFO_STEP.BENEFICIAL_OWNER_DATA;
+const SUBSTEP = CONST.NON_USD_BANK_ACCOUNT.BENEFICIAL_OWNER_INFO_STEP.SUBSTEP;
+
+type BeneficialOwnerDetailsFormProps = SubStepProps & {
+ ownerBeingModifiedID: string;
+ setOwnerBeingModifiedID?: (id: string) => void;
+ isUserEnteringHisOwnData: boolean;
+ totalOwnedPercentage: Record;
+ setTotalOwnedPercentage: (ownedPercentage: Record) => void;
+};
+
+const bodyContent: Array> = [Name, OwnershipPercentage, DateOfBirth, Address, Last4SSN, Confirmation];
+
function BeneficialOwnerInfo({onBackButtonPress, onSubmit}: BeneficialOwnerInfoProps) {
const {translate} = useLocalize();
- const styles = useThemeStyles();
+
+ const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT);
+ const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT);
+
+ const [ownerKeys, setOwnerKeys] = useState([]);
+ const [ownerBeingModifiedID, setOwnerBeingModifiedID] = useState(CONST.NON_USD_BANK_ACCOUNT.BENEFICIAL_OWNER_INFO_STEP.CURRENT_USER_KEY);
+ const [isEditingCreatedOwner, setIsEditingCreatedOwner] = useState(false);
+ const [isUserEnteringHisOwnData, setIsUserEnteringHisOwnData] = useState(false);
+ const [isUserOwner, setIsUserOwner] = useState(false);
+ const [isAnyoneElseOwner, setIsAnyoneElseOwner] = useState(false);
+ const [currentSubStep, setCurrentSubStep] = useState(SUBSTEP.IS_USER_BENEFICIAL_OWNER);
+ const [totalOwnedPercentage, setTotalOwnedPercentage] = useState>({});
+ const companyName = reimbursementAccount?.achData?.additionalData?.corpay?.[COMPANY_NAME] ?? reimbursementAccountDraft?.[COMPANY_NAME] ?? '';
+ const entityChart = reimbursementAccount?.achData?.additionalData?.corpay?.[ENTITY_CHART] ?? reimbursementAccountDraft?.[ENTITY_CHART] ?? [];
+
+ const policyID = reimbursementAccount?.achData?.policyID ?? '-1';
+ const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`);
+ const currency = policy?.outputCurrency ?? '';
+ const shouldAskForEntityChart = currency === CONST.CURRENCY.AUD;
+
+ const totalOwnedPercentageSum = Object.values(totalOwnedPercentage).reduce((acc, value) => acc + value, 0);
+ const canAddMoreOwners = totalOwnedPercentageSum <= 75;
+
+ const submit = () => {
+ const ownerFields = [FIRST_NAME, LAST_NAME, OWNERSHIP_PERCENTAGE, DOB, SSN_LAST_4, STREET, CITY, STATE, ZIP_CODE, COUNTRY];
+ const owners = ownerKeys.map((ownerKey) =>
+ ownerFields.reduce((acc, fieldName) => {
+ acc[fieldName] = reimbursementAccountDraft ? reimbursementAccountDraft?.[`${PREFIX}_${ownerKey}_${fieldName}`] : undefined;
+ return acc;
+ }, {} as Record),
+ );
+
+ FormActions.setDraftValues(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM, {
+ [OWNS_MORE_THAN_25_PERCENT]: isUserOwner,
+ [ANY_INDIVIDUAL_OWN_25_PERCENT_OR_MORE]: isAnyoneElseOwner,
+ [BENEFICIAL_OWNERS]: JSON.stringify(owners),
+ });
+ onSubmit();
+ };
+
+ const addOwner = (ownerID: string) => {
+ const newOwners = [...ownerKeys, ownerID];
+
+ setOwnerKeys(newOwners);
+ };
+
+ const handleOwnerDetailsFormSubmit = () => {
+ const isFreshOwner = ownerKeys.find((ownerID) => ownerID === ownerBeingModifiedID) === undefined;
+
+ if (isFreshOwner) {
+ addOwner(ownerBeingModifiedID);
+ }
+
+ let nextSubStep;
+ if (isEditingCreatedOwner || !canAddMoreOwners) {
+ nextSubStep = shouldAskForEntityChart && entityChart.length === 0 ? SUBSTEP.OWNERSHIP_CHART : SUBSTEP.BENEFICIAL_OWNERS_LIST;
+ } else {
+ nextSubStep = isUserEnteringHisOwnData ? SUBSTEP.IS_ANYONE_ELSE_BENEFICIAL_OWNER : SUBSTEP.ARE_THERE_MORE_BENEFICIAL_OWNERS;
+ }
+
+ setCurrentSubStep(nextSubStep);
+ setIsEditingCreatedOwner(false);
+ };
+
+ const {
+ componentToRender: BeneficialOwnerDetailsForm,
+ isEditing,
+ screenIndex,
+ nextScreen,
+ prevScreen,
+ moveTo,
+ resetScreenIndex,
+ goToTheLastStep,
+ } = useSubStep({bodyContent, startFrom: 0, onFinished: handleOwnerDetailsFormSubmit});
+
+ const prepareOwnerDetailsForm = () => {
+ const ownerID = Str.guid();
+ setOwnerBeingModifiedID(ownerID);
+ resetScreenIndex();
+ setCurrentSubStep(SUBSTEP.BENEFICIAL_OWNER_DETAILS_FORM);
+ };
+
+ const handleOwnerEdit = (ownerID: string) => {
+ setOwnerBeingModifiedID(ownerID);
+ setIsEditingCreatedOwner(true);
+ setCurrentSubStep(SUBSTEP.BENEFICIAL_OWNER_DETAILS_FORM);
+ };
+
+ const handleOwnershipChartSubmit = () => {
+ // TODO upload chart here in https://github.com/Expensify/App/issues/50906
+ setCurrentSubStep(SUBSTEP.BENEFICIAL_OWNERS_LIST);
+ };
+
+ const handleOwnershipChartEdit = () => {
+ setCurrentSubStep(SUBSTEP.OWNERSHIP_CHART);
+ };
+
+ const handleBackButtonPress = () => {
+ if (isEditing) {
+ goToTheLastStep();
+ return;
+ }
+
+ if (currentSubStep === SUBSTEP.IS_USER_BENEFICIAL_OWNER) {
+ onBackButtonPress();
+ } else if (currentSubStep === SUBSTEP.BENEFICIAL_OWNERS_LIST && !canAddMoreOwners) {
+ if (shouldAskForEntityChart) {
+ setCurrentSubStep(SUBSTEP.OWNERSHIP_CHART);
+ return;
+ }
+ setCurrentSubStep(SUBSTEP.BENEFICIAL_OWNER_DETAILS_FORM);
+ } else if (currentSubStep === SUBSTEP.BENEFICIAL_OWNERS_LIST && isAnyoneElseOwner) {
+ setCurrentSubStep(SUBSTEP.ARE_THERE_MORE_BENEFICIAL_OWNERS);
+ } else if (currentSubStep === SUBSTEP.BENEFICIAL_OWNERS_LIST && isUserOwner && !isAnyoneElseOwner) {
+ if (shouldAskForEntityChart) {
+ setCurrentSubStep(SUBSTEP.OWNERSHIP_CHART);
+ return;
+ }
+ setCurrentSubStep(SUBSTEP.IS_ANYONE_ELSE_BENEFICIAL_OWNER);
+ } else if (currentSubStep === SUBSTEP.IS_ANYONE_ELSE_BENEFICIAL_OWNER) {
+ setCurrentSubStep(SUBSTEP.IS_USER_BENEFICIAL_OWNER);
+ } else if (currentSubStep === SUBSTEP.BENEFICIAL_OWNER_DETAILS_FORM && screenIndex > 0) {
+ prevScreen();
+ } else if (currentSubStep === SUBSTEP.OWNERSHIP_CHART && canAddMoreOwners) {
+ if (ownerKeys.length === 0) {
+ setCurrentSubStep(SUBSTEP.IS_ANYONE_ELSE_BENEFICIAL_OWNER);
+ return;
+ }
+ setCurrentSubStep(SUBSTEP.ARE_THERE_MORE_BENEFICIAL_OWNERS);
+ } else if (currentSubStep === SUBSTEP.OWNERSHIP_CHART && !canAddMoreOwners) {
+ setCurrentSubStep(SUBSTEP.BENEFICIAL_OWNER_DETAILS_FORM);
+ } else {
+ setCurrentSubStep((subStep) => subStep - 1);
+ }
+ };
+
+ const handleNextSubStep = (value: boolean) => {
+ if (currentSubStep === SUBSTEP.IS_USER_BENEFICIAL_OWNER) {
+ // User is owner so we gather his data
+ if (value) {
+ setIsUserOwner(value);
+ setIsUserEnteringHisOwnData(value);
+ setCurrentSubStep(SUBSTEP.BENEFICIAL_OWNER_DETAILS_FORM);
+ return;
+ }
+
+ setIsUserOwner(value);
+ setIsUserEnteringHisOwnData(value);
+ setOwnerKeys((currentOwnersKeys) => currentOwnersKeys.filter((key) => key !== CONST.NON_USD_BANK_ACCOUNT.BENEFICIAL_OWNER_INFO_STEP.CURRENT_USER_KEY));
+
+ // User is an owner but there are 4 other owners already added, so we remove last one
+ if (value && ownerKeys.length === 4) {
+ setOwnerKeys((previousBeneficialOwners) => previousBeneficialOwners.slice(0, 3));
+ }
+
+ setCurrentSubStep(SUBSTEP.IS_ANYONE_ELSE_BENEFICIAL_OWNER);
+ return;
+ }
+
+ if (currentSubStep === SUBSTEP.IS_ANYONE_ELSE_BENEFICIAL_OWNER) {
+ setIsAnyoneElseOwner(value);
+ setIsUserEnteringHisOwnData(false);
+
+ // Someone else is an owner so we gather his data
+ if (canAddMoreOwners && value) {
+ prepareOwnerDetailsForm();
+ return;
+ }
+
+ // User went back in the flow, but he cannot add more owners, so we send him back to owners list
+ if (!canAddMoreOwners && value) {
+ setCurrentSubStep(SUBSTEP.BENEFICIAL_OWNERS_LIST);
+ return;
+ }
+
+ // User is not an owner and no one else is an owner
+ if (!isUserOwner && !value) {
+ setOwnerKeys([]);
+
+ // Gather ownership chart if AUD account
+ if (shouldAskForEntityChart) {
+ setCurrentSubStep(SUBSTEP.OWNERSHIP_CHART);
+ return;
+ }
+
+ // Otherwise submit whole form and go to Signer Info
+ submit();
+ return;
+ }
+
+ // User is an owner and no one else is an owner
+ if (isUserOwner && !value) {
+ setOwnerKeys([CONST.NON_USD_BANK_ACCOUNT.BENEFICIAL_OWNER_INFO_STEP.CURRENT_USER_KEY]);
+ // Gather ownership chart if AUD account
+ if (shouldAskForEntityChart) {
+ setCurrentSubStep(SUBSTEP.OWNERSHIP_CHART);
+ return;
+ }
+
+ // Otherwise send to the list of owners
+ setCurrentSubStep(SUBSTEP.BENEFICIAL_OWNERS_LIST);
+ return;
+ }
+ }
+
+ // Are there more UBOs
+ if (currentSubStep === SUBSTEP.ARE_THERE_MORE_BENEFICIAL_OWNERS) {
+ setIsUserEnteringHisOwnData(false);
+
+ // User went back in the flow, but he cannot add more owners, so we send him back to owners list
+ if (!canAddMoreOwners && value) {
+ setCurrentSubStep(SUBSTEP.BENEFICIAL_OWNERS_LIST);
+ return;
+ }
+
+ // Gather data of another owner
+ if (value) {
+ setIsAnyoneElseOwner(true);
+ prepareOwnerDetailsForm();
+ return;
+ }
+
+ // No more owners but we should gather entity chart
+ if (shouldAskForEntityChart) {
+ setCurrentSubStep(SUBSTEP.OWNERSHIP_CHART);
+ return;
+ }
+
+ // No more owners and no need to gather entity chart, so we send user to owners list
+ setCurrentSubStep(SUBSTEP.BENEFICIAL_OWNERS_LIST);
+ return;
+ }
+
+ // User reached the limit of UBOs
+ if (currentSubStep === SUBSTEP.BENEFICIAL_OWNER_DETAILS_FORM && !canAddMoreOwners) {
+ // Gather ownership chart if AUD account
+ if (shouldAskForEntityChart) {
+ setCurrentSubStep(SUBSTEP.OWNERSHIP_CHART);
+ return;
+ }
+
+ // Otherwise go to the list of owners
+ setCurrentSubStep(SUBSTEP.BENEFICIAL_OWNERS_LIST);
+ }
+ };
return (
-
-
+ )}
+
+ {currentSubStep === SUBSTEP.IS_ANYONE_ELSE_BENEFICIAL_OWNER && (
+
+ )}
+
+ {currentSubStep === SUBSTEP.BENEFICIAL_OWNER_DETAILS_FORM && (
+
+ )}
+
+ {currentSubStep === SUBSTEP.ARE_THERE_MORE_BENEFICIAL_OWNERS && (
+
+ )}
+
+ {currentSubStep === SUBSTEP.OWNERSHIP_CHART && }
+
+ {currentSubStep === SUBSTEP.BENEFICIAL_OWNERS_LIST && (
+
-
+ )}
);
}
diff --git a/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnersList.tsx b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnersList.tsx
new file mode 100644
index 000000000000..2b6853472f7f
--- /dev/null
+++ b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnersList.tsx
@@ -0,0 +1,117 @@
+import React from 'react';
+import {View} from 'react-native';
+import {useOnyx} from 'react-native-onyx';
+import Button from '@components/Button';
+import * as Expensicons from '@components/Icon/Expensicons';
+import MenuItem from '@components/MenuItem';
+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 useNetwork from '@hooks/useNetwork';
+import useThemeStyles from '@hooks/useThemeStyles';
+import getValuesForBeneficialOwner from '@pages/ReimbursementAccount/NonUSD/utils/getValuesForBeneficialOwner';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import INPUT_IDS from '@src/types/form/ReimbursementAccountForm';
+
+type BeneficialOwnersListProps = {
+ /** Method called when user confirms data */
+ handleConfirmation: () => void;
+
+ /** Method called when user presses on one of owners to edit its data */
+ handleOwnerEdit: (value: string) => void;
+
+ /** Method called when user presses on ownership chart push row */
+ handleOwnershipChartEdit: () => void;
+
+ /** List of owner keys */
+ ownerKeys: string[];
+};
+
+const {ENTITY_CHART} = INPUT_IDS.ADDITIONAL_DATA.CORPAY;
+
+function BeneficialOwnersList({handleConfirmation, ownerKeys, handleOwnerEdit, handleOwnershipChartEdit}: BeneficialOwnersListProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+ const {isOffline} = useNetwork();
+
+ const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT);
+ const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT);
+ const ownershipChartValue = reimbursementAccount?.achData?.additionalData?.corpay?.[ENTITY_CHART] ?? reimbursementAccountDraft?.[ENTITY_CHART] ?? [];
+
+ const policyID = reimbursementAccount?.achData?.policyID ?? '-1';
+ const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`);
+ const currency = policy?.outputCurrency ?? '';
+
+ const owners =
+ reimbursementAccountDraft &&
+ ownerKeys.map((ownerKey) => {
+ const ownerData = getValuesForBeneficialOwner(ownerKey, reimbursementAccountDraft);
+
+ return (
+