Skip to content

Commit ca84a2c

Browse files
authored
Merge pull request Expensify#89413 from Expensify/marcaaron-split-bulk-edit
Allow bulk editing of coding fields on split expenses
2 parents d166392 + af76cd6 commit ca84a2c

6 files changed

Lines changed: 107 additions & 16 deletions

File tree

src/libs/ReportUtils.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4813,11 +4813,6 @@ function canEditMultipleTransactions(
48134813
continue;
48144814
}
48154815

4816-
// Do not allow editing split expenses in bulk
4817-
if (transaction.comment?.source === CONST.IOU.TYPE.SPLIT || transaction.comment?.splits) {
4818-
return false;
4819-
}
4820-
48214816
const reportActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transaction.reportID}` as const;
48224817
const actionsForReport = {...(searchSnapshotData?.[reportActionsKey] ?? {}), ...(reportActions?.[reportActionsKey] ?? {})};
48234818
const reportAction = getIOUActionForTransactionID(Object.values(actionsForReport), transaction.transactionID);

src/libs/TransactionUtils/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2669,6 +2669,14 @@ function isExpenseSplit(transaction: OnyxEntry<Transaction>, originalTransaction
26692669
return !originalTransaction?.comment?.splits;
26702670
}
26712671

2672+
function isSplitChildTransaction(transaction: OnyxEntry<Transaction> | Transaction): boolean {
2673+
return transaction?.comment?.source === CONST.IOU.TYPE.SPLIT;
2674+
}
2675+
2676+
function hasSplitExpenseInSelection(transactions: Transaction[]): boolean {
2677+
return transactions.some(isSplitChildTransaction);
2678+
}
2679+
26722680
const getOriginalTransactionWithSplitInfo = (transaction: OnyxEntry<Transaction>, originalTransaction: OnyxEntry<Transaction>) => {
26732681
const {originalTransactionID, source, splits} = transaction?.comment ?? {};
26742682

@@ -2951,6 +2959,8 @@ export {
29512959
shouldShowViolation,
29522960
hasTransactionBeenRejected,
29532961
isExpenseSplit,
2962+
hasSplitExpenseInSelection,
2963+
isSplitChildTransaction,
29542964
getAttendeesListDisplayString,
29552965
isCorporateCardTransaction,
29562966
isExpenseUnreported,

src/libs/actions/IOU/BulkEdit.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
isSelfDM,
2323
shouldEnableNegative,
2424
} from '@libs/ReportUtils';
25-
import {calculateTaxAmount, getAmount, getClearedPendingFields, getCurrency, getTaxValue, getUpdatedTransaction, isOnHold} from '@libs/TransactionUtils';
25+
import {calculateTaxAmount, getAmount, getClearedPendingFields, getCurrency, getTaxValue, getUpdatedTransaction, isOnHold, isSplitChildTransaction} from '@libs/TransactionUtils';
2626
import ViolationsUtils from '@libs/Violations/ViolationsUtils';
2727
import {createTransactionThreadReport} from '@userActions/Report';
2828
import CONST from '@src/CONST';
@@ -150,6 +150,9 @@ function updateMultipleMoneyRequests({
150150
// Category, tag, tax, and billable only apply to expense/invoice reports and unreported (track) expenses.
151151
// For plain IOU transactions these fields are not applicable and must be silently skipped.
152152
const supportsExpenseFields = isUnreportedExpense || isFromExpenseReport || isInvoiceReportReportUtils(baseIouReport ?? undefined);
153+
// Split children must keep their amount/currency/tax in sync with the split parent's totals.
154+
// Allow coding fields (category, tag, merchant, etc.) but block these so we never put the split out of sync.
155+
const isSplitChild = isSplitChildTransaction(transaction);
153156
// Use the transaction's own policy for all per-transaction checks (permissions, tax, change-diffing).
154157
// Falls back to the shared bulk-edit policy when the transaction's workspace cannot be resolved.
155158
const transactionPolicy = (iouReport?.policyID ? allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${iouReport.policyID}`] : undefined) ?? policy;
@@ -171,23 +174,23 @@ function updateMultipleMoneyRequests({
171174
if (changes.created && canEditField(CONST.EDIT_REQUEST_FIELD.DATE)) {
172175
transactionChanges.created = changes.created;
173176
}
174-
if (changes.amount !== undefined && canEditField(CONST.EDIT_REQUEST_FIELD.AMOUNT)) {
177+
if (changes.amount !== undefined && !isSplitChild && canEditField(CONST.EDIT_REQUEST_FIELD.AMOUNT)) {
175178
transactionChanges.amount = changes.amount;
176179
}
177180
// When bulk-editing amount on a taxed expense without also changing taxCode, recompute
178181
// taxAmount from the transaction's existing taxCode so offline optimistic data and the
179182
// queued payload stay in sync with the new amount. Skip when the rate can't be resolved
180183
// (e.g. cross-policy bulk edit where the transaction's own policy is missing from cache)
181184
// to avoid silently overwriting a non-zero taxAmount with 0.
182-
if (changes.amount !== undefined && !changes.taxCode && transaction.taxCode && supportsExpenseFields && canEditField(CONST.EDIT_REQUEST_FIELD.TAX_RATE)) {
185+
if (changes.amount !== undefined && !isSplitChild && !changes.taxCode && transaction.taxCode && supportsExpenseFields && canEditField(CONST.EDIT_REQUEST_FIELD.TAX_RATE)) {
183186
const taxValue = getTaxValue(transactionPolicy, transaction, transaction.taxCode);
184187
if (taxValue) {
185188
const decimals = getCurrencyDecimals(getCurrency(transaction));
186189
const taxAmount = calculateTaxAmount(taxValue, Math.abs(changes.amount), decimals);
187190
transactionChanges.taxAmount = convertToBackendAmount(taxAmount);
188191
}
189192
}
190-
if (changes.currency && canEditField(CONST.EDIT_REQUEST_FIELD.CURRENCY)) {
193+
if (changes.currency && !isSplitChild && canEditField(CONST.EDIT_REQUEST_FIELD.CURRENCY)) {
191194
transactionChanges.currency = changes.currency;
192195
}
193196
if (changes.category !== undefined && supportsExpenseFields && canEditField(CONST.EDIT_REQUEST_FIELD.CATEGORY)) {
@@ -199,7 +202,7 @@ function updateMultipleMoneyRequests({
199202
if (changes.comment && canEditField(CONST.EDIT_REQUEST_FIELD.DESCRIPTION)) {
200203
transactionChanges.comment = getParsedComment(changes.comment);
201204
}
202-
if (changes.taxCode && supportsExpenseFields && canEditField(CONST.EDIT_REQUEST_FIELD.TAX_RATE)) {
205+
if (changes.taxCode && supportsExpenseFields && !isSplitChild && canEditField(CONST.EDIT_REQUEST_FIELD.TAX_RATE)) {
203206
transactionChanges.taxCode = changes.taxCode;
204207
const taxValue = getTaxValue(transactionPolicy, transaction, changes.taxCode);
205208
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';
@@ -70,6 +70,8 @@ function SearchEditMultiplePage() {
7070

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

73+
const hasSplitTransaction = hasSplitExpenseInSelection(selectedTransactionContexts.map(({transaction}) => transaction));
74+
7375
const isFieldDisabledForAnyTransaction = (field: ValueOf<typeof CONST.EDIT_REQUEST_FIELD>) =>
7476
selectedTransactionContexts.some(({transaction, report, reportAction, transactionPolicy}) => {
7577
// Unreported expenses have no report actions yet but are always editable
@@ -228,7 +230,7 @@ function SearchEditMultiplePage() {
228230
description: translate('iou.amount'),
229231
title: draftTransaction?.amount !== undefined ? convertToDisplayStringWithoutCurrency(draftTransaction.amount, currency) : '',
230232
route: ROUTES.SEARCH_EDIT_MULTIPLE_AMOUNT_RHP,
231-
disabled: hasCustomUnitTransaction || hasPartiallyEditableTransaction,
233+
disabled: hasCustomUnitTransaction || hasPartiallyEditableTransaction || hasSplitTransaction,
232234
},
233235
{
234236
description: translate('common.description'),
@@ -264,7 +266,7 @@ function SearchEditMultiplePage() {
264266
description: policy?.taxRates?.name ?? translate('common.tax'),
265267
title: draftTransaction?.taxCode ? (getTaxName(policy, draftTransaction) ?? '') : '',
266268
route: ROUTES.SEARCH_EDIT_MULTIPLE_TAX_RHP,
267-
disabled: hasPartiallyEditableTaxRateTransaction,
269+
disabled: hasPartiallyEditableTaxRateTransaction || hasSplitTransaction,
268270
},
269271
]
270272
: []),

tests/actions/IOUTest/BulkEditTest.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1093,6 +1093,78 @@ 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+
currentUserLogin: 'test@example.com',
1151+
currentUserAccountID: 1,
1152+
});
1153+
1154+
expect(writeSpy).toHaveBeenCalled();
1155+
const params = writeSpy.mock.calls.at(0)?.[1] as {updates: string};
1156+
const updates = JSON.parse(params.updates) as Record<string, unknown>;
1157+
expect(updates.category).toBe('Food');
1158+
expect(updates.amount).toBeUndefined();
1159+
expect(updates.currency).toBeUndefined();
1160+
expect(updates.taxCode).toBeUndefined();
1161+
expect(updates.taxValue).toBeUndefined();
1162+
expect(updates.taxAmount).toBeUndefined();
1163+
1164+
writeSpy.mockRestore();
1165+
canEditFieldSpy.mockRestore();
1166+
});
1167+
10961168
it('uses per-transaction policy for category tax mapping in cross-policy bulk edit', () => {
10971169
// Given: two different policies – transactionPolicy has expense rules mapping "Advertising" → "id_TAX_RATE_1",
10981170
// 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)