Skip to content

Commit b0b9a64

Browse files
committed
Allow bulk editing of coding fields on split expenses
Remove the blanket split-expense guard from canEditMultipleTransactions and add hasSplitExpenseInSelection to block only amount/currency/tax fields in the UI and write path, leaving category/tag/merchant/date/description editable.
1 parent d779a2a commit b0b9a64

6 files changed

Lines changed: 99 additions & 15 deletions

File tree

src/libs/ReportUtils.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4831,11 +4831,6 @@ function canEditMultipleTransactions(
48314831
continue;
48324832
}
48334833

4834-
// Do not allow editing split expenses in bulk
4835-
if (transaction.comment?.source === CONST.IOU.TYPE.SPLIT || transaction.comment?.splits) {
4836-
return false;
4837-
}
4838-
48394834
const reportActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transaction.reportID}` as const;
48404835
const actionsForReport = {...(searchSnapshotData?.[reportActionsKey] ?? {}), ...(reportActions?.[reportActionsKey] ?? {})};
48414836
const reportAction = getIOUActionForTransactionID(Object.values(actionsForReport), transaction.transactionID);

src/libs/TransactionUtils/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2661,6 +2661,10 @@ function isExpenseSplit(transaction: OnyxEntry<Transaction>, originalTransaction
26612661
return !originalTransaction?.comment?.splits;
26622662
}
26632663

2664+
function hasSplitExpenseInSelection(transactions: Transaction[]): boolean {
2665+
return transactions.some((transaction) => transaction.comment?.source === CONST.IOU.TYPE.SPLIT);
2666+
}
2667+
26642668
const getOriginalTransactionWithSplitInfo = (transaction: OnyxEntry<Transaction>, originalTransaction: OnyxEntry<Transaction>) => {
26652669
const {originalTransactionID, source, splits} = transaction?.comment ?? {};
26662670

@@ -2943,6 +2947,7 @@ export {
29432947
shouldShowViolation,
29442948
hasTransactionBeenRejected,
29452949
isExpenseSplit,
2950+
hasSplitExpenseInSelection,
29462951
getAttendeesListDisplayString,
29472952
isCorporateCardTransaction,
29482953
isExpenseUnreported,

src/libs/actions/IOU/BulkEdit.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,9 @@ function updateMultipleMoneyRequests({
149149
// Category, tag, tax, and billable only apply to expense/invoice reports and unreported (track) expenses.
150150
// For plain IOU transactions these fields are not applicable and must be silently skipped.
151151
const supportsExpenseFields = isUnreportedExpense || isFromExpenseReport || isInvoiceReportReportUtils(baseIouReport ?? undefined);
152+
// Split children must keep their amount/currency/tax in sync with the split parent's totals.
153+
// Allow coding fields (category, tag, merchant, etc.) but block these so we never desync the split.
154+
const isSplitChild = transaction.comment?.source === CONST.IOU.TYPE.SPLIT;
152155
// Use the transaction's own policy for all per-transaction checks (permissions, tax, change-diffing).
153156
// Falls back to the shared bulk-edit policy when the transaction's workspace cannot be resolved.
154157
const transactionPolicy = (iouReport?.policyID ? allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${iouReport.policyID}`] : undefined) ?? policy;
@@ -170,23 +173,23 @@ function updateMultipleMoneyRequests({
170173
if (changes.created && canEditField(CONST.EDIT_REQUEST_FIELD.DATE)) {
171174
transactionChanges.created = changes.created;
172175
}
173-
if (changes.amount !== undefined && canEditField(CONST.EDIT_REQUEST_FIELD.AMOUNT)) {
176+
if (changes.amount !== undefined && !isSplitChild && canEditField(CONST.EDIT_REQUEST_FIELD.AMOUNT)) {
174177
transactionChanges.amount = changes.amount;
175178
}
176179
// When bulk-editing amount on a taxed expense without also changing taxCode, recompute
177180
// taxAmount from the transaction's existing taxCode so offline optimistic data and the
178181
// queued payload stay in sync with the new amount. Skip when the rate can't be resolved
179182
// (e.g. cross-policy bulk edit where the transaction's own policy is missing from cache)
180183
// to avoid silently overwriting a non-zero taxAmount with 0.
181-
if (changes.amount !== undefined && !changes.taxCode && transaction.taxCode && supportsExpenseFields && canEditField(CONST.EDIT_REQUEST_FIELD.TAX_RATE)) {
184+
if (changes.amount !== undefined && !isSplitChild && !changes.taxCode && transaction.taxCode && supportsExpenseFields && canEditField(CONST.EDIT_REQUEST_FIELD.TAX_RATE)) {
182185
const taxValue = getTaxValue(transactionPolicy, transaction, transaction.taxCode);
183186
if (taxValue) {
184187
const decimals = getCurrencyDecimals(getCurrency(transaction));
185188
const taxAmount = calculateTaxAmount(taxValue, Math.abs(changes.amount), decimals);
186189
transactionChanges.taxAmount = convertToBackendAmount(taxAmount);
187190
}
188191
}
189-
if (changes.currency && canEditField(CONST.EDIT_REQUEST_FIELD.CURRENCY)) {
192+
if (changes.currency && !isSplitChild && canEditField(CONST.EDIT_REQUEST_FIELD.CURRENCY)) {
190193
transactionChanges.currency = changes.currency;
191194
}
192195
if (changes.category !== undefined && supportsExpenseFields && canEditField(CONST.EDIT_REQUEST_FIELD.CATEGORY)) {
@@ -198,7 +201,7 @@ function updateMultipleMoneyRequests({
198201
if (changes.comment && canEditField(CONST.EDIT_REQUEST_FIELD.DESCRIPTION)) {
199202
transactionChanges.comment = getParsedComment(changes.comment);
200203
}
201-
if (changes.taxCode && supportsExpenseFields && canEditField(CONST.EDIT_REQUEST_FIELD.TAX_RATE)) {
204+
if (changes.taxCode && supportsExpenseFields && !isSplitChild && canEditField(CONST.EDIT_REQUEST_FIELD.TAX_RATE)) {
202205
transactionChanges.taxCode = changes.taxCode;
203206
const taxValue = getTaxValue(transactionPolicy, transaction, changes.taxCode);
204207
transactionChanges.taxValue = taxValue;

src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {getCleanedTagName, getTagLists, hasDependentTags as hasDependentTagsPoli
2020
import {canEditFieldOfMoneyRequest, isInvoiceReport, isIOUReport} from '@libs/ReportUtils';
2121
import {getSearchBulkEditPolicyID} from '@libs/SearchUIUtils';
2222
import {hasEnabledTags, shouldShowDependentTagList} from '@libs/TagsOptionsListUtils';
23-
import {getTagArrayFromName, getTaxName, isDistanceRequest, isManagedCardTransaction, isPerDiemRequest, isTimeRequest} from '@libs/TransactionUtils';
23+
import {getTagArrayFromName, getTaxName, hasSplitExpenseInSelection, isDistanceRequest, isManagedCardTransaction, isPerDiemRequest, isTimeRequest} from '@libs/TransactionUtils';
2424
import CONST from '@src/CONST';
2525
import ONYXKEYS from '@src/ONYXKEYS';
2626
import ROUTES from '@src/ROUTES';
@@ -69,6 +69,8 @@ function SearchEditMultiplePage() {
6969

7070
const hasPerDiemOrTimeTransaction = selectedTransactionContexts.some(({transaction}) => isPerDiemRequest(transaction) || isTimeRequest(transaction));
7171

72+
const hasSplitTransaction = hasSplitExpenseInSelection(selectedTransactionContexts.map(({transaction}) => transaction));
73+
7274
const isFieldDisabledForAnyTransaction = (field: ValueOf<typeof CONST.EDIT_REQUEST_FIELD>) =>
7375
selectedTransactionContexts.some(({transaction, report, reportAction, transactionPolicy}) => {
7476
// Unreported expenses have no report actions yet but are always editable
@@ -227,7 +229,7 @@ function SearchEditMultiplePage() {
227229
description: translate('iou.amount'),
228230
title: draftTransaction?.amount !== undefined ? convertToDisplayStringWithoutCurrency(draftTransaction.amount, currency) : '',
229231
route: ROUTES.SEARCH_EDIT_MULTIPLE_AMOUNT_RHP,
230-
disabled: hasCustomUnitTransaction || hasPartiallyEditableTransaction,
232+
disabled: hasCustomUnitTransaction || hasPartiallyEditableTransaction || hasSplitTransaction,
231233
},
232234
{
233235
description: translate('common.description'),
@@ -263,7 +265,7 @@ function SearchEditMultiplePage() {
263265
description: policy?.taxRates?.name ?? translate('common.tax'),
264266
title: draftTransaction?.taxCode ? (getTaxName(policy, draftTransaction) ?? '') : '',
265267
route: ROUTES.SEARCH_EDIT_MULTIPLE_TAX_RHP,
266-
disabled: hasPartiallyEditableTaxRateTransaction,
268+
disabled: hasPartiallyEditableTaxRateTransaction || hasSplitTransaction,
267269
},
268270
]
269271
: []),

tests/actions/IOUTest/BulkEditTest.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1093,6 +1093,76 @@ describe('actions/IOU/BulkEdit', () => {
10931093
canEditFieldSpy.mockRestore();
10941094
});
10951095

1096+
it('applies category to split children but skips amount, currency, and tax', () => {
1097+
const transactionID = 'transaction-split-1';
1098+
const transactionThreadReportID = 'thread-split-1';
1099+
const iouReportID = 'iou-split-1';
1100+
const policy = createRandomPolicy(50, CONST.POLICY.TYPE.TEAM);
1101+
1102+
const transactionThread: Report = {
1103+
...createRandomReport(50, undefined),
1104+
reportID: transactionThreadReportID,
1105+
parentReportID: iouReportID,
1106+
policyID: policy.id,
1107+
};
1108+
const iouReport: Report = {
1109+
...createRandomReport(51, undefined),
1110+
reportID: iouReportID,
1111+
policyID: policy.id,
1112+
type: CONST.REPORT.TYPE.EXPENSE,
1113+
};
1114+
1115+
const reports = {
1116+
[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`]: transactionThread,
1117+
[`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]: iouReport,
1118+
};
1119+
1120+
const splitTransaction: Transaction = {
1121+
...createRandomTransaction(50),
1122+
transactionID,
1123+
reportID: iouReportID,
1124+
transactionThreadReportID,
1125+
amount: -1000,
1126+
currency: CONST.CURRENCY.USD,
1127+
category: 'OldCategory',
1128+
comment: {source: CONST.IOU.TYPE.SPLIT},
1129+
};
1130+
const transactions = {
1131+
[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: splitTransaction,
1132+
};
1133+
1134+
const canEditFieldSpy = jest.spyOn(require('@libs/ReportUtils'), 'canEditFieldOfMoneyRequest').mockReturnValue(true);
1135+
// eslint-disable-next-line rulesdir/no-multiple-api-calls
1136+
const writeSpy = jest.spyOn(API, 'write').mockImplementation(jest.fn());
1137+
1138+
updateMultipleMoneyRequests({
1139+
transactionIDs: [transactionID],
1140+
changes: {category: 'Food', amount: 5000, currency: CONST.CURRENCY.EUR, taxCode: 'id_TAX_RATE_1'},
1141+
policy,
1142+
reports,
1143+
transactions,
1144+
reportActions: {},
1145+
policyCategories: undefined,
1146+
policyTags: {},
1147+
hash: undefined,
1148+
introSelected: undefined,
1149+
betas: undefined,
1150+
});
1151+
1152+
expect(writeSpy).toHaveBeenCalled();
1153+
const params = writeSpy.mock.calls.at(0)?.[1] as {updates: string};
1154+
const updates = JSON.parse(params.updates) as Record<string, unknown>;
1155+
expect(updates.category).toBe('Food');
1156+
expect(updates.amount).toBeUndefined();
1157+
expect(updates.currency).toBeUndefined();
1158+
expect(updates.taxCode).toBeUndefined();
1159+
expect(updates.taxValue).toBeUndefined();
1160+
expect(updates.taxAmount).toBeUndefined();
1161+
1162+
writeSpy.mockRestore();
1163+
canEditFieldSpy.mockRestore();
1164+
});
1165+
10961166
it('uses per-transaction policy for category tax mapping in cross-policy bulk edit', () => {
10971167
// Given: two different policies – transactionPolicy has expense rules mapping "Advertising" → "id_TAX_RATE_1",
10981168
// while the shared bulk-edit policy has no expense rules at all.

tests/unit/canEditMultipleTransactionsTest.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -227,19 +227,28 @@ describe('canEditMultipleTransactions', () => {
227227
expect(result).toBe(true);
228228
});
229229

230-
it('returns false when selecting an unreported expense and a split expense', () => {
230+
it('returns true when selecting a reported split expense with editable coding fields', () => {
231+
const {transaction1, transaction2, reports, policies, reportActions} = buildTestData();
232+
233+
const splitTransaction: Transaction = {...transaction2, comment: {source: CONST.IOU.TYPE.SPLIT}};
234+
235+
const result = canEditMultipleTransactions([transaction1, splitTransaction], reportActions, reports, policies);
236+
expect(result).toBe(true);
237+
});
238+
239+
it('returns true when selecting an unreported expense and a reported split expense', () => {
231240
const {transaction1, transaction2, reports, policies, reportActions} = buildTestData();
232241

233242
const unreportedTransaction: Transaction = {...transaction1, reportID: CONST.REPORT.UNREPORTED_REPORT_ID};
234243
const splitTransaction: Transaction = {...transaction2, comment: {source: CONST.IOU.TYPE.SPLIT}};
235244

236245
// Unreported first, split second
237246
const result = canEditMultipleTransactions([unreportedTransaction, splitTransaction], reportActions, reports, policies);
238-
expect(result).toBe(false);
247+
expect(result).toBe(true);
239248

240249
// Split first, unreported second
241250
const resultReversed = canEditMultipleTransactions([splitTransaction, unreportedTransaction], reportActions, reports, policies);
242-
expect(resultReversed).toBe(false);
251+
expect(resultReversed).toBe(true);
243252
});
244253

245254
it('returns false when selecting an unreported expense and an approved expense', () => {

0 commit comments

Comments
 (0)