diff --git a/.github/ISSUE_TEMPLATE/Standard.md b/.github/ISSUE_TEMPLATE/Standard.md index 663c6004a534..fa50d48b341b 100644 --- a/.github/ISSUE_TEMPLATE/Standard.md +++ b/.github/ISSUE_TEMPLATE/Standard.md @@ -10,6 +10,7 @@ ___ **Version Number:** **Reproducible in staging?:** **Reproducible in production?:** +**If this was caught on HybridApp, is this reproducible on New Expensify Standalone?:** **If this was caught during regression testing, add the test name, ID and link from TestRail:** **Email or phone of affected tester (no customers):** **Logs:** https://stackoverflow.com/c/expensify/questions/4856 @@ -34,9 +35,11 @@ Can the user still use Expensify without this being fixed? Have you informed the Check off any platforms that are affected by this issue ---> Which of our officially supported platforms is this issue occurring on? -- [ ] Android: Native +- [ ] Android: Standalone +- [ ] Android: HybridApp - [ ] Android: mWeb Chrome -- [ ] iOS: Native +- [ ] iOS: Standalone +- [ ] iOS: HybridApp - [ ] iOS: mWeb Safari - [ ] MacOS: Chrome / Safari - [ ] MacOS: Desktop diff --git a/.github/workflows/buildAndroid.yml b/.github/workflows/buildAndroid.yml index 3115076ef62b..c88542068b92 100644 --- a/.github/workflows/buildAndroid.yml +++ b/.github/workflows/buildAndroid.yml @@ -20,11 +20,14 @@ on: description: The pull request number associated with this build, if relevant. type: string required: false + outputs: AAB_FILE_NAME: value: ${{ jobs.build.outputs.AAB_FILE_NAME }} APK_FILE_NAME: value: ${{ jobs.build.outputs.APK_FILE_NAME }} + APK_ARTIFACT_NAME: + value: ${{ jobs.build.outputs.APK_ARTIFACT_NAME }} workflow_dispatch: inputs: @@ -56,6 +59,7 @@ jobs: outputs: AAB_FILE_NAME: ${{ steps.build.outputs.AAB_FILE_NAME }} APK_FILE_NAME: ${{ steps.build.outputs.APK_FILE_NAME }} + APK_ARTIFACT_NAME: ${{ steps.build.outputs.APK_ARTIFACT_NAME }} steps: - name: Checkout @@ -159,6 +163,7 @@ jobs: echo "APK_PATH=$apkPath" echo "APK_FILE_NAME=$(basename "$apkPath")" echo "SHOULD_UPLOAD_SOURCEMAPS=$SHOULD_UPLOAD_SOURCEMAPS" + echo "APK_ARTIFACT_NAME=${{ inputs.artifact-prefix }}android-artifact-apk" >> "$GITHUB_OUTPUT" } >> "$GITHUB_OUTPUT" - name: Upload Android AAB artifact @@ -174,7 +179,7 @@ jobs: continue-on-error: true uses: actions/upload-artifact@v4 with: - name: ${{ inputs.artifact-prefix }}android-artifact-apk + name: ${{ steps.build.outputs.APK_ARTIFACT_NAME }} path: ${{ steps.build.outputs.APK_PATH }} - name: Upload Android sourcemaps artifact diff --git a/.github/workflows/e2ePerformanceTests.yml b/.github/workflows/e2ePerformanceTests.yml index f88e841617bb..d8e706d467ba 100644 --- a/.github/workflows/e2ePerformanceTests.yml +++ b/.github/workflows/e2ePerformanceTests.yml @@ -46,7 +46,7 @@ jobs: uses: ./.github/actions/javascript/getArtifactInfo with: GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_COMMIT_TOKEN }} - ARTIFACT_NAME: baseline-${{ steps.getMostRecentRelease.outputs.VERSION }}-android-artifact-apk + ARTIFACT_NAME: baseline-${{ steps.getMostRecentRelease.outputs.VERSION }}android-artifact-apk - name: Skip build if there's already an existing artifact for the baseline if: ${{ fromJSON(steps.checkForExistingArtifact.outputs.ARTIFACT_FOUND) }} @@ -125,7 +125,7 @@ jobs: uses: actions/download-artifact@v4 id: downloadBaselineAPK with: - name: baseline-${{ needs.prep.outputs.BASELINE_VERSION }}-android-artifact-apk + name: ${{ needs.buildBaseline.outputs.APK_ARTIFACT_NAME }} path: zip # Set github-token only if the baseline was built in this workflow run: github-token: ${{ needs.prep.outputs.BASELINE_ARTIFACT_WORKFLOW_ID && github.token }} @@ -139,7 +139,7 @@ jobs: uses: actions/download-artifact@v4 id: downloadDeltaAPK with: - name: delta-${{ needs.prep.outputs.DELTA_REF }}-android-artifact-apk + name: ${{ needs.buildDelta.outputs.APK_ARTIFACT_NAME }} path: zip - name: Rename delta APK diff --git a/android/app/build.gradle b/android/app/build.gradle index d87c8d487391..b07de7a4da33 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -13,7 +13,7 @@ apply from: project(':react-native-config').projectDir.getPath() + "/dotenv.grad /* Fullstory settings */ fullstory { org 'o-1WN56P-na1' - enabledVariants 'all' + enabledVariants 'production' logcatLevel 'debug' recordOnStart false } @@ -110,8 +110,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009004701 - versionName "9.0.47-1" + versionCode 1009005001 + versionName "9.0.50-1" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/assets/images/companyCards/card-bank_of_america.svg b/assets/images/companyCards/card-bank_of_america.svg deleted file mode 100644 index 684a6a0a28f5..000000000000 --- a/assets/images/companyCards/card-bank_of_america.svg +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/images/companyCards/card-other.svg b/assets/images/companyCards/card-other.svg deleted file mode 100644 index 11ff21285626..000000000000 --- a/assets/images/companyCards/card-other.svg +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 90ac50d1dcbd..75cb080f1349 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -9,7 +9,7 @@ "dependencies": { "electron-context-menu": "^2.3.0", "electron-log": "^4.4.8", - "electron-updater": "^6.3.6", + "electron-updater": "^6.3.8", "mime-types": "^2.1.35", "node-machine-id": "^1.1.12" }, @@ -59,9 +59,9 @@ } }, "node_modules/builder-util-runtime": { - "version": "9.2.7", - "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.7.tgz", - "integrity": "sha512-0qw2vcbA66LW/ImxZSy0vKQr9OqrpFXxtLyITBla7CdLlgz9fZkVAhKBi8EmNYIplL9j3zizB3mcgWnwVC6Fmg==", + "version": "9.2.9", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.9.tgz", + "integrity": "sha512-DWeHdrRFVvNnVyD4+vMztRpXegOGaQHodsAjyhstTbUNBIjebxM1ahxokQL+T1v8vpW8SY7aJ5is/zILH82lAw==", "license": "MIT", "dependencies": { "debug": "^4.3.4", @@ -156,12 +156,12 @@ "integrity": "sha512-QQ4GvrXO+HkgqqEOYbi+DHL7hj5JM+nHi/j+qrN9zeeXVKy8ZABgbu4CnG+BBqDZ2+tbeq9tUC4DZfIWFU5AZA==" }, "node_modules/electron-updater": { - "version": "6.3.6", - "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.3.6.tgz", - "integrity": "sha512-SciFV8nt04rwFFlW8Ph7NkoXVgISMJ9i7aAmovFmp3xFd6GUPBKpLKJNGPy10/R0hJKe/9fDkuVQ75LEZEQ+Ng==", + "version": "6.3.8", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.3.8.tgz", + "integrity": "sha512-OFsA2vyuOZgsqq4EW6tgW8X8e521ybDmQyIYLqss7HdXev+Ak90YatzpIECOBJXpmro5YDq4yZ2xFsKXqPt1DQ==", "license": "MIT", "dependencies": { - "builder-util-runtime": "9.2.7", + "builder-util-runtime": "9.2.9", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", @@ -469,9 +469,9 @@ "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==" }, "builder-util-runtime": { - "version": "9.2.7", - "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.7.tgz", - "integrity": "sha512-0qw2vcbA66LW/ImxZSy0vKQr9OqrpFXxtLyITBla7CdLlgz9fZkVAhKBi8EmNYIplL9j3zizB3mcgWnwVC6Fmg==", + "version": "9.2.9", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.9.tgz", + "integrity": "sha512-DWeHdrRFVvNnVyD4+vMztRpXegOGaQHodsAjyhstTbUNBIjebxM1ahxokQL+T1v8vpW8SY7aJ5is/zILH82lAw==", "requires": { "debug": "^4.3.4", "sax": "^1.2.4" @@ -538,11 +538,11 @@ "integrity": "sha512-QQ4GvrXO+HkgqqEOYbi+DHL7hj5JM+nHi/j+qrN9zeeXVKy8ZABgbu4CnG+BBqDZ2+tbeq9tUC4DZfIWFU5AZA==" }, "electron-updater": { - "version": "6.3.6", - "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.3.6.tgz", - "integrity": "sha512-SciFV8nt04rwFFlW8Ph7NkoXVgISMJ9i7aAmovFmp3xFd6GUPBKpLKJNGPy10/R0hJKe/9fDkuVQ75LEZEQ+Ng==", + "version": "6.3.8", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.3.8.tgz", + "integrity": "sha512-OFsA2vyuOZgsqq4EW6tgW8X8e521ybDmQyIYLqss7HdXev+Ak90YatzpIECOBJXpmro5YDq4yZ2xFsKXqPt1DQ==", "requires": { - "builder-util-runtime": "9.2.7", + "builder-util-runtime": "9.2.9", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", diff --git a/desktop/package.json b/desktop/package.json index 2392c98434c1..326d6f24f740 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -6,7 +6,7 @@ "dependencies": { "electron-context-menu": "^2.3.0", "electron-log": "^4.4.8", - "electron-updater": "^6.3.6", + "electron-updater": "^6.3.8", "mime-types": "^2.1.35", "node-machine-id": "^1.1.12" }, diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-US-Business-Bank-Account.md b/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-US-Business-Bank-Account.md index 1ad70117ed5c..49aaa480e2dc 100644 --- a/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-US-Business-Bank-Account.md +++ b/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-US-Business-Bank-Account.md @@ -8,7 +8,7 @@ You can choose to connect either a business deposit-only account that only recei | Business deposit-only account | Verified business account | |---------------------------------------------------|------------------------------------------------------| -| ✔ Receive reimbursements for invoices | ✔ Reimburse expenses via direct bank transfer | +| ✔ Receive payments for invoices | ✔ Reimburse expenses via direct bank transfer | | | ✔ Pay bills | | | ✔ Issue Expensify Cards | diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Pay-Bills.md b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Create-and-Pay-Bills.md similarity index 95% rename from docs/articles/expensify-classic/bank-accounts-and-payments/payments/Pay-Bills.md rename to docs/articles/expensify-classic/bank-accounts-and-payments/payments/Create-and-Pay-Bills.md index 465f6742eaea..aff11c059d81 100644 --- a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Pay-Bills.md +++ b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Create-and-Pay-Bills.md @@ -1,26 +1,26 @@ --- -title: Pay Bills +title: Create and Pay Bills description: Expensify bill management and payment methods. --- Streamline your operations by receiving and paying vendor or supplier bills directly in Expensify. Vendors can send bills even if they don't have an Expensify account, and you can manage payments seamlessly. -## Receive Bills in Expensify +# Receive Bills in Expensify You can receive bills in three ways: - Directly from Vendors: Provide your Expensify billing email to vendors. - Forwarding Emails: Forward bills received in your email to Expensify. - Manual Upload: For physical bills, create a Bill in Expensify from the Reports page. -## Bill Pay Workflow +# Bill Pay Workflow 1. When a vendor or supplier sends a bill to Expensify, the document is automatically SmartScanned, and a Bill is created. This Bill is managed by the primary domain contact, who can view it on the Reports page within their default group policy. 2. Once the Bill is ready for processing, it follows the established approval workflow. As each person approves it, the Bill appears in the next approver’s Inbox. The final approver will pay the Bill using one of the available payment methods. 3. During this process, the Bill is coded with the appropriate GL codes from your connected accounting software. After completing the approval workflow, the Bill can be exported back to your accounting system. -## Payment Methods +# Payment Methods There are multiple ways to pay Bills in Expensify. Let’s go over each method below. -### ACH bank-to-bank transfer +## ACH bank-to-bank transfer To use this payment method, you must have a [business bank account connected to your Expensify account](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-US-Business-Bank-Account). @@ -32,7 +32,7 @@ To use this payment method, you must have a [business bank account connected to **Fees:** None -### Credit or Debit Card +## Credit or Debit Card This option is available to all US and International customers receiving a bill from a US vendor with a US business bank account. **To pay with a credit or debit card:** @@ -43,13 +43,13 @@ This option is available to all US and International customers receiving a bill **Fees:** 2.9% of the total amount paid. -### Venmo +## Venmo If both you and the vendor must have Venmo connected to Expensify, you can pay the bill by following the steps outlined [here](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/Third-Party-Payments#setting-up-third-party-payments). **Fees:** Venmo charges a 3% sender’s fee. -### Pay outside of Expensify +## Pay outside of Expensify If you are unable to pay using one of the above methods, you can still mark the Bill as paid. This will update its status to indicate that the payment was made outside Expensify. **To mark a Bill as paid outside of Expensify:** diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Pay-an-Invoice.md b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Pay-an-Invoice.md new file mode 100644 index 000000000000..84fafc949527 --- /dev/null +++ b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Pay-an-Invoice.md @@ -0,0 +1,65 @@ +--- +title: Pay an Invoice +description: A guide to different methods of paying an invoice +--- + +
+ +There are multiple ways to pay Invoices in Expensify. Let’s go over each method below. + +# How to Pay Invoices + +1. Sign in to your [Expensify web account](www.expensify.com). +2. Click on the Invoice you’d like to pay to see the details. +3. Click on the **Pay** button. +4. Follow the prompts to pay through one of the following methods. + +### ACH bank-to-bank transfer + +To use this payment method, you must have a [business bank account connected to your Expensify account](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-US-Business-Bank-Account). + +**To pay with an ACH bank-to-bank transfer:** +1. Sign in to your [Expensify web account](www.expensify.com). +2. Go to the **Home** or **Reports** page and locate the Invoice that needs to be paid. +3. Click the **Pay** button to be redirected to the Invoice. +4. Choose the ACH option from the drop-down list. + +**Fees:** None + +### Credit or Debit Card +This option is available to all US and International customers receiving an invoice from a US vendor with a US business bank account. + +**To pay with a credit or debit card:** +1. Sign in to your [Expensify web account](www.expensify.com). +2. Click on the Invoice you’d like to pay to see the details. +3. Click the **Pay** button. +4. Enter your credit card or debit card details. + +**Fees:** 2.9% credit card payment fee. + +### Venmo +If both you and the vendor must have Venmo connected to Expensify, you can pay the Invoice by following the steps outlined [here](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/Third-Party-Payments#setting-up-third-party-payments). + +**Fees:** Venmo and Paypal.me charges may apply. + + +### Pay outside of Expensify +If you are unable to pay using one of the above methods, you can still mark the Invoice as paid. This will update its status to indicate that the payment was made outside Expensify. + +**To mark an Invoice as paid outside of Expensify:** +1. Sign in to your [Expensify web account](www.expensify.com). +2. Click on the Invoice you’d like to pay to see the details. +3. Click the Pay button. +4. Choose **I’ll do it manually**. + +**Fees:** None. + +{% include faq-begin.md %} + +## What’s the difference between an Invoice and an Expense Report in Expensify? +An invoice is an expense submitted to a client or contractor for payment. An expense report is an expense/group of expenses submitted to an employer for reimbursement. + +## What’s the difference between a Bill and an Invoice in Expensify? +A Bill is an amount owed to a payee (usually a vendor or supplier) and is usually created from a vendor invoice. An Invoice is a receivable and indicates an amount owed to you by someone else. + +{% include faq-end.md %} diff --git a/docs/articles/expensify-classic/connections/quickbooks-online/Quickbooks-Online-Troubleshooting.md b/docs/articles/expensify-classic/connections/quickbooks-online/Quickbooks-Online-Troubleshooting.md index 158a55b93e0f..6a43141f1ab9 100644 --- a/docs/articles/expensify-classic/connections/quickbooks-online/Quickbooks-Online-Troubleshooting.md +++ b/docs/articles/expensify-classic/connections/quickbooks-online/Quickbooks-Online-Troubleshooting.md @@ -18,7 +18,7 @@ This error occurs when the account applied as a category to the expense in Expen 5. Click on the pencil icon on the right to check if you have "In multiple accounts" selected: 6. If "In multiple accounts" is selected, go to Chart of Accounts and click Edit for the account in question. 7. Check the billable option and select an income account within your chart of accounts -8. Sync your QuickBooks Online connection in Settings > Workspaces > [click workspace] > Connections. +8. Sync your QuickBooks Online connection in Settings > Workspaces > [workspace name] > Connections. 9. Open the report and click the Export to button and then the QuickBooks Online option. # ExpensiError QBO046: Feature Not Included in Subscription @@ -40,11 +40,11 @@ _Please note: Self Employed is not supported:_ **Why does this happen?** -QuickBooks Online requires all expenses exported from Expensify to use a category matching an account in your chart of accounts. If a category from another source is used, QuickBooks Online will reject the expense. This errors occurs when an expense on the report has a category applied that is not valid in QuickBooks Online. +QuickBooks Online requires all expenses exported from Expensify to use a category matching an account in your chart of accounts. If a category from another source is used, QuickBooks Online will reject the expense. This error occurs when an expense on the report has a category applied that is not valid in QuickBooks Online. ## How to fix it -1. Sync your QuickBooks Online connection in Expensify from Settings > Workspaces > [click workspace] > Connections, and click the **Sync Now** button. +1. Sync your QuickBooks Online connection in Expensify from Settings > Workspaces > [workspace name] > Connections, and click the **Sync Now** button. 2. Review the expenses on the report. If any appear with a red _Category no longer valid_ violation, recategorize the expense until all expenses are violation-free. 3. Click the **Export t**o button and then the **QuickBooks Online** option. - If you receive the same error, continue. @@ -56,7 +56,7 @@ QuickBooks Online requires all expenses exported from Expensify to use a categor **Why does this happen?** -This error occurs when you have an Employee Record set up with the employee's name, which prevents the Expensify integration from automatically creating the Vendor Record with the same name, since QuickBooks Online won't allow you to have an employee and vendor with the same name. +This error occurs when you have an Employee Record set up with the employee's name. This prevents the Expensify integration from automatically creating the Vendor Record with the same name since QuickBooks Online won't allow you to have an employee and vendor with the same name. ## How to fix it @@ -67,7 +67,7 @@ There are two different ways you can resolve this error. 1. Log into QuickBooks Online. 2. Access the Employee Records for your submitters. 3. Edit the name to differentiate them from the name they have on their account in Expensify. -4. Sync your QuickBooks Online connection in Settings > Workspaces > [click workspace] > Connections. +4. Sync your QuickBooks Online connection in Settings > Workspaces > [workspace name] > Connections. 5. Open the report and click the Export to button and then the QuickBooks Online option. **Option 2**: @@ -85,7 +85,7 @@ This error occurs when you are exporting reimbursable expenses as Journal Entrie There are three different ways you can resolve this error. - Select a different type of export for reimbursable expenses under Settings > Workspaces > [worksapce name] > Connections > Configure > Export tab. -- Enable _Automatically Create Entities_ under Settings > Workspaces > [worksapce name] > Connections > Configure > Advanced to create vendor records automatically. +- Enable _Automatically Create Entities_ under Settings > Workspaces > [workspace name] > Connections > Configure > Advanced to create vendor records automatically. - Manually create vendor records in QuickBooks Online for each employee. # ExpensiError QBO099: Items marked as billable must have sales information checked @@ -97,7 +97,7 @@ This error occurs when an Item category on an expense does not have sales inform ## How to fix it 1. Log into QuickBooks Online. -2. Navigate to to your items list. +2. Navigate to your items list. 3. Click **Edit** to the right of the item used on the report with the error. Here you will see an option to check either "Sales" or "Purchasing". 4. Check the option for **Sales**. 5. Select an income account. @@ -146,12 +146,12 @@ This error occurs because the currency on the Vendor record in QuickBooks Online 1. Log into QuickBooks Online. 2. Open the vendor record. -3. Update the record to use with the correct A/P account, currency and an email matching their Expensify email. -You can find the correct Vendor record by exporting your QuickBooks Online [vendor list](https://community.expensify.com/home/leaving?allowTrusted=1&target=https%3A%2F%2Fqbo.intuit.com%2Fapp%2Fvendors) to a spreadsheet (click the export icon on the right-hand side of the page), and search for the email address of the person who submitted the report. +3. Update the record to use with the correct A/P account, currency, and email matching their Expensify email. +You can find the correct Vendor record by exporting your QuickBooks Online vendor list to a spreadsheet (click the export icon on the right-hand side of the page), and search for the email address of the person who submitted the report. If you have multiple Vendors with different currencies with the same email, Expensify is likely trying to export to the wrong one. -1. Try removing the email address from the vendor in QuickBooks Online you aren't trying to export to. +1. Try removing the email address from the vendor in QuickBooks Online that you aren't trying to export to. 2. Sync your QuickBooks Online connection in Settings > Workspaces > [workspace name] > Connections. 3. Open the report and click the **Export to** button and then the **QuickBooks Online** option. @@ -160,13 +160,13 @@ If this still fails, you'll need to confirm that the A/P account selected in Exp 1. Navigate to Settings > Workspaces > [workspace name] > Connections. 2. Under the Exports tab check that both A/P accounts are the correct currency. -# Why are company card expenses exporting to the wrong account in QuickBooks Online? +# Why are company card expenses exported to the wrong account in QuickBooks Online? Multiple factors could be causing your company card transactions to export to the wrong place in your accounting system, but the best place to start is always the same. 1. First, confirm that the company cards have been mapped to the correct accounts in Settings > Domains > Company Cards > click the **Edit Export button** for the card to view the account. -2. Next, confirm the expenses in question have been imported from the company card? - - Only expenses that have the Card+Lock icon next to them will export according to the mapping settings that you configure in the domain settings. +2. Next, confirm the expenses in question have been imported from the company card. + - Only expenses with the Card+Lock icon next to them will export according to the mapping settings that you configure in the domain settings. It’s important to note that expenses imported from a card linked at the individual account level, expenses created from a SmartScanned receipt, and manually created cash expenses will export to the default bank account selected in your connection's configuration settings. @@ -174,7 +174,7 @@ It’s important to note that expenses imported from a card linked at the indivi The user exporting the report must be a domain admin. You can check the history and comment section at the bottom of the report to see who exported the report. -If your reports are being exported automatically by Concierge, the user listed as the Preferred Exporter under Settings > Workspaces > [workspaces name] > Connections > click **Configure** must be a domain admin as well. +If your reports are being exported automatically by Concierge, the user listed as the Preferred Exporter under Settings > Workspaces > [workspace name] > Connections > click **Configure** must also be a domain admin. If the report exporter is not a domain admin, all company card expenses will export to the bank account set in Settings > Workspaces > [workspace name] > Connections > click **Configure** for non-reimbursable expenses. diff --git a/docs/articles/expensify-classic/domains/SAML-SSO.md b/docs/articles/expensify-classic/domains/SAML-SSO.md index e4b27b238e46..a6032afe8d24 100644 --- a/docs/articles/expensify-classic/domains/SAML-SSO.md +++ b/docs/articles/expensify-classic/domains/SAML-SSO.md @@ -2,18 +2,20 @@ title: Managing Single Sign-On (SSO) and User Authentication in Expensify description: Learn how to effectively manage Single Sign-On (SSO) and user authentication in Expensify alongside your preferred SSO provider. Our comprehensive guide covers SSO setup, domain verification, and specific instructions for popular providers like AWS, Okta, and Microsoft Azure. Streamline user access and enhance security with Expensify's SAML-based SSO integration. --- -# Overview -This article provides a comprehensive guide on managing Single Sign-On (SSO) and user authentication in Expensify alongside your preferred SSO provider. Expensify uses SAML to enable and manage SSO between Expensify and your SSO provider. -# How to Use SSO in Expensify -Before setting up Single Sign-On with Expensify you will need to make sure your domain has been verified. Once the domain is verified, you can access the SSO settings by navigating to Settings > Domains > [Domain Name] > SAML. -On this page, you can: +# Using SSO in Expensify +Before setting up Single Sign-On with Expensify you will need to make sure the [domain is verified](https://help.expensify.com/articles/expensify-classic/domains/Claim-And-Verify-A-Domain#step-2-verify-domain-ownership). + +Once the domain is verified, you can access the SSO settings by navigating to Settings > Domains > [Domain Name] > SAML. + +## The Domains page +**On this page, you can:** - Get Expensify's Service Provider MetaData. You will need to give this to your identity provider. - Enter your Identity Provider MetaData. Please contact your SAML SSO provider if you are unsure how to get this. - Choose whether you want to make SAML SSO required for login. If you choose this option, members will only be able to log in to Expensify via SAML SSO. -Instructions for setting up Expensify for specific SSO providers can be found below. If you do not see your provider listed below, please contact them and request instructions. + +**Below are instructions for setting up Expensify for specific SSO providers:** - [Amazon Web Services (AWS SSO)](https://static.global.sso.amazonaws.com/app-202a715cb67cddd9/instructions/index.htm) -- [Bitium](https://support.bitium.com/administration/saml-expensify/) - [Google SAML](https://support.google.com/a/answer/7371682) (for GSuite, not Google SSO) - [Microsoft Azure Active Directory](https://azure.microsoft.com/en-us/documentation/articles/active-directory-saas-expensify-tutorial/) - [Okta](https://saml-doc.okta.com/SAML_Docs/How-to-Configure-SAML-2.0-for-Expensify.html) @@ -22,22 +24,39 @@ Instructions for setting up Expensify for specific SSO providers can be found be - [SAASPASS](https://saaspass.com/saaspass/expensify-two-factor-authentication-2fa-single-sign-on-sso-saml.html) - Microsoft Active Directory Federation Services (see instructions in the FAQ section below) -When SSO is enabled, employees will be prompted to sign in through Single Sign-On when using their company email (private domain email) and also a public email (e.g. gmail.com) linked as a secondary login. +If your provider is not listed, please contact them and request instructions. + +When SSO is enabled, employees will be prompted to sign in through Single Sign-On when using their company email (private domain email) and also a public email (e.g. gmail.com) linked as a [Secondary Login](https://help.expensify.com/articles/expensify-classic/settings/Change-or-add-email-address). + +{% include faq-begin.md %} + +## What should I do if I’m getting an error when trying to set up SSO? +You can double-check your configuration data for errors using samltool.com. If you’re still having issues, you can contact your Account Manager or Concierge for assistance. + +## What is the EntityID for Expensify? +The entityID for Expensify is https://expensify.com. Remember not to copy and paste any extra slashes or spaces. If you've enabled the Multi-Domain support (see below) then your entityID will be https://expensify.com/mydomainname.com. + +## Can you have multiple domains with only one entity ID? +Yes. Please send a message to the Concierge or your account manager, and we will enable the use of the same entity ID with multiple domains. ## How can I update the Microsoft Azure SSO Certificate? -Expensify's SAML configuration doesn't support multiple active certificates. This means that if you create the new certification ahead of time without first removing the old one, the respective IdP will include two unique x509 certificates instead of one and the connection will break. Should you need to access Expensify, switching back to the old certificate will continue to allow access while that certificate is still valid. +Expensify's SAML configuration doesn't support multiple active certificates. This means that if you create the new certification ahead of time without first removing the old one, the respective IDP will include two unique x509 certificates instead of one, and the connection will break. Should you need to access Expensify, switching back to the old certificate will continue to allow access while that certificate is still valid. -To transfer from one Microsoft Azure certificate to another, please follow the below steps: -1. In Azure Directory , create your new certificate. +**To transfer from one Microsoft Azure certificate to another, please follow the below steps:** +1. In Azure Directory, create your new certificate. 2. In Azure Director, remove the old, expiring certificate. -3. In Azure Directory, activate the remaining certificate, and get a new IdP for Expensify from it. -4. In Expensify, replace the previous IdP with the new IdP. -5. Log in via SSO. If login continues to fails, write into Concierge for assistance. +3. In Azure Directory, activate the remaining certificate and get a new IDP for Expensify from it. +4. In Expensify, replace the previous IDP with the new IDP. +5. Log in via SSO. If login continues to fail, write to Concierge for assistance. -## How can I enable deactivating users with the Okta SSO integration? -Companies using Okta can deactivate users in Expensify using the Okta SCIM API. This means that when a user is deactivated in Okta their access to Expensify will expire and they will be logged out of both the web and mobile apps. Deactivating a user through Okta will not close their account in Expensify, if you are offboarding this employee, you will still want to close the account. You will need have a verified domain and SAML fully setup before completing setting up the deactivation feature. +## How can I enable "deactivating users" with the Okta SSO integration? +Companies using Okta can deactivate users in Expensify using the Okta SCIM API: +- When a user is deactivated in Okta, their access to Expensify expires, and they are logged out of both the web and mobile apps. +- Deactivating a user through Okta will not close their account in Expensify +- If you are offboarding this employee, you will still want to close the account. +- A verified domain and a complete SAML setup are required before you can configure the deactivation feature. -To enable deactivating users in Okta, follow these steps: +**To enable deactivating users in Okta, follow these steps:** 1. In Expensify, head to *Settings > Domains > _[Domain Name]_ > SAML* 2. Ensure that the toggle is set to Enabled for *SAML Login* and *Required for login* 3. In Okta, go to *Admin > Applications > Add Application* @@ -49,18 +68,18 @@ To enable deactivating users in Okta, follow these steps: 9. Then, go to *Directory > Profile Editor > Okta user > Profile* 10. Click the information bubble to the right of the *First name* and *Last name* attributes 11. Uncheck *Yes* under *Attribute required* field and press *Save Attribute*. -12. Email concierge@expensify.com providing your domain and request that Okta SCIM be enabled. You will receive a response when this step has been completed. +12. Email concierge@expensify.com, providing your domain, and request that Okta SCIM be enabled. You will receive a response when this step has been completed. 13. In Expensify, go to *Domains > _[Domain Name]_ > SAML > Show Token* and copy the Okta SCIM Token you received. 14. In Okta, go to *Admin > Applications > Expensify > Provisioning > API Integration > Configure API Integration* -15. Select Enable API Integration and paste the Okta SCIM Token in API Token field and then click Save. -15. Go to To App, click Edit Provisioning Users, select Enable Deactivate Users and then Save. (You may also need to set up the Expensify Attribute Mappings if you have not previously in steps 9-11). +15. Select Enable API Integration, paste the Okta SCIM Token in the API Token field, and then click Save. +15. Go to To App, click Edit Provisioning Users, select Enable Deactivate Users, and then Save. (You may also need to set up the Expensify Attribute Mappings if you have not previously in steps 9-11). Successful activation of this function will be indicated by the green Push User Deactivation icon being enabled at the top of the app page. -## How can I set up SAML authentication with Microsoft ADFS? -Before getting started, you will need to have a verified domain and Control plan in order to set up SSO with Microsoft ADFS. +## How do I set up the SAML authentication with Microsoft ADFS? +Before getting started, you will need a verified domain and Control plan to set up SSO with Microsoft ADFS. -To enable SSO with Microsoft ADFS follow these steps: +**To enable SSO with Microsoft ADFS follow these steps:** 1. Open the ADFS management console, and click the *Add Relying Party Trust* link on the right. 2. Check the option to *Import data about the relying party from a file*, then click the *Browse* button. You will input the XML file of Expensify’s metadata which can be found on the Expensify SAML setup page. 3. The metadata file will provide the critical information that ADFS needs to set up the trust. In ADFS, give it a name, and click Next. @@ -69,22 +88,10 @@ To enable SSO with Microsoft ADFS follow these steps: 6. The new trust is now created. Highlight the trust, then click *Edit claim rules* on the right. 7. Click *Add a Rule*. 8. The default option should be *Send LDAP Attributes as Claims*. Click Next. -9. Depending upon how your Active Directory is set up, you may or may not have a useful email address associated with each user, or you may have a policy to use the UPN as the user attribute for authentication. If so, using the UPN user attribute may be appropriate for you. If not, you can use the emailaddress attribute. +9. Depending upon how your Active Directory is set up, you may or may not have a useful email address associated with each user, or you may have a policy to use the UPN as the user attribute for authentication. If so, using the UPN user attribute may be appropriate for you. If not, you can use the email address attribute. 10. Give the rule a name like *Get email address from AD*. Choose Active Directory as the attribute store from the dropdown list. Choose your source user attribute to pass to Expensify that has users’ email address info in it, usually either *E-Mail-Address* or *User-Principal-Name*. Select the outgoing claim type as “E-Mail Address”. Click OK. 11. Add another rule; this time, we want to *Transform an Incoming Claim*. Click Next. 12. Name the rule *Send email address*. The Incoming claim type should be *E-Mail Address*. The outgoing claim type should be *Name ID*, and the outgoing name ID format should be *Email*. Click OK. 13. You should now have two claim rules. -Assuming you’ve also set up Expensify SAML configuration with your metadata, SAML logins on Expensify.com should now work. For reference, ADFS’ default metadata path is: https://yourservicename.yourdomainname.com/FederationMetadata/2007-06/FederationMetadata.xml. - -{% include faq-begin.md %} -## What should I do if I’m getting an error when trying to set up SSO? -You can double check your configuration data for errors using samltool.com. If you’re still having issues, you can reach out to your Account Manager or contact Concierge for assistance. - -## What is the EntityID for Expensify? -The entityID for Expensify is https://expensify.com. Remember not to copy and paste any extra slashes or spaces. If you've enabled the Multi-Domain support (see below) then your entityID will be https://expensify.com/mydomainname.com. - -## Can you have multiple domains with only one entityID? -Yes. Please send a message to Concierge or your account manager and we will enable the ability to use the same entityID with multiple domains. - {% include faq-end.md %} diff --git a/docs/articles/expensify-classic/expenses/Add-Invoices-in-Bulk.md b/docs/articles/expensify-classic/expenses/Add-Invoices-in-Bulk.md new file mode 100644 index 000000000000..6257c1e6d84d --- /dev/null +++ b/docs/articles/expensify-classic/expenses/Add-Invoices-in-Bulk.md @@ -0,0 +1,48 @@ +--- +title: Bulk Upload Invoices +description: How to Bulk Upload Invoices +--- + +Expensify offers importing multiple invoices (bulk import) via CSV to save you from manually creating individual invoices. + +## Uploading Invoices into Expensify + +1. Click the **Reports** tab. +2. Click the **New Report** drop-down. +3. Select **Bulk Import Invoices**. +4. Click the sample CSV link to download your custom CSV template to your browser or computer. +5. Add the invoice details following the formatting rules (see below **CSV formatting guide** section) +6. Click **Upload CSV** + +## CSV formatting guide + +- Send to: recipient's email address (ex: john.smith@companydomain.com) +- Share: email address (ex: julie.clarke@companydomain.com) +- Report Name: this will be the name of the Invoice report +- Merchant: business name of invoice sender +- Amount: use the number format in this column. Negative amounts cannot be invoiced. +- Date: YYYY-MM-DD formatting +- Due Date: YYYY-MM-DD formatting + +## After the Invoices are uploaded + +- After you click **Upload**, the invoices will automatically be created and viewable on the **Reports** page. +- The **Send To** contact will get an email notifying them of the invoice you sent. +- You can manually edit the invoice details. +- You can manually upload a PDF of the invoice to the report. + +{% include faq-begin.md %} + +## Are there any fees associated with Invoices in Expensify? +No, Invoices are part of the [Control Plan](https://help.expensify.com/articles/expensify-classic/expensify-billing/Change-Plan-Or-Subscription#change-group-plan). + +## Can Invoices be revised once they are sent? +If you sent an invoice by mistake, you can click **Undo Send** on the invoice to revoke it. If you’d like to add more details to a sent invoice, you can add those as a [Report comment](https://help.expensify.com/articles/expensify-classic/reports/Add-comments-and-attachments-to-a-report) for everyone to view. + +## How do I communicate with the payor +You can communicate with the payor through [Report comments](https://help.expensify.com/articles/expensify-classic/reports/Add-comments-and-attachments-to-a-report). + +## What’s the difference between an Invoice and an Expense Report in Expensify? +An invoice is an expense submitted to a client or contractor for payment. An expense report is an expense or group of expenses submitted to an employer for reimbursement. + +{% include faq-end.md %} diff --git a/docs/articles/expensify-classic/expenses/Send-and-Receive-Payment-for-Invoices.md b/docs/articles/expensify-classic/expenses/Send-and-Receive-Payment-for-Invoices.md new file mode 100644 index 000000000000..c2ebb64b0af6 --- /dev/null +++ b/docs/articles/expensify-classic/expenses/Send-and-Receive-Payment-for-Invoices.md @@ -0,0 +1,39 @@ +--- +title: Send and Receive Payment for Invoices +description: How to Send and Receive Payments for Invoices +--- + +Simplify your back office by sending invoices to vendors and suppliers in Expensify. +Invoices can be sent to anyone with or without an Expensify account and paid directly to your business bank account through Expensify. + +## How to send an invoice in Expensify + +1. Sign in to your [Expensify web account](www.expensify.com) +2. Customize your company invoices following the steps in this [help article](https://help.expensify.com/articles/expensify-classic/workspaces/Set-Up-Invoicing). (Optional) +3. From the **Reports** page, click the drop-down and select **Invoice**. +4. Upload a PDF/image of the invoice. +5. Add applicable tags and categories based on your workspace settings. +6. Click **Send**. + +## How to Receive an Invoice Payment in Expensify + +1. To use Expensify payments, you must have a [business bank account connected to your Expensify account](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-US-Business-Bank-Account). +2. Ensure the payment details are on the invoice sent to the payor. +3. The payor will receive a notification of the submitted invoice. +4. They will have the option to pay the invoice through Expensify. + +{% include faq-begin.md %} + +## Are there any fees associated with Invoices in Expensify? +No, Invoices are part of the [Control Plan](https://help.expensify.com/articles/expensify-classic/expensify-billing/Change-Plan-Or-Subscription#change-group-plan). + +## Can Invoices be revised once they are sent? +If you sent an invoice by mistake, you can click **Undo Send** on the invoice to revoke it. If you’d like to add more details to a sent invoice, you can add those as a [Report comment](https://help.expensify.com/articles/expensify-classic/reports/Add-comments-and-attachments-to-a-report) for everyone to view. + +## How do I communicate with the payor +You can communicate with the payor through [Report comments](https://help.expensify.com/articles/expensify-classic/reports/Add-comments-and-attachments-to-a-report). + +## What’s the difference between an Invoice and an Expense Report in Expensify? +An invoice is an expense submitted to a client or contractor for payment. An expense report is an expense or group of expenses submitted to an employer for reimbursement. + +{% include faq-end.md %} diff --git a/docs/articles/expensify-classic/expenses/Trips.md b/docs/articles/expensify-classic/expenses/Trips.md index 04f95c96eb44..1c84cd9189f7 100644 --- a/docs/articles/expensify-classic/expenses/Trips.md +++ b/docs/articles/expensify-classic/expenses/Trips.md @@ -2,38 +2,35 @@ title: Trips description: Automate getting paid back for your travel through Expensify's Trips feature. --- -# Overview -Discover how Expensify streamlines your travel expense management when it comes to trips. With the automatic classification of trip receipts and real-time notifications for travel changes, you can effortlessly stay organized and informed on all your trips. - -When a travel receipt/itinerary is uploaded into Expensify and SmartScanned, the Trips section of the mobile app will automatically populate your trip information.. If your flight has any cancellations, unexpected changes, or delays, we will make sure you know about it. We will notify you of the change as soon as it happens via the mobile app. +When a travel receipt or itinerary is uploaded to Expensify via SmartScan, the Trips section of the mobile app automatically populates your trip information. If your flight has any cancellations, unexpected changes, or delays, we will notify you of them as soon as they occur via the mobile app. For the receipt to be processed as a Trip, it must include the total amount of the expense, date, and merchant name. If your company is using a travel integration from the list shown below, you can automate this process entirely: - [TravelPerk](https://help.expensify.com/articles/expensify-classic/integrations/travel-integrations/TravelPerk) - [Egencia](https://help.expensify.com/articles/expensify-classic/integrations/travel-integrations/Egencia) -- [Navan](https://help.expensify.com/articles/expensify-classic/integrations/travel-integrations/Trip-Actions) +- [Navan](https://help.expensify.com/articles/expensify-classic/connections/Navan) -# How to add a Trip to your account +# Add a Trip to your account -Trip receipts are typically sent via email, and will include multiple pages. With that in mind, we recommend emailing receipts directly to Expensify for ease. +Trip receipts are typically sent via email and will include multiple pages. For ease, we recommend emailing receipts directly to Expensify. -To email a flight or hotel receipt, you’ll forward the receipt from your Expensify-associated email address to receipts@expensify.com. +To email a flight or hotel receipt, simply forward the receipt from your Expensify-associated email address to receipts@expensify.com. -# How to access your Trip information +# Access your Trip information To view details about your past or upcoming trips, follow these steps within the Expensify mobile app: 1. Open the Expensify mobile app -2. Navigate to the "Menu" option (top left ≡ icon) +2. Navigate to the **Menu** option (top left ≡ icon) 3. Select **Trips** {% include faq-begin.md %} ## How do I capture Trip receipts sent to my personal email address? -If you received your receipt in an email that is not associated with your Expensify account, you can add this email as a [secondary login](https://help.expensify.com/articles/expensify-classic/account-settings/Account-Details#how-to-add-a-secondary-login) to directly forward the receipt into your account. +If you received your receipt in an email that is not associated with your Expensify account, you can add this email as a [secondary login](https://help.expensify.com/articles/expensify-classic/settings/Change-or-add-email-address) to directly forward the receipt into your account. ## How do I upload Trip receipts that were not sent to me by email? -If your trip receipt was not sent to you by email, you can manually upload the receipt to your account. Check out this resource for more information on [manually uploading receipts](https://help.expensify.com/articles/expensify-classic/expenses/expenses/Upload-Receipts#manually-upload). +If your trip receipt was not sent to you by email, you can manually upload the receipt to your account. Check out this resource for more information on [manually creating expenses](https://help.expensify.com/articles/expensify-classic/expenses/Add-an-expense#add-an-expense-manually). {% include faq-end.md %} diff --git a/docs/articles/expensify-classic/expensify-billing/Consolidated-Domain-Billing.md b/docs/articles/expensify-classic/expensify-billing/Consolidated-Domain-Billing.md index 2e829c0785d3..671d0c41e772 100644 --- a/docs/articles/expensify-classic/expensify-billing/Consolidated-Domain-Billing.md +++ b/docs/articles/expensify-classic/expensify-billing/Consolidated-Domain-Billing.md @@ -16,9 +16,12 @@ When a Domain Admin enables Consolidated Domain Billing, all Group workspaces ow If you don’t have multiple billing owners across your organization, or if you want to keep billing separate for any reason, then this feature isn’t necessary. If you have an Annual Subscription and enable Consolidated Domain Billing, the Consolidated Domain Billing feature will gather the amounts due for each Group workspace Billing Owner (listed under **Settings > Workspaces > Group**). To make full use of the Annual Subscription for all workspaces in your domain, you should also be the billing owner for all Group workspaces. + {% include faq-begin.md %} + ## How do I take over the billing of a workspace with Consolidated Domain Billing enabled? You’ll have to toggle off Consolidated Domain Billing, take over ownership of the workspace, and then toggle it back on. + ## Can I use Consolidated Domain Billing to cover the bill for some workspaces, but not others? No, this feature means that you’ll be paying the bill for all domain members who choose a subscription. diff --git a/docs/articles/expensify-classic/expensify-billing/Receipt-Breakdown.md b/docs/articles/expensify-classic/expensify-billing/Receipt-Breakdown.md index 21e2db5604f8..8f512fb71512 100644 --- a/docs/articles/expensify-classic/expensify-billing/Receipt-Breakdown.md +++ b/docs/articles/expensify-classic/expensify-billing/Receipt-Breakdown.md @@ -2,46 +2,36 @@ title: Receipts Breakdown description: This article goes over the Expensify receipt for billing owners. --- +**Your receipt is broken up into the following sections:** +- A high-level summary of your total Expensify bill +- Ways to reduce your bill and get paid to use Expensify +- A billing breakdown that covers all activity and discounts +- An activity breakdown by workspace -# Overview -This article will give you (the billing owner) a detailed breakdown of your Expensify bill. +## A high-level summary -Your receipt is broken up into multiple sections that include: -1. A high-level summary of your total Expensify bill -2. Ways to reduce your bill and get paid to use Expensify -3. A billing breakdown that covers all activity and discounts -4. An activity breakdown by workspace +- The top section will show the total amount you paid as the billing owner of Expensify Workspaces and give you a breakdown of the price per member. +- Every member of your workspace(s) can store data, review data, and access free features like Expensify Chat. +- We show the total price and then calculate the price per member by using the number of members across all of the workspaces you own. +- Further down in the receipt, there's a breakdown of the members who generated billable activity. -## How-to understand the high-level summary -The top section will show the total amount you paid as the billing owner of Expensify workspaces and give you a breakdown of price per member. Every member of your workspace(s) gets to store data, review data, and access free features like Expensify Chat. Thus, we show the total price and then use all of the members across all of the workspaces you own to calculate the price per member. Further down in the receipt, and in this article, we break down the members who generated billable activity. - -## How-to reduce your bill and get paid to use Expensify +## Reduce your bill and get paid to use Expensify Chances are you can actually get paid to use Expensify with the Expensify Card. In this section of the receipt, we outline how much money you're leaving on the table by not using the Expensify Card. You can click `Get started` to connect with your account manager (if you have one) or Concierge, both of whom can help get you started with the card. _Note: Currently, we offer Expensify Cards to companies with US bank accounts._ -## How-to understand your billing breakdown +## The billing breakdown Your receipt will have a detailed breakdown of activity and discounts across all workspaces. Here's a description of items that may appear on your bill: -- [Number of] Inactive workspace members @ $0.00 - - All inactive members from any of your workspaces. -- [Number of] Chat-only members @ $0.00 - - Any workspace members who chatted but didn't generate any other billable activity. Learn more about [chatting for free.](https://help.expensify.com/articles/new-expensify/chat/Introducing-Expensify-Chat) -- [Number of] Annual Control members @ $18.00 - - Any members included in your annual subscription on the Control plan. -- [Number of] Pay-per-use Control members @ $36.00 - - Any members above your annual subscription size on the Control plan. They're billed at the pay-per-use rate. -- [Number of] Annual Collect members @ $10.00 - - Any members included in your annual subscription on the Collect plan. -- [Number of] Pay-per-use Collect members @ $20.00 - - Any members above your annual subscription size on the Collect plan. These members are billed at the pay-per-use rate. -- X% Expensify Card discount with $Y spend - - The % discount you're getting based on total settled US purchases across your Expensify Cards. -- X% Expensify Card cash back credit for $Y spend - - The amount of cash back you've earned based on total settled US purchases across your Expensify Cards. -- 50% ExpensifyApproved! partner discount - - If you're part of an accounting firm, you get an additional discount for being our partner. [Learn more about our ExpensifyApproved! accountants program.](https://use.expensify.com/accountants-program) -- Total - - Sum of all the line items above. +- **[Number of] Inactive workspace members @ $0.00:** All inactive members from any of your workspaces. +- **[Number of] Chat-only members @ $0.00:** Any workspace members who chatted but didn't generate any other billable activity. Learn more about [chatting for free.](https://help.expensify.com/new-expensify/hubs/chat/) +- **[Number of] Annual Control members @ $18.00:** Any members included in your annual subscription on the Control plan. +- **[Number of] Pay-per-use Control members @ $36.00:** Any members above your annual subscription size on the Control plan. They're billed at the pay-per-use rate. +- **[Number of] Annual Collect members @ $10.00:** Any members included in your annual subscription on the Collect plan. +- **[Number of] Pay-per-use Collect members @ $20.00:** Any members above your annual subscription size on the Collect plan. These members are billed at the pay-per-use rate. +- **X% Expensify Card discount with $Y spend:** The % discount you're getting based on total settled US purchases across your Expensify Cards. +- **X% Expensify Card cash back credit for $Y spend:** The amount of cash back you've earned based on total settled US purchases across your Expensify Cards. +- **50% ExpensifyApproved! partner discount:** If you're part of an accounting firm, you get an additional discount for being our partner -- learn more about our ExpensifyApproved! accountant program [here](https://use.expensify.com/accountants-program). +- **Total:** The sum of all the line items above. -## How-to understand your activity breakdown +## The activity breakdown This section will list all of your workspaces alongside their IDs and break down the billing for each of them. diff --git a/docs/articles/expensify-classic/expensify-partner-program/Card-Revenue-Share.md b/docs/articles/expensify-classic/expensify-partner-program/Card-Revenue-Share.md index b6c3bc0904c0..663a5e3cd9c8 100644 --- a/docs/articles/expensify-classic/expensify-partner-program/Card-Revenue-Share.md +++ b/docs/articles/expensify-classic/expensify-partner-program/Card-Revenue-Share.md @@ -2,26 +2,23 @@ title: Expensify Card revenue share for ExpensifyApproved! partners description: Earn money when your clients adopt the Expensify Card --- -# Overview You can now earn additional income for your firm every time your client uses their Expensify Card. In short, your firm gets 0.5% of your clients’ total Expensify Card spend as cash back. The more your clients spend, the more cashback your firm receives! This program is currently only available to US-based ExpensifyApproved! partner accountants. -# Become a domain admin -To benefit from this program, you or a member of your firm must be a domain admin on your client’s domain in Expensify. +# Become a Domain Admin +To benefit from this program, you or a member of your firm must be a domain admin on the client’s domain in Expensify: 1. Head to *Settings > Domains* -2. Click the name of your client's domain +2. Click the client's domain + - If you can click on the domain and access the domain settings, you are a Domain Admin + - If you’re not a Domain Admin, your client can add you as one by heading to **Settings > Domains > [Client's Domain] > Domain Admins > Add Admin**. -If you can click into the domain and access the domain settings, that means you are a domain admin. +_**Note:** You can view all domain admins under Settings > Domains > [Client's Domain] > Domain Admins._ -Note: You can view all domain admins under *Settings > Domains > [Client's domain name] > Domain Admins*. +# Connect a deposit-only business bank account +[Follow these instructions](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-US-Business-Bank-Account#connect-a-business-deposit-only-account) to connect a deposit-only business bank account. -If you’re not a domain admin, your client can add you as one by heading to **Settings > Domains > [Client's domain name] > Domain Admins > Add admin**. - -# Connect a deposit account -Next, connect a deposit-only business bank account. Any revenue earned will be deposited directly into that account. - -Instructions to connect a deposit-only business bank account are [here](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/deposit-accounts/Deposit-Accounts-USD#how-to-connect-a-business-deposit-only-bank-account). +Once that's complete, any revenue earned will be deposited directly into that bank account. {% include faq-begin.md %} diff --git a/docs/articles/expensify-classic/spending-insights/Insights.md b/docs/articles/expensify-classic/spending-insights/Insights.md index c5ee218352fd..edd9c2207466 100644 --- a/docs/articles/expensify-classic/spending-insights/Insights.md +++ b/docs/articles/expensify-classic/spending-insights/Insights.md @@ -1,12 +1,11 @@ --- title: Custom Reporting and Insights -description: How to get the most out of the Custom Reporing and Insights +description: How to get the most out of the Custom Reporting and Insights redirect_from: articles/other/Insights/ --- -# Overview -The Insights dashboard allows you to monitor all aspects of company spending across categories, employees, projects, departments, and more. You can see trends in real-time, forecast company budgets, and build unlimited custom reports with help from our trained specialist team. +The Insights dashboard allows you to monitor all aspects of company spending across categories, employees, projects, departments, and more. You can see trends in real time, forecast company budgets, and build unlimited custom reports with help from our trained specialist team. ![Insights Pie Chart](https://help.expensify.com/assets/images/insights-chart.png){:width="100%"} ## Review your Insights data @@ -15,7 +14,7 @@ The Insights dashboard allows you to monitor all aspects of company spending acr 2. Select a specific date range (the default view has the current month pre-selected) 3. Use the filter options to select the categories, tags, employees, or any other parameter 4. Make sure that View in the top right corner is set to the pie chart icon -5. You can view any dataset in more detail by clicking in the “View Raw Data” column +5. You can view any dataset in more detail by clicking in the **View Raw Data** column ## Export your Insights data @@ -26,46 +25,47 @@ The Insights dashboard allows you to monitor all aspects of company spending acr ## Create a Custom Export Report for your Expenses 1. Navigate to **Settings > Account > Preferences > scroll down to CSV Export Formats** -2. Build up a report using these [formulas]((https://help.expensify.com/articles/expensify-classic/spending-insights/Custom-Templates)) +2. Build up a report using these [expense-level formulas](https://help.expensify.com/articles/expensify-classic/spending-insights/Custom-Templates#expense-level) 3. Click the **Custom Export** button on the Insights page and your Account Manager will help get you started on building up your report ## Create a Custom Export Report for your Workspace 1. Navigate to **Settings > Workspaces > Group > [Workspace Name] > Export Formats** -2. Build up a report using these [formulas](https://help.expensify.com/articles/expensify-classic/spending-insights/Custom-Templates) -3. If you need any help, click the **Support** button on the top left to contact your Account Manager +2. Build up a report using these [report-level formulas](https://help.expensify.com/articles/expensify-classic/spending-insights/Custom-Templates#report-level) +3. If you need any help, click the **Support** button on the top left to contact Concierge or your Account Manager {% include faq-begin.md %} -#### Can I share my custom export report? +## Can I share my custom export report? If you would like to create a custom export report that can be shared with other workspace admins, you can do so by navigating to the **[Settings > Workspaces > Group > [Workspace Name] > Export Formats** page. Custom export reports created under the **Settings > Account > Preferences** page are only available to the member who created them. -#### Can I put expenses from different workspaces on the same report? +## Can I put expenses from different workspaces on the same report? -Custom export reports created under the Settings > Account > Preferences page can export expenses from multiple workspaces, and custom export formats created under Settings > Workspaces> Group > [Workspace Name] > Export Formats are for expenses reported under that workspace only. +Custom export reports created under the **Settings > Account > Preferences** page can export expenses from multiple workspaces. -#### Are there any default export reports available? +Custom export formats created under **Settings > Workspaces> Group > [Workspace Name] > Export Formats** are for expenses reported under that workspace only. + +## Are there any default export reports available? Yes! We have [seven default reports](https://help.expensify.com/articles/expensify-classic/spending-insights/Default-Export-Templates) available to export directly from the Reports page: -- **All Data** - Expense Level Export** - the name says it all! This is for the people who want ALL the details from their expense reports. We're talking Tax, Merchant Category Codes, Approvers - you name it, this report's got it! -- **All Data** - Report Level Export - this is the report for those who don't need to see each individual expense but want to see a line-by-line breakdown at a report level - submitter, total amount, report ID - that kind of stuff -- **Basic Export** - this is the best way to get a simple breakdown of all your expenses - just the basics -- **Canadian Multiple Tax Export** - tax, GST, PST...if you need to know tax then this is the export you want! -- **Category Export** - want to see a breakdown of your expenses by Category? This is the export you -- **Per Diem Export** - the name says it all -- **Tag Export** - much like the Category Export, but for Tags +- **All Data — Expense Level Export**: Use this to view all of the details from their expense reports (tax, merchant category codes, approvals, etc.) +- **All Data—Report Level Export**: This report is great if you don't need to see each individual expense but want to see a line-by-line breakdown at a report level (submitter, total amount, report ID, etc.) +- **Basic Export**: The best way to get a simple breakdown of all your expenses +- **Canadian Multiple Tax Export**: To gain a better understanding of the various Canadian taxes tied to expenses (GST, PST, etc.) +- **Category Export**: Use this to see a breakdown of your expenses by Category +- **Per Diem Export**: Use to export your Per Diem details +- **Tag Export**: Similar to the Category Export, but for Tags *These reports will be emailed directly to your email address rather than automatically downloaded.* -#### How many expenses can I export in one report? +## How many expenses can I export in one report? The custom export reports are best for small-to-medium chunks of data. If you want to export large amounts of data, we recommend you use a [default export report](https://help.expensify.com/articles/expensify-classic/spending-insights/Default-Export-Templates) that you can run from the Reports page. -#### What other kinds of export reports can my Account Manager help me create? +## What other kinds of export reports can my Account Manager help me create? We’ve built a huge variety of custom reports for customers, so make sure to reach out to your Account Manager for more details. Some examples of custom reports we’ve built for customers before are: - - Accrual Report - Aged Approval Reports - Attendee Reporting diff --git a/docs/articles/expensify-classic/workspaces/Create-tags.md b/docs/articles/expensify-classic/workspaces/Create-tags.md index ad3f51bc8c58..0743b53ff5fa 100644 --- a/docs/articles/expensify-classic/workspaces/Create-tags.md +++ b/docs/articles/expensify-classic/workspaces/Create-tags.md @@ -1,23 +1,20 @@ --- -title: Create tags +title: Create Tags description: Code expenses by creating tags ---
You can tag expenses for a specific department, project, location, cost center, customer, etc. You can also use different tags for each workspace to create customized coding for different employees. -You can use single tags or multi-level tags: -- **Single Tags**: Employees click one dropdown to select one tag. Single tags are helpful if employees need to select only one tag from a list, for example their department. -- **Multi-level Tags**: Employees click multiple dropdowns to select more than one tag. You can also create dependent tags that only appear if another tag has already been selected. Multi-tags are helpful if you have multiple tags, for example projects, locations, cost centers, etc., for employees to select, or if you have dependent tags. For example, if an employee selects a specific department, another tag can appear where they have to select their project. +**There are two options for tag configuration in Expensify:** +- **Single Tags**: Employees click one dropdown to select one tag. Single tags are helpful if employees need to select only one tag from a list, for example, their department. +- **Multi-level Tags**: Employees click multiple dropdowns to select more than one tag. You can also create dependent tags that only appear if another tag has already been selected. Multi-tags are helpful if you have multiple tags, for example, projects, locations, cost centers, etc., for employees to select or if you have dependent tags. For example, if an employee selects a specific department, another tag can appear where they have to select their project. -To add your tags, you can either import them for an accounting system or spreadsheet, or add them manually. +# Individual Tags -# Single tags - -## Import a spreadsheet - -You can add a list of single tags by importing them in a .csv, .txt, .xls, or .xlsx spreadsheet. +## Import via spreadsheet +You can add a list of single tags by importing them via .csv, .txt, .xls, or .xlsx spreadsheet: 1. Hover over Settings, then click **Workspaces**. 2. Click the **Group** tab on the left. 3. Click the desired workspace name. @@ -30,31 +27,35 @@ Each time you upload a list of tags, it will override your previous list. To avo {% include end-info.html %} ## Manually add individual tags - +You can also add single tags by adding them manually: 1. Hover over Settings, then click **Workspaces**. 2. Click the **Group** tab on the left. 3. Click the desired workspace name. 4. Click the **Tags** tab on the left. 5. Enter a tag name into the field and click **Add**. -# Multi-level tags +# Multi-level Tags + +## Automatic import via accounting integration + +When you first connect your accounting integration (for example, QuickBooks Online, QuickBooks Desktop, Sage Intacct, Xero, or NetSuite), you’ll configure classes, customers, projects, department locations, etc., that automatically import into Expensify as tags. -## Automatic import with accounting integration +To update your tags in Expensify, you must first update the tag in your accounting system: +1. Hover over Settings, then click **Workspaces**. +2. Click the **Group** tab on the left. +3. Click the desired workspace name. +4. Click the **Connections** tab on the left. +5. Click **Sync Now**. -When you first connect your accounting integration (for example, QuickBooks Online, QuickBooks Desktop, Sage Intacct, Xero, or NetSuite), you’ll configure classes, customers, projects, departments locations, etc. that automatically import into Expensify as tags. +Once the tags are updated in your accounting integration, the changes will automatically reflect in Expensify after the connection sync is run. -1. To update your tags in Expensify, you must first update the tag in your accounting system. Then in Expensify, -2. Hover over Settings, then click **Workspaces**. -3. Click the **Group** tab on the left. -4. Click the desired workspace name. -5. Click the **Connections** tab on the left. -6. Click **Sync Now**. +## Import via spreadsheet -## Import a spreadsheet +You can add mutli-level tags by importing them in a .csv, .txt, .xls, or .xlsx spreadsheet. -You can add a list of single tags by importing them in a .csv, .txt, .xls, or .xlsx spreadsheet. +First, determine whether you will use independent (a separate tag for department and project) or dependent tags (the project tags populate different options based on the department selected) and whether you will capture general ledger (GL) codes. -1. Determine whether you will use independent (a separate tag for department and project) or dependent tags (the project tags populate different options based on the department selected), and whether you will capture general ledge (GL) codes. Then use one of the following templates to build your tags list: +Then use one of the following templates to build your tags list: - [Dependent tags with GL codes]({{site.url}}/assets/Files/Dependent+with+GL+codes+format.csv) - [Dependent tags without GL codes]({{site.url}}/assets/Files/Dependent+without+GL+codes+format.csv) - [Independent tags with GL codes]({{site.url}}/assets/Files/Independent+with+GL+codes+format.csv) @@ -64,21 +65,22 @@ You can add a list of single tags by importing them in a .csv, .txt, .xls, or .x If you have more than 50,000 tags, divide them into two separate files. {% include end-info.html %} -2. Hover over Settings, then click **Workspaces**. -3. Click the **Group** tab on the left. -4. Click the desired workspace name. -5. Click the **Tags** tab on the left. -6. Enable the “Use multiple levels of tags” option. -7. Click **Import from Spreadsheet**. -8. Select the applicable checkboxes and click **Upload Tags**. +To import multi-level tags: +1. Hover over Settings, then click **Workspaces**. +2. Click the **Group** tab on the left. +3. Click the desired workspace name. +4. Click the **Tags** tab on the left. +5. Enable the “Use multiple levels of tags” option. +6. Click **Import from Spreadsheet**. +7. Select the applicable checkboxes and click **Upload Tags**. {% include info.html %} Each time you upload a list of tags, it will override your previous list. To avoid losing tags, update your current spreadsheet and re-import it into Expensify. {% include end-info.html %} -# FAQs +# FAQ -**Why can’t I see a "Do you want to use multiple level tags" option on my workspace.** +## Why can’t I see a multi-level tags option on my workspace? If you are connected to an accounting integration, you will not see this feature. You will need to add those tags in your integration first, then sync the connection. diff --git a/docs/articles/new-expensify/connections/sage-intacct/Connect-to-Sage-Intacct.md b/docs/articles/new-expensify/connections/sage-intacct/Connect-to-Sage-Intacct.md index 8e69a03fb666..c577c17e8463 100644 --- a/docs/articles/new-expensify/connections/sage-intacct/Connect-to-Sage-Intacct.md +++ b/docs/articles/new-expensify/connections/sage-intacct/Connect-to-Sage-Intacct.md @@ -4,37 +4,37 @@ description: Integrate Sage Intacct with Expensify order: 1 --- -# Connect to Sage Intacct +The Sage Intacct integration allows for automated syncing and reduces manual entries. The integration allows you to import your standard dimensions (like department, class, location, customer, and project/job) as well as user-defined dimensions for selection in Expensify. -Enjoy automated syncing and reduce manual entries with the Expensify and Sage Intacct integration. Gain in-depth, real-time financial insights with user-defined dimensions, as well as expense coding by department, class, location, customer, and project (job). +The features available for the Expensify connection with Sage Intacct vary based on your Sage Intacct subscription. The features may still be visible in Expensify even if you don't have access, but you will receive an error if the feature isn't available with your subscription. {% include info.html %} The Sage Intacct integration is only available on the Control plan. {% include end-info.html %} -## Overview +# Overview -Expensify’s integration with Sage Intacct allows you to connect using either role-based permissions or user-based permissions and exporting either expense reports or vendor bills. +Expensify’s integration with Sage Intacct allows you to connect using either role-based permissions or user-based permissions and to export either expense reports or vendor bills. -Checklist of items to complete: +**Checklist of items to complete:** 1. Create a web services user and configure permissions -1. Enable the T&E module (only required if exporting out-of-pocket expenses as Expense Reports) -1. Set up Employees in Sage Intacct (only required if exporting expenses as Expense Reports) -1. Set up Expense Types (only required if exporting expenses as Expense Reports) -1. Enable Customization Services -1. Download the Expensify Package -1. Upload the Expensify Package in Sage Intacct -1. Add web services authorization -1. Enter credentials and connect Expensify and Sage Intacct -1. Configure integration sync options - -## Step 1a: Create a web services user (Connecting with User-based permissions) -Note: If the steps in this section look different in your Sage Intacct instance, you likely use role-based permissions. If that’s the case, follow the steps [here]. +2. Enable the T&E module (only required if exporting out-of-pocket expenses as Expense Reports) +3. Set up Employees in Sage Intacct (only required if exporting expenses as Expense Reports) +4. Set up Expense Types (only required if exporting expenses as Expense Reports) +5. Enable Customization Services +6. Download the Expensify Package +7. Upload the Expensify Package in Sage Intacct +8. Add web services authorization +9. Enter credentials and connect Expensify and Sage Intacct +10. Configure integration sync options + +# Step 1a: Create a web services user (Connecting with User-based permissions) +Note: If the steps in this section look different from your Sage Intacct instance, you likely use role-based permissions. If that’s the case, start with [Step 1b](#step-1b-create-a-web-services-user-connecting-with-role-based-permissions). To connect to Sage Intacct, you’ll need to create a special web services user (please note that Sage Intacct does not charge extra for web services users). -1. Go to **Company > Web Services Users > New**. +1. Go to **Company > Web Services Users > New** 2. Configure the user as outlined below: - **User ID**: “xmlgateway_expensify” - **Last Name and First Name:** “Expensify” @@ -59,15 +59,15 @@ These are the permissions required for this integration when exporting out-of-po - **Projects (Read-only)** - Only required if using Projects or Customers - **Accounts Payable (All)** - Only required if exporting any expenses expenses as vendor bills -## Step 1b: Create a web services user (Connecting with Role-based permissions) -Note: If the steps in this section look different in your Sage Intacct instance, you likely use role-based permissions. If that’s the case, follow the steps [here]. +# Step 1b: Create a web services user (Connecting with Role-based permissions) +Note: If the steps in this section look different in your Sage Intacct instance, you likely use role-based permissions. If that’s the case, start with [Step 1a](#step-1a-create-a-web-services-user-connecting-with-user-based-permissions). **First, you'll need to create the new role:** 1. In Sage Intacct, click **Company**, then click on the **+ button** next to Roles -1. Name the role "Expensify", then click **Save** -1. Go to **Roles > Subscriptions** and find the “Expensify” role you just created -1. Configure correct permissions for this role by clicking the checkbox and then clicking on the Permissions hyperlink. These are the permissions required for this integration when exporting out-of-pocket expenses as vendor bills: +2. Name the role "Expensify", then click **Save** +3. Go to **Roles > Subscriptions** and find the “Expensify” role you just created +4. Configure correct permissions for this role by clicking the checkbox and then clicking on the Permissions hyperlink. These are the permissions required for this integration when exporting out-of-pocket expenses as vendor bills: - **Administration (All)** - **Company (Read-only)** - **Cash Management (All)** @@ -89,7 +89,7 @@ Note: If the steps in this section look different in your Sage Intacct instance, 3. Assign the role to that user: click the **+ button**, then select the “Expensify” role and click **Save** -## Step 2: Enable and configure the Time & Expenses Module +# Step 2: Enable and configure the Time & Expenses Module **Note: This step is only required if exporting out-of-pocket expenses from Expensify to Sage Intacct as Expense Reports.** Enabling the T&E module is a paid subscription through Sage Intacct and the T&E module is often included in your Sage Intacct instance. For information on the costs of enabling this module, please contact your Sage Intacct account manager. @@ -118,7 +118,7 @@ In Sage Intacct, go to **Company menu > Subscriptions > Time & Expenses** and to 6. Click **Save** to confirm your configuration -## Step 3: Set up Employees in Sage Intacct +# Step 3: Set up Employees in Sage Intacct **Note: This step is only required if exporting out-of-pocket expenses from Expensify to Sage Intacct as Expense Reports.** To set up employees in Sage Intacct: @@ -135,7 +135,7 @@ To set up employees in Sage Intacct: 1. Fill in their Primary Email Address along with any other required information -## Step 4: Set up Expense Types in Sage Intacct +# Step 4: Set up Expense Types in Sage Intacct **Note: This step is only required if exporting out-of-pocket expenses from Expensify to Sage Intacct as Expense Reports.** Expense Types provide a user-friendly way to display the names of your expense accounts to your employees. To set up expense types in Sage Intacct: @@ -150,20 +150,20 @@ Expense Types provide a user-friendly way to display the names of your expense a - **Description** - **Account Number** (from your General Ledger) -## Step 5: Enable Customization Services +# Step 5: Enable Customization Services **Note:** If you already have Platform Services enabled, you can skip this step. To enable Customization Services, go to **Company > Subscriptions > Customization Services**. -## Step 6: Download the Expensify Package +# Step 6: Download the Expensify Package 1. In Expensify, go to Settings > Workspaces 1. Click into the workspace where you'd like to connect to Sage Intacct - If you already use Expensify, you can optionally create a test workspace by clicking **New Workspace** at the top-right of the Workspaces page. A test workspace allows you to have a sandbox environment for testing before implementing the integration live. 1. Go to **Connections > Sage Intacct > Connect to Sage Intacct** 1. Select **Download Package** (You only need to download the file; we’ll upload it from your Downloads folder later) -## Step 7: Upload Package in Sage Intacct +# Step 7: Upload Package in Sage Intacct If you use Customization Services: 1. Go to **Customization Services > Custom Packages > New Package** @@ -177,20 +177,20 @@ If you use Platform Services: 1. Click **Import** -## Step 8: Add Web Services Authorization +# Step 8: Add Web Services Authorization 1. Go to **Company > Company Info > Security** in Sage Intacct and click **Edit** 2. Scroll down to **Web Services Authorizations** and add “expensify” (all lower case) as a Sender ID -## Step 9: Enter Credentials and Connect Expensify and Sage Intacct +# Step 9: Enter Credentials and Connect Expensify and Sage Intacct 1. In Expensify, go to **Settings > Workspaces > [Workspace Name] > Accounting** 1. Click **Set up** next to Sage Intacct 1. Enter the credentials you set for your web services user in Step 1 1. Click **Confirm** -## FAQ +# FAQ -### Why wasn't my report automatically exported to Sage Intacct? +## Why wasn't my report automatically exported to Sage Intacct? There are a number of factors that can cause auto-export to fail. If this happens, you will find the specific export error in the report comments for the report that failed to export. Once you’ve resolved any errors, you can manually export the report to Sage Intacct. -### Can I export negative expenses to Sage Intacct? +## Can I export negative expenses to Sage Intacct? Yes, you can export negative expenses to Sage Intacct. If you are exporting out-of-pocket expenses as expense reports, then the total of each exported report cannot be negative. diff --git a/docs/articles/new-expensify/expensify-card/Upgrade-to-the-new-Expensify-Card-from-Visa.md b/docs/articles/new-expensify/expensify-card/Upgrade-to-the-new-Expensify-Card-from-Visa.md index 56e456eb1256..8fffec75e744 100644 --- a/docs/articles/new-expensify/expensify-card/Upgrade-to-the-new-Expensify-Card-from-Visa.md +++ b/docs/articles/new-expensify/expensify-card/Upgrade-to-the-new-Expensify-Card-from-Visa.md @@ -4,25 +4,26 @@ description: Get the new Expensify Visa® Commercial Card ---
-If your company is already using Expensify Cards, you can upgrade your cards for free to the new Expensify Visa® Commercial Card to get even more tools to manage employee spending, including: -- Unlimited virtual cards +When you upgrade the Expensify Cards to the new program, you'll have access to even more tools to manage employee spending, including: +- Unlimited [virtual cards](https://use.expensify.com/unlimited-virtual-cards) - Controlled spending amounts on virtual cards to manage subscriptions - Tighter controls for managing spend across employees and merchants -- Fixed or monthly spend limits for each card +- Fixed or monthly spending limits for each card - Unique naming for each virtual card for simplified expense categorization -# Upgrade your company’s Expensify Cards - {% include info.html %} -This process must be completed by a Domain Admin. Although the process is available for all Domain Admins, only one admin needs to complete these steps. +The Expensify Card upgrade must be completed by November 1, 2024. +{% include end-info.html %} -Before completing this process, you’ll want to: +# Upgrade your company’s Expensify Card program +This process must be completed by a Domain Admin. Any domain Admin can complete the upgrade, but only one admin needs to complete these steps. -- Have your employees update their address if needed so that they receive their new Expensify Card in the mail before completing the steps below. -- Ensure that existing cardholders have a limit greater than $0 if you want them to receive a new Expensify Card. If their limit is $0, increase the limit. -{% include end-info.html %} +**Before updating the card program:** +- Make sure your employees' address is up-to-date in their Expensify account +- Confirm the employees who should be receiving a new Expensify Card have a card limit set that's greater than $0 -1. On your Home page, click the task titled “Upgrade to the new and improved Expensify Card.” +## Steps to upgrade the Expensify Cards +1. On your Home page, click the task titled _Upgrade to the new and improved Expensify Card._ 2. Review and agree to the Terms of Service. 3. Click **Get the new card**. All existing cardholders with a limit greater than $0 will be automatically mailed a new physical card to the address they have on file. Virtual cards will be automatically issued and available for immediate use. 4. If you have Positive Pay enabled for your settlement account, contact your bank as soon as possible to whitelist the new ACH ID: 2270239450. @@ -36,19 +37,19 @@ Cards won’t be issued to any employees who don’t currently have them. In thi {% include faq-begin.md %} -**Why don’t I see the task to agree to new terms on my Home page?** +## Why don’t I see the task to agree to new terms on my Home page? There are a few reasons why you might not see the task on your Home page: - You may not be a Domain Admin - Another domain admin has already accepted the terms - The task may be hidden. To find hidden tasks, scroll to the bottom of the Home page and click **Show Hidden Tasks** to see all of your available tasks. -**Will this affect the continuous reconciliation process?** +## Will this affect the continuous reconciliation process? No. During the transition period, you may have some employees with old cards and some with new cards, so you’ll have two different debits (settlements) made to your settlement account for each settlement period. Once all spending has transitioned to the new cards, you’ll only see one debit/settlement. -**Do I have to upgrade to the new Expensify Visa® Commercial Card?** +## Do I have to upgrade to the new Expensify Visa® Commercial Card? -Yes. We’ll provide a deadline soon. But don’t worry—you’ll have plenty of time to upgrade. +Yes, the Expensify Cards will not work on the old program. This must be completed by November 1, 2024. {% include faq-end.md %}
diff --git a/docs/assets/images/ExpensifyHelp-AttendeeTracking-1.png b/docs/assets/images/ExpensifyHelp-AttendeeTracking-1.png new file mode 100644 index 000000000000..e3c08b9133b8 Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-AttendeeTracking-1.png differ diff --git a/docs/assets/images/quickbooks-desktop-access-rights.png b/docs/assets/images/quickbooks-desktop-access-rights.png new file mode 100644 index 000000000000..bcdd35b8c827 Binary files /dev/null and b/docs/assets/images/quickbooks-desktop-access-rights.png differ diff --git a/docs/assets/images/quickbooks-desktop-advanced-settings.png b/docs/assets/images/quickbooks-desktop-advanced-settings.png new file mode 100644 index 000000000000..181380ed7674 Binary files /dev/null and b/docs/assets/images/quickbooks-desktop-advanced-settings.png differ diff --git a/docs/assets/images/quickbooks-desktop-coding-settings.png b/docs/assets/images/quickbooks-desktop-coding-settings.png new file mode 100644 index 000000000000..7b9fc8086c9f Binary files /dev/null and b/docs/assets/images/quickbooks-desktop-coding-settings.png differ diff --git a/docs/assets/images/quickbooks-desktop-company-preferences.png b/docs/assets/images/quickbooks-desktop-company-preferences.png new file mode 100644 index 000000000000..31f2be54bfb8 Binary files /dev/null and b/docs/assets/images/quickbooks-desktop-company-preferences.png differ diff --git a/docs/assets/images/quickbooks-desktop-export-settings.png b/docs/assets/images/quickbooks-desktop-export-settings.png new file mode 100644 index 000000000000..3ff190bc2d60 Binary files /dev/null and b/docs/assets/images/quickbooks-desktop-export-settings.png differ diff --git a/docs/assets/images/quickbooks-desktop-exported-report-comments.png b/docs/assets/images/quickbooks-desktop-exported-report-comments.png new file mode 100644 index 000000000000..2b0d2939e4b0 Binary files /dev/null and b/docs/assets/images/quickbooks-desktop-exported-report-comments.png differ diff --git a/docs/assets/images/quickbooks-desktop-web-connector.png b/docs/assets/images/quickbooks-desktop-web-connector.png new file mode 100644 index 000000000000..b2086420edd8 Binary files /dev/null and b/docs/assets/images/quickbooks-desktop-web-connector.png differ diff --git a/docs/redirects.csv b/docs/redirects.csv index 37e56f31c78d..90baeff59260 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -584,3 +584,6 @@ https://community.expensify.com/discussion/6699/faq-troubleshooting-known-bank-s https://community.expensify.com/discussion/4730/faq-expenses-are-exporting-to-the-wrong-accounts-whys-that,https://help.expensify.com/articles/expensify-classic/connect-credit-cards/company-cards/Company-Card-Settings https://community.expensify.com/discussion/9000/how-to-integrate-with-deel,https://help.expensify.com/articles/expensify-classic/connections/Deel https://community.expensify.com/categories/expensify-classroom,https://use.expensify.com +https://help.expensify.com/articles/expensify-classic/articles/expensify-classic/expenses/Send-Receive-for-Invoices,https://help.expensify.com/articles/expensify-classic/articles/expensify-classic/expenses/Send-and-Receive-Payment-for-Invoices.md +https://help.expensify.com/articles/expensify-classic/articles/expensify-classic/expenses/Bulk-Upload-Multiple-Invoices,https://help.expensify.com/articles/expensify-classic/articles/expensify-classic/expenses/Add-Invoices-in-Bulk +https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Pay-Bills,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Create-and-Pay-Bills diff --git a/ios/NewApp_AdHoc.mobileprovision.gpg b/ios/NewApp_AdHoc.mobileprovision.gpg index d2f181f6b7f4..76307ce1b460 100644 Binary files a/ios/NewApp_AdHoc.mobileprovision.gpg and b/ios/NewApp_AdHoc.mobileprovision.gpg differ diff --git a/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg b/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg index c0afa40ecb29..6437d0a3f096 100644 Binary files a/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg and b/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg differ diff --git a/ios/NewExpensify.xcodeproj/project.pbxproj b/ios/NewExpensify.xcodeproj/project.pbxproj index 1a29a275b956..96baba0d4e87 100644 --- a/ios/NewExpensify.xcodeproj/project.pbxproj +++ b/ios/NewExpensify.xcodeproj/project.pbxproj @@ -43,7 +43,7 @@ D27CE6B77196EF3EF450EEAC /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 0D3F9E814828D91464DF9D35 /* PrivacyInfo.xcprivacy */; }; DD79042B2792E76D004484B4 /* RCTBootSplash.mm in Sources */ = {isa = PBXBuildFile; fileRef = DD79042A2792E76D004484B4 /* RCTBootSplash.mm */; }; DDCB2E57F334C143AC462B43 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D20D83B0E39BA6D21761E72 /* ExpoModulesProvider.swift */; }; - E51DC681C7DEE40AEBDDFBFE /* (null) in Frameworks */ = {isa = PBXBuildFile; }; + E51DC681C7DEE40AEBDDFBFE /* BuildFile in Frameworks */ = {isa = PBXBuildFile; }; E9DF872D2525201700607FDC /* AirshipConfig.plist in Resources */ = {isa = PBXBuildFile; fileRef = E9DF872C2525201700607FDC /* AirshipConfig.plist */; }; ED222ED90E074A5481A854FA /* ExpensifyNeue-BoldItalic.otf in Resources */ = {isa = PBXBuildFile; fileRef = 8B28D84EF339436DBD42A203 /* ExpensifyNeue-BoldItalic.otf */; }; F0C450EA2705020500FD2970 /* colors.json in Resources */ = {isa = PBXBuildFile; fileRef = F0C450E92705020500FD2970 /* colors.json */; }; @@ -176,8 +176,8 @@ buildActionMask = 2147483647; files = ( 383643682B6D4AE2005BB9AE /* DeviceCheck.framework in Frameworks */, - E51DC681C7DEE40AEBDDFBFE /* (null) in Frameworks */, - E51DC681C7DEE40AEBDDFBFE /* (null) in Frameworks */, + E51DC681C7DEE40AEBDDFBFE /* BuildFile in Frameworks */, + E51DC681C7DEE40AEBDDFBFE /* BuildFile in Frameworks */, 8744C5400E24E379441C04A4 /* libPods-NewExpensify.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1848,6 +1848,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; CXX = ""; + DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = i386; diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index cc66a06c4d86..1cd1cdf3ee77 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.0.47 + 9.0.50 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.47.1 + 9.0.50.1 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 8bbff99eccaa..7fd36223c03d 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 9.0.47 + 9.0.50 CFBundleSignature ???? CFBundleVersion - 9.0.47.1 + 9.0.50.1 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index cf89ff25aaa7..078041a521b9 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.0.47 + 9.0.50 CFBundleVersion - 9.0.47.1 + 9.0.50.1 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 5a5c2bb90562..1242ab7a5a39 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1606,16 +1606,29 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - react-native-config (1.5.0): - - react-native-config/App (= 1.5.0) - - react-native-config/App (1.5.0): - - RCT-Folly + - react-native-config (1.5.3): + - react-native-config/App (= 1.5.3) + - react-native-config/App (1.5.3): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.01.01.00) - RCTRequired - RCTTypeSafety - - React - - React-Codegen + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-NativeModulesApple - React-RCTFabric + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core + - Yoga - react-native-document-picker (9.3.1): - DoubleConversion - glog @@ -1687,7 +1700,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - react-native-keyboard-controller (1.14.0): + - react-native-keyboard-controller (1.14.1): - DoubleConversion - glog - hermes-engine @@ -2360,7 +2373,7 @@ PODS: - RNGoogleSignin (10.0.1): - GoogleSignIn (~> 7.0) - React-Core - - RNLiveMarkdown (0.1.143): + - RNLiveMarkdown (0.1.164): - DoubleConversion - glog - hermes-engine @@ -2380,9 +2393,9 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - RNLiveMarkdown/common (= 0.1.143) + - RNLiveMarkdown/newarch (= 0.1.164) - Yoga - - RNLiveMarkdown/common (0.1.143): + - RNLiveMarkdown/newarch (0.1.164): - DoubleConversion - glog - hermes-engine @@ -3173,12 +3186,12 @@ SPEC CHECKSUMS: react-native-airship: e10f6823d8da49bbcb2db4bdb16ff954188afccc react-native-blob-util: 221c61c98ae507b758472ac4d2d489119d1a6c44 react-native-cameraroll: 478a0c1fcdd39f08f6ac272b7ed06e92b2c7c129 - react-native-config: 5ce986133b07fc258828b20b9506de0e683efc1c + react-native-config: 742a9e0a378a78d0eaff1fb3477d8c0ae222eb51 react-native-document-picker: e9d83c149bdd72dc01cf8dcb8df0389c6bd5fddb react-native-geolocation: b9bd12beaf0ebca61a01514517ca8455bd26fa06 react-native-image-picker: f8a13ff106bcc7eb00c71ce11fdc36aac2a44440 react-native-key-command: aae312752fcdfaa2240be9a015fc41ce54087546 - react-native-keyboard-controller: 17d5830f2bd6c6cad44682eb2cc13f9078eff985 + react-native-keyboard-controller: 902c07f41a415b632583b384427a71770a8b02a3 react-native-launch-arguments: 5f41e0abf88a15e3c5309b8875d6fd5ac43df49d react-native-netinfo: fb5112b1fa754975485884ae85a3fb6a684f49d5 react-native-pager-view: 94195f1bf32e7f78359fa20057c97e632364a08b @@ -3229,7 +3242,7 @@ SPEC CHECKSUMS: RNFS: 4ac0f0ea233904cb798630b3c077808c06931688 RNGestureHandler: 8781e2529230a1bc3ea8d75e5c3cd071b6c6aed7 RNGoogleSignin: ccaa4a81582cf713eea562c5dd9dc1961a715fd0 - RNLiveMarkdown: e44918843c2638692348f39eafc275698baf0444 + RNLiveMarkdown: b2bd97a6f1206be16cf6536c092fe39f986aca34 RNLocalize: d4b8af4e442d4bcca54e68fc687a2129b4d71a81 rnmapbox-maps: 460d6ff97ae49c7d5708c3212c6521697c36a0c4 RNPermissions: 0b1429b55af59d1d08b75a8be2459f65a8ac3f28 diff --git a/package-lock.json b/package-lock.json index 30d2bf8760ff..b7e42c99e7d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "new.expensify", - "version": "9.0.47-1", + "version": "9.0.50-1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.47-1", + "version": "9.0.50-1", "hasInstallScript": true, "license": "MIT", "dependencies": { "@dotlottie/react-player": "^1.6.3", - "@expensify/react-native-live-markdown": "0.1.143", + "@expensify/react-native-live-markdown": "0.1.164", "@expo/metro-runtime": "~3.2.3", "@firebase/app": "^0.10.10", "@firebase/performance": "^0.6.8", @@ -78,7 +78,7 @@ "react-native-android-location-enabler": "^2.0.1", "react-native-blob-util": "0.19.4", "react-native-collapsible": "^1.6.2", - "react-native-config": "1.5.0", + "react-native-config": "1.5.3", "react-native-dev-menu": "^4.1.1", "react-native-device-info": "10.3.1", "react-native-document-picker": "^9.3.1", @@ -90,18 +90,18 @@ "react-native-image-picker": "^7.0.3", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#cb392140db4953a283590d7cf93b4d0461baa2a9", "react-native-key-command": "^1.0.8", - "react-native-keyboard-controller": "1.14.0", + "react-native-keyboard-controller": "1.14.1", "react-native-launch-arguments": "^4.0.2", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "2.0.71", + "react-native-onyx": "2.0.73", "react-native-pager-view": "6.4.1", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", "react-native-permissions": "^3.10.0", "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#da50d2c5c54e268499047f9cc98b8df4196c1ddf", "react-native-plaid-link-sdk": "11.11.0", - "react-native-qrcode-svg": "git+https://github.com/Expensify/react-native-qrcode-svg-old", + "react-native-qrcode-svg": "6.3.11", "react-native-quick-sqlite": "git+https://github.com/margelo/react-native-quick-sqlite#99f34ebefa91698945f3ed26622e002bd79489e0", "react-native-reanimated": "3.15.1", "react-native-release-profiler": "^0.2.1", @@ -3635,9 +3635,9 @@ } }, "node_modules/@expensify/react-native-live-markdown": { - "version": "0.1.143", - "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.143.tgz", - "integrity": "sha512-hZXYjKyTl/b2p7Ig9qhoB7cfVtTTcoE2cWvea8NJT3f5ZYckdyHDAgHI4pg0S0N68jP205Sk5pzqlltZUpZk5w==", + "version": "0.1.164", + "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.164.tgz", + "integrity": "sha512-x1/Oa+I1AI82xWEFYd2kSkSj4rZ1q2JG4aEDomUHSqcNjuQetQPw9kVFN5DaLHt0Iu0iKEUrXIhy5LpMSHJQLg==", "workspaces": [ "parser", "example", @@ -34469,11 +34469,10 @@ } }, "node_modules/react-native-config": { - "version": "1.5.0", - "license": "MIT", + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/react-native-config/-/react-native-config-1.5.3.tgz", + "integrity": "sha512-3D05Abgk5DfDw9w258EzXvX5AkU7eqj3u9H0H0L4gUga4nYg/zuupcrpGbpF4QeXBcJ84jjs6g8JaEP6VBT7Pg==", "peerDependencies": { - "react": "*", - "react-native": "*", "react-native-windows": ">=0.61" }, "peerDependenciesMeta": { @@ -34622,9 +34621,9 @@ "license": "MIT" }, "node_modules/react-native-keyboard-controller": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.14.0.tgz", - "integrity": "sha512-JW9k2fehFXOpvLWh1YcgyubLodg/HPi6bR11sCZB/BOawf1tnbGnqk967B8XkxDOKHH6mg+z82quCvv8ALh1rg==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.14.1.tgz", + "integrity": "sha512-HUrZTaaDPxm94EVXlguwJB2gm6mc+VRTTzR66luFGZJZnL2SJoxN+dwsNW3twkwUVDrCPPA3U21q9YWUKVmwvg==", "peerDependencies": { "react": "*", "react-native": "*", @@ -35411,9 +35410,9 @@ } }, "node_modules/react-native-onyx": { - "version": "2.0.71", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.71.tgz", - "integrity": "sha512-LE3CYMdyRrXFrd+PbPpYFqQAQ5CE7EzibdM2ljhHrnTp3pDjtOjhXBjjVNV1rujgkvX56QXfX63ag/DRfqPMNw==", + "version": "2.0.73", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.73.tgz", + "integrity": "sha512-ZgzTS9TV3wIh6cYfBM5sXrYz5A37x47a61n07e24p22gr7DosBX6J8ixaVCkC25G58A+2A+jRfzdtwRC5yW34A==", "dependencies": { "ascii-table": "0.0.9", "fast-equals": "^4.0.3", @@ -35509,11 +35508,13 @@ } }, "node_modules/react-native-qrcode-svg": { - "version": "6.3.0", - "resolved": "git+ssh://git@github.com/Expensify/react-native-qrcode-svg-old.git#295f87d45c0f10d9b50838ad28fa70e47d054c3b", + "version": "6.3.11", + "resolved": "https://registry.npmjs.org/react-native-qrcode-svg/-/react-native-qrcode-svg-6.3.11.tgz", + "integrity": "sha512-bhjh4KT8NTQjJyu/tGaplR53OIqtvUJcWZ713H++GLKRpldNDyywwLVW+HdlGZ3N7jk3TxCchQMDMzndLlV4sA==", "dependencies": { "prop-types": "^15.8.0", - "qrcode": "^1.5.1" + "qrcode": "^1.5.1", + "text-encoding": "^0.7.0" }, "peerDependencies": { "react": "*", @@ -39398,6 +39399,12 @@ "node": ">=8" } }, + "node_modules/text-encoding": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/text-encoding/-/text-encoding-0.7.0.tgz", + "integrity": "sha512-oJQ3f1hrOnbRLOcwKz0Liq2IcrvDeZRHXhd9RgLrsT+DjWY/nty1Hi7v3dtkaEYbPYe0mUoOfzRrMwfXXwgPUA==", + "deprecated": "no longer maintained" + }, "node_modules/text-segmentation": { "version": "1.0.3", "license": "MIT", diff --git a/package.json b/package.json index 99f2ed65942f..1705abd4cce5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.47-1", + "version": "9.0.50-1", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -66,7 +66,7 @@ }, "dependencies": { "@dotlottie/react-player": "^1.6.3", - "@expensify/react-native-live-markdown": "0.1.143", + "@expensify/react-native-live-markdown": "0.1.164", "@expo/metro-runtime": "~3.2.3", "@firebase/app": "^0.10.10", "@firebase/performance": "^0.6.8", @@ -133,7 +133,7 @@ "react-native-android-location-enabler": "^2.0.1", "react-native-blob-util": "0.19.4", "react-native-collapsible": "^1.6.2", - "react-native-config": "1.5.0", + "react-native-config": "1.5.3", "react-native-dev-menu": "^4.1.1", "react-native-device-info": "10.3.1", "react-native-document-picker": "^9.3.1", @@ -145,18 +145,18 @@ "react-native-image-picker": "^7.0.3", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#cb392140db4953a283590d7cf93b4d0461baa2a9", "react-native-key-command": "^1.0.8", - "react-native-keyboard-controller": "1.14.0", + "react-native-keyboard-controller": "1.14.1", "react-native-launch-arguments": "^4.0.2", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "2.0.71", + "react-native-onyx": "2.0.73", "react-native-pager-view": "6.4.1", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", "react-native-permissions": "^3.10.0", "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#da50d2c5c54e268499047f9cc98b8df4196c1ddf", "react-native-plaid-link-sdk": "11.11.0", - "react-native-qrcode-svg": "git+https://github.com/Expensify/react-native-qrcode-svg-old", + "react-native-qrcode-svg": "6.3.11", "react-native-quick-sqlite": "git+https://github.com/margelo/react-native-quick-sqlite#99f34ebefa91698945f3ed26622e002bd79489e0", "react-native-reanimated": "3.15.1", "react-native-release-profiler": "^0.2.1", diff --git a/patches/react-native-config+1.5.0.patch b/patches/react-native-config+1.5.3.patch similarity index 71% rename from patches/react-native-config+1.5.0.patch rename to patches/react-native-config+1.5.3.patch index 4b5a597de4dd..d2c093705032 100644 --- a/patches/react-native-config+1.5.0.patch +++ b/patches/react-native-config+1.5.3.patch @@ -1,68 +1,11 @@ -diff --git a/node_modules/react-native-config/README.md b/node_modules/react-native-config/README.md -index 8424402..ca29e39 100644 ---- a/node_modules/react-native-config/README.md -+++ b/node_modules/react-native-config/README.md -@@ -78,13 +78,13 @@ if cocoapods are used in the project then pod has to be installed as well: - **MainApplication.java** - - ```diff -- + import com.lugg.ReactNativeConfig.ReactNativeConfigPackage; -+ + import com.lugg.RNCConfig.RNCConfigPackage; - - @Override - protected List getPackages() { - return Arrays.asList( - new MainReactPackage() -- + new ReactNativeConfigPackage() -+ + new RNCConfigPackage() - ); - } - ``` diff --git a/node_modules/react-native-config/android/build.gradle b/node_modules/react-native-config/android/build.gradle -index c8f7fd4..86b3e1a 100644 +index d3bdb07..1629423 100644 --- a/node_modules/react-native-config/android/build.gradle +++ b/node_modules/react-native-config/android/build.gradle -@@ -15,6 +15,55 @@ def safeExtGet(prop, fallback) { +@@ -15,6 +15,18 @@ def safeExtGet(prop, fallback) { rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback } -+def resolveReactNativeDirectory() { -+ def reactNativeLocation = safeExtGet("REACT_NATIVE_NODE_MODULES_DIR", null) -+ if (reactNativeLocation != null) { -+ return file(reactNativeLocation) -+ } -+ -+ // monorepo workaround -+ // react-native can be hoisted or in project's own node_modules -+ def reactNativeFromProjectNodeModules = file("${rootProject.projectDir}/../node_modules/react-native") -+ if (reactNativeFromProjectNodeModules.exists()) { -+ return reactNativeFromProjectNodeModules -+ } -+ -+ def reactNativeFromNodeModulesWithRNCConfig = file("${projectDir}/../../react-native") -+ if (reactNativeFromNodeModulesWithRNCConfig.exists()) { -+ return reactNativeFromNodeModulesWithRNCConfig -+ } -+ -+ throw new Exception( -+ "[react-native-config] Unable to resolve react-native location in " + -+ "node_modules. You should add project extension property (in app/build.gradle) " + -+ "`REACT_NATIVE_NODE_MODULES_DIR` with path to react-native." -+ ) -+} -+ -+def getReactNativeMinorVersion() { -+ def REACT_NATIVE_DIR = resolveReactNativeDirectory() -+ -+ def reactProperties = new Properties() -+ file("$REACT_NATIVE_DIR/ReactAndroid/gradle.properties").withInputStream { reactProperties.load(it) } -+ -+ def REACT_NATIVE_VERSION = reactProperties.getProperty("VERSION_NAME") -+ def REACT_NATIVE_MINOR_VERSION = REACT_NATIVE_VERSION.startsWith("0.0.0-") ? 1000 : REACT_NATIVE_VERSION.split("\\.")[1].toInteger() -+ -+ return REACT_NATIVE_MINOR_VERSION -+} -+ +def isNewArchitectureEnabled() { + // To opt-in for the New Architecture, you can either: + // - Set `newArchEnabled` to true inside the `gradle.properties` file @@ -75,10 +18,10 @@ index c8f7fd4..86b3e1a 100644 + apply plugin: "com.facebook.react" +} + - android { - compileSdkVersion rootProject.ext.compileSdkVersion - -@@ -23,10 +72,23 @@ android { + def supportsNamespace() { + def parsed = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION.tokenize('.'); + def major = parsed[0].toInteger(); +@@ -44,10 +56,23 @@ android { targetSdkVersion rootProject.ext.targetSdkVersion versionCode 1 versionName "1.0" @@ -102,44 +45,45 @@ index c8f7fd4..86b3e1a 100644 } repositories { -@@ -34,5 +96,9 @@ repositories { +@@ -55,5 +80,5 @@ repositories { } dependencies { - implementation "com.facebook.react:react-native:${safeExtGet("reactNative", "+")}" // from node_modules -+ if (isNewArchitectureEnabled() && getReactNativeMinorVersion() < 71) { -+ implementation project(":ReactAndroid") -+ } else { -+ implementation 'com.facebook.react:react-native:+' -+ } ++ implementation 'com.facebook.react:react-native:+' } diff --git a/node_modules/react-native-config/android/src/main/java/com/lugg/RNCConfig/RNCConfigModule.java b/node_modules/react-native-config/android/src/main/java/com/lugg/RNCConfig/RNCConfigModule.java -index 0b52515..bef2834 100644 +index 55b853b..2784795 100644 --- a/node_modules/react-native-config/android/src/main/java/com/lugg/RNCConfig/RNCConfigModule.java +++ b/node_modules/react-native-config/android/src/main/java/com/lugg/RNCConfig/RNCConfigModule.java -@@ -13,20 +13,32 @@ import java.lang.reflect.Field; +@@ -11,41 +11,58 @@ import java.lang.reflect.Field; import java.util.Map; import java.util.HashMap; -public class RNCConfigModule extends ReactContextBaseJavaModule { +public class RNCConfigModule extends NativeConfigModuleSpec { + public static final String NAME = "RNCConfigModule"; -+ - public RNCConfigModule(ReactApplicationContext reactContext) { - super(reactContext); - } - @Override - public String getName() { -- return "RNCConfigModule"; -+ return NAME; - } +- public RNCConfigModule(ReactApplicationContext reactContext) { +- super(reactContext); +- } ++ public RNCConfigModule(ReactApplicationContext reactContext) { ++ super(reactContext); ++ } - @Override -- public Map getConstants() { +- @Override +- public String getName() { +- return "RNCConfigModule"; +- } ++ @Override ++ public String getName() { ++ return NAME; ++ } ++ ++ @Override + public Map getTypedExportedConstants() { - final Map constants = new HashMap<>(); - ++ final Map constants = new HashMap<>(); ++ + // Codegen ensures that the constants defined in the module spec and in the native module implementation + // are consistent, which is tad problematic in this case, as the constants are dependant on the `.env` + // file. The simple workaround is to define a `constants` object that will contain actual constants. @@ -149,33 +93,64 @@ index 0b52515..bef2834 100644 + // we export { constants: { constant1: "value1", constant2: "value2" } } + // because of type safety on the new arch + final Map realConstants = new HashMap<>(); -+ - try { - Context context = getReactApplicationContext(); - int resId = context.getResources().getIdentifier("build_config_package", "string", context.getPackageName()); -@@ -40,7 +52,7 @@ public class RNCConfigModule extends ReactContextBaseJavaModule { - Field[] fields = clazz.getDeclaredFields(); - for(Field f: fields) { + +- @Override +- public Map getConstants() { +- final Map constants = new HashMap<>(); ++ try { ++ Context context = getReactApplicationContext(); ++ int resId = context.getResources().getIdentifier("build_config_package", "string", context.getPackageName()); ++ String className; ++ try { ++ className = context.getString(resId); ++ } catch (Resources.NotFoundException e) { ++ className = getReactApplicationContext().getApplicationContext().getPackageName(); ++ } ++ Class clazz = Class.forName(className + ".BuildConfig"); ++ Field[] fields = clazz.getDeclaredFields(); ++ for(Field f: fields) { try { -- constants.put(f.getName(), f.get(null)); +- Context context = getReactApplicationContext(); +- int resId = context.getResources().getIdentifier("build_config_package", "string", context.getPackageName()); +- String className; +- try { +- className = context.getString(resId); +- } catch (Resources.NotFoundException e) { +- className = getReactApplicationContext().getApplicationContext().getPackageName(); +- } +- Class clazz = Class.forName(className + ".BuildConfig"); +- Field[] fields = clazz.getDeclaredFields(); +- for (Field f : fields) { +- try { +- constants.put(f.getName(), f.get(null)); +- } catch (IllegalAccessException e) { +- Log.d("ReactNative", "ReactConfig: Could not access BuildConfig field " + f.getName()); +- } +- } +- } catch (ClassNotFoundException e) { +- Log.d("ReactNative", "ReactConfig: Could not find BuildConfig class"); + realConstants.put(f.getName(), f.get(null)); } - catch (IllegalAccessException e) { - Log.d("ReactNative", "ReactConfig: Could not access BuildConfig field " + f.getName()); -@@ -51,6 +63,8 @@ public class RNCConfigModule extends ReactContextBaseJavaModule { - Log.d("ReactNative", "ReactConfig: Could not find BuildConfig class"); +- return constants; ++ catch (IllegalAccessException e) { ++ Log.d("ReactNative", "ReactConfig: Could not access BuildConfig field " + f.getName()); ++ } ++ } ++ } ++ catch (ClassNotFoundException e) { ++ Log.d("ReactNative", "ReactConfig: Could not find BuildConfig class"); } - ++ + constants.put("constants", realConstants); + - return constants; - } ++ return constants; ++ } } diff --git a/node_modules/react-native-config/android/src/main/java/com/lugg/RNCConfig/RNCConfigPackage.java b/node_modules/react-native-config/android/src/main/java/com/lugg/RNCConfig/RNCConfigPackage.java -index 9251c09..2edd797 100644 +index 599a81a..2edd797 100644 --- a/node_modules/react-native-config/android/src/main/java/com/lugg/RNCConfig/RNCConfigPackage.java +++ b/node_modules/react-native-config/android/src/main/java/com/lugg/RNCConfig/RNCConfigPackage.java -@@ -1,29 +1,42 @@ +@@ -1,27 +1,42 @@ package com.lugg.RNCConfig; -import com.facebook.react.ReactPackage; @@ -184,26 +159,24 @@ index 9251c09..2edd797 100644 import com.facebook.react.bridge.NativeModule; import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.react.uimanager.ViewManager; -+import com.facebook.react.module.model.ReactModuleInfo; -+import com.facebook.react.module.model.ReactModuleInfoProvider; - -import java.util.Arrays; -import java.util.Collections; -import java.util.List; ++import com.facebook.react.module.model.ReactModuleInfo; ++import com.facebook.react.module.model.ReactModuleInfoProvider; + +-public class RNCConfigPackage implements ReactPackage { +import java.util.HashMap; +import java.util.Map; --public class RNCConfigPackage implements ReactPackage { - @Override - public List createNativeModules(ReactApplicationContext reactContext) { -- return Arrays.asList( -- new RNCConfigModule(reactContext) -- ); +- return Arrays.asList(new RNCConfigModule(reactContext)); - } +public class RNCConfigPackage extends TurboReactPackage { -- public List> createJSModules() { -- return Collections.emptyList(); +- public List> createJSModules() { +- return Collections.emptyList(); + @Override + public NativeModule getModule(String name, ReactApplicationContext reactContext) { + if (name.equals(RNCConfigModule.NAME)) { @@ -336,31 +309,29 @@ index 70866c4..a8f3624 100644 -export const Config = NativeModules.RNCConfigModule || {} export default Config; -diff --git a/node_modules/react-native-config/ios/ReactNativeConfig/GeneratedDotEnv.m b/node_modules/react-native-config/ios/ReactNativeConfig/GeneratedDotEnv.m -index 04a2f3d..59df625 100644 ---- a/node_modules/react-native-config/ios/ReactNativeConfig/GeneratedDotEnv.m -+++ b/node_modules/react-native-config/ios/ReactNativeConfig/GeneratedDotEnv.m -@@ -1 +1 @@ -- #define DOT_ENV @{ }; -+ #define DOT_ENV @{ @"ENV":@"dev",@"API_URL":@"http://localhost" }; diff --git a/node_modules/react-native-config/ios/ReactNativeConfig/RNCConfigModule.h b/node_modules/react-native-config/ios/ReactNativeConfig/RNCConfigModule.h -index 755d103..5341aca 100644 +index 755d103..4e4c564 100644 --- a/node_modules/react-native-config/ios/ReactNativeConfig/RNCConfigModule.h +++ b/node_modules/react-native-config/ios/ReactNativeConfig/RNCConfigModule.h -@@ -1,3 +1,9 @@ +@@ -1,12 +1,15 @@ +-#if __has_include() +-#import +-#elif __has_include("React/RCTBridgeModule.h") +-#import "React/RCTBridgeModule.h" +#ifdef RCT_NEW_ARCH_ENABLED +#import "RNCConfigSpec.h" -+ -+@interface RNCConfigModule : NSObject -+#else -+ - #if __has_include() - #import - #elif __has_include("React/RCTBridgeModule.h") -@@ -7,6 +13,7 @@ - #endif + #else +-#import "RCTBridgeModule.h" +-#endif ++#import ++#endif // RCT_NEW_ARCH_ENABLED - @interface RNCConfigModule : NSObject +-@interface RNCConfigModule : NSObject ++@interface RNCConfigModule : NSObject ++#ifdef RCT_NEW_ARCH_ENABLED ++ ++#else ++ +#endif // RCT_NEW_ARCH_ENABLED + (NSDictionary *)env; @@ -440,10 +411,10 @@ index 0000000..1cacb65 + +@end diff --git a/node_modules/react-native-config/package.json b/node_modules/react-native-config/package.json -index b4d1fba..0a018a7 100644 +index f758725..f338b41 100644 --- a/node_modules/react-native-config/package.json +++ b/node_modules/react-native-config/package.json -@@ -26,6 +26,7 @@ +@@ -27,6 +27,7 @@ "android/", "ios/", "windows/", @@ -451,8 +422,8 @@ index b4d1fba..0a018a7 100644 "index.js", "index.d.ts", "react-native-config.podspec", -@@ -38,11 +39,21 @@ - "semantic-release": "^17.0.4" +@@ -39,11 +40,21 @@ + "semantic-release": "^19.0.5" }, "peerDependencies": { + "react": "*", @@ -474,7 +445,7 @@ index b4d1fba..0a018a7 100644 } } diff --git a/node_modules/react-native-config/react-native-config.podspec b/node_modules/react-native-config/react-native-config.podspec -index 35313d4..56bce4a 100644 +index 449b970..88b14c5 100644 --- a/node_modules/react-native-config/react-native-config.podspec +++ b/node_modules/react-native-config/react-native-config.podspec @@ -4,6 +4,8 @@ require 'json' @@ -486,7 +457,7 @@ index 35313d4..56bce4a 100644 Pod::Spec.new do |s| s.name = 'react-native-config' s.version = package['version'] -@@ -33,8 +35,27 @@ HOST_PATH="$SRCROOT/../.." +@@ -35,8 +37,13 @@ HOST_PATH="$SRCROOT/../.." s.default_subspec = 'App' s.subspec 'App' do |app| @@ -495,21 +466,7 @@ index 35313d4..56bce4a 100644 + app.source_files = 'ios/**/*.{h,m,mm}' + + if fabric_enabled -+ folly_compiler_flags = '-DFOLLY_NO_CONFIG -DFOLLY_MOBILE=1 -DFOLLY_USE_LIBCPP=1 -Wno-comma -Wno-shorten-64-to-32' -+ -+ app.pod_target_xcconfig = { -+ 'HEADER_SEARCH_PATHS' => '"$(PODS_ROOT)/boost" "$(PODS_ROOT)/boost-for-react-native" "$(PODS_ROOT)/RCT-Folly"', -+ 'CLANG_CXX_LANGUAGE_STANDARD' => 'c++17', -+ } -+ app.compiler_flags = folly_compiler_flags + ' -DRCT_NEW_ARCH_ENABLED' -+ -+ app.dependency "React" -+ app.dependency "React-RCTFabric" # This is for fabric component -+ app.dependency "React-Codegen" -+ app.dependency "RCT-Folly" -+ app.dependency "RCTRequired" -+ app.dependency "RCTTypeSafety" -+ app.dependency "ReactCommon/turbomodule/core" ++ install_modules_dependencies(app) + else + app.dependency 'React-Core' + end diff --git a/patches/react-native-keyboard-controller+1.14.0+001+disable-android.patch b/patches/react-native-keyboard-controller+1.14.1+001+disable-android.patch similarity index 100% rename from patches/react-native-keyboard-controller+1.14.0+001+disable-android.patch rename to patches/react-native-keyboard-controller+1.14.1+001+disable-android.patch diff --git a/src/CONST.ts b/src/CONST.ts index fab8cf0fdaf6..171dc7ff2c8a 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -136,12 +136,24 @@ const onboardingEmployerOrSubmitMessage: OnboardingMessageType = { type OnboardingPurposeType = ValueOf; +type OnboardingCompanySizeType = ValueOf; + +type OnboardingAccountingType = ValueOf | null; + const onboardingInviteTypes = { IOU: 'iou', INVOICE: 'invoice', CHAT: 'chat', } as const; +const onboardingCompanySize = { + MICRO: '1-10', + SMALL: '11-50', + MEDIUM_SMALL: '51-100', + MEDIUM: '101-1000', + LARGE: '1001+', +} as const; + type OnboardingInviteType = ValueOf; type OnboardingTaskType = { @@ -193,6 +205,10 @@ const CONST = { IN: 'in', OUT: 'out', }, + POPOVER_ACCOUNT_SWITCHER_POSITION: { + horizontal: 12, + vertical: 80, + }, // Multiplier for gyroscope animation in order to make it a bit more subtle ANIMATION_GYROSCOPE_VALUE: 0.4, ANIMATION_PAID_DURATION: 200, @@ -477,7 +493,6 @@ const CONST = { P2P_DISTANCE_REQUESTS: 'p2pDistanceRequests', SPOTNANA_TRAVEL: 'spotnanaTravel', REPORT_FIELDS_FEATURE: 'reportFieldsFeature', - WORKSPACE_FEEDS: 'workspaceFeeds', COMPANY_CARD_FEEDS: 'companyCardFeeds', DIRECT_FEEDS: 'directFeeds', NETSUITE_USA_TAX: 'netsuiteUsaTax', @@ -730,6 +745,9 @@ const CONST = { HOW_TO_CONNECT_TO_SAGE_INTACCT: 'https://help.expensify.com/articles/expensify-classic/integrations/accounting-integrations/Sage-Intacct#how-to-connect-to-sage-intacct', PRICING: `https://www.expensify.com/pricing`, COMPANY_CARDS_HELP: 'https://help.expensify.com/articles/expensify-classic/connect-credit-cards/company-cards/Commercial-Card-Feeds', + COMPANY_CARDS_STRIPE_HELP: 'https://dashboard.stripe.com/login?redirect=%2Fexpenses%2Fsettings', + COMPANY_CARDS_CONNECT_CREDIT_CARDS_HELP_URL: + 'https://help.expensify.com/articles/expensify-classic/connect-credit-cards/company-cards/Commercial-Card-Feeds#what-is-the-difference-between-commercial-card-feeds-and-your-direct-bank-connections', CUSTOM_REPORT_NAME_HELP_URL: 'https://help.expensify.com/articles/expensify-classic/spending-insights/Custom-Templates', CONFIGURE_REIMBURSEMENT_SETTINGS_HELP_URL: 'https://help.expensify.com/articles/expensify-classic/workspaces/Configure-Reimbursement-Settings', COPILOT_HELP_URL: 'https://help.expensify.com/articles/expensify-classic/copilots-and-delegates/Assign-or-remove-a-Copilot', @@ -1091,7 +1109,7 @@ const CONST = { }, TIMING: { CALCULATE_MOST_RECENT_LAST_MODIFIED_ACTION: 'calc_most_recent_last_modified_action', - CHAT_FINDER_RENDER: 'search_render', + SEARCH_ROUTER_RENDER: 'search_router_render', CHAT_RENDER: 'chat_render', OPEN_REPORT: 'open_report', HOMEPAGE_INITIAL_RENDER: 'homepage_initial_render', @@ -1469,6 +1487,21 @@ const CONST = { }, QUICKBOOKS_ONLINE: 'quickbooksOnline', + QUICKBOOKS_DESKTOP_CONFIG: { + EXPORT_DATE: 'exportDate', + EXPORTER: 'exporter', + MARK_CHECKS_TO_BE_PRINTED: 'markChecksToBePrinted', + REIMBURSABLE_ACCOUNT: 'reimbursableAccount', + REIMBURSABLE: 'reimbursable', + AUTO_SYNC: 'autoSync', + ENABLE_NEW_CATEGORIES: 'enableNewCategories', + SHOULD_AUTO_CREATE_VENDOR: 'shouldAutoCreateVendor', + MAPPINGS: { + CLASSES: 'classes', + CUSTOMERS: 'customers', + }, + }, + QUICKBOOKS_CONFIG: { ENABLE_NEW_CATEGORIES: 'enableNewCategories', SYNC_CLASSES: 'syncClasses', @@ -1577,6 +1610,13 @@ const CONST = { JOURNAL_ENTRY: 'journal_entry', }, + QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE: { + VENDOR_BILL: 'VENDOR_BILL', + CHECK: 'CHECK', + JOURNAL_ENTRY: 'JOURNAL_ENTRY', + NOTHING: 'NOTHING', + }, + SAGE_INTACCT_REIMBURSABLE_EXPENSE_TYPE: { EXPENSE_REPORT: 'EXPENSE_REPORT', VENDOR_BILL: 'VENDOR_BILL', @@ -1847,6 +1887,12 @@ const CONST = { VENDOR_BILL: 'bill', }, + QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE: { + CREDIT_CARD: 'CREDIT_CARD_CHARGE', + JOURNAL_ENTRY: 'JOURNAL_ENTRY', + VENDOR_BILL: 'VENDOR_BILL', + }, + MISSING_PERSONAL_DETAILS_INDEXES: { MAPPING: { LEGAL_NAME: 0, @@ -2518,16 +2564,41 @@ const CONST = { CARD_TITLE_INPUT_LIMIT: 255, }, COMPANY_CARDS: { + CONNECTION_ERROR: 'connectionError', STEP: { + SELECT_BANK: 'SelectBank', + SELECT_FEED_TYPE: 'SelectFeedType', CARD_TYPE: 'CardType', CARD_INSTRUCTIONS: 'CardInstructions', CARD_NAME: 'CardName', CARD_DETAILS: 'CardDetails', + BANK_CONNECTION: 'BankConnection', + AMEX_CUSTOM_FEED: 'AmexCustomFeed', }, CARD_TYPE: { AMEX: 'amex', VISA: 'visa', MASTERCARD: 'mastercard', + STRIPE: 'stripe', + }, + FEED_TYPE: { + CUSTOM: 'customFeed', + DIRECT: 'directFeed', + }, + BANKS: { + AMEX: 'American Express', + BANK_OF_AMERICA: 'Bank of America', + BREX: 'Brex', + CAPITAL_ONE: 'Capital One', + CHASE: 'Chase', + CITI_BANK: 'Citibank', + STRIPE: 'Stripe', + WELLS_FARGO: 'Wells Fargo', + OTHER: 'Other', + }, + AMEX_CUSTOM_FEED: { + CORPORATE: 'American Express Corporate Cards', + BUSINESS: 'American Express Business Cards', }, DELETE_TRANSACTIONS: { RESTRICT: 'corporate', @@ -4489,6 +4560,7 @@ const CONST = { SELECTABLE_ONBOARDING_CHOICES: {...selectableOnboardingChoices}, ONBOARDING_SIGNUP_QUALIFIERS: {...signupQualifiers}, ONBOARDING_INVITE_TYPES: {...onboardingInviteTypes}, + ONBOARDING_COMPANY_SIZE: {...onboardingCompanySize}, ACTIONABLE_TRACK_EXPENSE_WHISPER_MESSAGE: 'What would you like to do with this expense?', ONBOARDING_CONCIERGE: { [onboardingChoices.EMPLOYER]: @@ -5611,6 +5683,7 @@ const CONST = { KEYWORD: 'keyword', IN: 'in', }, + EMPTY_VALUE: 'none', }, REFERRER: { @@ -5843,6 +5916,19 @@ type FeedbackSurveyOptionID = ValueOf; type CancellationType = ValueOf; -export type {Country, IOUAction, IOUType, RateAndUnit, OnboardingPurposeType, IOURequestType, SubscriptionType, FeedbackSurveyOptionID, CancellationType, OnboardingInviteType}; +export type { + Country, + IOUAction, + IOUType, + RateAndUnit, + OnboardingPurposeType, + OnboardingCompanySizeType, + IOURequestType, + SubscriptionType, + FeedbackSurveyOptionID, + CancellationType, + OnboardingInviteType, + OnboardingAccountingType, +}; export default CONST; diff --git a/src/Expensify.tsx b/src/Expensify.tsx index f5d4655c4861..7822ec16b879 100644 --- a/src/Expensify.tsx +++ b/src/Expensify.tsx @@ -3,7 +3,7 @@ import React, {useCallback, useContext, useEffect, useLayoutEffect, useMemo, use import type {NativeEventSubscription} from 'react-native'; import {AppState, Linking, NativeModules, Platform} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; -import Onyx, {useOnyx, withOnyx} from 'react-native-onyx'; +import Onyx, {useOnyx} from 'react-native-onyx'; import ConfirmModal from './components/ConfirmModal'; import DeeplinkWrapper from './components/DeeplinkWrapper'; import EmojiPicker from './components/EmojiPicker/EmojiPicker'; @@ -55,7 +55,7 @@ Onyx.registerLogger(({level, message}) => { } }); -type ExpensifyOnyxProps = { +type ExpensifyProps = { /** Whether the app is waiting for the server's response to determine if a room is public */ isCheckingPublicRoom: OnyxEntry; @@ -77,18 +77,7 @@ type ExpensifyOnyxProps = { /** Last visited path in the app */ lastVisitedPath: OnyxEntry; }; - -type ExpensifyProps = ExpensifyOnyxProps; - -function Expensify({ - isCheckingPublicRoom = true, - updateAvailable, - isSidebarLoaded = false, - screenShareRequest, - updateRequired = false, - focusModeNotification = false, - lastVisitedPath, -}: ExpensifyProps) { +function Expensify() { const appStateChangeListener = useRef(null); const [isNavigationReady, setIsNavigationReady] = useState(false); const [isOnyxMigrated, setIsOnyxMigrated] = useState(false); @@ -98,7 +87,15 @@ function Expensify({ const [account] = useOnyx(ONYXKEYS.ACCOUNT); const [session] = useOnyx(ONYXKEYS.SESSION); const [lastRoute] = useOnyx(ONYXKEYS.LAST_ROUTE); + const [userMetadata] = useOnyx(ONYXKEYS.USER_METADATA); const [shouldShowRequire2FAModal, setShouldShowRequire2FAModal] = useState(false); + const [isCheckingPublicRoom] = useOnyx(ONYXKEYS.IS_CHECKING_PUBLIC_ROOM); + const [updateAvailable] = useOnyx(ONYXKEYS.UPDATE_AVAILABLE); + const [updateRequired] = useOnyx(ONYXKEYS.UPDATE_REQUIRED); + const [isSidebarLoaded] = useOnyx(ONYXKEYS.IS_SIDEBAR_LOADED); + const [screenShareRequest] = useOnyx(ONYXKEYS.SCREEN_SHARE_REQUEST); + const [focusModeNotification] = useOnyx(ONYXKEYS.FOCUS_MODE_NOTIFICATION); + const [lastVisitedPath] = useOnyx(ONYXKEYS.LAST_VISITED_PATH); useEffect(() => { if (!account?.needsTwoFactorAuthSetup || account.requiresTwoFactorAuth) { @@ -148,15 +145,17 @@ function Expensify({ // Initialize this client as being an active client ActiveClientManager.init(); - // Initialize Fullstory lib - FS.init(); - // Used for the offline indicator appearing when someone is offline const unsubscribeNetInfo = NetworkConnection.subscribeToNetInfo(); return unsubscribeNetInfo; }, []); + useEffect(() => { + // Initialize Fullstory lib + FS.init(userMetadata); + }, [userMetadata]); + // Log the platform and config to debug .env issues useEffect(() => { Log.info('App launched', false, {Platform, CONFIG}); @@ -304,30 +303,4 @@ function Expensify({ Expensify.displayName = 'Expensify'; -export default withOnyx({ - isCheckingPublicRoom: { - key: ONYXKEYS.IS_CHECKING_PUBLIC_ROOM, - initWithStoredValues: false, - }, - updateAvailable: { - key: ONYXKEYS.UPDATE_AVAILABLE, - initWithStoredValues: false, - }, - updateRequired: { - key: ONYXKEYS.UPDATE_REQUIRED, - initWithStoredValues: false, - }, - isSidebarLoaded: { - key: ONYXKEYS.IS_SIDEBAR_LOADED, - }, - screenShareRequest: { - key: ONYXKEYS.SCREEN_SHARE_REQUEST, - }, - focusModeNotification: { - key: ONYXKEYS.FOCUS_MODE_NOTIFICATION, - initWithStoredValues: false, - }, - lastVisitedPath: { - key: ONYXKEYS.LAST_VISITED_PATH, - }, -})(Expensify); +export default Expensify; diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 2a325bb4a74f..0b69fe9be80b 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -1,6 +1,6 @@ import type {ValueOf} from 'type-fest'; import type CONST from './CONST'; -import type {OnboardingPurposeType} from './CONST'; +import type {OnboardingCompanySizeType, OnboardingPurposeType} from './CONST'; import type * as FormTypes from './types/form'; import type * as OnyxTypes from './types/onyx'; import type Onboarding from './types/onyx/Onboarding'; @@ -342,6 +342,9 @@ const ONYXKEYS = { /** Onboarding policyID selected by the user during Onboarding flow */ ONBOARDING_POLICY_ID: 'onboardingPolicyID', + /** Onboarding company size selected by the user during Onboarding flow */ + ONBOARDING_COMPANY_SIZE: 'onboardingCompanySize', + /** Onboarding Purpose selected by the user during Onboarding flow */ ONBOARDING_ADMINS_CHAT_REPORT_ID: 'onboardingAdminsChatReportID', @@ -557,8 +560,6 @@ const ONYXKEYS = { DISPLAY_NAME_FORM_DRAFT: 'displayNameFormDraft', ONBOARDING_PERSONAL_DETAILS_FORM: 'onboardingPersonalDetailsForm', ONBOARDING_PERSONAL_DETAILS_FORM_DRAFT: 'onboardingPersonalDetailsFormDraft', - ONBOARDING_PERSONAL_WORK: 'onboardingWorkForm', - ONBOARDING_PERSONAL_WORK_DRAFT: 'onboardingWorkFormDraft', ROOM_NAME_FORM: 'roomNameForm', ROOM_NAME_FORM_DRAFT: 'roomNameFormDraft', REPORT_DESCRIPTION_FORM: 'reportDescriptionForm', @@ -721,7 +722,6 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.PROFILE_SETTINGS_FORM]: FormTypes.ProfileSettingsForm; [ONYXKEYS.FORMS.DISPLAY_NAME_FORM]: FormTypes.DisplayNameForm; [ONYXKEYS.FORMS.ONBOARDING_PERSONAL_DETAILS_FORM]: FormTypes.DisplayNameForm; - [ONYXKEYS.FORMS.ONBOARDING_PERSONAL_WORK]: FormTypes.WorkForm; [ONYXKEYS.FORMS.ROOM_NAME_FORM]: FormTypes.RoomNameForm; [ONYXKEYS.FORMS.REPORT_DESCRIPTION_FORM]: FormTypes.ReportDescriptionForm; [ONYXKEYS.FORMS.LEGAL_NAME_FORM]: FormTypes.LegalNameForm; @@ -960,6 +960,7 @@ type OnyxValuesMapping = { [ONYXKEYS.MAX_CANVAS_HEIGHT]: number; [ONYXKEYS.MAX_CANVAS_WIDTH]: number; [ONYXKEYS.ONBOARDING_PURPOSE_SELECTED]: OnboardingPurposeType; + [ONYXKEYS.ONBOARDING_COMPANY_SIZE]: OnboardingCompanySizeType; [ONYXKEYS.ONBOARDING_CUSTOM_CHOICES]: OnboardingPurposeType[] | []; [ONYXKEYS.ONBOARDING_ERROR_MESSAGE]: string; [ONYXKEYS.ONBOARDING_POLICY_ID]: string; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 02bb07de83b2..98ea64bc65b4 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -74,7 +74,6 @@ const ROUTES = { route: 'flag/:reportID/:reportActionID', getRoute: (reportID: string, reportActionID: string, backTo?: string) => getUrlWithBackToParam(`flag/${reportID}/${reportActionID}` as const, backTo), }, - CHAT_FINDER: 'chat-finder', PROFILE: { route: 'a/:accountID', getRoute: (accountID?: string | number, backTo?: string, login?: string) => { @@ -138,6 +137,14 @@ const ROUTES = { route: 'settings/security/delegate/:login/role/:role', getRoute: (login: string, role?: string) => `settings/security/delegate/${encodeURIComponent(login)}/role/${role}` as const, }, + SETTINGS_UPDATE_DELEGATE_ROLE: { + route: 'settings/security/delegate/:login/update-role/:currentRole', + getRoute: (login: string, currentRole: string) => `settings/security/delegate/${encodeURIComponent(login)}/update-role/${currentRole}` as const, + }, + SETTINGS_UPDATE_DELEGATE_ROLE_MAGIC_CODE: { + route: 'settings/security/delegate/:login/update-role/:role/magic-code', + getRoute: (login: string, role: string) => `settings/security/delegate/${encodeURIComponent(login)}/update-role/${role}/magic-code` as const, + }, SETTINGS_DELEGATE_CONFIRM: { route: 'settings/security/delegate/:login/role/:role/confirm', getRoute: (login: string, role: string) => `settings/security/delegate/${encodeURIComponent(login)}/role/${role}/confirm` as const, @@ -303,8 +310,12 @@ const ROUTES = { }, ATTACHMENTS: { route: 'attachment', - getRoute: (reportID: string, type: ValueOf, url: string, accountID?: number) => - `attachment?source=${encodeURIComponent(url)}&type=${type}${reportID ? `&reportID=${reportID}` : ''}${accountID ? `&accountID=${accountID}` : ''}` as const, + getRoute: (reportID: string, type: ValueOf, url: string, accountID?: number, isAuthTokenRequired?: boolean) => { + const reportParam = reportID ? `&reportID=${reportID}` : ''; + const accountParam = accountID ? `&accountID=${accountID}` : ''; + const authTokenParam = isAuthTokenRequired ? '&isAuthTokenRequired=true' : ''; + return `attachment?source=${encodeURIComponent(url)}&type=${type}${reportParam}${accountParam}${authTokenParam}` as const; + }, }, REPORT_PARTICIPANTS: { route: 'r/:reportID/participants', @@ -658,10 +669,70 @@ const ROUTES = { route: 'settings/workspaces/:policyID/accounting/quickbooks-online/export/date-select', getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-online/export/date-select` as const, }, + WORKSPACE_ACCOUNTING_QUICKBOOKS_DESKTOP_ADVANCED: { + route: 'settings/workspaces/:policyID/accounting/quickbooks-desktop/advanced', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-desktop/advanced` as const, + }, + POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_EXPORT_DATE_SELECT: { + route: 'settings/workspaces/:policyID/accounting/quickbooks-desktop/export/date-select', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-desktop/export/date-select` as const, + }, + POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_PREFERRED_EXPORTER: { + route: 'settings/workspaces/:policyID/accounting/quickbooks-desktop/export/preferred-exporter', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-desktop/export/preferred-exporter` as const, + }, + POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_EXPORT_OUT_OF_POCKET_EXPENSES: { + route: 'settings/workspaces/:policyID/accounting/quickbooks-desktop/export/out-of-pocket-expense', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-desktop/export/out-of-pocket-expense` as const, + }, + POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_EXPORT_OUT_OF_POCKET_EXPENSES_ACCOUNT_SELECT: { + route: 'settings/workspaces/:policyID/accounting/quickbooks-desktop/export/out-of-pocket-expense/account-select', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-desktop/export/out-of-pocket-expense/account-select` as const, + }, + POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_EXPORT_OUT_OF_POCKET_EXPENSES_SELECT: { + route: 'settings/workspaces/:policyID/accounting/quickbooks-desktop/export/out-of-pocket-expense/entity-select', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-desktop/export/out-of-pocket-expense/entity-select` as const, + }, POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_EXPORT: { route: 'settings/workspaces/:policyID/accounting/quickbooks-desktop/export', getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-desktop/export` as const, }, + POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_SETUP_MODAL: { + route: 'settings/workspaces/:policyID/accounting/quickbooks-desktop/setup-modal', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-desktop/setup-modal` as const, + }, + POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_SETUP_REQUIRED_DEVICE_MODAL: { + route: 'settings/workspaces/:policyID/accounting/quickbooks-desktop/setup-required-device', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-desktop/setup-required-device` as const, + }, + POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_TRIGGER_FIRST_SYNC: { + route: 'settings/workspaces/:policyID/accounting/quickbooks-desktop/trigger-first-sync', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-desktop/trigger-first-sync` as const, + }, + POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_IMPORT: { + route: 'settings/workspaces/:policyID/accounting/quickbooks-desktop/import', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-desktop/import` as const, + }, + POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_CHART_OF_ACCOUNTS: { + route: 'settings/workspaces/:policyID/accounting/quickbooks-desktop/import/accounts', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-desktop/import/accounts` as const, + }, + POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_CLASSES: { + route: 'settings/workspaces/:policyID/accounting/quickbooks-desktop/import/classes', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-desktop/import/classes` as const, + }, + POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_CLASSES_DISPLAYED_AS: { + route: 'settings/workspaces/:policyID/accounting/quickbooks-desktop/import/classes/displayed_as', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-desktop/import/classes/displayed_as` as const, + }, + POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_CUSTOMERS: { + route: 'settings/workspaces/:policyID/accounting/quickbooks-desktop/import/customers', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-desktop/import/customers` as const, + }, + POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_CUSTOMERS_DISPLAYED_AS: { + route: 'settings/workspaces/:policyID/accounting/quickbooks-desktop/import/customers/displayed_as', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-desktop/import/customers/displayed_as` as const, + }, WORKSPACE_PROFILE_NAME: { route: 'settings/workspaces/:policyID/profile/name', getRoute: (policyID: string) => `settings/workspaces/${policyID}/profile/name` as const, @@ -1163,9 +1234,13 @@ const ROUTES = { route: 'onboarding/personal-details', getRoute: (backTo?: string) => getUrlWithBackToParam(`onboarding/personal-details`, backTo), }, - ONBOARDING_WORK: { - route: 'onboarding/work', - getRoute: (backTo?: string) => getUrlWithBackToParam(`onboarding/work`, backTo), + ONBOARDING_EMPLOYEES: { + route: 'onboarding/employees', + getRoute: (backTo?: string) => getUrlWithBackToParam(`onboarding/employees`, backTo), + }, + ONBOARDING_ACCOUNTING: { + route: 'onboarding/accounting', + getRoute: (backTo?: string) => getUrlWithBackToParam(`onboarding/accounting`, backTo), }, ONBOARDING_PURPOSE: { route: 'onboarding/purpose', @@ -1176,8 +1251,10 @@ const ROUTES = { TRANSACTION_RECEIPT: { route: 'r/:reportID/transaction/:transactionID/receipt', - getRoute: (reportID: string, transactionID: string, readonly = false) => `r/${reportID}/transaction/${transactionID}/receipt${readonly ? '?readonly=true' : ''}` as const, + getRoute: (reportID: string, transactionID: string, readonly = false, isFromReviewDuplicates = false) => + `r/${reportID}/transaction/${transactionID}/receipt?readonly=${readonly}${isFromReviewDuplicates ? '&isFromReviewDuplicates=true' : ''}` as const, }, + TRANSACTION_DUPLICATE_REVIEW_PAGE: { route: 'r/:threadReportID/duplicates/review', getRoute: (threadReportID: string, backTo?: string) => getUrlWithBackToParam(`r/${threadReportID}/duplicates/review` as const, backTo), @@ -1530,18 +1607,6 @@ const ROUTES = { route: 'settings/workspaces/:policyID/accounting/sage-intacct/advanced/payment-account', getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/sage-intacct/advanced/payment-account` as const, }, - POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_SETUP_MODAL: { - route: 'settings/workspaces/:policyID/accounting/quickbooks-desktop/setup-modal', - getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-desktop/setup-modal` as const, - }, - POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_SETUP_REQUIRED_DEVICE_MODAL: { - route: 'settings/workspaces/:policyID/accounting/quickbooks-desktop/setup-required-device', - getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-desktop/setup-required-device` as const, - }, - POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_TRIGGER_FIRST_SYNC: { - route: 'settings/workspaces/:policyID/accounting/quickbooks-desktop/trigger-first-sync', - getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-desktop/trigger-first-sync` as const, - }, DEBUG_REPORT: { route: 'debug/report/:reportID', getRoute: (reportID: string) => `debug/report/${reportID}` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 21067fb9e178..719c67f0365b 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -135,13 +135,14 @@ const SCREENS = { DELEGATE_ROLE: 'Settings_Delegate_Role', DELEGATE_CONFIRM: 'Settings_Delegate_Confirm', DELEGATE_MAGIC_CODE: 'Settings_Delegate_Magic_Code', + UPDATE_DELEGATE_ROLE: 'Settings_Delegate_Update_Role', + UPDATE_DELEGATE_ROLE_MAGIC_CODE: 'Settings_Delegate_Update_Magic_Code', }, }, SAVE_THE_WORLD: { ROOT: 'SaveTheWorld_Root', }, LEFT_MODAL: { - CHAT_FINDER: 'ChatFinder', WORKSPACE_SWITCHER: 'WorkspaceSwitcher', }, RIGHT_MODAL: { @@ -315,7 +316,22 @@ const SCREENS = { QUICKBOOKS_ONLINE_ADVANCED: 'Policy_Accounting_Quickbooks_Online_Advanced', QUICKBOOKS_ONLINE_ACCOUNT_SELECTOR: 'Policy_Accounting_Quickbooks_Online_Account_Selector', QUICKBOOKS_ONLINE_INVOICE_ACCOUNT_SELECTOR: 'Policy_Accounting_Quickbooks_Online_Invoice_Account_Selector', + QUICKBOOKS_DESKTOP_ADVANCED: 'Policy_Accounting_Quickbooks_Desktop_Advanced', + QUICKBOOKS_DESKTOP_EXPORT_DATE_SELECT: 'Workspace_Accounting_Quickbooks_Desktop_Export_Date_Select', + QUICKBOOKS_DESKTOP_EXPORT_PREFERRED_EXPORTER: 'Workspace_Accounting_Quickbooks_Desktop_Export_Preferred_Exporter', + QUICKBOOKS_DESKTOP_EXPORT_OUT_OF_POCKET_EXPENSES: 'Workspace_Accounting_Quickbooks_Desktop_Export_Out_Of_Pocket_Expenses', + QUICKBOOKS_DESKTOP_EXPORT_OUT_OF_POCKET_EXPENSES_SELECT: 'Workspace_Accounting_Quickbooks_Desktop_Export_Out_Of_Pocket_Expenses_Select', + QUICKBOOKS_DESKTOP_EXPORT_OUT_OF_POCKET_EXPENSES_ACCOUNT_SELECT: 'Workspace_Accounting_Quickbooks_Desktop_Export_Out_Of_Pocket_Expenses_Account_Select', QUICKBOOKS_DESKTOP_EXPORT: 'Workspace_Accounting_Quickbooks_Desktop_Export', + QUICKBOOKS_DESKTOP_SETUP_MODAL: 'Policy_Accouting_Quickbooks_Desktop_Setup_Modal', + QUICKBOOKS_DESKTOP_SETUP_REQUIRED_DEVICE_MODAL: 'Policy_Accouting_Quickbooks_Desktop_Setup_Required_Device_Modal', + QUICKBOOKS_DESKTOP_TRIGGER_FIRST_SYNC: 'Policy_Accouting_Quickbooks_Desktop_Trigger_First_Sync', + QUICKBOOKS_DESKTOP_IMPORT: 'Policy_Accounting_Quickbooks_Desktop_Import', + QUICKBOOKS_DESKTOP_CHART_OF_ACCOUNTS: 'Policy_Accounting_Quickbooks_Desktop_Import_Chart_Of_Accounts', + QUICKBOOKS_DESKTOP_CLASSES: 'Policy_Accounting_Quickbooks_Desktop_Import_Classes', + QUICKBOOKS_DESKTOP_CLASSES_DISPLAYED_AS: 'Policy_Accounting_Quickbooks_Desktop_Import_Classes_Dipslayed_As', + QUICKBOOKS_DESKTOP_CUSTOMERS: 'Policy_Accounting_Quickbooks_Desktop_Import_Customers', + QUICKBOOKS_DESKTOP_CUSTOMERS_DISPLAYED_AS: 'Policy_Accounting_Quickbooks_Desktop_Import_Customers_Dipslayed_As', XERO_IMPORT: 'Policy_Accounting_Xero_Import', XERO_ORGANIZATION: 'Policy_Accounting_Xero_Customers', XERO_CHART_OF_ACCOUNTS: 'Policy_Accounting_Xero_Import_Chart_Of_Accounts', @@ -385,9 +401,6 @@ const SCREENS = { SAGE_INTACCT_NON_REIMBURSABLE_CREDIT_CARD_ACCOUNT: 'Policy_Accounting_Sage_Intacct_Non_Reimbursable_Credit_Card_Account', SAGE_INTACCT_ADVANCED: 'Policy_Accounting_Sage_Intacct_Advanced', SAGE_INTACCT_PAYMENT_ACCOUNT: 'Policy_Accounting_Sage_Intacct_Payment_Account', - QUICKBOOKS_DESKTOP_SETUP_MODAL: 'Policy_Accouting_Quickbooks_Desktop_Setup_Modal', - QUICKBOOKS_DESKTOP_SETUP_REQUIRED_DEVICE_MODAL: 'Policy_Accouting_Quickbooks_Desktop_Setup_Required_Device_Modal', - QUICKBOOKS_DESKTOP_TRIGGER_FIRST_SYNC: 'Policy_Accouting_Quickbooks_Desktop_Trigger_First_Sync', CARD_RECONCILIATION: 'Policy_Accounting_Card_Reconciliation', RECONCILIATION_ACCOUNT_SETTINGS: 'Policy_Accounting_Reconciliation_Account_Settings', }, @@ -527,7 +540,8 @@ const SCREENS = { ONBOARDING: { PERSONAL_DETAILS: 'Onboarding_Personal_Details', PURPOSE: 'Onboarding_Purpose', - WORK: 'Onboarding_Work', + EMPLOYEES: 'Onboarding_Employees', + ACCOUNTING: 'Onboarding_Accounting', }, WELCOME_VIDEO: { diff --git a/src/components/AccountSwitcher.tsx b/src/components/AccountSwitcher.tsx index 9b5d21743bef..8ccab44a2cb9 100644 --- a/src/components/AccountSwitcher.tsx +++ b/src/components/AccountSwitcher.tsx @@ -9,6 +9,7 @@ import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; import {clearDelegatorErrors, connect, disconnect} from '@libs/actions/Delegate'; import * as ErrorUtils from '@libs/ErrorUtils'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; @@ -22,10 +23,8 @@ import Avatar from './Avatar'; import ConfirmModal from './ConfirmModal'; import Icon from './Icon'; import * as Expensicons from './Icon/Expensicons'; -import type {MenuItemProps} from './MenuItem'; -import MenuItemList from './MenuItemList'; -import type {MenuItemWithLink} from './MenuItemList'; -import Popover from './Popover'; +import type {PopoverMenuItem} from './PopoverMenu'; +import PopoverMenu from './PopoverMenu'; import {PressableWithFeedback} from './Pressable'; import Text from './Text'; @@ -41,6 +40,7 @@ function AccountSwitcher() { const [session] = useOnyx(ONYXKEYS.SESSION); const [user] = useOnyx(ONYXKEYS.USER); const buttonRef = useRef(null); + const {windowHeight} = useWindowDimensions(); const [shouldShowDelegatorMenu, setShouldShowDelegatorMenu] = useState(false); const [shouldShowOfflineModal, setShouldShowOfflineModal] = useState(false); @@ -49,10 +49,14 @@ function AccountSwitcher() { const isActingAsDelegate = !!account?.delegatedAccess?.delegate ?? false; const canSwitchAccounts = canUseNewDotCopilot && (delegators.length > 0 || isActingAsDelegate); - const createBaseMenuItem = (personalDetails: PersonalDetails | undefined, errors?: Errors, additionalProps: MenuItemWithLink = {}): MenuItemWithLink => { + const createBaseMenuItem = ( + personalDetails: PersonalDetails | undefined, + errors?: Errors, + additionalProps: Partial> = {}, + ): PopoverMenuItem => { const error = Object.values(errors ?? {}).at(0) ?? ''; return { - title: personalDetails?.displayName ?? personalDetails?.login, + text: personalDetails?.displayName ?? personalDetails?.login ?? '', description: Str.removeSMSDomain(personalDetails?.login ?? ''), avatarID: personalDetails?.accountID ?? -1, icon: personalDetails?.avatar ?? '', @@ -66,14 +70,12 @@ function AccountSwitcher() { }; }; - const menuItems = (): MenuItemProps[] => { + const menuItems = (): PopoverMenuItem[] => { const currentUserMenuItem = createBaseMenuItem(currentUserPersonalDetails, undefined, { - wrapperStyle: [styles.buttonDefaultBG], - focused: true, shouldShowRightIcon: true, iconRight: Expensicons.Checkmark, success: true, - key: `${currentUserPersonalDetails?.login}-current`, + isSelected: true, }); if (isActingAsDelegate) { @@ -89,34 +91,32 @@ function AccountSwitcher() { return [ createBaseMenuItem(delegatePersonalDetails, error, { - onPress: () => { + onSelected: () => { if (isOffline) { Modal.close(() => setShouldShowOfflineModal(true)); return; } disconnect(); }, - key: `${delegateEmail}-delegate`, }), currentUserMenuItem, ]; } - const delegatorMenuItems: MenuItemProps[] = delegators + const delegatorMenuItems: PopoverMenuItem[] = delegators .filter(({email}) => email !== currentUserPersonalDetails.login) - .map(({email, role, errorFields}, index) => { + .map(({email, role, errorFields}) => { const error = ErrorUtils.getLatestErrorField({errorFields}, 'connect'); const personalDetails = PersonalDetailsUtils.getPersonalDetailByEmail(email); return createBaseMenuItem(personalDetails, error, { badgeText: translate('delegate.role', {role}), - onPress: () => { + onSelected: () => { if (isOffline) { Modal.close(() => setShouldShowOfflineModal(true)); return; } connect(email); }, - key: `${email}-${index}`, }); }); @@ -181,23 +181,27 @@ function AccountSwitcher() { {canSwitchAccounts && ( - { setShouldShowDelegatorMenu(false); clearDelegatorErrors(); }} anchorRef={buttonRef} - anchorPosition={styles.accountSwitcherAnchorPosition} - > - - {translate('delegate.switchAccount')} - - - + anchorPosition={CONST.POPOVER_ACCOUNT_SWITCHER_POSITION} + anchorAlignment={{ + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, + }} + menuItems={menuItems()} + headerText={translate('delegate.switchAccount')} + containerStyles={[{maxHeight: windowHeight / 2}, styles.pb0, styles.mw100, shouldUseNarrowLayout ? {} : styles.wFitContent]} + headerStyles={styles.pt0} + innerContainerStyle={styles.pb0} + scrollContainerStyle={styles.pb4} + shouldUseScrollView + shouldUpdateFocusedIndex={false} + /> )} ; -}; - -type BaseAnchorForAttachmentsOnlyProps = AnchorForAttachmentsOnlyProps & - BaseAnchorForAttachmentsOnlyOnyxProps & { - /** Press in handler for the link */ - onPressIn?: () => void; +type BaseAnchorForAttachmentsOnlyProps = AnchorForAttachmentsOnlyProps & { + /** Press in handler for the link */ + onPressIn?: () => void; - /** Press out handler for the link */ - onPressOut?: () => void; - }; + /** Press out handler for the link */ + onPressOut?: () => void; +}; -function BaseAnchorForAttachmentsOnly({style, source = '', displayName = '', download, onPressIn, onPressOut}: BaseAnchorForAttachmentsOnlyProps) { +function BaseAnchorForAttachmentsOnly({style, source = '', displayName = '', onPressIn, onPressOut}: BaseAnchorForAttachmentsOnlyProps) { const sourceURLWithAuth = addEncryptedAuthTokenToURL(source); const sourceID = (source.match(CONST.REGEX.ATTACHMENT_ID) ?? [])[1]; + const [download] = useOnyx(`${ONYXKEYS.COLLECTION.DOWNLOAD}${sourceID}`); + const {isOffline} = useNetwork(); const styles = useThemeStyles(); @@ -43,7 +37,7 @@ function BaseAnchorForAttachmentsOnly({style, source = '', displayName = '', dow {({anchor, report, reportNameValuePairs, action, checkIfContextMenuActive, isDisabled}) => ( { if (isDownloading || isOffline || !sourceID) { return; @@ -69,6 +63,7 @@ function BaseAnchorForAttachmentsOnly({style, source = '', displayName = '', dow shouldShowDownloadIcon={!!sourceID && !isOffline} shouldShowLoadingSpinnerIcon={isDownloading} isUsedAsChatAttachment + isUploading={!sourceID} /> )} @@ -78,11 +73,4 @@ function BaseAnchorForAttachmentsOnly({style, source = '', displayName = '', dow BaseAnchorForAttachmentsOnly.displayName = 'BaseAnchorForAttachmentsOnly'; -export default withOnyx({ - download: { - key: ({source}) => { - const sourceID = (source?.match(CONST.REGEX.ATTACHMENT_ID) ?? [])[1]; - return `${ONYXKEYS.COLLECTION.DOWNLOAD}${sourceID}`; - }, - }, -})(BaseAnchorForAttachmentsOnly); +export default BaseAnchorForAttachmentsOnly; diff --git a/src/components/AttachmentModal.tsx b/src/components/AttachmentModal.tsx index 8de7f4575e75..0bc233812ca7 100644 --- a/src/components/AttachmentModal.tsx +++ b/src/components/AttachmentModal.tsx @@ -132,6 +132,8 @@ type AttachmentModalProps = { fallbackSource?: AvatarSource; canEditReceipt?: boolean; + + shouldDisableSendButton?: boolean; }; function AttachmentModal({ @@ -158,6 +160,7 @@ function AttachmentModal({ shouldShowNotFoundPage = false, type = undefined, accountID = undefined, + shouldDisableSendButton = false, }: AttachmentModalProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -589,7 +592,7 @@ function AttachmentModal({ textStyles={[styles.buttonConfirmText]} text={translate('common.send')} onPress={submitAndClose} - isDisabled={isConfirmButtonDisabled} + isDisabled={isConfirmButtonDisabled || shouldDisableSendButton} pressOnEnter /> diff --git a/src/components/Attachments/AttachmentView/DefaultAttachmentView/index.tsx b/src/components/Attachments/AttachmentView/DefaultAttachmentView/index.tsx index ee594f66aabc..e6ac9f9f21c7 100644 --- a/src/components/Attachments/AttachmentView/DefaultAttachmentView/index.tsx +++ b/src/components/Attachments/AttachmentView/DefaultAttachmentView/index.tsx @@ -24,9 +24,12 @@ type DefaultAttachmentViewProps = { containerStyles?: StyleProp; icon?: IconAsset; + + /** Flag indicating if the attachment is being uploaded. */ + isUploading?: boolean; }; -function DefaultAttachmentView({fileName = '', shouldShowLoadingSpinnerIcon = false, shouldShowDownloadIcon, containerStyles, icon}: DefaultAttachmentViewProps) { +function DefaultAttachmentView({fileName = '', shouldShowLoadingSpinnerIcon = false, shouldShowDownloadIcon, containerStyles, icon, isUploading}: DefaultAttachmentViewProps) { const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -53,7 +56,7 @@ function DefaultAttachmentView({fileName = '', shouldShowLoadingSpinnerIcon = fa )} {shouldShowLoadingSpinnerIcon && ( - + ; -}; +type AttachmentViewProps = Attachment & { + /** Whether this view is the active screen */ + isFocused?: boolean; -type AttachmentViewProps = AttachmentViewOnyxProps & - Attachment & { - /** Whether this view is the active screen */ - isFocused?: boolean; + /** Function for handle on press */ + onPress?: (e?: GestureResponderEvent | KeyboardEvent) => void; - /** Function for handle on press */ - onPress?: (e?: GestureResponderEvent | KeyboardEvent) => void; + /** Whether the attachment is used in attachment modal */ + isUsedInAttachmentModal?: boolean; - isUsedInAttachmentModal?: boolean; + /** Flag to show/hide download icon */ + shouldShowDownloadIcon?: boolean; - /** Flag to show/hide download icon */ - shouldShowDownloadIcon?: boolean; + /** Flag to show the loading indicator */ + shouldShowLoadingSpinnerIcon?: boolean; - /** Flag to show the loading indicator */ - shouldShowLoadingSpinnerIcon?: boolean; + /** Notify parent that the UI should be modified to accommodate keyboard */ + onToggleKeyboard?: (shouldFadeOut: boolean) => void; - /** Notify parent that the UI should be modified to accommodate keyboard */ - onToggleKeyboard?: (shouldFadeOut: boolean) => void; + /** A callback when the PDF fails to load */ + onPDFLoadError?: () => void; - /** A callback when the PDF fails to load */ - onPDFLoadError?: () => void; + /** Extra styles to pass to View wrapper */ + containerStyles?: StyleProp; - /** Extra styles to pass to View wrapper */ - containerStyles?: StyleProp; + /** Denotes whether it is a workspace avatar or not */ + isWorkspaceAvatar?: boolean; - /** Denotes whether it is a workspace avatar or not */ - isWorkspaceAvatar?: boolean; + /** Denotes whether it is an icon (ex: SVG) */ + maybeIcon?: boolean; - /** Denotes whether it is an icon (ex: SVG) */ - maybeIcon?: boolean; + /** Fallback source to use in case of error */ + fallbackSource?: AttachmentSource; - /** Fallback source to use in case of error */ - fallbackSource?: AttachmentSource; + /* Whether it is hovered or not */ + isHovered?: boolean; - /* Whether it is hovered or not */ - isHovered?: boolean; + /** Whether the attachment is used as a chat attachment */ + isUsedAsChatAttachment?: boolean; - /** Whether the attachment is used as a chat attachment */ - isUsedAsChatAttachment?: boolean; + /* Flag indicating whether the attachment has been uploaded. */ + isUploaded?: boolean; - /* Flag indicating whether the attachment has been uploaded. */ - isUploaded?: boolean; - }; + /** Flag indicating if the attachment is being uploaded. */ + isUploading?: boolean; +}; function AttachmentView({ source, @@ -95,16 +92,20 @@ function AttachmentView({ isWorkspaceAvatar, maybeIcon, fallbackSource, - transaction, + transactionID = '-1', reportActionID, isHovered, duration, isUsedAsChatAttachment, isUploaded = true, + isUploading = false, }: AttachmentViewProps) { const {translate} = useLocalize(); const {updateCurrentlyPlayingURL} = usePlaybackContext(); const attachmentCarouselPagerContext = useContext(AttachmentCarouselPagerContext); + + const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); + const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -288,20 +289,16 @@ function AttachmentView({ ); } AttachmentView.displayName = 'AttachmentView'; -export default memo( - withOnyx({ - transaction: { - key: ({transactionID}) => `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - }, - })(AttachmentView), -); +export default memo(AttachmentView); export type {AttachmentViewProps}; diff --git a/src/components/BrokenConnectionDescription.tsx b/src/components/BrokenConnectionDescription.tsx new file mode 100644 index 000000000000..c54bd0058f99 --- /dev/null +++ b/src/components/BrokenConnectionDescription.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as PolicyUtils from '@libs/PolicyUtils'; +import * as ReportUtils from '@libs/ReportUtils'; +import Navigation from '@navigation/Navigation'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {Policy, Report} from '@src/types/onyx'; +import TextLink from './TextLink'; + +type BrokenConnectionDescriptionProps = { + /** Transaction id of the corresponding report */ + transactionID: string; + + /** Current report */ + report: OnyxEntry; + + /** Policy which the report is tied to */ + policy: OnyxEntry; +}; + +function BrokenConnectionDescription({transactionID, policy, report}: BrokenConnectionDescriptionProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const [transactionViolations] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`); + + const brokenConnection530Error = transactionViolations?.find((violation) => violation.data?.rterType === CONST.RTER_VIOLATION_TYPES.BROKEN_CARD_CONNECTION_530); + const brokenConnectionError = transactionViolations?.find((violation) => violation.data?.rterType === CONST.RTER_VIOLATION_TYPES.BROKEN_CARD_CONNECTION); + const isPolicyAdmin = PolicyUtils.isPolicyAdmin(policy); + + if (!brokenConnection530Error && !brokenConnectionError) { + return ''; + } + + if (brokenConnection530Error) { + return translate('violations.brokenConnection530Error'); + } + + if (isPolicyAdmin && !ReportUtils.isCurrentUserSubmitter(report?.reportID ?? '')) { + return ( + <> + {`${translate('violations.adminBrokenConnectionError')}`} + Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(policy?.id ?? '-1'))} + >{`${translate('workspace.common.companyCards')}`} + . + + ); + } + + if (ReportUtils.isReportApproved(report) || ReportUtils.isReportManuallyReimbursed(report) || (ReportUtils.isProcessingReport(report) && !PolicyUtils.isInstantSubmitEnabled(policy))) { + return translate('violations.memberBrokenConnectionError'); + } + + return `${translate('violations.memberBrokenConnectionError')} ${translate('violations.markAsCashToIgnore')}`; +} + +BrokenConnectionDescription.displayName = 'BrokenConnectionDescription'; + +export default BrokenConnectionDescription; diff --git a/src/components/Composer/index.native.tsx b/src/components/Composer/implementation/index.native.tsx similarity index 99% rename from src/components/Composer/index.native.tsx rename to src/components/Composer/implementation/index.native.tsx index ffceccc84c8d..9f237dd02424 100644 --- a/src/components/Composer/index.native.tsx +++ b/src/components/Composer/implementation/index.native.tsx @@ -5,6 +5,7 @@ import React, {useCallback, useEffect, useMemo, useRef} from 'react'; import type {NativeSyntheticEvent, TextInput, TextInputChangeEventData, TextInputPasteEventData} from 'react-native'; import {StyleSheet} from 'react-native'; import type {FileObject} from '@components/AttachmentModal'; +import type {ComposerProps} from '@components/Composer/types'; import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput'; import RNMarkdownTextInput from '@components/RNMarkdownTextInput'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; @@ -17,7 +18,6 @@ import updateIsFullComposerAvailable from '@libs/ComposerUtils/updateIsFullCompo import * as EmojiUtils from '@libs/EmojiUtils'; import * as FileUtils from '@libs/fileDownload/FileUtils'; import CONST from '@src/CONST'; -import type {ComposerProps} from './types'; const excludeNoStyles: Array = []; const excludeReportMentionStyle: Array = ['mentionReport']; diff --git a/src/components/Composer/implementation/index.tsx b/src/components/Composer/implementation/index.tsx new file mode 100755 index 000000000000..4431007793cb --- /dev/null +++ b/src/components/Composer/implementation/index.tsx @@ -0,0 +1,368 @@ +import type {MarkdownStyle} from '@expensify/react-native-live-markdown'; +import lodashDebounce from 'lodash/debounce'; +import type {BaseSyntheticEvent, ForwardedRef} from 'react'; +import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; +// eslint-disable-next-line no-restricted-imports +import type {NativeSyntheticEvent, TextInput, TextInputKeyPressEventData, TextInputSelectionChangeEventData} from 'react-native'; +import {DeviceEventEmitter, StyleSheet} from 'react-native'; +import type {ComposerProps} from '@components/Composer/types'; +import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput'; +import RNMarkdownTextInput from '@components/RNMarkdownTextInput'; +import useHtmlPaste from '@hooks/useHtmlPaste'; +import useIsScrollBarVisible from '@hooks/useIsScrollBarVisible'; +import useMarkdownStyle from '@hooks/useMarkdownStyle'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL'; +import * as Browser from '@libs/Browser'; +import updateIsFullComposerAvailable from '@libs/ComposerUtils/updateIsFullComposerAvailable'; +import * as EmojiUtils from '@libs/EmojiUtils'; +import * as FileUtils from '@libs/fileDownload/FileUtils'; +import isEnterWhileComposition from '@libs/KeyboardShortcut/isEnterWhileComposition'; +import CONST from '@src/CONST'; + +const excludeNoStyles: Array = []; +const excludeReportMentionStyle: Array = ['mentionReport']; +const imagePreviewAuthRequiredURLs = [CONST.EXPENSIFY_URL, CONST.STAGING_EXPENSIFY_URL]; + +// Enable Markdown parsing. +// On web we like to have the Text Input field always focused so the user can easily type a new chat +function Composer( + { + value, + defaultValue, + maxLines = -1, + onKeyPress = () => {}, + style, + autoFocus = false, + shouldCalculateCaretPosition = false, + isDisabled = false, + onClear = () => {}, + onPasteFile = () => {}, + onSelectionChange = () => {}, + setIsFullComposerAvailable = () => {}, + checkComposerVisibility = () => false, + selection: selectionProp = { + start: 0, + end: 0, + }, + isComposerFullSize = false, + shouldContainScroll = true, + isGroupPolicyReport = false, + ...props + }: ComposerProps, + ref: ForwardedRef, +) { + const textContainsOnlyEmojis = useMemo(() => EmojiUtils.containsOnlyEmojis(value ?? ''), [value]); + const theme = useTheme(); + const styles = useThemeStyles(); + const markdownStyle = useMarkdownStyle(value, !isGroupPolicyReport ? excludeReportMentionStyle : excludeNoStyles); + const StyleUtils = useStyleUtils(); + const textInput = useRef(null); + const [selection, setSelection] = useState< + | { + start: number; + end?: number; + positionX?: number; + positionY?: number; + } + | undefined + >({ + start: selectionProp.start, + end: selectionProp.end, + }); + const [isRendered, setIsRendered] = useState(false); + const isScrollBarVisible = useIsScrollBarVisible(textInput, value ?? ''); + const [prevScroll, setPrevScroll] = useState(); + const isReportFlatListScrolling = useRef(false); + + useEffect(() => { + if (!!selection && selectionProp.start === selection.start && selectionProp.end === selection.end) { + return; + } + setSelection(selectionProp); + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps + }, [selectionProp]); + + /** + * Adds the cursor position to the selection change event. + */ + const addCursorPositionToSelectionChange = (event: NativeSyntheticEvent) => { + const webEvent = event as BaseSyntheticEvent; + const sel = window.getSelection(); + if (shouldCalculateCaretPosition && isRendered && sel) { + const range = sel.getRangeAt(0).cloneRange(); + range.collapse(true); + const rect = range.getClientRects()[0] || range.startContainer.parentElement?.getClientRects()[0]; + const containerRect = textInput.current?.getBoundingClientRect(); + + let x = 0; + let y = 0; + if (rect && containerRect) { + x = rect.left - containerRect.left; + y = rect.top - containerRect.top + (textInput?.current?.scrollTop ?? 0) - rect.height / 2; + } + + const selectionValue = { + start: webEvent.nativeEvent.selection.start, + end: webEvent.nativeEvent.selection.end, + positionX: x - CONST.SPACE_CHARACTER_WIDTH, + positionY: y, + }; + + onSelectionChange({ + ...webEvent, + nativeEvent: { + ...webEvent.nativeEvent, + selection: selectionValue, + }, + }); + setSelection(selectionValue); + } else { + onSelectionChange(webEvent); + setSelection(webEvent.nativeEvent.selection); + } + }; + + /** + * Check the paste event for an attachment, parse the data and call onPasteFile from props with the selected file, + * Otherwise, convert pasted HTML to Markdown and set it on the composer. + */ + const handlePaste = useCallback( + (event: ClipboardEvent) => { + const isVisible = checkComposerVisibility(); + const isFocused = textInput.current?.isFocused(); + const isContenteditableDivFocused = document.activeElement?.nodeName === 'DIV' && document.activeElement?.hasAttribute('contenteditable'); + + if (!(isVisible || isFocused)) { + return true; + } + + if (textInput.current !== event.target && !(isContenteditableDivFocused && !event.clipboardData?.files.length)) { + const eventTarget = event.target as HTMLInputElement | HTMLTextAreaElement | null; + // To make sure the composer does not capture paste events from other inputs, we check where the event originated + // If it did originate in another input, we return early to prevent the composer from handling the paste + const isTargetInput = eventTarget?.nodeName === 'INPUT' || eventTarget?.nodeName === 'TEXTAREA' || eventTarget?.contentEditable === 'true'; + if (isTargetInput || (!isFocused && isContenteditableDivFocused && event.clipboardData?.files.length)) { + return true; + } + + textInput.current?.focus(); + } + + event.preventDefault(); + + const TEXT_HTML = 'text/html'; + + const clipboardDataHtml = event.clipboardData?.getData(TEXT_HTML) ?? ''; + + // If paste contains files, then trigger file management + if (event.clipboardData?.files.length && event.clipboardData.files.length > 0) { + // Prevent the default so we do not post the file name into the text box + onPasteFile(event.clipboardData.files[0]); + return true; + } + + // If paste contains base64 image + if (clipboardDataHtml?.includes(CONST.IMAGE_BASE64_MATCH)) { + const domparser = new DOMParser(); + const pastedHTML = clipboardDataHtml; + const embeddedImages = domparser.parseFromString(pastedHTML, TEXT_HTML)?.images; + + if (embeddedImages.length > 0 && embeddedImages[0].src) { + const src = embeddedImages[0].src; + const file = FileUtils.base64ToFile(src, 'image.png'); + onPasteFile(file); + return true; + } + } + + // If paste contains image from Google Workspaces ex: Sheets, Docs, Slide, etc + if (clipboardDataHtml?.includes(CONST.GOOGLE_DOC_IMAGE_LINK_MATCH)) { + const domparser = new DOMParser(); + const pastedHTML = clipboardDataHtml; + const embeddedImages = domparser.parseFromString(pastedHTML, TEXT_HTML).images; + + if (embeddedImages.length > 0 && embeddedImages[0]?.src) { + const src = embeddedImages[0].src; + if (src.includes(CONST.GOOGLE_DOC_IMAGE_LINK_MATCH)) { + fetch(src) + .then((response) => response.blob()) + .then((blob) => { + const file = new File([blob], 'image.jpg', {type: 'image/jpeg'}); + onPasteFile(file); + }); + return true; + } + } + } + return false; + }, + [onPasteFile, checkComposerVisibility], + ); + + useEffect(() => { + if (!textInput.current) { + return; + } + const debouncedSetPrevScroll = lodashDebounce(() => { + if (!textInput.current) { + return; + } + setPrevScroll(textInput.current.scrollTop); + }, 100); + + textInput.current.addEventListener('scroll', debouncedSetPrevScroll); + return () => { + textInput.current?.removeEventListener('scroll', debouncedSetPrevScroll); + }; + }, []); + + useEffect(() => { + const scrollingListener = DeviceEventEmitter.addListener(CONST.EVENTS.SCROLLING, (scrolling: boolean) => { + isReportFlatListScrolling.current = scrolling; + }); + + return () => scrollingListener.remove(); + }, []); + + useEffect(() => { + const handleWheel = (e: MouseEvent) => { + if (isReportFlatListScrolling.current) { + e.preventDefault(); + return; + } + e.stopPropagation(); + }; + textInput.current?.addEventListener('wheel', handleWheel, {passive: false}); + + return () => { + textInput.current?.removeEventListener('wheel', handleWheel); + }; + }, []); + + useEffect(() => { + if (!textInput.current || prevScroll === undefined) { + return; + } + // eslint-disable-next-line react-compiler/react-compiler + textInput.current.scrollTop = prevScroll; + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps + }, [isComposerFullSize]); + + useHtmlPaste(textInput, handlePaste, true); + + useEffect(() => { + setIsRendered(true); + }, []); + + const clear = useCallback(() => { + if (!textInput.current) { + return; + } + + const currentText = textInput.current.value; + textInput.current.clear(); + + // We need to reset the selection to 0,0 manually after clearing the text input on web + const selectionEvent = { + nativeEvent: { + selection: { + start: 0, + end: 0, + }, + }, + } as NativeSyntheticEvent; + onSelectionChange(selectionEvent); + setSelection({start: 0, end: 0}); + + onClear(currentText); + }, [onClear, onSelectionChange]); + + useImperativeHandle( + ref, + () => { + const textInputRef = textInput.current; + if (!textInputRef) { + throw new Error('textInputRef is not available. This should never happen and indicates a developer error.'); + } + + return { + ...textInputRef, + // Overwrite clear with our custom implementation, which mimics how the native TextInput's clear method works + clear, + // We have to redefine these methods as they are inherited by prototype chain and are not accessible directly + blur: () => textInputRef.blur(), + focus: () => textInputRef.focus(), + get scrollTop() { + return textInputRef.scrollTop; + }, + }; + }, + [clear], + ); + + const handleKeyPress = useCallback( + (e: NativeSyntheticEvent) => { + // Prevent onKeyPress from being triggered if the Enter key is pressed while text is being composed + if (!onKeyPress || isEnterWhileComposition(e as unknown as KeyboardEvent)) { + return; + } + + onKeyPress(e); + }, + [onKeyPress], + ); + + const scrollStyleMemo = useMemo(() => { + if (shouldContainScroll) { + return isScrollBarVisible ? [styles.overflowScroll, styles.overscrollBehaviorContain] : styles.overflowHidden; + } + return styles.overflowAuto; + }, [shouldContainScroll, styles.overflowAuto, styles.overflowScroll, styles.overscrollBehaviorContain, styles.overflowHidden, isScrollBarVisible]); + + const inputStyleMemo = useMemo( + () => [ + StyleSheet.flatten([style, {outline: 'none'}]), + StyleUtils.getComposeTextAreaPadding(isComposerFullSize), + Browser.isMobileSafari() || Browser.isSafari() ? styles.rtlTextRenderForSafari : {}, + scrollStyleMemo, + StyleUtils.getComposerMaxHeightStyle(maxLines, isComposerFullSize), + isComposerFullSize ? {height: '100%', maxHeight: 'none'} : undefined, + textContainsOnlyEmojis ? styles.onlyEmojisTextLineHeight : {}, + ], + + [style, styles.rtlTextRenderForSafari, styles.onlyEmojisTextLineHeight, scrollStyleMemo, StyleUtils, maxLines, isComposerFullSize, textContainsOnlyEmojis], + ); + + return ( + (textInput.current = el)} + selection={selection} + style={[inputStyleMemo]} + markdownStyle={markdownStyle} + value={value} + defaultValue={defaultValue} + autoFocus={autoFocus} + /* eslint-disable-next-line react/jsx-props-no-spreading */ + {...props} + onSelectionChange={addCursorPositionToSelectionChange} + onContentSizeChange={(e) => { + updateIsFullComposerAvailable({maxLines, isComposerFullSize, isDisabled, setIsFullComposerAvailable}, e, styles); + }} + disabled={isDisabled} + onKeyPress={handleKeyPress} + addAuthTokenToImageURLCallback={addEncryptedAuthTokenToURL} + imagePreviewAuthRequiredURLs={imagePreviewAuthRequiredURLs} + /> + ); +} + +Composer.displayName = 'Composer'; + +export default React.forwardRef(Composer); diff --git a/src/components/Composer/index.e2e.tsx b/src/components/Composer/index.e2e.tsx new file mode 100644 index 000000000000..38cf065f7b8e --- /dev/null +++ b/src/components/Composer/index.e2e.tsx @@ -0,0 +1,19 @@ +import type {ForwardedRef} from 'react'; +import React from 'react'; +import type {TextInput} from 'react-native'; +import Composer from './implementation'; +import type {ComposerProps} from './types'; + +function ComposerE2E(props: ComposerProps, ref: ForwardedRef) { + return ( + + ); +} + +export default React.forwardRef(ComposerE2E); diff --git a/src/components/Composer/index.tsx b/src/components/Composer/index.tsx old mode 100755 new mode 100644 index 15087193a593..d9474effa478 --- a/src/components/Composer/index.tsx +++ b/src/components/Composer/index.tsx @@ -1,409 +1,3 @@ -import type {MarkdownStyle} from '@expensify/react-native-live-markdown'; -import lodashDebounce from 'lodash/debounce'; -import type {BaseSyntheticEvent, ForwardedRef} from 'react'; -import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; -import {flushSync} from 'react-dom'; -// eslint-disable-next-line no-restricted-imports -import type {NativeSyntheticEvent, Text as RNText, TextInput, TextInputKeyPressEventData, TextInputSelectionChangeEventData, ViewStyle} from 'react-native'; -import {DeviceEventEmitter, StyleSheet, View} from 'react-native'; -import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput'; -import RNMarkdownTextInput from '@components/RNMarkdownTextInput'; -import Text from '@components/Text'; -import useHtmlPaste from '@hooks/useHtmlPaste'; -import useIsScrollBarVisible from '@hooks/useIsScrollBarVisible'; -import useMarkdownStyle from '@hooks/useMarkdownStyle'; -import useStyleUtils from '@hooks/useStyleUtils'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; -import * as Browser from '@libs/Browser'; -import updateIsFullComposerAvailable from '@libs/ComposerUtils/updateIsFullComposerAvailable'; -import * as EmojiUtils from '@libs/EmojiUtils'; -import * as FileUtils from '@libs/fileDownload/FileUtils'; -import isEnterWhileComposition from '@libs/KeyboardShortcut/isEnterWhileComposition'; -import CONST from '@src/CONST'; -import type {ComposerProps} from './types'; +import Composer from './implementation'; -/** - * Retrieves the characters from the specified cursor position up to the next space or new line. - * - * @param inputString - The input string. - * @param cursorPosition - The position of the cursor within the input string. - * @returns - The substring from the cursor position up to the next space or new line. - * If no space or new line is found, returns the substring from the cursor position to the end of the input string. - */ -const getNextChars = (inputString: string, cursorPosition: number): string => { - // Get the substring starting from the cursor position - const subString = inputString.substring(cursorPosition); - - // Find the index of the next space or new line character - const spaceIndex = subString.search(/[ \n]/); - - if (spaceIndex === -1) { - return subString; - } - - // If there is a space or new line, return the substring up to the space or new line - return subString.substring(0, spaceIndex); -}; - -const excludeNoStyles: Array = []; -const excludeReportMentionStyle: Array = ['mentionReport']; - -// Enable Markdown parsing. -// On web we like to have the Text Input field always focused so the user can easily type a new chat -function Composer( - { - value, - defaultValue, - maxLines = -1, - onKeyPress = () => {}, - style, - autoFocus = false, - shouldCalculateCaretPosition = false, - isDisabled = false, - onClear = () => {}, - onPasteFile = () => {}, - onSelectionChange = () => {}, - setIsFullComposerAvailable = () => {}, - checkComposerVisibility = () => false, - selection: selectionProp = { - start: 0, - end: 0, - }, - isComposerFullSize = false, - shouldContainScroll = true, - isGroupPolicyReport = false, - ...props - }: ComposerProps, - ref: ForwardedRef, -) { - const textContainsOnlyEmojis = useMemo(() => EmojiUtils.containsOnlyEmojis(value ?? ''), [value]); - const theme = useTheme(); - const styles = useThemeStyles(); - const markdownStyle = useMarkdownStyle(value, !isGroupPolicyReport ? excludeReportMentionStyle : excludeNoStyles); - const StyleUtils = useStyleUtils(); - const textRef = useRef(null); - const textInput = useRef(null); - const [selection, setSelection] = useState< - | { - start: number; - end?: number; - positionX?: number; - positionY?: number; - } - | undefined - >({ - start: selectionProp.start, - end: selectionProp.end, - }); - const [caretContent, setCaretContent] = useState(''); - const [valueBeforeCaret, setValueBeforeCaret] = useState(''); - const [textInputWidth, setTextInputWidth] = useState(''); - const [isRendered, setIsRendered] = useState(false); - const isScrollBarVisible = useIsScrollBarVisible(textInput, value ?? ''); - const [prevScroll, setPrevScroll] = useState(); - const isReportFlatListScrolling = useRef(false); - - useEffect(() => { - if (!!selection && selectionProp.start === selection.start && selectionProp.end === selection.end) { - return; - } - setSelection(selectionProp); - // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - }, [selectionProp]); - - /** - * Adds the cursor position to the selection change event. - */ - const addCursorPositionToSelectionChange = (event: NativeSyntheticEvent) => { - const webEvent = event as BaseSyntheticEvent; - if (shouldCalculateCaretPosition && isRendered) { - // we do flushSync to make sure that the valueBeforeCaret is updated before we calculate the caret position to receive a proper position otherwise we will calculate position for the previous state - flushSync(() => { - setValueBeforeCaret((webEvent.target as HTMLInputElement).value.slice(0, webEvent.nativeEvent.selection.start)); - setCaretContent(getNextChars(value ?? '', webEvent.nativeEvent.selection.start)); - }); - const selectionValue = { - start: webEvent.nativeEvent.selection.start, - end: webEvent.nativeEvent.selection.end, - positionX: (textRef.current?.offsetLeft ?? 0) - CONST.SPACE_CHARACTER_WIDTH, - positionY: textRef.current?.offsetTop, - }; - - onSelectionChange({ - ...webEvent, - nativeEvent: { - ...webEvent.nativeEvent, - selection: selectionValue, - }, - }); - setSelection(selectionValue); - } else { - onSelectionChange(webEvent); - setSelection(webEvent.nativeEvent.selection); - } - }; - - /** - * Check the paste event for an attachment, parse the data and call onPasteFile from props with the selected file, - * Otherwise, convert pasted HTML to Markdown and set it on the composer. - */ - const handlePaste = useCallback( - (event: ClipboardEvent) => { - const isVisible = checkComposerVisibility(); - const isFocused = textInput.current?.isFocused(); - const isContenteditableDivFocused = document.activeElement?.nodeName === 'DIV' && document.activeElement?.hasAttribute('contenteditable'); - - if (!(isVisible || isFocused)) { - return true; - } - - if (textInput.current !== event.target && !(isContenteditableDivFocused && !event.clipboardData?.files.length)) { - const eventTarget = event.target as HTMLInputElement | HTMLTextAreaElement | null; - // To make sure the composer does not capture paste events from other inputs, we check where the event originated - // If it did originate in another input, we return early to prevent the composer from handling the paste - const isTargetInput = eventTarget?.nodeName === 'INPUT' || eventTarget?.nodeName === 'TEXTAREA' || eventTarget?.contentEditable === 'true'; - if (isTargetInput || (!isFocused && isContenteditableDivFocused && event.clipboardData?.files.length)) { - return true; - } - - textInput.current?.focus(); - } - - event.preventDefault(); - - const TEXT_HTML = 'text/html'; - - const clipboardDataHtml = event.clipboardData?.getData(TEXT_HTML) ?? ''; - - // If paste contains files, then trigger file management - if (event.clipboardData?.files.length && event.clipboardData.files.length > 0) { - // Prevent the default so we do not post the file name into the text box - onPasteFile(event.clipboardData.files[0]); - return true; - } - - // If paste contains base64 image - if (clipboardDataHtml?.includes(CONST.IMAGE_BASE64_MATCH)) { - const domparser = new DOMParser(); - const pastedHTML = clipboardDataHtml; - const embeddedImages = domparser.parseFromString(pastedHTML, TEXT_HTML)?.images; - - if (embeddedImages.length > 0 && embeddedImages[0].src) { - const src = embeddedImages[0].src; - const file = FileUtils.base64ToFile(src, 'image.png'); - onPasteFile(file); - return true; - } - } - - // If paste contains image from Google Workspaces ex: Sheets, Docs, Slide, etc - if (clipboardDataHtml?.includes(CONST.GOOGLE_DOC_IMAGE_LINK_MATCH)) { - const domparser = new DOMParser(); - const pastedHTML = clipboardDataHtml; - const embeddedImages = domparser.parseFromString(pastedHTML, TEXT_HTML).images; - - if (embeddedImages.length > 0 && embeddedImages[0]?.src) { - const src = embeddedImages[0].src; - if (src.includes(CONST.GOOGLE_DOC_IMAGE_LINK_MATCH)) { - fetch(src) - .then((response) => response.blob()) - .then((blob) => { - const file = new File([blob], 'image.jpg', {type: 'image/jpeg'}); - onPasteFile(file); - }); - return true; - } - } - } - return false; - }, - [onPasteFile, checkComposerVisibility], - ); - - useEffect(() => { - if (!textInput.current) { - return; - } - const debouncedSetPrevScroll = lodashDebounce(() => { - if (!textInput.current) { - return; - } - setPrevScroll(textInput.current.scrollTop); - }, 100); - - textInput.current.addEventListener('scroll', debouncedSetPrevScroll); - return () => { - textInput.current?.removeEventListener('scroll', debouncedSetPrevScroll); - }; - }, []); - - useEffect(() => { - const scrollingListener = DeviceEventEmitter.addListener(CONST.EVENTS.SCROLLING, (scrolling: boolean) => { - isReportFlatListScrolling.current = scrolling; - }); - - return () => scrollingListener.remove(); - }, []); - - useEffect(() => { - const handleWheel = (e: MouseEvent) => { - if (isReportFlatListScrolling.current) { - e.preventDefault(); - return; - } - e.stopPropagation(); - }; - textInput.current?.addEventListener('wheel', handleWheel, {passive: false}); - - return () => { - textInput.current?.removeEventListener('wheel', handleWheel); - }; - }, []); - - useEffect(() => { - if (!textInput.current || prevScroll === undefined) { - return; - } - // eslint-disable-next-line react-compiler/react-compiler - textInput.current.scrollTop = prevScroll; - // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - }, [isComposerFullSize]); - - useHtmlPaste(textInput, handlePaste, true); - - useEffect(() => { - setIsRendered(true); - }, []); - - const clear = useCallback(() => { - if (!textInput.current) { - return; - } - - const currentText = textInput.current.value; - textInput.current.clear(); - - // We need to reset the selection to 0,0 manually after clearing the text input on web - const selectionEvent = { - nativeEvent: { - selection: { - start: 0, - end: 0, - }, - }, - } as NativeSyntheticEvent; - onSelectionChange(selectionEvent); - setSelection({start: 0, end: 0}); - - onClear(currentText); - }, [onClear, onSelectionChange]); - - useImperativeHandle( - ref, - () => { - const textInputRef = textInput.current; - if (!textInputRef) { - throw new Error('textInputRef is not available. This should never happen and indicates a developer error.'); - } - - return { - ...textInputRef, - // Overwrite clear with our custom implementation, which mimics how the native TextInput's clear method works - clear, - // We have to redefine these methods as they are inherited by prototype chain and are not accessible directly - blur: () => textInputRef.blur(), - focus: () => textInputRef.focus(), - get scrollTop() { - return textInputRef.scrollTop; - }, - }; - }, - [clear], - ); - - const handleKeyPress = useCallback( - (e: NativeSyntheticEvent) => { - // Prevent onKeyPress from being triggered if the Enter key is pressed while text is being composed - if (!onKeyPress || isEnterWhileComposition(e as unknown as KeyboardEvent)) { - return; - } - - onKeyPress(e); - }, - [onKeyPress], - ); - - const renderElementForCaretPosition = ( - - - {`${valueBeforeCaret} `} - - {`${caretContent}`} - - - - ); - - const scrollStyleMemo = useMemo(() => { - if (shouldContainScroll) { - return isScrollBarVisible ? [styles.overflowScroll, styles.overscrollBehaviorContain] : styles.overflowHidden; - } - return styles.overflowAuto; - }, [shouldContainScroll, styles.overflowAuto, styles.overflowScroll, styles.overscrollBehaviorContain, styles.overflowHidden, isScrollBarVisible]); - - const inputStyleMemo = useMemo( - () => [ - StyleSheet.flatten([style, {outline: 'none'}]), - StyleUtils.getComposeTextAreaPadding(isComposerFullSize), - Browser.isMobileSafari() || Browser.isSafari() ? styles.rtlTextRenderForSafari : {}, - scrollStyleMemo, - StyleUtils.getComposerMaxHeightStyle(maxLines, isComposerFullSize), - isComposerFullSize ? {height: '100%', maxHeight: 'none'} : undefined, - textContainsOnlyEmojis ? styles.onlyEmojisTextLineHeight : {}, - ], - - [style, styles.rtlTextRenderForSafari, styles.onlyEmojisTextLineHeight, scrollStyleMemo, StyleUtils, maxLines, isComposerFullSize, textContainsOnlyEmojis], - ); - - return ( - <> - (textInput.current = el)} - selection={selection} - style={[inputStyleMemo]} - markdownStyle={markdownStyle} - value={value} - defaultValue={defaultValue} - autoFocus={autoFocus} - /* eslint-disable-next-line react/jsx-props-no-spreading */ - {...props} - onSelectionChange={addCursorPositionToSelectionChange} - onContentSizeChange={(e) => { - setTextInputWidth(`${e.nativeEvent.contentSize.width}px`); - updateIsFullComposerAvailable({maxLines, isComposerFullSize, isDisabled, setIsFullComposerAvailable}, e, styles); - }} - disabled={isDisabled} - onKeyPress={handleKeyPress} - /> - {shouldCalculateCaretPosition && renderElementForCaretPosition} - - ); -} - -Composer.displayName = 'Composer'; - -export default React.forwardRef(Composer); +export default Composer; diff --git a/src/components/ContextMenuItem.tsx b/src/components/ContextMenuItem.tsx index fe7be45e1b57..c57c10e2c2b9 100644 --- a/src/components/ContextMenuItem.tsx +++ b/src/components/ContextMenuItem.tsx @@ -133,7 +133,7 @@ function ContextMenuItem( title={itemText} icon={itemIcon} onPress={triggerPressAndUpdateSuccess} - wrapperStyle={[styles.pr9, wrapperStyle]} + wrapperStyle={[styles.pr8, wrapperStyle]} success={!isThrottledButtonActive} description={description} descriptionTextStyle={styles.breakWord} diff --git a/src/components/DelegateNoAccessModal.tsx b/src/components/DelegateNoAccessModal.tsx index 8b708459c122..442c3ec9c4e2 100644 --- a/src/components/DelegateNoAccessModal.tsx +++ b/src/components/DelegateNoAccessModal.tsx @@ -15,13 +15,11 @@ export default function DelegateNoAccessModal({isNoDelegateAccessMenuVisible = f const {translate} = useLocalize(); const noDelegateAccessPromptStart = translate('delegate.notAllowedMessageStart', {accountOwnerEmail: delegatorEmail}); const noDelegateAccessHyperLinked = translate('delegate.notAllowedMessageHyperLinked'); - const noDelegateAccessPromptEnd = translate('delegate.notAllowedMessageEnd'); const delegateNoAccessPrompt = ( {noDelegateAccessPromptStart} - {noDelegateAccessHyperLinked} - {noDelegateAccessPromptEnd} + {noDelegateAccessHyperLinked}. ); diff --git a/src/components/DotIndicatorMessage.tsx b/src/components/DotIndicatorMessage.tsx index 564d2eeb8c75..d831fca562c3 100644 --- a/src/components/DotIndicatorMessage.tsx +++ b/src/components/DotIndicatorMessage.tsx @@ -8,6 +8,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {isReceiptError} from '@libs/ErrorUtils'; import fileDownload from '@libs/fileDownload'; import * as Localize from '@libs/Localize'; +import CONST from '@src/CONST'; import type {ReceiptError} from '@src/types/onyx/Transaction'; import Icon from './Icon'; import * as Expensicons from './Icon/Expensicons'; @@ -53,6 +54,60 @@ function DotIndicatorMessage({messages = {}, style, type, textStyles}: DotIndica const isErrorMessage = type === 'error'; + const renderMessage = (message: string | ReceiptError, index: number) => { + if (isReceiptError(message)) { + return ( + + {Localize.translateLocal('iou.error.receiptFailureMessage')} + { + fileDownload(message.source, message.filename); + }} + > + {Localize.translateLocal('iou.error.saveFileMessage')} + + + {Localize.translateLocal('iou.error.loseFileMessage')} + + ); + } + + if (message === CONST.COMPANY_CARDS.CONNECTION_ERROR) { + return ( + + {Localize.translateLocal('workspace.companyCards.brokenConnectionErrorFirstPart')} + { + // TODO: re-navigate the user to the bank’s website to re-authenticate https://github.com/Expensify/App/issues/50448 + }} + > + {Localize.translateLocal('workspace.companyCards.brokenConnectionErrorLink')} + + + {Localize.translateLocal('workspace.companyCards.brokenConnectionErrorSecondPart')} + + ); + } + + return ( + + {message} + + ); + }; + return ( @@ -61,36 +116,7 @@ function DotIndicatorMessage({messages = {}, style, type, textStyles}: DotIndica fill={isErrorMessage ? theme.danger : theme.success} /> - - {uniqueMessages.map((message, i) => - isReceiptError(message) ? ( - - {Localize.translateLocal('iou.error.receiptFailureMessage')} - { - fileDownload(message.source, message.filename); - }} - > - {Localize.translateLocal('iou.error.saveFileMessage')} - - - {Localize.translateLocal('iou.error.loseFileMessage')} - - ) : ( - - {message} - - ), - )} - + {uniqueMessages.map(renderMessage)} ); } diff --git a/src/components/FocusTrap/TOP_TAB_SCREENS.ts b/src/components/FocusTrap/TOP_TAB_SCREENS.ts index 6bee36b86883..34610c4b0f11 100644 --- a/src/components/FocusTrap/TOP_TAB_SCREENS.ts +++ b/src/components/FocusTrap/TOP_TAB_SCREENS.ts @@ -1,5 +1,10 @@ +import type {TupleToUnion} from 'type-fest'; import CONST from '@src/CONST'; -const TOP_TAB_SCREENS: string[] = [CONST.TAB.NEW_CHAT, CONST.TAB.NEW_ROOM, CONST.TAB_REQUEST.DISTANCE, CONST.TAB_REQUEST.MANUAL, CONST.TAB_REQUEST.SCAN]; +const TOP_TAB_SCREENS = [CONST.TAB.NEW_CHAT, CONST.TAB.NEW_ROOM, CONST.TAB_REQUEST.DISTANCE, CONST.TAB_REQUEST.MANUAL, CONST.TAB_REQUEST.SCAN] as const; + +type TopTabScreen = TupleToUnion; + +export type {TopTabScreen}; export default TOP_TAB_SCREENS; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx index bbf7dfcb0c6a..2d52d26f9af6 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx @@ -1,5 +1,5 @@ import React, {memo} from 'react'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import type {CustomRendererProps, TBlock} from 'react-native-render-html'; import {AttachmentContext} from '@components/AttachmentContext'; @@ -90,7 +90,7 @@ function ImageRenderer({tnode}: ImageRendererProps) { return; } - const route = ROUTES.ATTACHMENTS?.getRoute(reportID ?? '-1', type, source, accountID); + const route = ROUTES.ATTACHMENTS?.getRoute(reportID ?? '-1', type, source, accountID, isAttachmentOrReceipt); Navigation.navigate(route); }} onLongPress={(event) => { @@ -114,13 +114,20 @@ function ImageRenderer({tnode}: ImageRendererProps) { ImageRenderer.displayName = 'ImageRenderer'; -export default withOnyx({ - user: { - key: ONYXKEYS.USER, - }, -})( - memo( - ImageRenderer, - (prevProps, nextProps) => prevProps.tnode.attributes === nextProps.tnode.attributes && prevProps.user?.shouldUseStagingServer === nextProps.user?.shouldUseStagingServer, - ), +const ImageRendererMemorize = memo( + ImageRenderer, + (prevProps, nextProps) => prevProps.tnode.attributes === nextProps.tnode.attributes && prevProps.user?.shouldUseStagingServer === nextProps.user?.shouldUseStagingServer, ); + +function ImageRendererWrapper(props: CustomRendererProps) { + const [user] = useOnyx(ONYXKEYS.USER); + return ( + + ); +} + +export default ImageRendererWrapper; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext.tsx index 9fe1088c9809..e44d3ef97df6 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext.tsx @@ -2,6 +2,7 @@ import {createContext} from 'react'; type MentionReportContextProps = { currentReportID: string; + exactlyMatch?: boolean; }; const MentionReportContext = createContext({ diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx index 7aa0f5eca22a..3ab907dc767d 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx @@ -3,7 +3,7 @@ import React, {useContext, useMemo} from 'react'; import type {TextStyle} from 'react-native'; import {StyleSheet} from 'react-native'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; -import {useOnyx, withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import type {CustomRendererProps, TPhrasing, TText} from 'react-native-render-html'; import {ShowContextMenuContext} from '@components/ShowContextMenuContext'; import Text from '@components/Text'; @@ -18,12 +18,7 @@ import type {Report} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import MentionReportContext from './MentionReportContext'; -type MentionReportOnyxProps = { - /** All reports shared with the user */ - reports: OnyxCollection; -}; - -type MentionReportRendererProps = MentionReportOnyxProps & CustomRendererProps; +type MentionReportRendererProps = CustomRendererProps; const removeLeadingLTRAndHash = (value: string) => value.replace(CONST.UNICODE.LTR, '').replace('#', ''); @@ -53,11 +48,12 @@ const getMentionDetails = (htmlAttributeReportID: string, currentReport: OnyxEnt return {reportID, mentionDisplayText}; }; -function MentionReportRenderer({style, tnode, TDefaultRenderer, reports, ...defaultRendererProps}: MentionReportRendererProps) { +function MentionReportRenderer({style, tnode, TDefaultRenderer, ...defaultRendererProps}: MentionReportRendererProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const htmlAttributeReportID = tnode.attributes.reportid; - const {currentReportID: currentReportIDContext} = useContext(MentionReportContext); + const {currentReportID: currentReportIDContext, exactlyMatch} = useContext(MentionReportContext); + const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const currentReportID = useCurrentReportID(); const currentReportIDValue = currentReportIDContext || currentReportID?.currentReportID; @@ -86,7 +82,7 @@ function MentionReportRenderer({style, tnode, TDefaultRenderer, reports, ...defa // eslint-disable-next-line react/jsx-props-no-spreading {...defaultRendererProps} style={ - isGroupPolicyReport + isGroupPolicyReport && (!exactlyMatch || navigationRoute) ? [styles.link, styleWithoutColor, StyleUtils.getMentionStyle(isCurrentRoomMention), {color: StyleUtils.getMentionTextColor(isCurrentRoomMention)}] : [] } @@ -111,17 +107,4 @@ function MentionReportRenderer({style, tnode, TDefaultRenderer, reports, ...defa MentionReportRenderer.displayName = 'MentionReportRenderer'; -const chatReportSelector = (report: OnyxEntry): Report => - (report && { - reportID: report.reportID, - reportName: report.reportName, - displayName: report.displayName, - policyID: report.policyID, - }) as Report; - -export default withOnyx({ - reports: { - key: ONYXKEYS.COLLECTION.REPORT, - selector: chatReportSelector, - }, -})(MentionReportRenderer); +export default MentionReportRenderer; diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx index eb04ad5540eb..e1843ee506d5 100755 --- a/src/components/HeaderWithBackButton/index.tsx +++ b/src/components/HeaderWithBackButton/index.tsx @@ -191,7 +191,7 @@ function HeaderWithBackButton({ /> )} {middleContent} - + {children} {shouldShowDownloadButton && ( diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts index e3604dc5a86e..bae8f6af1ab2 100644 --- a/src/components/Icon/Illustrations.ts +++ b/src/components/Icon/Illustrations.ts @@ -1,7 +1,15 @@ import AmexCompanyCards from '@assets/images/companyCards/amex.svg'; import AmexCardCompanyCardDetail from '@assets/images/companyCards/card-amex.svg'; +import BankOfAmericaCompanyCardDetail from '@assets/images/companyCards/card-bofa.svg'; +import BrexCompanyCardDetail from '@assets/images/companyCards/card-brex.svg'; +import CapitalOneCompanyCardDetail from '@assets/images/companyCards/card-capitalone.svg'; +import ChaseCompanyCardDetail from '@assets/images/companyCards/card-chase.svg'; +import CitibankCompanyCardDetail from '@assets/images/companyCards/card-citi.svg'; import MasterCardCompanyCardDetail from '@assets/images/companyCards/card-mastercard.svg'; +import StripeCompanyCardDetail from '@assets/images/companyCards/card-stripe.svg'; import VisaCompanyCardDetail from '@assets/images/companyCards/card-visa.svg'; +import WellsFargoCompanyCardDetail from '@assets/images/companyCards/card-wellsfargo.svg'; +import OtherCompanyCardDetail from '@assets/images/companyCards/card=-generic.svg'; import CompanyCardsEmptyState from '@assets/images/companyCards/emptystate__card-pos.svg'; import MasterCardCompanyCards from '@assets/images/companyCards/mastercard.svg'; import CompanyCardsPendingState from '@assets/images/companyCards/pendingstate_laptop-with-hourglass-and-cards.svg'; @@ -244,4 +252,12 @@ export { MasterCardCompanyCardDetail, AmexCardCompanyCardDetail, TurtleInShell, + BankOfAmericaCompanyCardDetail, + BrexCompanyCardDetail, + CapitalOneCompanyCardDetail, + ChaseCompanyCardDetail, + CitibankCompanyCardDetail, + OtherCompanyCardDetail, + StripeCompanyCardDetail, + WellsFargoCompanyCardDetail, }; diff --git a/src/components/MagicCodeInput.tsx b/src/components/MagicCodeInput.tsx index 89b9fc9a9e16..ce4f3380a9b7 100644 --- a/src/components/MagicCodeInput.tsx +++ b/src/components/MagicCodeInput.tsx @@ -277,8 +277,10 @@ function MagicCodeInput( const indexBeforeLastEditIndex = editIndex === 0 ? editIndex : editIndex - 1; const indexToFocus = numbers.at(editIndex) === CONST.MAGIC_CODE_EMPTY_CHAR ? indexBeforeLastEditIndex : editIndex; - const formElement = inputRefs.current as HTMLFormElement | null; - (formElement?.[indexToFocus] as HTMLInputElement)?.focus(); + if (indexToFocus !== undefined) { + lastFocusedIndex.current = indexToFocus; + inputRefs.current?.focus(); + } onChangeTextProp(value.substring(0, indexToFocus)); return; @@ -314,6 +316,7 @@ function MagicCodeInput( onChangeTextProp(composeToString(numbers)); if (newFocusedIndex !== undefined) { + lastFocusedIndex.current = newFocusedIndex; inputRefs.current?.focus(); } } diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 28a1b97f1268..9da2f76b16a1 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -26,6 +26,7 @@ import type * as OnyxTypes from '@src/types/onyx'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import type IconAsset from '@src/types/utils/IconAsset'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; +import BrokenConnectionDescription from './BrokenConnectionDescription'; import Button from './Button'; import ConfirmModal from './ConfirmModal'; import DelegateNoAccessModal from './DelegateNoAccessModal'; @@ -109,6 +110,7 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea const hasOnlyPendingTransactions = allTransactions.length > 0 && allTransactions.every((t) => TransactionUtils.isExpensifyCardTransaction(t) && TransactionUtils.isPending(t)); const transactionIDs = allTransactions.map((t) => t.transactionID); const hasAllPendingRTERViolations = TransactionUtils.allHavePendingRTERViolation([transaction?.transactionID ?? '-1']); + const shouldShowBrokenConnectionViolation = TransactionUtils.shouldShowBrokenConnectionViolation(transaction?.transactionID ?? '-1', moneyRequestReport, policy); const hasOnlyHeldExpenses = ReportUtils.hasOnlyHeldExpenses(moneyRequestReport?.reportID ?? ''); const isPayAtEndExpense = TransactionUtils.isPayAtEndExpense(transaction); const isArchivedReport = ReportUtils.isArchivedRoomWithID(moneyRequestReport?.reportID); @@ -122,6 +124,10 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea const onlyShowPayElsewhere = useMemo(() => !canIOUBePaid && getCanIOUBePaid(true), [canIOUBePaid, getCanIOUBePaid]); + const shouldShowMarkAsCashButton = + hasAllPendingRTERViolations || + (shouldShowBrokenConnectionViolation && (!PolicyUtils.isPolicyAdmin(policy) || ReportUtils.isCurrentUserSubmitter(moneyRequestReport?.reportID ?? ''))); + const shouldShowPayButton = canIOUBePaid || onlyShowPayElsewhere; const shouldShowApproveButton = useMemo(() => IOU.canApproveIOU(moneyRequestReport, policy), [moneyRequestReport, policy]); @@ -136,18 +142,21 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea isDraft && reimbursableSpend !== 0 && !hasAllPendingRTERViolations && + !shouldShowBrokenConnectionViolation && (moneyRequestReport?.ownerAccountID === currentUserAccountID || isAdmin || moneyRequestReport?.managerID === currentUserAccountID); const shouldShowExportIntegrationButton = !shouldShowPayButton && !shouldShowSubmitButton && connectedIntegration && isAdmin && ReportUtils.canBeExported(moneyRequestReport); - const shouldShowSettlementButton = (shouldShowPayButton || shouldShowApproveButton) && !hasAllPendingRTERViolations && !shouldShowExportIntegrationButton; + const shouldShowSettlementButton = + (shouldShowPayButton || shouldShowApproveButton) && !hasAllPendingRTERViolations && !shouldShowExportIntegrationButton && !shouldShowBrokenConnectionViolation; const shouldDisableSubmitButton = shouldShowSubmitButton && !ReportUtils.isAllowedToSubmitDraftExpenseReport(moneyRequestReport); const isFromPaidPolicy = policyType === CONST.POLICY.TYPE.TEAM || policyType === CONST.POLICY.TYPE.CORPORATE; - const shouldShowStatusBar = hasAllPendingRTERViolations || hasOnlyHeldExpenses || hasScanningReceipt || isPayAtEndExpense || hasOnlyPendingTransactions; + const shouldShowStatusBar = + hasAllPendingRTERViolations || shouldShowBrokenConnectionViolation || hasOnlyHeldExpenses || hasScanningReceipt || isPayAtEndExpense || hasOnlyPendingTransactions; const shouldShowNextStep = !ReportUtils.isClosedExpenseReportWithNoExpenses(moneyRequestReport) && isFromPaidPolicy && !!nextStep?.message?.length && !shouldShowStatusBar; const shouldShowAnyButton = - shouldShowSettlementButton || shouldShowApproveButton || shouldShowSubmitButton || shouldShowNextStep || hasAllPendingRTERViolations || shouldShowExportIntegrationButton; + shouldShowSettlementButton || shouldShowApproveButton || shouldShowSubmitButton || shouldShowNextStep || shouldShowMarkAsCashButton || shouldShowExportIntegrationButton; const bankAccountRoute = ReportUtils.getBankAccountRoute(chatReport); const formattedAmount = CurrencyUtils.convertToDisplayString(reimbursableSpend, moneyRequestReport?.currency); const [nonHeldAmount, fullAmount] = ReportUtils.getNonHeldAndFullAmount(moneyRequestReport, policy); @@ -236,6 +245,18 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea if (hasOnlyHeldExpenses) { return {icon: getStatusIcon(Expensicons.Stopwatch), description: translate('iou.expensesOnHold')}; } + if (shouldShowBrokenConnectionViolation) { + return { + icon: getStatusIcon(Expensicons.Hourglass), + description: ( + + ), + }; + } if (hasAllPendingRTERViolations) { return {icon: getStatusIcon(Expensicons.Hourglass), description: translate('iou.pendingMatchWithCreditCardDescription')}; } @@ -344,7 +365,7 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea /> )} - {hasAllPendingRTERViolations && !shouldUseNarrowLayout && ( + {shouldShowMarkAsCashButton && !shouldUseNarrowLayout && (