diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 73c92d3c0139..dcf52ed5b50f 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -1770,7 +1770,7 @@ "../../tests/unit/MentionUserRendererTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 8 "../../tests/unit/MentionUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../tests/unit/MiddlewareTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 20 -"../../tests/unit/ModifiedExpenseMessageTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 6 +"../../tests/unit/ModifiedExpenseMessageTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 7 "../../tests/unit/ModifiedExpenseMessageTest.ts" "no-restricted-imports" 1 "../../tests/unit/MoneyRequestReportTransactionListActiveTransactionIDsTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../tests/unit/MoneyRequestReportUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 8 @@ -1798,7 +1798,7 @@ "../../tests/unit/PersonalDetailsSelectorTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 "../../tests/unit/PolicyChangeLogContentTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../tests/unit/PolicySelectorTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 22 -"../../tests/unit/PolicyUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 111 +"../../tests/unit/PolicyUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 119 "../../tests/unit/PopoverMenuV2Test.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../tests/unit/PressResponderTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../tests/unit/QuickActionUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 7 @@ -1866,7 +1866,7 @@ "../../tests/unit/ValidateAttachmentFileTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 "../../tests/unit/ValidationUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../tests/unit/VideoRendererTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../tests/unit/ViolationUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 20 +"../../tests/unit/ViolationUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 22 "../../tests/unit/WhisperContentMentionContextTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../tests/unit/WorkflowUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 8 "../../tests/unit/WorkspaceReportFieldUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 3d3410e0d8f9..6006aac9a793 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2983,6 +2983,7 @@ const CONST = { }, ACCOUNTING_METHOD: 'accountingMethod', TRAVEL_INVOICING_PAYABLE_ACCOUNT: 'travelInvoicingPayableAccountID', + DEFAULT_CONTACT: 'defaultContact', }, SAGE_INTACCT_MAPPING_VALUE: { diff --git a/src/ROUTES.ts b/src/ROUTES.ts index cebee2179f7b..17bd944925f5 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -397,6 +397,10 @@ const DYNAMIC_ROUTES = { path: 'bank-account-select', entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_EXPORT], }, + POLICY_ACCOUNTING_XERO_NON_REIMBURSABLE_DEFAULT_CONTACT_SELECT: { + path: 'default-supplier-select', + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_EXPORT], + }, POLICY_ACCOUNTING_XERO_BILL_STATUS_SELECTOR: { path: 'purchase-bill-status-selector', entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_EXPORT], diff --git a/src/SCREENS.ts b/src/SCREENS.ts index a87ec988f59f..ca242f4ed0e4 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -625,6 +625,7 @@ const SCREENS = { DYNAMIC_XERO_EXPORT_PREFERRED_EXPORTER_SELECT: 'Dynamic_Workspace_Accounting_Xero_Export_Preferred_Exporter_Select', XERO_BILL_PAYMENT_ACCOUNT_SELECTOR: 'Policy_Accounting_Xero_Bill_Payment_Account_Selector', DYNAMIC_XERO_EXPORT_BANK_ACCOUNT_SELECT: 'Dynamic_Policy_Accounting_Xero_Export_Bank_Account_Select', + DYNAMIC_XERO_NON_REIMBURSABLE_DEFAULT_CONTACT_SELECT: 'Dynamic_Policy_Accounting_Xero_Non_Reimbursable_Default_Contact_Select', NETSUITE_IMPORT_MAPPING: 'Policy_Accounting_NetSuite_Import_Mapping', NETSUITE_IMPORT_CUSTOM_FIELD: 'Policy_Accounting_NetSuite_Import_Custom_Field', NETSUITE_IMPORT_CUSTOM_FIELD_VIEW: 'Policy_Accounting_NetSuite_Import_Custom_Field_View', diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index b191f9062552..22f0970495b4 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -73,6 +73,7 @@ import { isMultiLevelTags, isPolicyAccessible, isTaxTrackingEnabled, + isXeroActiveMatchingSource, } from '@libs/PolicyUtils'; import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import {getReportName} from '@libs/ReportNameUtils'; @@ -473,6 +474,7 @@ function MoneyRequestView({ const transactionVendor = transaction?.comment?.vendor; const transactionVendorName = findVendorByID(policy, transactionVendor?.externalID)?.name ?? ''; const shouldShowVendor = hasVendorFeature(policy, isBetaEnabled(CONST.BETAS.VENDOR_MATCHING)) && !(updatedTransaction?.reimbursable ?? !!transactionReimbursable) && !isInvoice; + const vendorFieldLabel = isXeroActiveMatchingSource(policy) ? translate('common.supplier') : translate('common.vendor'); const tripID = getTripIDFromTransactionParentReportID(parentReport?.parentReportID); const shouldShowViewTripDetails = hasReservationList(transaction) && !!tripID; @@ -1263,7 +1265,7 @@ function MoneyRequestView({ {shouldShowVendor && ( = { change: 'Ändern', category: 'Kategorie', vendor: 'Anbieter', + supplier: 'Lieferant', report: 'Bericht', billable: 'Abrechenbar', nonBillable: 'Nicht abrechenbar', @@ -4904,6 +4905,10 @@ ${amount} für ${merchant} – ${date}`, }, noAccountsFound: 'Keine Konten gefunden', noAccountsFoundDescription: 'Bitte fügen Sie das Konto in Xero hinzu und synchronisieren Sie die Verbindung erneut', + defaultSupplier: 'Standardlieferant', + defaultSupplierDescription: 'Legen Sie einen Standardsupplier fest, der beim Export auf alle Kreditkartentransaktionen angewendet wird.', + noSuppliersFound: 'Keine Lieferanten gefunden', + noSuppliersFoundDescription: 'Bitte fügen Sie den Lieferanten in Xero hinzu und synchronisieren Sie die Verbindung erneut.', accountingMethods: { label: 'Wann exportieren', description: 'Wähle aus, wann die Ausgaben exportiert werden sollen:', @@ -9718,7 +9723,6 @@ Hier ist ein *Testbeleg*, um dir zu zeigen, wie es funktioniert:`, pdfFailedBody: 'Your file could not be generated. Try again, or reach out to Concierge for help.', readyPartialBody: ({count, total}: {count: number; total: number}) => `${count} of ${total} reports exported. If it didn't automatically download, use the button below. See which reports failed in Concierge.`, - close: 'Close', }, domain: { diff --git a/src/languages/en.ts b/src/languages/en.ts index a602e6238653..5450bdff6b69 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -334,6 +334,7 @@ const translations = { change: 'Change', category: 'Category', vendor: 'Vendor', + supplier: 'Supplier', report: 'Report', billable: 'Billable', nonBillable: 'Non-billable', @@ -4997,6 +4998,10 @@ const translations = { }, noAccountsFound: 'No accounts found', noAccountsFoundDescription: 'Please add the account in Xero and sync the connection again', + defaultSupplier: 'Default supplier', + defaultSupplierDescription: 'Set a default supplier that will apply to all credit card transactions upon export.', + noSuppliersFound: 'No suppliers found', + noSuppliersFoundDescription: 'Please add the supplier in Xero and sync the connection again.', accountingMethods: { label: 'When to Export', description: 'Choose when to export the expenses:', diff --git a/src/languages/es.ts b/src/languages/es.ts index 15087b043c81..0bb4c10de111 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -272,6 +272,7 @@ const translations: TranslationDeepObject = { change: 'Cambio', category: 'Categoría', vendor: 'Proveedor', + supplier: 'Proveedor', report: 'Informe', billable: 'Facturable', nonBillable: 'No facturable', @@ -4785,6 +4786,10 @@ ${amount} para ${merchant} - ${date}`, }, noAccountsFound: 'No se ha encontrado ninguna cuenta', noAccountsFoundDescription: 'Añade la cuenta en Xero y sincroniza de nuevo la conexión', + defaultSupplier: 'Proveedor predeterminado', + defaultSupplierDescription: 'Establece un proveedor predeterminado que se aplicará a todas las transacciones con tarjeta de crédito al exportar.', + noSuppliersFound: 'No se encontraron proveedores', + noSuppliersFoundDescription: 'Por favor, añade el proveedor en Xero y sincroniza de nuevo la conexión.', accountingMethods: { label: 'Cuándo Exportar', description: 'Elige cuándo exportar los gastos:', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 8e700e34026c..685db3d8e902 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -317,6 +317,7 @@ const translations: TranslationDeepObject = { change: 'Modifier', category: 'Catégorie', vendor: 'Fournisseur', + supplier: 'Fournisseur', report: 'Note de frais', billable: 'Facturable', nonBillable: 'Non refacturable', @@ -4916,6 +4917,10 @@ ${amount} pour ${merchant} - ${date}`, }, noAccountsFound: 'Aucun compte trouvé', noAccountsFoundDescription: 'Veuillez ajouter le compte dans Xero et synchroniser à nouveau la connexion', + defaultSupplier: 'Fournisseur par défaut', + defaultSupplierDescription: 'Définissez un fournisseur par défaut qui s’appliquera à toutes les transactions par carte de crédit lors de l’exportation.', + noSuppliersFound: 'Aucun fournisseur trouvé', + noSuppliersFoundDescription: 'Veuillez ajouter le fournisseur dans Xero et synchroniser à nouveau la connexion.', accountingMethods: { label: 'Quand exporter', description: 'Choisissez quand exporter les dépenses :', @@ -9750,7 +9755,6 @@ Voici un *reçu test* pour vous montrer comment ça fonctionne :`, pdfFailedBody: 'Your file could not be generated. Try again, or reach out to Concierge for help.', readyPartialBody: ({count, total}: {count: number; total: number}) => `${count} of ${total} reports exported. If it didn't automatically download, use the button below. See which reports failed in Concierge.`, - close: 'Close', }, domain: { diff --git a/src/languages/it.ts b/src/languages/it.ts index fc1658236d3f..5c969a3458cb 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -317,6 +317,7 @@ const translations: TranslationDeepObject = { change: 'Modifica', category: 'Categoria', vendor: 'Fornitore', + supplier: 'Fornitore', report: 'Report', billable: 'Fatturabile', nonBillable: 'Non fatturabile', @@ -4889,6 +4890,10 @@ ${amount} per ${merchant} - ${date}`, }, noAccountsFound: 'Nessun account trovato', noAccountsFoundDescription: 'Aggiungi l’account in Xero e sincronizza nuovamente la connessione', + defaultSupplier: 'Fornitore predefinito', + defaultSupplierDescription: 'Imposta un fornitore predefinito che verrà applicato a tutte le transazioni con carta di credito al momento dell’esportazione.', + noSuppliersFound: 'Nessun fornitore trovato', + noSuppliersFoundDescription: 'Aggiungi il fornitore in Xero e sincronizza di nuovo la connessione.', accountingMethods: { label: 'Quando esportare', description: 'Scegli quando esportare le spese:', @@ -9706,7 +9711,6 @@ Ecco una *ricevuta di prova* per mostrarti come funziona:`, pdfFailedBody: 'Your file could not be generated. Try again, or reach out to Concierge for help.', readyPartialBody: ({count, total}: {count: number; total: number}) => `${count} of ${total} reports exported. If it didn't automatically download, use the button below. See which reports failed in Concierge.`, - close: 'Close', }, domain: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 1b06b1fc90eb..3f4af0754d35 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -317,6 +317,7 @@ const translations: TranslationDeepObject = { change: '変更', category: 'カテゴリ', vendor: 'ベンダー', + supplier: 'サプライヤー', report: 'レポート', billable: '請求可能', nonBillable: '請求不可', @@ -4849,6 +4850,10 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' }, noAccountsFound: 'アカウントが見つかりません', noAccountsFoundDescription: 'Xero にアカウントを追加して、もう一度同期してください', + defaultSupplier: 'デフォルト仕入先', + defaultSupplierDescription: 'エクスポート時にすべてのクレジットカード取引に適用されるデフォルトの仕入先を設定します。', + noSuppliersFound: '仕入先が見つかりません', + noSuppliersFoundDescription: 'Xero に仕入先を追加して、もう一度同期してください。', accountingMethods: { label: 'エクスポートのタイミング', description: '経費をエクスポートするタイミングを選択:', @@ -9583,7 +9588,6 @@ ${reportName}`, pdfFailedBody: 'Your file could not be generated. Try again, or reach out to Concierge for help.', readyPartialBody: ({count, total}: {count: number; total: number}) => `${count} of ${total} reports exported. If it didn't automatically download, use the button below. See which reports failed in Concierge.`, - close: 'Close', }, domain: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 17a2fa7d61d0..3e83cbb99487 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -317,6 +317,7 @@ const translations: TranslationDeepObject = { change: 'Wijzigen', category: 'Categorie', vendor: 'Leverancier', + supplier: 'Leverancier', report: 'Rapport', billable: 'Factureerbaar', nonBillable: 'Niet-factureerbaar', @@ -4879,6 +4880,10 @@ ${amount} voor ${merchant} - ${date}`, }, noAccountsFound: 'Geen accounts gevonden', noAccountsFoundDescription: 'Voeg het account toe in Xero en synchroniseer de verbinding opnieuw', + defaultSupplier: 'Standaardleverancier', + defaultSupplierDescription: 'Stel een standaardsupplier in die wordt toegepast op alle creditcardtransacties bij het exporteren.', + noSuppliersFound: 'Geen leveranciers gevonden', + noSuppliersFoundDescription: 'Voeg de leverancier toe in Xero en synchroniseer de koppeling opnieuw.', accountingMethods: { label: 'Wanneer exporteren', description: 'Kies wanneer de onkosten moeten worden geëxporteerd:', @@ -9672,7 +9677,6 @@ Hier is een *proefbon* om je te laten zien hoe het werkt:`, pdfFailedBody: 'Your file could not be generated. Try again, or reach out to Concierge for help.', readyPartialBody: ({count, total}: {count: number; total: number}) => `${count} of ${total} reports exported. If it didn't automatically download, use the button below. See which reports failed in Concierge.`, - close: 'Close', }, domain: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 81c400582abb..54c367c3060c 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -317,6 +317,7 @@ const translations: TranslationDeepObject = { change: 'Zmień', category: 'Kategoria', vendor: 'Dostawca', + supplier: 'Dostawca', report: 'Raport', billable: 'Fakturowalne', nonBillable: 'Nierozliczalne', @@ -4872,6 +4873,10 @@ ${amount} dla ${merchant} - ${date}`, }, noAccountsFound: 'Nie znaleziono kont', noAccountsFoundDescription: 'Dodaj proszę konto w Xero i zsynchronizuj połączenie ponownie', + defaultSupplier: 'Domyślny dostawca', + defaultSupplierDescription: 'Ustaw domyślnego dostawcę, który zostanie zastosowany do wszystkich transakcji kartą kredytową podczas eksportu.', + noSuppliersFound: 'Nie znaleziono dostawców', + noSuppliersFoundDescription: 'Dodaj dostawcę w Xero i zsynchronizuj połączenie ponownie.', accountingMethods: { label: 'Kiedy eksportować', description: 'Wybierz, kiedy eksportować wydatki:', @@ -9657,7 +9662,6 @@ Oto *paragon testowy*, żeby pokazać Ci, jak to działa:`, pdfFailedBody: 'Your file could not be generated. Try again, or reach out to Concierge for help.', readyPartialBody: ({count, total}: {count: number; total: number}) => `${count} of ${total} reports exported. If it didn't automatically download, use the button below. See which reports failed in Concierge.`, - close: 'Close', }, domain: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 13ae04229d98..f88af25aed67 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -317,6 +317,7 @@ const translations: TranslationDeepObject = { change: 'Alterar', category: 'Categoria', vendor: 'Fornecedor', + supplier: 'Fornecedor', report: 'Relatório', billable: 'Faturável', nonBillable: 'Não faturável', @@ -4871,6 +4872,10 @@ ${amount} para ${merchant} - ${date}`, }, noAccountsFound: 'Nenhuma conta encontrada', noAccountsFoundDescription: 'Adicione a conta no Xero e sincronize a conexão novamente', + defaultSupplier: 'Fornecedor padrão', + defaultSupplierDescription: 'Defina um fornecedor padrão que será aplicado a todas as transações de cartão de crédito na exportação.', + noSuppliersFound: 'Nenhum fornecedor encontrado', + noSuppliersFoundDescription: 'Adicione o fornecedor no Xero e sincronize a conexão novamente.', accountingMethods: { label: 'Quando Exportar', description: 'Escolha quando exportar as despesas:', @@ -9661,7 +9666,6 @@ Aqui está um *comprovante de teste* para mostrar como funciona:`, pdfFailedBody: 'Your file could not be generated. Try again, or reach out to Concierge for help.', readyPartialBody: ({count, total}: {count: number; total: number}) => `${count} of ${total} reports exported. If it didn't automatically download, use the button below. See which reports failed in Concierge.`, - close: 'Close', }, domain: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 84b6e80ebaa6..eefdb98e3875 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -317,6 +317,7 @@ const translations: TranslationDeepObject = { change: '更改', category: '类别', vendor: '供应商', + supplier: '供应商', report: '报表', billable: '可计费', nonBillable: '不可计费', @@ -4753,6 +4754,10 @@ ${amount},商户:${merchant} - 日期:${date}`, }, noAccountsFound: '未找到账户', noAccountsFoundDescription: '请在 Xero 中添加该账户,然后再次同步连接', + defaultSupplier: '默认供应商', + defaultSupplierDescription: '设置一个默认供应商,在导出时应用于所有信用卡交易。', + noSuppliersFound: '未找到供应商', + noSuppliersFoundDescription: '请在 Xero 中添加该供应商,然后再次同步连接。', accountingMethods: { label: '何时导出', description: '选择何时导出报销:', @@ -9401,7 +9406,6 @@ ${reportName}`, pdfFailedBody: 'Your file could not be generated. Try again, or reach out to Concierge for help.', readyPartialBody: ({count, total}: {count: number; total: number}) => `${count} of ${total} reports exported. If it didn't automatically download, use the button below. See which reports failed in Concierge.`, - close: 'Close', }, domain: { diff --git a/src/libs/ModifiedExpenseMessage.ts b/src/libs/ModifiedExpenseMessage.ts index b4e1446497b6..6be038066d30 100644 --- a/src/libs/ModifiedExpenseMessage.ts +++ b/src/libs/ModifiedExpenseMessage.ts @@ -15,7 +15,7 @@ import {formatList} from './Localize'; import Log from './Log'; import Parser from './Parser'; import {getPersonalDetailByEmail} from './PersonalDetailsUtils'; -import {findVendorByID, getCleanedTagName, getCommaSeparatedTagNameWithSanitizedColons, getSortedTagKeys, isPolicyAdmin} from './PolicyUtils'; +import {findVendorByID, getCleanedTagName, getCommaSeparatedTagNameWithSanitizedColons, getSortedTagKeys, isPolicyAdmin, isXeroActiveMatchingSource} from './PolicyUtils'; import {getOriginalMessage, isModifiedExpenseAction} from './ReportActionsUtils'; // This cycle import is safe because ReportNameUtils was extracted from ReportUtils to separate report name computation logic. // The functions imported here are pure utility functions that don't create initialization-time dependencies. @@ -457,12 +457,12 @@ function getForReportAction({ const hasModifiedVendor = isReportActionOriginalMessageAnObject && ('oldVendor' in reportActionOriginalMessage || 'vendor' in reportActionOriginalMessage); if (hasModifiedVendor) { // Vendor is stored on the action as `{externalID, isManuallySet}` (or absent/null). Resolve - // the display name from any connection that has the vendor data (QBO or Intacct), without - // gating on the workspace's current export mode — a past "set vendor" action should still - // render the vendor name after an admin switches the non-reimbursable export type. If the - // vendor has been removed from the integration entirely the name is unrecoverable, so fall - // back to the externalID so the fragment still identifies which vendor was set rather than - // rendering `set vendor ""`. + // the display name from any connection that has the vendor data (QBO, Intacct, or Xero), + // without gating on the workspace's current export mode — a past "set vendor" action should + // still render the vendor name after an admin switches the non-reimbursable export type. If + // the vendor has been removed from the integration entirely the name is unrecoverable, so + // fall back to the externalID so the fragment still identifies which vendor was set rather + // than rendering `set vendor ""`. const resolveVendorName = (entry: typeof reportActionOriginalMessage.vendor): string => { if (!entry?.externalID) { return ''; @@ -473,7 +473,7 @@ function getForReportAction({ translate, resolveVendorName(reportActionOriginalMessage?.vendor), resolveVendorName(reportActionOriginalMessage?.oldVendor), - translate('common.vendor'), + isXeroActiveMatchingSource(policy) ? translate('common.supplier') : translate('common.vendor'), true, setFragments, removalFragments, diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 25ea6b9eec5a..7f5c1b3d5b77 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -783,6 +783,8 @@ const SettingsModalStackNavigator = createModalStackNavigator('../../../../pages/workspace/accounting/xero/export/XeroTravelInvoicingPayableAccountSelectPage').default, [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_EXPORT_BANK_ACCOUNT_SELECT]: () => require('../../../../pages/workspace/accounting/xero/export/DynamicXeroBankAccountSelectPage').default, + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_NON_REIMBURSABLE_DEFAULT_CONTACT_SELECT]: () => + require('../../../../pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage').default, [SCREENS.WORKSPACE.ACCOUNTING.XERO_ADVANCED]: () => require('../../../../pages/workspace/accounting/xero/advanced/XeroAdvancedPage').default, [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_AUTO_SYNC]: () => require('../../../../pages/workspace/accounting/xero/advanced/DynamicXeroAutoSyncPage').default, [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_ACCOUNTING_METHOD]: () => diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts index 3411b25a6db9..3839b5fc87e3 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts @@ -112,6 +112,7 @@ const WORKSPACE_TO_RHP: Partial['config'] = { [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_EXPORT]: DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_EXPORT.path, [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_EXPORT_PURCHASE_BILL_DATE_SELECT]: DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_EXPORT_PURCHASE_BILL_DATE_SELECT.path, [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_EXPORT_BANK_ACCOUNT_SELECT]: DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_EXPORT_BANK_ACCOUNT_SELECT.path, + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_NON_REIMBURSABLE_DEFAULT_CONTACT_SELECT]: + DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_NON_REIMBURSABLE_DEFAULT_CONTACT_SELECT.path, [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_TRAVEL_INVOICING_CONFIGURATION]: DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_TRAVEL_INVOICING_CONFIGURATION.path, [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_TRAVEL_INVOICING_PAYABLE_ACCOUNT_SELECT]: DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_TRAVEL_INVOICING_PAYABLE_ACCOUNT_SELECT.path, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 2c49e16df4bf..850de96108e6 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -924,6 +924,9 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_EXPORT_BANK_ACCOUNT_SELECT]: { policyID: string; }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_NON_REIMBURSABLE_DEFAULT_CONTACT_SELECT]: { + policyID: string; + }; [SCREENS.WORKSPACE.ACCOUNTING.XERO_INVOICE_ACCOUNT_SELECTOR]: { policyID: string; }; diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 2e6789fc98fa..e2466338d560 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -2136,6 +2136,31 @@ function isIntacctVendorMatchingActive(policy: OnyxEntry): boolean { return policy?.connections?.[CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT]?.config?.export?.nonReimbursable === CONST.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSE_TYPE.CREDIT_CARD_CHARGE; } +/** + * True when Xero is connected. Xero has no export-destination enum (bank-transactions is the only + * non-reimbursable mode), so connection presence is sufficient — mirrors `Xero::hasVendorFeature` + * on the PHP side. This is the *eligibility* predicate used by `hasVendorFeature`, NOT the source + * predicate — on dual-connected workspaces QBO/Intacct precedence still applies in + * `getMatchingVendors`. Use `isXeroActiveMatchingSource` when the question is "is Xero the + * integration whose vendors are actually being shown to the user?" (e.g. for the Supplier/Vendor + * label flip in the expense row, picker, and modified-expense fragments). + */ +function isXeroVendorMatchingActive(policy: OnyxEntry): boolean { + return !!policy?.connections?.[CONST.POLICY.CONNECTIONS.NAME.XERO]; +} + +/** + * True when Xero is the *active* vendor-matching source for the workspace — i.e. Xero is + * connected AND neither QBO nor Intacct is in a vendor-matching export mode. Mirrors the precedence + * in `getMatchingVendors` (QBO → Intacct → Xero) so the UI labels, copy, and inactive-vendor + * guardrail stay bound to whichever integration's vendor list is actually being consulted. Without + * this scoping, a workspace with active QBO matching + a lingering Xero connection would render + * QBO vendors under the "Supplier" label. + */ +function isXeroActiveMatchingSource(policy: OnyxEntry): boolean { + return isXeroVendorMatchingActive(policy) && !isQBOVendorMatchingActive(policy) && !isIntacctVendorMatchingActive(policy); +} + /** * Vendor matching feature gate. Returns true when the workspace has the `vendorMatching` beta * enabled AND a supported accounting integration is connected with a non-reimbursable export type @@ -2145,19 +2170,20 @@ function isIntacctVendorMatchingActive(policy: OnyxEntry): boolean { * Supported integrations: * - QBO with non-reimbursable export = Credit Card or Debit Card (R1) * - Sage Intacct with non-reimbursable export = Credit Card Charge (R2) + * - Xero (R4) — no export-destination enum; connection present is sufficient */ function hasVendorFeature(policy: OnyxEntry, isVendorMatchingBetaEnabled: boolean): boolean { if (!isVendorMatchingBetaEnabled || !policy) { return false; } - return isQBOVendorMatchingActive(policy) || isIntacctVendorMatchingActive(policy); + return isQBOVendorMatchingActive(policy) || isIntacctVendorMatchingActive(policy) || isXeroVendorMatchingActive(policy); } /** * Returns the vendor list imported into the workspace from whichever connected integration scopes - * the vendor field for this workspace (QBO or Sage Intacct). Empty array when no integration is - * connected or the sync hasn't populated vendors yet. Source of truth for the vendor selector RHP - * and inactive-vendor lookups. + * the vendor field for this workspace (QBO, Sage Intacct, or Xero). Empty array when no + * integration is connected or the sync hasn't populated vendors yet. Source of truth for the + * vendor selector RHP and inactive-vendor lookups. * * Selection mirrors `hasVendorFeature`: each branch is gated on the integration's own * non-reimbursable export destination, so a dual-connected workspace (e.g. mid-migration with stale @@ -2166,7 +2192,9 @@ function hasVendorFeature(policy: OnyxEntry, isVendorMatchingBetaEnabled * * The shape is normalized to `Vendor` (id + name). For Intacct's `SageIntacctDataElementWithValue`, * the human-readable label lives in `value` (Intacct's `name` is an internal code), matching how - * `getSageIntacctVendors` and `getDefaultVendorName` populate the existing Intacct export UI. + * `getSageIntacctVendors` and `getDefaultVendorName` populate the existing Intacct export UI. Xero + * stores suppliers as a keyed object at `connections.xero.data.contacts`, normalized here to the + * same `Vendor` shape. */ function getMatchingVendors(policy: OnyxEntry): Vendor[] { if (!policy) { @@ -2179,6 +2207,13 @@ function getMatchingVendors(policy: OnyxEntry): Vendor[] { const intacctVendors = policy.connections?.[CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT]?.data?.vendors ?? []; return intacctVendors.map((vendor) => ({id: vendor.id, name: vendor.value, currency: '', email: ''})); } + if (isXeroVendorMatchingActive(policy)) { + const xeroContacts = policy.connections?.[CONST.POLICY.CONNECTIONS.NAME.XERO]?.data?.contacts; + if (!xeroContacts) { + return []; + } + return Object.values(xeroContacts).map((contact) => ({id: contact.id, name: contact.name, currency: '', email: contact.email})); + } return []; } @@ -2221,9 +2256,41 @@ function findVendorByID(policy: OnyxEntry, vendorID: string | undefined) if (intacctVendor) { return {id: intacctVendor.id, name: intacctVendor.value, currency: '', email: ''}; } + const xeroContact = policy.connections?.[CONST.POLICY.CONNECTIONS.NAME.XERO]?.data?.contacts?.[vendorID]; + if (xeroContact) { + return {id: xeroContact.id, name: xeroContact.name, currency: '', email: xeroContact.email}; + } return undefined; } +/** + * Xero-scoped supplier list, normalized to the shared `Vendor` shape. Use this from Xero-specific + * UI (the default-supplier picker, the Xero export config row) so the data source stays bound to + * `connections.xero.data.contacts` regardless of whether QBO or Intacct is the *active* matching + * source on a dual-connected workspace — `getMatchingVendors` is integration-priority-aware and + * would return non-Xero vendors in that state, which is wrong for Xero-only controls. + */ +function getXeroSuppliers(policy: OnyxEntry): Vendor[] { + const contacts = policy?.connections?.[CONST.POLICY.CONNECTIONS.NAME.XERO]?.data?.contacts; + if (!contacts) { + return []; + } + return Object.values(contacts).map((contact) => ({id: contact.id, name: contact.name, currency: '', email: contact.email})); +} + +/** + * Xero-scoped supplier lookup. Same rationale as `getXeroSuppliers`: bound strictly to Xero data + * so the Xero export config display can never accidentally render a non-Xero vendor's name when + * another integration is the active matching source. + */ +function getXeroSupplierByID(policy: OnyxEntry, supplierID: string | undefined): Vendor | undefined { + if (!supplierID) { + return undefined; + } + const contact = policy?.connections?.[CONST.POLICY.CONNECTIONS.NAME.XERO]?.data?.contacts?.[supplierID]; + return contact ? {id: contact.id, name: contact.name, currency: '', email: contact.email} : undefined; +} + function getValidConnectedIntegration(policy: Policy | undefined, connectionNames: readonly ConnectionName[] = getAccountingConnectionNames()) { return connectionNames.find((integration) => !!policy?.connections?.[integration] && !isConnectionUnverified(policy, integration)); } @@ -2640,6 +2707,9 @@ export { findVendorByID, getMatchingVendorByID, getMatchingVendors, + getXeroSupplierByID, + getXeroSuppliers, + isXeroActiveMatchingSource, hasVendorFeature, getValidConnectedIntegration, getCountOfEnabledTagsOfList, diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index 80c0e78fca89..e72e720ae2fb 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -23,6 +23,7 @@ import { isAttendeeTrackingEnabled as isAttendeeTrackingEnabledForPolicy, isDefaultTagName, isTaxTrackingEnabled, + isXeroActiveMatchingSource, } from '@libs/PolicyUtils'; import {isCurrentUserSubmitter} from '@libs/ReportUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; @@ -477,11 +478,23 @@ const ViolationsUtils = { newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.INACTIVE_VENDOR}); } } else if (transactionVendorID) { - const matchedVendor = getMatchingVendorByID(policy, transactionVendorID); - if (!matchedVendor && !hasInactiveVendorViolation) { - newTransactionViolations.push({name: CONST.VIOLATIONS.INACTIVE_VENDOR, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true}); - } else if (matchedVendor && hasInactiveVendorViolation) { - newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.INACTIVE_VENDOR}); + // When Xero is the *active* matching source (i.e. neither QBO nor Intacct is in + // a vendor-matching export mode), skip the inactive-vendor check while + // Integration-Server has not yet synced suppliers — `data.contacts === undefined` + // means the vendor list is unknown, so flagging would be premature. Scoped to the + // active-source case so dual/stale-connection states (e.g. active QBO + lingering + // Xero with no contacts synced) still run the QBO check normally instead of being + // silenced by the Xero connection's existence. + const xeroContactsSynced = policy.connections?.[CONST.POLICY.CONNECTIONS.NAME.XERO]?.data?.contacts !== undefined; + if (isXeroActiveMatchingSource(policy) && !xeroContactsSynced) { + // No-op — supplier list not yet known for this workspace. + } else { + const matchedVendor = getMatchingVendorByID(policy, transactionVendorID); + if (!matchedVendor && !hasInactiveVendorViolation) { + newTransactionViolations.push({name: CONST.VIOLATIONS.INACTIVE_VENDOR, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true}); + } else if (matchedVendor && hasInactiveVendorViolation) { + newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.INACTIVE_VENDOR}); + } } } else if (hasInactiveVendorViolation) { // Vendor was cleared while the feature is still active — drop the now-stale violation. diff --git a/src/pages/iou/request/step/IOURequestStepVendor.tsx b/src/pages/iou/request/step/IOURequestStepVendor.tsx index 867576699ff7..f7ab7dd89284 100644 --- a/src/pages/iou/request/step/IOURequestStepVendor.tsx +++ b/src/pages/iou/request/step/IOURequestStepVendor.tsx @@ -14,7 +14,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {updateMoneyRequestVendor} from '@libs/actions/IOU/UpdateMoneyRequest'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Navigation from '@libs/Navigation/Navigation'; -import {getMatchingVendors, hasVendorFeature} from '@libs/PolicyUtils'; +import {getMatchingVendors, hasVendorFeature, isXeroActiveMatchingSource} from '@libs/PolicyUtils'; import {isPerDiemRequest} from '@libs/TransactionUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -56,12 +56,14 @@ function IOURequestStepVendor({ const delegateAccountID = useDelegateAccountID(); const isFeatureAvailable = hasVendorFeature(policy, isBetaEnabled(CONST.BETAS.VENDOR_MATCHING)); + const isOnXero = isXeroActiveMatchingSource(policy); - // Vendor is scoped to non-reimbursable expenses on a policy expense chat; block deep-link / stale-open access if the transaction is reimbursable or is an invoice (invoices are non-reimbursable but don't route through the QBO CC vendor-matching flow). + // Vendor is scoped to non-reimbursable expenses on a policy expense chat; block deep-link / stale-open access if the transaction is reimbursable or is an invoice (invoices are non-reimbursable but don't route through the vendor-matching flow). const isReimbursable = !!transaction?.reimbursable; const isInvoice = iouType === CONST.IOU.TYPE.INVOICE; const vendors = getMatchingVendors(policy); const currentVendorID = transaction?.comment?.vendor?.externalID; + const vendorLabel = isOnXero ? translate('common.supplier') : translate('common.vendor'); const trimmedSearch = searchValue.trim().toLowerCase(); const vendorRows: VendorListItem[] = vendors @@ -118,15 +120,15 @@ function IOURequestStepVendor({ icon={illustrations.Telescope} iconWidth={variables.emptyListIconWidth} iconHeight={variables.emptyListIconHeight} - title={translate('workspace.qbo.noAccountsFound')} - subtitle={translate('workspace.qbo.noAccountsFoundDescription')} + title={isOnXero ? translate('workspace.xero.noSuppliersFound') : translate('workspace.qbo.noAccountsFound')} + subtitle={isOnXero ? translate('workspace.xero.noSuppliersFoundDescription') : translate('workspace.qbo.noAccountsFoundDescription')} containerStyle={styles.pb10} /> ) : null; return ( (!policyID ? undefined : Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_NON_REIMBURSABLE_DEFAULT_CONTACT_SELECT.path))), + title: defaultSupplierName, + subscribedSettings: [CONST.XERO_CONFIG.DEFAULT_CONTACT], + }, + ] + : []), ]; return ( diff --git a/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx b/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx new file mode 100644 index 000000000000..4f29382923d1 --- /dev/null +++ b/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx @@ -0,0 +1,109 @@ +import React, {useCallback, useMemo} from 'react'; +import BlockingView from '@components/BlockingViews/BlockingView'; +import type {SelectorType} from '@components/SelectionScreen'; +import SelectionScreen from '@components/SelectionScreen'; +import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import usePermissions from '@hooks/usePermissions'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {updateManyPolicyConnectionConfigs} from '@libs/actions/connections'; +import {clearXeroErrorField} from '@libs/actions/Policy/Policy'; +import {getLatestErrorField} from '@libs/ErrorUtils'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import Navigation from '@libs/Navigation/Navigation'; +import {getXeroSuppliers, hasVendorFeature, settingsPendingAction} from '@libs/PolicyUtils'; +import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; +import withPolicyConnections from '@pages/workspace/withPolicyConnections'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; + +function DynamicXeroNonReimbursableDefaultContactSelectPage({policy}: WithPolicyConnectionsProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {isBetaEnabled} = usePermissions(); + const illustrations = useMemoizedLazyIllustrations(['Telescope']); + + const policyID = policy?.id; + const xeroConfig = policy?.connections?.xero?.config; + const currentContactID = xeroConfig?.defaultContact; + // Match the parent page's gate so direct deep-links (or stale-open tabs after the beta is + // revoked) cannot reach the supplier updater. The parent page hides the row when the feature + // is off, but the route remains addressable on its own. + const isFeatureAvailable = hasVendorFeature(policy, isBetaEnabled(CONST.BETAS.VENDOR_MATCHING)); + + const suppliers = useMemo(() => getXeroSuppliers(policy), [policy]); + const data: SelectorType[] = useMemo( + () => + suppliers.map((supplier) => ({ + value: supplier.id, + text: supplier.name, + keyForList: supplier.id, + isSelected: supplier.id === currentContactID, + })), + [suppliers, currentContactID], + ); + + const goBack = useCallback(() => { + Navigation.goBack(policyID ? createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_EXPORT.path, ROUTES.POLICY_ACCOUNTING.getRoute(policyID)) : undefined); + }, [policyID]); + + const selectSupplier = useCallback( + ({value}: SelectorType) => { + // Defense-in-depth: if the screen render race-ended with isFeatureAvailable still + // truthy and the user managed to tap, refuse to persist the update. + if (!isFeatureAvailable) { + goBack(); + return; + } + if (value !== currentContactID && policyID) { + updateManyPolicyConnectionConfigs( + policyID, + CONST.POLICY.CONNECTIONS.NAME.XERO, + {[CONST.XERO_CONFIG.DEFAULT_CONTACT]: value}, + {[CONST.XERO_CONFIG.DEFAULT_CONTACT]: currentContactID}, + ); + } + goBack(); + }, + [currentContactID, policyID, isFeatureAvailable, goBack], + ); + + const listEmptyContent = useMemo( + () => ( + + ), + [translate, styles.pb10, illustrations.Telescope], + ); + + return ( + item.isSelected)?.keyForList} + listEmptyContent={listEmptyContent} + connectionName={CONST.POLICY.CONNECTIONS.NAME.XERO} + onBackButtonPress={goBack} + pendingAction={settingsPendingAction([CONST.XERO_CONFIG.DEFAULT_CONTACT], xeroConfig?.pendingFields)} + errors={getLatestErrorField(xeroConfig ?? {}, CONST.XERO_CONFIG.DEFAULT_CONTACT)} + errorRowStyles={[styles.ph5, styles.pv3]} + onClose={() => clearXeroErrorField(policyID, CONST.XERO_CONFIG.DEFAULT_CONTACT)} + /> + ); +} + +export default withPolicyConnections(DynamicXeroNonReimbursableDefaultContactSelectPage); diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index 7473b11e5d4c..7c8a378c0a75 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -644,6 +644,18 @@ type XeroTrackingCategory = { name: string; }; +/** Xero supplier contact imported into the workspace. */ +type XeroContact = { + /** Contact ID assigned by Xero */ + id: string; + + /** Display name of the contact */ + name: string; + + /** Contact's email address */ + email: string; +}; + /** * Data imported from Xero * @@ -653,6 +665,9 @@ type XeroConnectionData = { /** Collection of bank accounts */ bankAccounts: Account[]; + /** Supplier contacts keyed by their Xero contact ID. Undefined until Integration-Server has synced suppliers for the workspace. */ + contacts?: Record; + /** TODO: Will be handled in another issue */ countryCode: string; @@ -781,6 +796,9 @@ type XeroConnectionConfig = OnyxCommon.OnyxValueWithOfflineFeedback< /** ID of Xero organization */ tenantID: string; + /** Default supplier contact used as a fallback when a non-reimbursable card transaction has no contact set. */ + defaultContact?: string; + /** TODO: Will be handled in another issue */ errors?: OnyxCommon.Errors; diff --git a/tests/unit/ModifiedExpenseMessageTest.ts b/tests/unit/ModifiedExpenseMessageTest.ts index cdcb87237bac..c2981ef4561c 100644 --- a/tests/unit/ModifiedExpenseMessageTest.ts +++ b/tests/unit/ModifiedExpenseMessageTest.ts @@ -2271,6 +2271,86 @@ describe('ModifiedExpenseMessage', () => { expect(result).toEqual('set the vendor to "v-deleted"'); }); }); + + describe('Xero supplier changes (R4)', () => { + // Xero policy with two named supplier contacts. The resolver reads + // `connections.xero.data.contacts` (keyed Record), and the label switches to + // "supplier" instead of "vendor" because the policy is on Xero. + const policyWithXeroSuppliers: Policy = { + id: 'p-1', + name: 'My Workspace', + role: CONST.POLICY.ROLE.ADMIN, + type: CONST.POLICY.TYPE.TEAM, + owner: 'test@example.com', + outputCurrency: CONST.CURRENCY.USD, + isPolicyExpenseChatEnabled: true, + connections: { + xero: { + data: { + contacts: { + xcAcme: {id: 'xcAcme', name: 'Acme Xero', email: 'acme@example.com'}, + xcOffice: {id: 'xcOffice', name: 'Office Supplies Xero', email: 'office@example.com'}, + }, + }, + }, + }, + } as unknown as Policy; + + it('renders "set the supplier to X" for a Xero workspace when the supplier is set for the first time', () => { + const reportAction = { + ...createRandomReportAction(1), + actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE, + originalMessage: { + vendor: {externalID: 'xcAcme', isManuallySet: true}, + }, + }; + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: policyWithXeroSuppliers, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + }); + expect(result).toEqual('set the supplier to "Acme Xero"'); + }); + + it('renders "changed the supplier to Y (previously X)" when the supplier is changed on a Xero workspace', () => { + const reportAction = { + ...createRandomReportAction(1), + actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE, + originalMessage: { + oldVendor: {externalID: 'xcAcme', isManuallySet: false}, + vendor: {externalID: 'xcOffice', isManuallySet: true}, + }, + }; + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: policyWithXeroSuppliers, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + }); + expect(result).toEqual('changed the supplier to "Office Supplies Xero" (previously "Acme Xero")'); + }); + + it('falls back to rendering the externalID for a Xero supplier no longer in the contacts list', () => { + const reportAction = { + ...createRandomReportAction(1), + actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE, + originalMessage: { + vendor: {externalID: 'xcDeleted', isManuallySet: false}, + }, + }; + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: policyWithXeroSuppliers, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + }); + expect(result).toEqual('set the supplier to "xcDeleted"'); + }); + }); }); }); }); diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index 3799fc24670c..40d4b5a96573 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -34,6 +34,8 @@ import { getTagListByOrderWeight, getUberConnectionErrorDirectlyFromPolicy, getUnitRateValue, + getXeroSupplierByID, + getXeroSuppliers, hasConfiguredRules, hasDependentTags, hasDynamicExternalWorkflow, @@ -46,6 +48,7 @@ import { hasVendorFeature, isPolicyMemberWithoutPendingDelete, isSubmitterApproveBlockedOnSubmitWorkspace, + isXeroActiveMatchingSource, shouldShowPolicy, sortPoliciesByName, sortWorkspacesBySelected, @@ -3106,6 +3109,25 @@ describe('PolicyUtils', () => { } as unknown as Connections, }) as Policy; + // Sentinel for "Xero connected but Integration-Server has not yet synced suppliers" + // (i.e. `data.contacts` is undefined on the connection). Distinct from the populated + // default so callers can opt into the unsynced state without colliding with the + // default-parameter mechanic, which would otherwise replace an explicit `undefined` + // with the default contacts list. + const XERO_CONTACTS_UNSYNCED = Symbol('XERO_CONTACTS_UNSYNCED'); + const buildXeroPolicy = ( + contacts: Record | typeof XERO_CONTACTS_UNSYNCED = {xc1: {id: 'xc1', name: 'Acme Xero', email: 'acme@example.com'}}, + ): Policy => + ({ + ...createRandomPolicy(0), + connections: { + [CONST.POLICY.CONNECTIONS.NAME.XERO]: { + config: {}, + data: contacts === XERO_CONTACTS_UNSYNCED ? {} : {contacts}, + }, + } as unknown as Connections, + }) as Policy; + describe('hasVendorFeature', () => { it('returns true when beta is enabled and QBO non-reimbursable export is Credit Card', () => { expect(hasVendorFeature(buildQBOPolicy(CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD), true)).toBe(true); @@ -3119,6 +3141,17 @@ describe('PolicyUtils', () => { expect(hasVendorFeature(buildIntacctPolicy(CONST.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSE_TYPE.CREDIT_CARD_CHARGE), true)).toBe(true); }); + it('returns true when beta is enabled and Xero is connected (R4) — no export-destination enum on Xero', () => { + expect(hasVendorFeature(buildXeroPolicy(), true)).toBe(true); + }); + + it('returns true when beta is enabled and Xero is connected but contacts have not synced yet — feature gate is connection-based, not data-based', () => { + // The matcher itself short-circuits when contacts is undefined (see + // ViolationsUtils' inactiveVendor guardrail); hasVendorFeature stays true so the + // App still surfaces the UI surfaces (picker, default-supplier row). + expect(hasVendorFeature(buildXeroPolicy(XERO_CONTACTS_UNSYNCED), true)).toBe(true); + }); + it('returns false when beta is disabled, even with Credit Card export configured', () => { expect(hasVendorFeature(buildQBOPolicy(CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD), false)).toBe(false); }); @@ -3127,6 +3160,10 @@ describe('PolicyUtils', () => { expect(hasVendorFeature(buildIntacctPolicy(CONST.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSE_TYPE.CREDIT_CARD_CHARGE), false)).toBe(false); }); + it('returns false when beta is disabled, even with Xero connected', () => { + expect(hasVendorFeature(buildXeroPolicy(), false)).toBe(false); + }); + it('returns false when QBO non-reimbursable export is Vendor Bill', () => { expect(hasVendorFeature(buildQBOPolicy(CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL), true)).toBe(false); }); @@ -3210,6 +3247,24 @@ describe('PolicyUtils', () => { expect(getMatchingVendors(policy)).toEqual(qboVendors); }); + it('returns the Xero supplier list normalized from the keyed Record to the shared Vendor shape (R4)', () => { + const policy = buildXeroPolicy({ + xc1: {id: 'xc1', name: 'Acme Xero', email: 'acme@example.com'}, + xc2: {id: 'xc2', name: 'Other Xero', email: 'other@example.com'}, + }); + expect(getMatchingVendors(policy)).toEqual([ + {id: 'xc1', name: 'Acme Xero', currency: '', email: 'acme@example.com'}, + {id: 'xc2', name: 'Other Xero', currency: '', email: 'other@example.com'}, + ]); + }); + + it('returns an empty array when Xero is connected but contacts have not synced yet (data.contacts undefined)', () => { + // Integration-Server has not yet completed a supplier sync for this workspace. + // The matcher must treat the contact list as unknown rather than failing — this + // is the same guardrail that prevents inactiveVendor from firing falsely. + expect(getMatchingVendors(buildXeroPolicy(XERO_CONTACTS_UNSYNCED))).toEqual([]); + }); + it('returns an empty array when no supported connection exists', () => { const policy = {...createRandomPolicy(0), connections: {}} as Policy; expect(getMatchingVendors(policy)).toEqual([]); @@ -3237,6 +3292,18 @@ describe('PolicyUtils', () => { expect(getMatchingVendorByID(policy, 'iv-2')).toEqual({id: 'iv-2', name: 'Other Intacct', currency: '', email: ''}); }); + it('returns the matching Xero supplier (normalized) when the ID exists in the contacts list (R4)', () => { + const policy = buildXeroPolicy({ + xc1: {id: 'xc1', name: 'Acme Xero', email: 'acme@example.com'}, + xc2: {id: 'xc2', name: 'Other Xero', email: 'other@example.com'}, + }); + expect(getMatchingVendorByID(policy, 'xc2')).toEqual({id: 'xc2', name: 'Other Xero', currency: '', email: 'other@example.com'}); + }); + + it('returns undefined for a Xero supplier ID that is not in the contacts list (inactive-supplier case)', () => { + expect(getMatchingVendorByID(buildXeroPolicy(), 'xcMissing')).toBeUndefined(); + }); + it('returns undefined when the ID is not in the list (the inactive-vendor case)', () => { const policy = buildQBOPolicy(CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD); expect(getMatchingVendorByID(policy, 'v-missing')).toBeUndefined(); @@ -3259,6 +3326,11 @@ describe('PolicyUtils', () => { expect(findVendorByID(policy, 'iv-1')).toEqual({id: 'iv-1', name: 'Acme Intacct', currency: '', email: ''}); }); + it('resolves a Xero supplier (normalized) from connections.xero.data.contacts (R4)', () => { + const policy = buildXeroPolicy({xc1: {id: 'xc1', name: 'Acme Xero', email: 'acme@example.com'}}); + expect(findVendorByID(policy, 'xc1')).toEqual({id: 'xc1', name: 'Acme Xero', currency: '', email: 'acme@example.com'}); + }); + it('prefers the active Intacct integration over stale QBO data when both hold the same vendor ID', () => { // Workspace state: QBO connected but in Vendor Bill mode (not vendor-matching), // Intacct connected in Credit Card Charge mode (the active matching integration). @@ -3329,6 +3401,154 @@ describe('PolicyUtils', () => { expect(findVendorByID(policy, undefined)).toBeUndefined(); }); }); + + describe('getXeroSuppliers (Xero-scoped, R4)', () => { + it('returns the Xero supplier list normalized to the shared Vendor shape', () => { + const policy = buildXeroPolicy({ + xc1: {id: 'xc1', name: 'Acme Xero', email: 'acme@example.com'}, + xc2: {id: 'xc2', name: 'Other Xero', email: 'other@example.com'}, + }); + expect(getXeroSuppliers(policy)).toEqual([ + {id: 'xc1', name: 'Acme Xero', currency: '', email: 'acme@example.com'}, + {id: 'xc2', name: 'Other Xero', currency: '', email: 'other@example.com'}, + ]); + }); + + it('returns Xero contacts even when QBO is the active matching source (dual-connection state)', () => { + // This is the scenario the Xero-scoped helper exists for: a workspace has QBO + // actively matching (Credit Card export) AND a Xero connection with synced + // contacts. The Xero default-supplier picker must list Xero suppliers, not QBO + // vendors, even though `getMatchingVendors` would return QBO here. + const xeroPolicy = buildXeroPolicy({xc1: {id: 'xc1', name: 'Acme Xero', email: 'acme@example.com'}}); + const policy = { + ...xeroPolicy, + connections: { + ...xeroPolicy.connections, + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}, + data: {vendors: [{id: 'qbo-active', name: 'Acme QBO', currency: 'USD'}]}, + }, + }, + } as Policy; + expect(getXeroSuppliers(policy)).toEqual([{id: 'xc1', name: 'Acme Xero', currency: '', email: 'acme@example.com'}]); + }); + + it('returns an empty array when Xero contacts have not synced yet (data.contacts undefined)', () => { + expect(getXeroSuppliers(buildXeroPolicy(XERO_CONTACTS_UNSYNCED))).toEqual([]); + }); + + it('returns an empty array when Xero is not connected', () => { + const policy = {...createRandomPolicy(0), connections: {}} as Policy; + expect(getXeroSuppliers(policy)).toEqual([]); + }); + }); + + describe('getXeroSupplierByID (Xero-scoped, R4)', () => { + it('returns the matching Xero supplier when the ID exists in the contacts list', () => { + const policy = buildXeroPolicy({ + xc1: {id: 'xc1', name: 'Acme Xero', email: 'acme@example.com'}, + xc2: {id: 'xc2', name: 'Other Xero', email: 'other@example.com'}, + }); + expect(getXeroSupplierByID(policy, 'xc2')).toEqual({id: 'xc2', name: 'Other Xero', currency: '', email: 'other@example.com'}); + }); + + it('returns the Xero supplier even when QBO is connected with a same-ID vendor (dual-connection state)', () => { + // Workspace state: Xero is connected with a supplier id `shared`, and QBO is also + // connected with a vendor coincidentally named `shared`. The Xero export config + // display row must show the Xero supplier name, not the QBO vendor's. + const xeroPolicy = buildXeroPolicy({shared: {id: 'shared', name: 'Xero Supplier', email: 'xero@example.com'}}); + const policy = { + ...xeroPolicy, + connections: { + ...xeroPolicy.connections, + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}, + data: {vendors: [{id: 'shared', name: 'QBO Vendor', currency: 'USD'}]}, + }, + }, + } as Policy; + expect(getXeroSupplierByID(policy, 'shared')).toEqual({id: 'shared', name: 'Xero Supplier', currency: '', email: 'xero@example.com'}); + }); + + it('returns undefined when the ID is not in the Xero contacts list', () => { + expect(getXeroSupplierByID(buildXeroPolicy(), 'xcMissing')).toBeUndefined(); + }); + + it('returns undefined when supplierID is undefined', () => { + expect(getXeroSupplierByID(buildXeroPolicy(), undefined)).toBeUndefined(); + }); + + it('returns undefined when Xero contacts have not synced yet', () => { + expect(getXeroSupplierByID(buildXeroPolicy(XERO_CONTACTS_UNSYNCED), 'xc1')).toBeUndefined(); + }); + }); + + describe('isXeroActiveMatchingSource (R4)', () => { + it('returns true when only Xero is connected', () => { + expect(isXeroActiveMatchingSource(buildXeroPolicy())).toBe(true); + }); + + it('returns false when QBO is the active matching source even if Xero is also connected', () => { + // The exact dual-connection state the helper was introduced for: QBO is in + // vendor-matching export mode (CC), and Xero is connected with contacts. The + // label flip / picker copy must stay on "Vendor" because the user is actually + // seeing QBO vendors via getMatchingVendors' precedence rule. + const xeroPolicy = buildXeroPolicy({xc1: {id: 'xc1', name: 'Acme Xero', email: 'acme@example.com'}}); + const policy = { + ...xeroPolicy, + connections: { + ...xeroPolicy.connections, + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}, + data: {vendors: [{id: 'v-1', name: 'Acme QBO', currency: 'USD'}]}, + }, + }, + } as Policy; + expect(isXeroActiveMatchingSource(policy)).toBe(false); + }); + + it('returns false when Intacct is the active matching source even if Xero is also connected', () => { + const xeroPolicy = buildXeroPolicy({xc1: {id: 'xc1', name: 'Acme Xero', email: 'acme@example.com'}}); + const policy = { + ...xeroPolicy, + connections: { + ...xeroPolicy.connections, + [CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT]: { + config: {export: {nonReimbursable: CONST.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSE_TYPE.CREDIT_CARD_CHARGE}}, + data: {vendors: [{id: 'iv-1', name: 'V001', value: 'Acme Intacct'}]}, + }, + }, + } as Policy; + expect(isXeroActiveMatchingSource(policy)).toBe(false); + }); + + it('returns true when QBO is connected but in a non-matching export mode (Vendor Bill) + Xero is connected', () => { + // QBO is connected but not in CC/DC mode, so isQBOVendorMatchingActive is false. + // Xero is connected, so it takes over as the active source via getMatchingVendors' + // fall-through. + const xeroPolicy = buildXeroPolicy({xc1: {id: 'xc1', name: 'Acme Xero', email: 'acme@example.com'}}); + const policy = { + ...xeroPolicy, + connections: { + ...xeroPolicy.connections, + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL}, + data: {vendors: [{id: 'v-1', name: 'Acme QBO', currency: 'USD'}]}, + }, + }, + } as Policy; + expect(isXeroActiveMatchingSource(policy)).toBe(true); + }); + + it('returns false when Xero is not connected', () => { + const policy = {...createRandomPolicy(0), connections: {}} as Policy; + expect(isXeroActiveMatchingSource(policy)).toBe(false); + }); + + it('returns false when policy is undefined', () => { + expect(isXeroActiveMatchingSource(undefined)).toBe(false); + }); + }); }); describe('hasPolicyRulesError', () => { diff --git a/tests/unit/ViolationUtilsTest.ts b/tests/unit/ViolationUtilsTest.ts index eb1897a381cc..7f15c75aafb6 100644 --- a/tests/unit/ViolationUtilsTest.ts +++ b/tests/unit/ViolationUtilsTest.ts @@ -2244,6 +2244,143 @@ describe('getViolationsOnyxData', () => { }); expect(result.value).not.toContainEqual(inactiveVendorViolation); }); + + describe('Xero (R4)', () => { + // Sentinel for "Xero connected, contacts not yet synced". Explicit symbol avoids the + // default-parameter trap where `undefined` would fall back to the populated default. + const XERO_CONTACTS_UNSYNCED = Symbol('XERO_CONTACTS_UNSYNCED'); + const policyWithXeroVendorFeature = ( + contacts: Record | typeof XERO_CONTACTS_UNSYNCED = { + xcActive: {id: 'xcActive', name: 'Acme Xero', email: 'acme@example.com'}, + }, + ) => + ({ + requiresTag: false, + requiresCategory: false, + connections: { + [CONST.POLICY.CONNECTIONS.NAME.XERO]: { + config: {}, + data: contacts === XERO_CONTACTS_UNSYNCED ? {} : {contacts}, + }, + }, + }) as unknown as Policy; + + it('adds the violation when the Xero supplier ID is not in the synced contacts list', () => { + policy = policyWithXeroVendorFeature(); + transaction.comment = {...transaction.comment, vendor: {externalID: 'xcMissing', isManuallySet: true}}; + const result = ViolationsUtils.getViolationsOnyxData({ + updatedTransaction: transaction, + transactionViolations, + policy, + policyTagList: policyTags, + policyCategories, + hasDependentTags: false, + isInvoiceTransaction: false, + }); + expect(result.value).toEqual(expect.arrayContaining([inactiveVendorViolation])); + }); + + it('removes an existing violation when the Xero supplier is restored in the contacts list', () => { + policy = policyWithXeroVendorFeature(); + transaction.comment = {...transaction.comment, vendor: {externalID: 'xcActive', isManuallySet: true}}; + const result = ViolationsUtils.getViolationsOnyxData({ + updatedTransaction: transaction, + transactionViolations: [inactiveVendorViolation], + policy, + policyTagList: policyTags, + policyCategories, + hasDependentTags: false, + isInvoiceTransaction: false, + }); + expect(result.value).not.toContainEqual(inactiveVendorViolation); + }); + + it('does NOT add the violation when Xero contacts have not synced yet (data.contacts undefined) — the guardrail', () => { + // Integration-Server hasn't populated suppliers for the workspace yet. We don't + // know the supplier list, so we must not flag the existing transaction vendor as + // missing. Otherwise every matched transaction would falsely flag inactive between + // the beta flip and the first supplier sync. + policy = policyWithXeroVendorFeature(XERO_CONTACTS_UNSYNCED); + transaction.comment = {...transaction.comment, vendor: {externalID: 'xcAnything', isManuallySet: true}}; + const result = ViolationsUtils.getViolationsOnyxData({ + updatedTransaction: transaction, + transactionViolations, + policy, + policyTagList: policyTags, + policyCategories, + hasDependentTags: false, + isInvoiceTransaction: false, + }); + expect(result.value).not.toContainEqual(inactiveVendorViolation); + }); + + it('does NOT remove a server-fired violation while Xero contacts are unsynced (avoids stripping the server signal during the sync gap)', () => { + // Mirror of the prior test from the inverse angle: when contacts are unknown and a + // server-fired inactiveVendor violation is already on the transaction, the App + // must preserve it rather than wiping it during the sync gap. + policy = policyWithXeroVendorFeature(XERO_CONTACTS_UNSYNCED); + transaction.comment = {...transaction.comment, vendor: {externalID: 'xcActive', isManuallySet: true}}; + const result = ViolationsUtils.getViolationsOnyxData({ + updatedTransaction: transaction, + transactionViolations: [inactiveVendorViolation], + policy, + policyTagList: policyTags, + policyCategories, + hasDependentTags: false, + isInvoiceTransaction: false, + }); + expect(result.value).toEqual(expect.arrayContaining([inactiveVendorViolation])); + }); + + it('does not add the violation when the vendorMatching beta is disabled, even with Xero connected', () => { + isBetaEnabledSpy.mockImplementation(() => false); + policy = policyWithXeroVendorFeature(); + transaction.comment = {...transaction.comment, vendor: {externalID: 'xcMissing', isManuallySet: true}}; + const result = ViolationsUtils.getViolationsOnyxData({ + updatedTransaction: transaction, + transactionViolations, + policy, + policyTagList: policyTags, + policyCategories, + hasDependentTags: false, + isInvoiceTransaction: false, + }); + expect(result.value).not.toContainEqual(inactiveVendorViolation); + }); + + it('still fires for a missing QBO vendor when both QBO and Xero are connected but Xero contacts are unsynced (regression — dual-connection state)', () => { + // QBO is the active matching source (Credit Card export). Xero is also connected — + // e.g. an admin started setting up Xero but Integration-Server hasn't synced + // contacts yet. The guardrail must only fire when Xero is the active source; here + // QBO owns the vendor list, so a QBO vendor ID that isn't in the QBO vendor list + // must still flag inactive. + policy = { + requiresTag: false, + requiresCategory: false, + connections: { + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}, + data: {vendors: [{id: 'v-active', name: 'Acme QBO', currency: 'USD'}]}, + }, + [CONST.POLICY.CONNECTIONS.NAME.XERO]: { + config: {}, + data: {}, + }, + }, + } as unknown as Policy; + transaction.comment = {...transaction.comment, vendor: {externalID: 'v-missing', isManuallySet: true}}; + const result = ViolationsUtils.getViolationsOnyxData({ + updatedTransaction: transaction, + transactionViolations, + policy, + policyTagList: policyTags, + policyCategories, + hasDependentTags: false, + isInvoiceTransaction: false, + }); + expect(result.value).toEqual(expect.arrayContaining([inactiveVendorViolation])); + }); + }); }); describe('shouldRemoveRejectedExpenseViolation (move transaction / explicit removal)', () => { const autoRejectedViolation: TransactionViolation = {