Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 79 additions & 9 deletions src/libs/PolicyUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import type {TupleToUnion, ValueOf} from 'type-fest';

import {Str} from 'expensify-common';

import {getQuickbooksOnlineIntegrationName} from './AccountingUtils';
import {getBankAccountFromID} from './actions/BankAccounts';
import {hasSynchronizationErrorMessage, isConnectionUnverified} from './actions/connections';
import {shouldShowQBOReimbursableExportDestinationAccountError} from './actions/connections/QuickbooksOnline';
Expand Down Expand Up @@ -2459,16 +2460,23 @@ function isXeroVendorMatchingActive(policy: OnyxEntry<Policy>): boolean {
return !!policy?.connections?.[CONST.POLICY.CONNECTIONS.NAME.XERO]?.config?.isConfigured;
}

/**
* True when Rillet is connected AND configured. Mirrors `Rillet::hasVendorFeature` on the PHP side.
*/
function isRilletVendorMatchingActive(policy: OnyxEntry<Policy>): boolean {
return !!policy?.connections?.[CONST.POLICY.CONNECTIONS.NAME.RILLET]?.config?.isConfigured;
}

/**
* 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
* in `getActiveVendorMatchingIntegration` (QBO → Intacct → Xero → Rillet) so the UI labels, copy, and
Comment thread
ShridharGoel marked this conversation as resolved.
* 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<Policy>): boolean {
return isXeroVendorMatchingActive(policy) && !isQBOVendorMatchingActive(policy) && !isIntacctVendorMatchingActive(policy);
return getActiveVendorMatchingIntegration(policy) === CONST.POLICY.CONNECTIONS.NAME.XERO;
}

/**
Expand All @@ -2478,10 +2486,11 @@ function isXeroActiveMatchingSource(policy: OnyxEntry<Policy>): boolean {
* the field.
*
* The `vendorMatching` beta only gates the integrations that haven't reached GA yet, so
* `isVendorMatchingBetaEnabled` is consulted on the Intacct and Xero branches but not on QBO:
* `isVendorMatchingBetaEnabled` is consulted on the Intacct, Xero, and Rillet branches but not on QBO:
* - QBO (R1) with non-reimbursable export = Credit Card or Debit Card. GA, so no beta required
* - Sage Intacct (R2) with non-reimbursable export = Credit Card Charge. Beta required
* - Xero (R3) has no export destination enum, so a present connection is enough. Beta required
* - Xero (R3) has no export destination enum, so a configured connection is enough. Beta required
* - Rillet (R4) configured connection. Beta required
*/
function hasVendorFeature(policy: OnyxEntry<Policy>, isVendorMatchingBetaEnabled: boolean): boolean {
if (!policy) {
Expand All @@ -2490,12 +2499,12 @@ function hasVendorFeature(policy: OnyxEntry<Policy>, isVendorMatchingBetaEnabled
if (isQBOVendorMatchingActive(policy)) {
return true;
}
return isVendorMatchingBetaEnabled && (isIntacctVendorMatchingActive(policy) || isXeroVendorMatchingActive(policy));
return isVendorMatchingBetaEnabled && (isIntacctVendorMatchingActive(policy) || isXeroVendorMatchingActive(policy) || isRilletVendorMatchingActive(policy));
}

/**
* Single source of truth for which connected integration scopes the vendor field for this workspace
* (QBO, Sage Intacct, or Xero) and what its vendor list looks like. Returns `undefined` when no
* (QBO, Sage Intacct, Xero, or Rillet) and what its vendor list looks like. Returns `undefined` when no
* vendor-matching integration is active OR when the active integration's list hasn't synced yet —
* distinct from `[]` (loaded-empty). Lets callers tell "no vendors" from "not loaded".
*
Expand Down Expand Up @@ -2527,6 +2536,12 @@ function getActiveVendorMatchingIntegration(policy: OnyxEntry<Policy>): Connecti
if (isIntacctVendorMatchingActive(policy)) {
return CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT;
}
if (isXeroVendorMatchingActive(policy)) {
return CONST.POLICY.CONNECTIONS.NAME.XERO;
}
if (isRilletVendorMatchingActive(policy)) {
return CONST.POLICY.CONNECTIONS.NAME.RILLET;
}
return undefined;
}

Expand Down Expand Up @@ -2556,6 +2571,18 @@ function getActiveVendorMatchingVendors(policy: OnyxEntry<Policy>): Vendor[] | u
}
return Object.values(xeroContacts).map((contact) => ({id: contact.id, name: contact.name, currency: '', email: contact.email}));
}
if (isRilletVendorMatchingActive(policy)) {
const rilletVendors = policy.connections?.[CONST.POLICY.CONNECTIONS.NAME.RILLET]?.data?.vendors;
if (rilletVendors === undefined) {
return undefined;
}
return rilletVendors.map((vendor) => ({
id: vendor.id,
name: vendor.name,
currency: '',
email: vendor.email ?? '',
}));
}
return undefined;
}

Expand Down Expand Up @@ -2627,6 +2654,15 @@ function findVendorByID(policy: OnyxEntry<Policy>, vendorID: string | undefined)
if (xeroContact) {
return {id: xeroContact.id, name: xeroContact.name, currency: '', email: xeroContact.email};
}
const rilletVendor = policy.connections?.[CONST.POLICY.CONNECTIONS.NAME.RILLET]?.data?.vendors?.find((vendor) => vendor.id === vendorID);
if (rilletVendor) {
return {
id: rilletVendor.id,
name: rilletVendor.name,
currency: '',
email: rilletVendor.email ?? '',
};
}
return undefined;
}

Expand All @@ -2647,10 +2683,42 @@ function getVendorRuleDisplayValue(policy: OnyxEntry<Policy>, vendorID: string,
}

const historicalVendorName = findVendorByID(policy, vendorID)?.name;
const hasActiveVendorMatchingSource = getActiveVendorMatchingIntegration(policy) !== undefined || isXeroActiveMatchingSource(policy);
const hasActiveVendorMatchingSource = getActiveVendorMatchingIntegration(policy) !== undefined;
return historicalVendorName ?? (hasActiveVendorMatchingSource ? vendorID : unavailableLabel);
}

/**
* Source-specific empty state copy for the vendor selector when the active integration has zero vendors.
*/
function getVendorEmptyState(policy: OnyxEntry<Policy>, translate: LocaleContextProps['translate']): {title: string; subtitle: string} {
const activeIntegration = getActiveVendorMatchingIntegration(policy);
switch (activeIntegration) {
case CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT:
return {
title: translate('workspace.sageIntacct.noAccountsFound'),
subtitle: translate('workspace.sageIntacct.noAccountsFoundDescription'),
};
case CONST.POLICY.CONNECTIONS.NAME.XERO:
return {
title: translate('workspace.xero.noSuppliersFound'),
subtitle: translate('workspace.xero.noSuppliersFoundDescription'),
};
case CONST.POLICY.CONNECTIONS.NAME.RILLET:
return {
title: translate('workspace.rillet.noVendorsFound'),
subtitle: translate('workspace.rillet.noVendorsFoundDescription'),
};
case CONST.POLICY.CONNECTIONS.NAME.QBO:
default: {
const integrationName = getQuickbooksOnlineIntegrationName(policy, translate);
return {
title: translate('workspace.qbo.noAccountsFound'),
subtitle: translate('workspace.qbo.noAccountsFoundDescription', integrationName),
};
}
}
}

/**
* 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
Expand Down Expand Up @@ -3147,9 +3215,11 @@ export {
getActiveVendorMatchingIntegration,
getMatchingVendorByID,
getMatchingVendors,
getVendorEmptyState,
getVendorRuleDisplayValue,
getXeroSupplierByID,
getXeroSuppliers,
isRilletVendorMatchingActive,
isXeroActiveMatchingSource,
isXeroVendorMatchingActive,
hasVendorFeature,
Expand Down
10 changes: 4 additions & 6 deletions src/pages/iou/request/step/IOURequestStepVendor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,9 @@ 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, isXeroActiveMatchingSource} from '@libs/PolicyUtils';
import {getMatchingVendors, getVendorEmptyState, hasVendorFeature, isXeroActiveMatchingSource} from '@libs/PolicyUtils';
import {isPerDiemRequest} from '@libs/TransactionUtils';

import {getQuickbooksOnlineIntegrationName} from '@pages/workspace/accounting/utils';

import variables from '@styles/variables';

import CONST from '@src/CONST';
Expand Down Expand Up @@ -67,7 +65,7 @@ function IOURequestStepVendor({

const isFeatureAvailable = hasVendorFeature(policy, isBetaEnabled(CONST.BETAS.VENDOR_MATCHING));
const isOnXero = isXeroActiveMatchingSource(policy);
const integrationName = getQuickbooksOnlineIntegrationName(policy, translate);
const emptyState = getVendorEmptyState(policy, translate);

// 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;
Expand Down Expand Up @@ -135,8 +133,8 @@ function IOURequestStepVendor({
icon={illustrations.Telescope}
iconWidth={variables.emptyListIconWidth}
iconHeight={variables.emptyListIconHeight}
title={isOnXero ? translate('workspace.xero.noSuppliersFound') : translate('workspace.qbo.noAccountsFound')}
subtitle={isOnXero ? translate('workspace.xero.noSuppliersFoundDescription') : translate('workspace.qbo.noAccountsFoundDescription', integrationName)}
title={emptyState.title}
subtitle={emptyState.subtitle}
containerStyle={styles.pb10}
/>
) : null;
Expand Down
11 changes: 8 additions & 3 deletions src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,9 +168,14 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro
// vendors right now), so it can't double as the visibility gate.
//
// Beta gating mirrors `hasVendorFeature`: QBO (R1) is GA, so a connected QBO workspace always
// sees the row regardless of the `vendorMatching` beta. Sage Intacct (R2) and Xero (R3) haven't
// reached GA, so they only show the row while the beta is enabled.
const vendorMatchingConnection = getConnectedIntegration(policy, [CONST.POLICY.CONNECTIONS.NAME.QBO, CONST.POLICY.CONNECTIONS.NAME.XERO, CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT]);
// sees the row regardless of the `vendorMatching` beta. Sage Intacct (R2), Xero (R3), and Rillet (R4)
// haven't reached GA, so they only show the row while the beta is enabled.
const vendorMatchingConnection = getConnectedIntegration(policy, [
CONST.POLICY.CONNECTIONS.NAME.QBO,
CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT,
CONST.POLICY.CONNECTIONS.NAME.XERO,
CONST.POLICY.CONNECTIONS.NAME.RILLET,
]);
const shouldShowVendorsFeature = vendorMatchingConnection === CONST.POLICY.CONNECTIONS.NAME.QBO || (isVendorMatchingEnabled && !!vendorMatchingConnection);

const warnAccountingManagesOrganizeFeature = async () => {
Expand Down
Loading
Loading