Skip to content
7 changes: 7 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5669,6 +5669,13 @@ const CONST = {
COMPANY_CARD: 'CompanyCard',
EXPENSIFY_CARD: 'ExpensifyCard',
},
FORCE_REIMBURSABLE: {
FORCE_YES: 'force_yes',
FORCE_NO: 'force_no',
DEFAULT_YES: 'default_yes',
DEFAULT_NO: 'default_no',
NONE: 'none',
},
CARD_LIST: 'cardList',
FEED_KEY_SEPARATOR: '#',
CARD_NUMBER_MASK_CHAR: 'X',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ const taxSliceSelector = (t: OnyxEntry<Transaction>): TaxSlice | undefined => {

// --- ConfirmationFieldList: useFooterDerivedFlags ---

type DerivedFlagsSlice = Pick<Transaction, 'modifiedCurrency' | 'currency' | 'iouRequestType' | 'reportID' | 'managedCard' | 'reimbursable'>;
type DerivedFlagsSlice = Pick<Transaction, 'modifiedCurrency' | 'currency' | 'iouRequestType' | 'reportID' | 'managedCard' | 'reimbursable' | 'bank' | 'comment'>;

const derivedFlagsSliceSelector = (t: OnyxEntry<Transaction>): OnyxEntry<DerivedFlagsSlice> => {
if (!t) {
Expand All @@ -249,6 +249,9 @@ const derivedFlagsSliceSelector = (t: OnyxEntry<Transaction>): OnyxEntry<Derived
reportID: t.reportID,
managedCard: t.managedCard,
reimbursable: t.reimbursable,
bank: t.bank,
// Only the card feed's reimbursable setting is needed, so the slice doesn't change while the description is edited
comment: {forceReimbursable: t.comment?.forceReimbursable},
};
return slice;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@ import {isBillableEnabledOnPolicy} from '@libs/MoneyRequestReportUtils';
import {shouldShowConfirmationDate} from '@libs/MoneyRequestUtils';
import {canShowReimbursableToggleForPolicy, isSubmitPolicy} from '@libs/PolicyUtils';
import {hasEnabledTags} from '@libs/TagsOptionsListUtils';
import {getCurrency, isManagedCardTransaction, isScanRequest, shouldShowAttendees as shouldShowAttendeesTransactionUtils} from '@libs/TransactionUtils';
import {
getCurrency,
isCardTransactionReimbursableEditable,
isManagedCardTransaction,
isScanRequest,
shouldShowAttendees as shouldShowAttendeesTransactionUtils,
} from '@libs/TransactionUtils';

import CONST from '@src/CONST';
import type {IOUAction, IOUType} from '@src/CONST';
Expand Down Expand Up @@ -94,11 +100,12 @@ function useFooterDerivedFlags({

// A flag for showing the billable field
const shouldShowBillable = isBillableEnabledOnPolicy(policy);
// The workspace cash expense rules don't apply to card expenses, which follow their card feed's reimbursable setting instead
const shouldShowReimbursable =
(isPolicyExpenseChat || isTrackExpense) &&
!!policy &&
canShowReimbursableToggleForPolicy(policy, transaction?.reimbursable) &&
!isManagedCardTransaction(transaction) &&
(isManagedCardTransaction(transaction) || canShowReimbursableToggleForPolicy(policy, transaction?.reimbursable)) &&
isCardTransactionReimbursableEditable(transaction) &&
!isTypeInvoice;
// Submit workspaces ship Categories/Distance enabled by default, so never route their fields to the upgrade gate.
const isSubmitWorkspace = isSubmitPolicy(policy);
Expand Down
6 changes: 4 additions & 2 deletions src/components/ReportActionItem/MoneyRequestView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ import {
hasMissingSmartscanFields,
hasReservationList,
hasRoute as hasRouteTransactionUtils,
isCardTransactionReimbursableEditable,
isFromCreditCardImport as isCardTransactionTransactionUtils,
isCategoryBeingAnalyzed,
isCustomUnitRateIDForP2P,
Expand Down Expand Up @@ -553,10 +554,11 @@ function MoneyRequestView({
const shouldShowCategoryDisabledAlert = !policy?.areCategoriesEnabled && !!category;
const shouldShowTagDisabledAlert = (!policy?.areTagsEnabled || !policyHasEnabledTags) && !!transactionTag;
const shouldShowBillable = (isPolicyExpenseChat || isExpenseUnreported) && (!!transactionBillable || isBillableEnabledOnPolicy(policy) || !!updatedTransaction?.billable);
// The workspace cash expense rules don't apply to card expenses, which follow their card feed's reimbursable setting instead
const shouldShowReimbursable =
(isPolicyExpenseChat || (isExpenseUnreported && !!policy)) &&
canShowReimbursableToggleForPolicy(policy, updatedTransaction?.reimbursable ?? transactionReimbursable) &&
!isManagedCardTransactionTransactionUtils(transaction) &&
(isManagedCardTransactionTransactionUtils(transaction) || canShowReimbursableToggleForPolicy(policy, updatedTransaction?.reimbursable ?? transactionReimbursable)) &&
isCardTransactionReimbursableEditable(transaction) &&
!isInvoice;
const canEditReimbursable =
isEditable &&
Expand Down
2 changes: 2 additions & 0 deletions src/libs/DebugUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1212,6 +1212,7 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string)
linkedTrackedExpenseReportID: CONST.RED_BRICK_ROAD_PENDING_ACTION,
bank: CONST.RED_BRICK_ROAD_PENDING_ACTION,
liabilityType: CONST.RED_BRICK_ROAD_PENDING_ACTION,
forceReimbursable: CONST.RED_BRICK_ROAD_PENDING_ACTION,
cardName: CONST.RED_BRICK_ROAD_PENDING_ACTION,
feedCountry: CONST.RED_BRICK_ROAD_PENDING_ACTION,
cardNumber: CONST.RED_BRICK_ROAD_PENDING_ACTION,
Expand Down Expand Up @@ -1279,6 +1280,7 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string)
source: 'string',
originalTransactionID: 'string',
liabilityType: CONST.TRANSACTION.LIABILITY_TYPE,
forceReimbursable: CONST.COMPANY_CARD.FORCE_REIMBURSABLE,
vendor: 'object',
splits: 'array',
dismissedViolations: 'object',
Expand Down
19 changes: 11 additions & 8 deletions src/libs/TransactionUtils/duplicates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import Onyx from 'react-native-onyx';
// This cycle import is safe because this file was extracted from TransactionUtils/index.ts, which re-exports it.
// The functions imported here are pure helpers that aren't called at initialization time.
// eslint-disable-next-line import/no-cycle
import {getAmount, getCurrency, getDescription, getFormattedCreated, getMerchant, isManagedCardTransaction} from './index';
import {getAmount, getCurrency, getDescription, getFormattedCreated, getMerchant, getReimbursable, isCardTransactionReimbursableEditable, isExpensifyCardTransaction} from './index';

type FieldsToCompare = Record<string, Array<keyof Transaction>>;
type FieldsToChange = {
Expand Down Expand Up @@ -396,16 +396,19 @@ function compareDuplicateTransactionFields(
}
}
} else if (fieldName === 'reimbursable') {
// Managed card transactions are always non-reimbursable. The resolved reimbursable value is applied to
// the transaction that is kept, so we only force it to false — and hide the reimbursable review step —
// when that kept transaction is itself a managed card. We gate on the selected (kept) transaction rather
// than the reviewing transaction: the Review* pages recompute this from the thread's transaction (which
// may be the cash expense) while the kept transaction is the managed card, so gating on the reviewing
// The resolved reimbursable value is applied to the transaction that is kept, so when that kept transaction
// is a card expense whose reimbursable value can't be changed, we keep its own value and hide the
// reimbursable review step. Expensify Card expenses are always non-reimbursable, and company card expenses
// keep their forced value (e.g. "Force Yes" stays reimbursable). We gate on the selected (kept) transaction
// rather than the reviewing transaction: the Review* pages recompute this from the thread's transaction (which
// may be the cash expense) while the kept transaction is the card expense, so gating on the reviewing
// transaction would re-add the reimbursable step on back navigation. Gating on any duplicate in the set
// would instead wrongly convert a kept cash expense to non-reimbursable.
// would instead wrongly change the reimbursable value of a kept cash expense.
const selectedTransaction = transactions.find((transactionItem) => transactionItem?.transactionID === selectedTransactionID) ?? firstTransaction;
if (isManagedCardTransaction(selectedTransaction)) {
if (isExpensifyCardTransaction(selectedTransaction)) {
keep[fieldName] = false;
} else if (!isCardTransactionReimbursableEditable(selectedTransaction)) {
keep[fieldName] = getReimbursable(selectedTransaction);
} else if (areAllFieldsEqualForKey) {
keep[fieldName] = firstTransaction?.[keys[0]] ?? firstTransaction?.[keys[1]];
} else {
Expand Down
27 changes: 26 additions & 1 deletion src/libs/TransactionUtils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1004,7 +1004,7 @@ function getFormattedCreated(transaction: OnyxInputOrEntry<Transaction>, dateFor
/**
* Determine whether a transaction is made with an Expensify card.
*/
function isExpensifyCardTransaction(transaction: OnyxEntry<Transaction>): boolean {
function isExpensifyCardTransaction(transaction: OnyxEntry<Pick<Transaction, 'bank'>>): boolean {
return transaction?.bank === CONST.EXPENSIFY_CARD.BANK;
}

Expand All @@ -1015,6 +1015,30 @@ function isManagedCardTransaction(transaction: OnyxEntry<Pick<Transaction, 'mana
return !!transaction?.managedCard;
}

/**
* Determine whether the reimbursable value of a transaction can be changed by the user.
* Company card transactions follow their feed's reimbursable setting: only the "Do Not Force" options allow changes.
* Only the value stamped on the transaction at import is used, so changing the feed setting only affects expenses imported afterward.
* A transaction without a stamped value behaves as "Force No".
*/
function isCardTransactionReimbursableEditable(transaction: OnyxEntry<Pick<Transaction, 'managedCard' | 'bank' | 'comment'>>): boolean {
if (!isManagedCardTransaction(transaction)) {
return true;
}

if (isExpensifyCardTransaction(transaction)) {
return false;
}

const forceReimbursable = transaction?.comment?.forceReimbursable;

return (
forceReimbursable === CONST.COMPANY_CARD.FORCE_REIMBURSABLE.DEFAULT_YES ||
forceReimbursable === CONST.COMPANY_CARD.FORCE_REIMBURSABLE.DEFAULT_NO ||
forceReimbursable === CONST.COMPANY_CARD.FORCE_REIMBURSABLE.NONE
Comment on lines +1035 to +1038

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Show the read-only toggle for Force Yes

When a company-card expense is stamped force_yes, this predicate returns false, and every new caller uses that result as a visibility condition. Consequently, the Reimbursable toggle is hidden entirely, although Force Yes is supposed to display the enabled value while preventing edits; visibility and editability need to be represented separately so only force_no hides the field.

Useful? React with 👍 / 👎.

);
}

/**
* Determine whether a transaction is imported from a credit card.
* This includes managed cards (Expensify/Company cards) and personal cards imported via bank connection.
Expand Down Expand Up @@ -1907,6 +1931,7 @@ export {
hasPendingDistanceReceiptRegeneration,
isExpensifyCardTransaction,
isManagedCardTransaction,
isCardTransactionReimbursableEditable,
isDuplicate,
isPending,
hasOnlyPendingCardTransactions,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
getTagArrayFromName,
getTaxName,
hasSplitExpenseInSelection,
isCardTransactionReimbursableEditable,
isDistanceRequest,
isManagedCardTransaction,
isPerDiemRequest,
Expand Down Expand Up @@ -153,11 +154,17 @@ function SearchEditMultiplePage() {
});

const areSelectedTransactionsReimbursable = selectedTransactionContexts.every(({transaction, report, transactionPolicy}) => {
if (!isCardTransactionReimbursableEditable(transaction)) {
return false;
}

// Unreported expenses have no policy yet but reimbursable is always applicable
if (!transaction.reportID || transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID) {
return true;
}
return !isIOUReport(report) && !isInvoiceReport(report) && transactionPolicy?.disabledFields?.reimbursable === false && !isManagedCardTransaction(transaction);

// The workspace cash expense rules don't apply to card expenses, which follow their card feed's reimbursable setting instead
return !isIOUReport(report) && !isInvoiceReport(report) && (isManagedCardTransaction(transaction) || transactionPolicy?.disabledFields?.reimbursable === false);
});

const policyID = getSearchBulkEditPolicyID(selectedTransactionIDs, activePolicyID, mergedTransactions, mergedReports);
Expand Down
6 changes: 4 additions & 2 deletions src/pages/iou/DynamicSplitExpenseEditPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import {
getTag,
getTagForDisplay,
getTaxName,
isCardTransactionReimbursableEditable,
isDistanceRequest,
isManagedCardTransaction,
isManualDistanceRequest,
Expand Down Expand Up @@ -273,10 +274,11 @@ function DynamicSplitExpenseEditPage({route}: DynamicSplitExpenseEditPageProps)

const shouldShowBillable = (isPolicyExpenseChat || isExpenseUnreported) && (!!splitExpenseDraftTransactionDetails?.billable || isBillableEnabledOnPolicy(effectivePolicy));
const currentReimbursable = splitExpenseDraftTransaction?.reimbursable ?? transaction?.reimbursable;
// The workspace cash expense rules don't apply to card expenses, which follow their card feed's reimbursable setting instead
const shouldShowReimbursable =
(isPolicyExpenseChat || (isExpenseUnreported && !!effectivePolicy)) &&
canShowReimbursableToggleForPolicy(effectivePolicy, currentReimbursable) &&
!isManagedCardTransaction(transaction);
(isManagedCardTransaction(transaction) || canShowReimbursableToggleForPolicy(effectivePolicy, currentReimbursable)) &&
isCardTransactionReimbursableEditable(transaction);

const isDistance = isDistanceRequest(splitExpenseDraftTransaction);
const isManualDistance = isManualDistanceRequest(splitExpenseDraftTransaction);
Expand Down
3 changes: 3 additions & 0 deletions src/types/onyx/Transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@ type Comment = {

liabilityType?: ValueOf<typeof CONST.TRANSACTION.LIABILITY_TYPE>;

/** The card feed's reimbursable setting, stamped on company card transactions at import */
forceReimbursable?: ValueOf<typeof CONST.COMPANY_CARD.FORCE_REIMBURSABLE>;

/**
* Accounting-system vendor matched to this expense.
* Stored on non-reimbursable card expenses when a vendor is set either by the
Expand Down
52 changes: 52 additions & 0 deletions tests/ui/SearchEditMultiplePageTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,58 @@ describe('SearchEditMultiplePage', () => {
expect(screen.getByLabelText(/^Reimbursable/).props.accessibilityState).toEqual(expect.objectContaining({disabled: true}));
});

it('shows Reimbursable for "Do Not Force" company card expenses on an "Always" workspace', async () => {
// Given a workspace whose cash expenses are always non-reimbursable, and a company card expense stamped "Do Not Force"
await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, {defaultReimbursable: false, disabledFields: {reimbursable: true}});
await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${OPEN_TRANSACTION_ID}`, {managedCard: true, comment: {forceReimbursable: CONST.COMPANY_CARD.FORCE_REIMBURSABLE.DEFAULT_YES}});
await selectTransactions([OPEN_TRANSACTION_ID]);

// When the Edit multiple page opens
renderPage();
await waitForBatchedUpdatesWithAct();

// Then the Reimbursable row shows, because the workspace cash expense rules don't apply to card expenses
expect(screen.getByLabelText(/^Reimbursable/)).toBeOnTheScreen();
});

it('hides Reimbursable when the selection contains a forced company card expense', async () => {
// Given a cash expense and a company card expense stamped "Force No"
const cardTransactionID = 'cardTransaction';
await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${cardTransactionID}`, {
...buildTransaction(cardTransactionID, OPEN_REPORT_ID),
managedCard: true,
comment: {forceReimbursable: CONST.COMPANY_CARD.FORCE_REIMBURSABLE.FORCE_NO},
});
await selectTransactions([OPEN_TRANSACTION_ID, cardTransactionID]);

// When the Edit multiple page opens
renderPage();
await waitForBatchedUpdatesWithAct();

// Then the Reimbursable row is hidden, because the forced expense's reimbursable value can't change
expect(screen.queryByLabelText(/^Reimbursable/)).not.toBeOnTheScreen();
});

it('ignores the current card feed setting for a company card expense with no stamped value', async () => {
// Given a company card expense with no stamped value, whose card belongs to a feed that is now set to "Do Not Force"
const cardID = 1234;
const fundID = '5678';
const feed = CONST.COMPANY_CARD.FEED_BANK_NAME.VISA;
await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${OPEN_TRANSACTION_ID}`, {managedCard: true, cardID});
await Onyx.set(ONYXKEYS.CARD_LIST, {[cardID]: {cardID, bank: feed, fundID, state: CONST.EXPENSIFY_CARD.STATE.OPEN}});
await Onyx.set(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${fundID}`, {
settings: {companyCards: {[feed]: {forceReimbursable: CONST.COMPANY_CARD.FORCE_REIMBURSABLE.DEFAULT_NO}}},
});
await selectTransactions([OPEN_TRANSACTION_ID]);

// When the Edit multiple page opens
renderPage();
await waitForBatchedUpdatesWithAct();

// Then the Reimbursable row is hidden, because changing the feed setting only affects expenses imported afterward
expect(screen.queryByLabelText(/^Reimbursable/)).not.toBeOnTheScreen();
});

it('fetches policy categories and tags when they are enabled but not loaded yet', async () => {
// Given a workspace with categories and tags enabled whose categories and tags were never loaded, as after a cache clear
await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, {areCategoriesEnabled: true, areTagsEnabled: true});
Expand Down
Loading
Loading