-
Notifications
You must be signed in to change notification settings - Fork 4k
Expand file tree
/
Copy pathIOUUtils.ts
More file actions
697 lines (624 loc) · 32.2 KB
/
Copy pathIOUUtils.ts
File metadata and controls
697 lines (624 loc) · 32.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList';
import type {IOUAction, IOURequestType, IOUType} from '@src/CONST';
import CONST from '@src/CONST';
import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES';
import type {OnyxInputOrEntry, Policy, Report, ReportAction, ReportNameValuePairs, Rule, Transaction} from '@src/types/onyx';
import type {Attendee, Participant} from '@src/types/onyx/IOU';
import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails';
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
import {SafeString} from 'expensify-common';
import createDynamicRoute from './Navigation/helpers/dynamicRoutesUtils/createDynamicRoute';
import Navigation from './Navigation/Navigation';
import {isGroupPolicy} from './PolicyUtils';
import {getOriginalMessage} from './ReportActionMessageUtils';
import {isMoneyRequestAction} from './ReportActionTypeGuards';
import {canAddTransaction, generateReportID, getChatByParticipants, isArchivedReport, isSelfDM} from './ReportUtils';
import {endSpan, getSpan, startSpan} from './telemetry/activeSpans';
import {getTagArrayFromName, hasRoute, isDistanceRequest} from './TransactionUtils';
function navigateToStartMoneyRequestStep(requestType: IOURequestType, iouType: IOUType, transactionID: string, reportID: string, iouAction?: IOUAction, backToReport?: string): void {
if (iouAction === CONST.IOU.ACTION.CATEGORIZE || iouAction === CONST.IOU.ACTION.SUBMIT || iouAction === CONST.IOU.ACTION.SHARE) {
Navigation.goBack();
return;
}
// If the participants were automatically added to the transaction, then the user needs taken back to the starting step
switch (requestType) {
case CONST.IOU.REQUEST_TYPE.DISTANCE:
Navigation.goBack(ROUTES.MONEY_REQUEST_CREATE_TAB_DISTANCE.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID, backToReport), {compareParams: false});
break;
case CONST.IOU.REQUEST_TYPE.DISTANCE_MAP:
Navigation.goBack(ROUTES.DISTANCE_REQUEST_CREATE_TAB_MAP.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID, backToReport), {compareParams: false});
break;
case CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL:
Navigation.goBack(ROUTES.DISTANCE_REQUEST_CREATE_TAB_MANUAL.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID, backToReport), {compareParams: false});
break;
case CONST.IOU.REQUEST_TYPE.DISTANCE_GPS:
Navigation.goBack(ROUTES.DISTANCE_REQUEST_CREATE_TAB_GPS.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID, backToReport), {compareParams: false});
break;
case CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER:
Navigation.goBack(ROUTES.DISTANCE_REQUEST_CREATE_TAB_ODOMETER.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID, backToReport), {compareParams: false});
break;
case CONST.IOU.REQUEST_TYPE.SCAN:
Navigation.goBack(ROUTES.MONEY_REQUEST_CREATE_TAB_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID, backToReport), {compareParams: false});
break;
case CONST.IOU.REQUEST_TYPE.TIME:
Navigation.goBack(ROUTES.MONEY_REQUEST_CREATE_TAB_TIME.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID, backToReport), {compareParams: false});
break;
default:
Navigation.goBack(ROUTES.MONEY_REQUEST_CREATE_TAB_MANUAL.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID, backToReport), {compareParams: false});
break;
}
}
function navigateToParticipantPage(iouType: ValueOf<typeof CONST.IOU.TYPE>, transactionID: string, reportID: string) {
let navigationIOUType: IOUType = iouType;
switch (iouType) {
case CONST.IOU.TYPE.REQUEST:
navigationIOUType = CONST.IOU.TYPE.SUBMIT;
break;
case CONST.IOU.TYPE.SEND:
navigationIOUType = CONST.IOU.TYPE.PAY;
break;
default:
break;
}
// The base is explicit because the picker can be opened from a create tab, the Inbox or Search drop zone.
Navigation.navigate(
createDynamicRoute(
DYNAMIC_ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute({action: CONST.IOU.ACTION.CREATE, iouType: navigationIOUType, transactionID, reportID}),
ROUTES.MONEY_REQUEST_CREATE.getRoute(CONST.IOU.ACTION.CREATE, navigationIOUType, transactionID, reportID),
),
);
}
/**
* Calculates the amount per split.
*
* @param numberOfSplits - Number of splits EXCLUDING the remainder holder ("default user").
* @param total - IOU total amount in backend format (cents, no matter the currency)
* @param currency - Used to know how many decimal places are valid when splitting the total
* @param isDefaultUser - Whether we are calculating the amount for the remainder holder
* @param useFloorToLastRounding - `false` (legacy behavior) or `true` to floor all and put full remainder on the default user
* @param getCurrencyDecimals - Currency lookup supplied by React consumers.
*/
function calculateAmount(
numberOfSplits: number,
total: number,
currency: string,
isDefaultUser: boolean,
useFloorToLastRounding: boolean,
getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals'],
): number {
// Since the backend can maximum store 2 decimal places, any currency with more than 2 decimals
// has to be capped to 2 decimal places
const currencyUnit = Math.min(100, 10 ** getCurrencyDecimals(currency));
const totalInCurrencySubunit = (total / 100) * currencyUnit;
const totalParticipants = numberOfSplits + 1;
// New optional mode
if (useFloorToLastRounding) {
// For positive totals, floor for everyone and add the full remainder to the default user
// For negative totals, do the inverse of above and round up using Math.ceil to calculate the base share
const baseShareSubunit = totalInCurrencySubunit >= 0 ? Math.floor(totalInCurrencySubunit / totalParticipants) : Math.ceil(totalInCurrencySubunit / totalParticipants);
const remainderSubunit = totalInCurrencySubunit - baseShareSubunit * totalParticipants;
const subunitAmount = baseShareSubunit + (isDefaultUser ? remainderSubunit : 0);
return Math.round((subunitAmount * 100) / currencyUnit);
}
// Legacy behavior (backwards compatible): round equally and adjust default user by +/- difference
const amountPerPerson = Math.round(totalInCurrencySubunit / totalParticipants);
let finalAmount = amountPerPerson;
if (isDefaultUser) {
const sumAmount = amountPerPerson * totalParticipants;
const difference = totalInCurrencySubunit - sumAmount;
finalAmount = totalInCurrencySubunit !== sumAmount ? amountPerPerson + difference : amountPerPerson;
}
return Math.round((finalAmount * 100) / currencyUnit);
}
/**
* Calculate a split amount in backend cents from a percentage of the original amount.
* - Supports negative percentages (for cases where someone owes money back to the group)
* - Preserves decimal precision in percentage (supports 0.1 precision)
* - Handles all sign combinations: positive/negative amounts with positive/negative percentages
*/
function calculateSplitAmountFromPercentage(totalInCents: number, percentage: number): number {
// Calculate directly without clamping to support negative percentages
// This naturally handles all sign combinations:
// - Positive total × Positive % = Positive split (normal expense share)
// - Positive total × Negative % = Negative split (person owes money back)
// - Negative total × Positive % = Negative split (person gets refund share)
// - Negative total × Negative % = Positive split (person owes money on a refund)
const amount = Math.round((totalInCents * percentage) / 100);
// Return 0 for zero amounts to avoid -0
if (amount === 0) {
return 0;
}
return amount;
}
/**
* Given a list of split amounts (in backend cents) and the original total amount, calculate display percentages
* for each split so that:
* - Each row is a percentage of the original total with one decimal place (0.1 precision)
* - Equal amounts ALWAYS have equal percentages
* - The remainder needed to reach 100% goes to the last item (which should be the largest)
* - When the sum of split amounts does not match the original total (over/under splits), percentages still reflect
* each amount as a percentage of the original total and may sum to something other than 100
* - Preserves the sign of amounts (negative amounts result in negative percentages)
*/
function calculateSplitPercentagesFromAmounts(amountsInCents: number[], totalInCents: number): number[] {
const totalAbs = Math.abs(totalInCents);
if (totalAbs <= 0 || amountsInCents.length === 0) {
return amountsInCents.map(() => 0);
}
// Helper functions for decimal precision
const roundToOneDecimal = (value: number): number => Math.round(value * 10) / 10;
const floorToOneDecimal = (value: number): number => Math.floor(value * 10) / 10;
// Calculate percentages based on the relationship between amount sign and total sign
// When they match (both positive or both negative), show positive percentage
// When they differ, show negative percentage
const percentages = amountsInCents.map((amount) => {
if (totalAbs === 0) {
return 0;
}
let percentage = (amount / totalAbs) * 100;
// Flip sign if original total is negative (so user sees intuitive percentages)
if (totalInCents < 0) {
percentage = -percentage;
}
// For negative percentages, floor towards zero (Math.ceil for negative numbers)
return percentage < 0 ? Math.ceil(percentage * 10) / 10 : floorToOneDecimal(percentage);
});
// For remainder calculation, we need to work with absolute values to ensure equal amounts get equal percentages
const amountsAbs = amountsInCents.map((amount) => Math.abs(amount ?? 0));
const amountsTotal = amountsAbs.reduce((sum, curr) => sum + curr, 0);
// If the split amounts don't add up to the original total, return percentages as-is
// (the sum may not be 100, but that's expected when there's a validation error)
if (amountsTotal !== totalAbs) {
return percentages;
}
// Calculate remainder based on absolute sum and add it to the LAST item with MAXIMUM absolute amount
const sumOfAbsPercentages = roundToOneDecimal(percentages.map(Math.abs).reduce((sum, current) => sum + current, 0));
const remainder = roundToOneDecimal(100 - sumOfAbsPercentages);
if (remainder <= 0) {
return percentages;
}
// Add remainder to the last item with the MAXIMUM absolute amount
// This ensures 0-amount splits stay at 0%
const maxAmount = Math.max(...amountsAbs);
let lastMaxIndex = amountsAbs.length - 1; // fallback to last
for (let i = 0; i < amountsAbs.length; i += 1) {
if (amountsAbs.at(i) === maxAmount) {
lastMaxIndex = i;
}
}
const adjustedPercentages = [...percentages];
const currentPercentage = adjustedPercentages.at(lastMaxIndex) ?? 0;
// Add remainder with the same sign as the current percentage
adjustedPercentages[lastMaxIndex] = roundToOneDecimal(currentPercentage + (currentPercentage < 0 ? -remainder : remainder));
return adjustedPercentages;
}
/**
* The owner of the IOU report is the account who is owed money and the manager is the one who owes money!
* In case the owner/manager swap, we need to update the owner of the IOU report and the report total, since it is always positive.
* For example: if user1 owes user2 $10, then we have: {ownerAccountID: user2, managerID: user1, total: $10 (a positive amount, owed to user2)}
* If user1 requests $17 from user2, then we have: {ownerAccountID: user1, managerID: user2, total: $7 (still a positive amount, but now owed to user1)}
*
* @param isDeleting - whether the user is deleting the expense
* @param isUpdating - whether the user is updating the expense
*/
function updateIOUOwnerAndTotal<TReport extends OnyxInputOrEntry<Report>>(
iouReport: TReport,
actorAccountID: number,
amount: number,
currency: string,
isDeleting = false,
isUpdating = false,
isOnHold = false,
unHeldAmount = amount,
): TReport {
// For the update case, we have calculated the diff amount in the calculateDiffAmount function so there is no need to compare currencies here
if ((currency !== iouReport?.currency && !isUpdating) || !iouReport) {
return iouReport;
}
// Make a copy so we don't mutate the original object
const iouReportUpdate = {...iouReport};
// Let us ensure a valid value before updating the total amount.
iouReportUpdate.total = iouReportUpdate.total ?? 0;
iouReportUpdate.unheldTotal = iouReportUpdate.unheldTotal ?? 0;
// IOU reports have no non-reimbursable transactions, so reimbursableTotal mirrors total optimistically.
iouReportUpdate.reimbursableTotal = iouReportUpdate.reimbursableTotal ?? iouReportUpdate.total;
iouReportUpdate.unheldReimbursableTotal = iouReportUpdate.unheldReimbursableTotal ?? iouReportUpdate.unheldTotal;
if (actorAccountID === iouReport.ownerAccountID) {
iouReportUpdate.total += isDeleting ? -amount : amount;
iouReportUpdate.reimbursableTotal += isDeleting ? -amount : amount;
if (!isOnHold) {
iouReportUpdate.unheldTotal += isDeleting ? -unHeldAmount : unHeldAmount;
iouReportUpdate.unheldReimbursableTotal += isDeleting ? -unHeldAmount : unHeldAmount;
}
} else {
iouReportUpdate.total += isDeleting ? amount : -amount;
iouReportUpdate.reimbursableTotal += isDeleting ? amount : -amount;
if (!isOnHold) {
iouReportUpdate.unheldTotal += isDeleting ? unHeldAmount : -unHeldAmount;
iouReportUpdate.unheldReimbursableTotal += isDeleting ? unHeldAmount : -unHeldAmount;
}
}
if (iouReportUpdate.total < 0) {
// The total sign has changed and hence we need to flip the manager and owner of the report.
iouReportUpdate.ownerAccountID = iouReport.managerID;
iouReportUpdate.managerID = iouReport.ownerAccountID;
iouReportUpdate.total = -iouReportUpdate.total;
iouReportUpdate.unheldTotal = -iouReportUpdate.unheldTotal;
iouReportUpdate.reimbursableTotal = -iouReportUpdate.reimbursableTotal;
iouReportUpdate.unheldReimbursableTotal = -iouReportUpdate.unheldReimbursableTotal;
}
return iouReportUpdate;
}
/**
* Checks if the iou type is one of request, send, invoice or split.
*/
function isValidMoneyRequestType(iouType: string): boolean {
const moneyRequestType: string[] = [
CONST.IOU.TYPE.REQUEST,
CONST.IOU.TYPE.SUBMIT,
CONST.IOU.TYPE.SPLIT,
CONST.IOU.TYPE.SPLIT_EXPENSE,
CONST.IOU.TYPE.SEND,
CONST.IOU.TYPE.PAY,
CONST.IOU.TYPE.TRACK,
CONST.IOU.TYPE.INVOICE,
CONST.IOU.TYPE.CREATE,
];
return moneyRequestType.includes(iouType);
}
/**
* Inserts a newly selected tag into the already existing tags like a string
*
* @param transactionTags - currently selected tags for a report
* @param tag - a newly selected tag, that should be added to the transactionTags
* @param tagIndex - the index of a tag list
* @param hasMultipleTagLists - whether the policy has multiple levels tag
* @returns
*/
function insertTagIntoTransactionTagsString(transactionTags: string, tag: string, tagIndex: number, hasMultipleTagLists: boolean): string {
if (!hasMultipleTagLists) {
return tag;
}
const tagArray = transactionTags ? getTagArrayFromName(transactionTags) : [];
tagArray[tagIndex] = tag;
// Fill any sparse slots created when tagIndex > tagArray.length
for (let i = 0; i < tagArray.length; i++) {
if (tagArray.at(i) === undefined) {
tagArray[i] = '';
}
}
while (tagArray.length > 0 && !tagArray.at(-1)) {
tagArray.pop();
}
return tagArray.map((tagItem) => tagItem.trim()).join(CONST.COLON);
}
function isMovingTransactionFromTrackExpense(action?: IOUAction) {
if (action === CONST.IOU.ACTION.SUBMIT || action === CONST.IOU.ACTION.SHARE || action === CONST.IOU.ACTION.CATEGORIZE) {
return true;
}
return false;
}
function shouldShowReceiptEmptyState(iouType: IOUType, action: IOUAction, policy: OnyxInputOrEntry<Policy>, isPerDiemRequest: boolean) {
// Determine when to show the receipt empty state:
// - Show for pay, submit or track expense types
// - Hide for per diem requests
// - Hide when submitting a track expense to a non-paid group policy (personal users)
return (
(iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.TRACK || iouType === CONST.IOU.TYPE.PAY || iouType === CONST.IOU.TYPE.CREATE) &&
!isPerDiemRequest &&
(!isMovingTransactionFromTrackExpense(action) || isGroupPolicy(policy))
);
}
// From global create or a track expense, per diem enablement on any active policy is enough to show the tab -
// rates load lazily and may be absent right after a cache clear, so visibility must not gate on them.
function shouldShowPerDiemTabOption(iouType: IOUType, isFromGlobalCreate: boolean, hasCurrentPolicyPerDiemEnabled: boolean, doesPerDiemPolicyExist: boolean): boolean {
if (iouType === CONST.IOU.TYPE.SPLIT) {
return false;
}
const hasCurrentPolicyPerDiem = !isFromGlobalCreate && hasCurrentPolicyPerDiemEnabled;
const hasAnyPolicyPerDiem = (iouType === CONST.IOU.TYPE.TRACK || isFromGlobalCreate) && doesPerDiemPolicyExist;
return hasCurrentPolicyPerDiem || hasAnyPolicyPerDiem;
}
function shouldUseTransactionDraft(action: IOUAction | undefined, type?: IOUType) {
return action === CONST.IOU.ACTION.CREATE || type === CONST.IOU.TYPE.SPLIT_EXPENSE || isMovingTransactionFromTrackExpense(action);
}
function formatCurrentUserToAttendee(currentUser?: CurrentUserPersonalDetails) {
if (!currentUser) {
return;
}
const login = currentUser.login ? currentUser.login : (currentUser.email ?? '');
const displayName = currentUser.displayName ? currentUser.displayName : login;
if (!login) {
return;
}
const initialAttendee: Attendee = {
email: login,
displayName,
avatarUrl: SafeString(currentUser.avatar),
};
return [initialAttendee];
}
function navigateToConfirmationPage(
iouType: IOUType,
transactionID: string,
reportID: string,
backToReport: string | undefined,
shouldNavigateToSubmit = false,
reportIDParam: string | undefined = undefined,
fromManualDistanceRequest = false,
) {
endSpan(CONST.TELEMETRY.SPAN_SCAN_PROCESS_AND_NAVIGATE);
startSpan(CONST.TELEMETRY.SPAN_CONFIRMATION_MOUNT, {
name: CONST.TELEMETRY.SPAN_CONFIRMATION_MOUNT,
op: CONST.TELEMETRY.SPAN_CONFIRMATION_MOUNT,
parentSpan: getSpan(CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION) ?? getSpan(CONST.TELEMETRY.SPAN_ODOMETER_TO_CONFIRMATION),
});
switch (iouType) {
case CONST.IOU.TYPE.REQUEST:
Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(CONST.IOU.ACTION.CREATE, CONST.IOU.TYPE.SUBMIT, transactionID, reportID, backToReport));
break;
case CONST.IOU.TYPE.CREATE:
Navigation.navigate(
ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(
CONST.IOU.ACTION.CREATE,
CONST.IOU.TYPE.SUBMIT,
transactionID,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
reportIDParam || reportID,
backToReport,
),
);
break;
case CONST.IOU.TYPE.SEND:
if (fromManualDistanceRequest) {
Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID, backToReport));
} else {
Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(CONST.IOU.ACTION.CREATE, CONST.IOU.TYPE.PAY, transactionID, reportID));
}
break;
default:
Navigation.navigate(
ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(
CONST.IOU.ACTION.CREATE,
shouldNavigateToSubmit ? CONST.IOU.TYPE.SUBMIT : iouType,
transactionID,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
reportIDParam || reportID,
backToReport,
),
);
}
}
/**
* Get the existing transaction ID from a linked tracked expense report action.
* This is used when moving a transaction from track expense to submit.
*/
function getExistingTransactionID(linkedTrackedExpenseReportAction: ReportAction | undefined): string | undefined {
if (!linkedTrackedExpenseReportAction || !isMoneyRequestAction(linkedTrackedExpenseReportAction)) {
return undefined;
}
return getOriginalMessage(linkedTrackedExpenseReportAction)?.IOUTransactionID;
}
function calculateDefaultReimbursable({
iouType,
policy,
policyForMovingExpenses,
participant,
transactionReportID,
}: {
iouType: ValueOf<typeof CONST.IOU.TYPE>;
policy?: OnyxEntry<Policy>;
policyForMovingExpenses?: OnyxEntry<Policy>;
participant?: Participant;
transactionReportID?: string;
}): boolean {
const isCreatingTrackExpense = iouType === CONST.IOU.TYPE.TRACK;
const isUnreported = transactionReportID === CONST.REPORT.UNREPORTED_REPORT_ID;
const isPolicyExpenseChat = !!participant?.isPolicyExpenseChat;
const reportPolicy = isCreatingTrackExpense || isUnreported ? policyForMovingExpenses : policy;
return (isPolicyExpenseChat && isGroupPolicy(reportPolicy)) || isCreatingTrackExpense ? (reportPolicy?.defaultReimbursable ?? true) : true;
}
function getInitialPerDiemTargetReport(
report: OnyxEntry<Report>,
selfDMReport: OnyxEntry<Report>,
iouType: IOUType,
defaultExpensePolicy: OnyxEntry<Pick<Policy, 'autoReporting'>>,
personalPolicy: OnyxEntry<Pick<Policy, 'autoReporting'>>,
isFromGlobalCreate: boolean,
): {
targetReport: OnyxEntry<Report>;
targetIouType: IOUType;
transactionReportID: string | undefined;
} {
let targetReport = report;
let targetIouType = iouType;
if (isFromGlobalCreate) {
const shouldAutoReport = !!defaultExpensePolicy?.autoReporting || !!personalPolicy?.autoReporting;
if (!shouldAutoReport) {
targetReport = selfDMReport;
}
}
if (targetIouType === CONST.IOU.TYPE.TRACK) {
targetReport = selfDMReport;
}
const transactionReportID = isSelfDM(targetReport) ? CONST.REPORT.UNREPORTED_REPORT_ID : targetReport?.reportID;
if (transactionReportID === CONST.REPORT.UNREPORTED_REPORT_ID) {
targetIouType = CONST.IOU.TYPE.TRACK;
}
return {targetReport, targetIouType, transactionReportID};
}
/**
* Resolves the chat report ID for navigation, generating an optimistic ID if no existing chat is found.
*/
function resolveOptimisticChatReportID(participantAccountIDs: number[], existingReport?: OnyxInputOrEntry<Report>, optimisticChatReportID?: string) {
const existingChat = existingReport?.reportID ? existingReport : getChatByParticipants(participantAccountIDs);
if (existingChat?.reportID) {
return {optimisticChatReportID: undefined, chatReportID: existingChat.reportID};
}
const chatReportID = optimisticChatReportID ?? generateReportID();
return {optimisticChatReportID: chatReportID, chatReportID};
}
/** Returns `transactionReportID` if the participant isn't a workspace and has no existing chat, so the ID can be reused for their new chat report; otherwise undefined. */
function getReusableP2PReportID(participant: Participant, transactionReportID: string | undefined): string | undefined {
const isBrandNewP2PRecipient = !participant.isPolicyExpenseChat && !participant.reportID;
return isBrandNewP2PRecipient && !!transactionReportID && transactionReportID !== CONST.REPORT.UNREPORTED_REPORT_ID ? transactionReportID : undefined;
}
/**
* Whether the participant picker for this transaction should be restricted to workspaces only.
* A negative amount (or a distance request with zero quantity) implies money is owed *to* the
* current user, so only a workspace can be the counterparty — not an individual user.
* Scan requests are excluded because the amount isn't known until after SmartScan completes.
*/
function getIsWorkspacesOnlyForTransaction(transaction: OnyxEntry<Transaction>, iouRequestType: IOURequestType): boolean {
if (isDistanceRequest(transaction)) {
if (!hasRoute(transaction, true)) {
return false;
}
return transaction?.comment?.customUnit?.quantity === 0;
}
if (iouRequestType === CONST.IOU.REQUEST_TYPE.SCAN) {
return false;
}
return transaction?.amount !== undefined && transaction?.amount !== null && transaction?.amount < 0;
}
/**
* Whether a report carries a real workspace policy. The self-DM / personal report uses the placeholder
* `CONST.POLICY.ID_FAKE` ('_FAKE_') policyID, which is truthy and would otherwise pass naive `report?.policyID`
* checks. Money-request policy resolution must treat it as "no real policy" so a placeholder/stale route report
* (e.g. the self-DM a submissions-disabled workspace flow is seeded onto) does not shadow the selected workspace
* chat's real policy when picking which report to derive the policyID from. See #96576.
*/
function reportHasRealPolicy(report: OnyxEntry<Report>): boolean {
return !!report?.policyID && report.policyID !== CONST.POLICY.ID_FAKE;
}
/**
* Picks which report a money-request page should derive its policyID from. Candidates are passed in preference order
* (usually route report, then transaction report, then participant report): the first one carrying a real workspace
* policy wins, so a placeholder/stale candidate can't shadow a real one (see `reportHasRealPolicy`). When none has a
* real policy the first defined candidate is returned, preserving each page's original fallback behavior.
*/
function pickReportForPolicy(...reports: Array<OnyxEntry<Report>>): OnyxEntry<Report> {
return reports.find((report) => reportHasRealPolicy(report)) ?? reports.find((report) => !!report);
}
/** The policyID of the workspace the user explicitly picked for this expense, taken from the transaction participants. */
function getSelectedWorkspacePolicyID(transaction: OnyxEntry<Transaction>, action: IOUAction): string | undefined {
if (action === CONST.IOU.ACTION.EDIT) {
return undefined;
}
return transaction?.participants?.find((participant) => participant?.isSender)?.policyID ?? transaction?.participants?.find((participant) => participant?.isPolicyExpenseChat)?.policyID;
}
/** Resolves which Report should receive a money-request: the picked transaction report when usable, undefined to force a new optimistic IOU, otherwise the route report. */
function resolveReportForMoneyRequest({
transaction,
transactionReport,
routeReport,
reportNameValuePair,
rules,
}: {
transaction: OnyxEntry<Transaction>;
transactionReport: OnyxEntry<Report>;
routeReport: OnyxEntry<Report>;
reportNameValuePair: OnyxInputOrEntry<ReportNameValuePairs>;
rules: OnyxCollection<Rule>;
}): OnyxEntry<Report> {
if (transaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID) {
return undefined;
}
const canUseTransactionReport = canAddTransaction(transactionReport, rules, isArchivedReport(reportNameValuePair), false);
const shouldUseTransactionReport = !!transactionReport && (canUseTransactionReport || !routeReport);
if (shouldUseTransactionReport) {
return transactionReport;
}
const isTransactionReportDifferentFromRoute = !!transaction?.reportID && !!routeReport?.reportID && transaction.reportID !== routeReport.reportID;
if (isTransactionReportDifferentFromRoute) {
return undefined;
}
return routeReport;
}
/**
* Check whether a money-request participant represents a P2P chat (i.e. another user, not a
* policy-expense chat and not a self DM).
*/
function isParticipantP2P(participant: {accountID?: number; isPolicyExpenseChat?: boolean; isSelfDM?: boolean} | undefined): boolean {
return !!(participant?.accountID && !participant.isPolicyExpenseChat && !participant.isSelfDM);
}
/**
* A participant points at the current user's self-DM when it carries the self-DM flag, or — for flows that seed the
* raw account before the flag is set (e.g. the new manual expense flow, which skips the iouType -> TRACK conversion) —
* when it resolves to the current user. Workspace (policy expense chat) and sender (invoice) participants are excluded.
*/
function isSelfDMParticipant(participant: Participant | undefined, currentUserAccountID: number | undefined): boolean {
if (!participant || participant.isSender || participant.isPolicyExpenseChat) {
return false;
}
return participant.isSelfDM === true || (!!currentUserAccountID && participant.accountID === currentUserAccountID);
}
/**
* An expense targets the current user's self-DM (and therefore must be tracked rather than requested) when it has a
* single selected participant that resolves to the current user's self-DM. SPLIT/INVOICE/PAY are never self-DM
* destinations. This is the single source of truth shared by the confirmation step (to resolve the destination report)
* and the submission hook (to route through trackExpense), so both stay in sync.
*/
function isSelfDMSoleDestination(participants: Participant[], iouType: IOUType, currentUserAccountID: number | undefined): boolean {
if (iouType === CONST.IOU.TYPE.SPLIT || iouType === CONST.IOU.TYPE.INVOICE || iouType === CONST.IOU.TYPE.PAY) {
return false;
}
const selectedParticipants = participants.filter((participant) => participant.selected);
const soleSelectedParticipant = selectedParticipants.length === 1 ? selectedParticipants.at(0) : undefined;
return isSelfDMParticipant(soleSelectedParticipant, currentUserAccountID);
}
/**
* Single source of truth for whether a LOOKING_AROUND user's self-DM create routes to Spend > Expenses (Search).
* Suppressed offline because Search reads a server-populated snapshot that can't load offline (would render empty),
* so offline these users fall back to the self-DM (Personal Space) landing. Every create path calls this so the
* pre-mount / dismiss / final-landing decisions can't disagree.
*/
function isLookingAroundSearchRoutingActive(isLookingAroundUser: boolean | undefined, isOffline: boolean | undefined): boolean {
return !!isLookingAroundUser && !isOffline;
}
/**
* Resolves the reportID that should be set on the transaction draft for
* global-create flows with default participants. Returns undefined when
* no early set is needed (non-global-create or empty participants).
*/
function resolveEarlyReportID(isFromGlobalCreate: boolean, participants: Participant[] | undefined): string | undefined {
if (!isFromGlobalCreate || !participants || participants.length === 0) {
return undefined;
}
const firstParticipant = participants.at(0);
if (firstParticipant?.isSelfDM) {
return CONST.REPORT.UNREPORTED_REPORT_ID;
}
return firstParticipant?.reportID;
}
export {
calculateAmount,
calculateSplitAmountFromPercentage,
calculateSplitPercentagesFromAmounts,
getExistingTransactionID,
insertTagIntoTransactionTagsString,
isMovingTransactionFromTrackExpense,
shouldUseTransactionDraft,
isValidMoneyRequestType,
navigateToStartMoneyRequestStep,
updateIOUOwnerAndTotal,
formatCurrentUserToAttendee,
navigateToParticipantPage,
shouldShowReceiptEmptyState,
shouldShowPerDiemTabOption,
navigateToConfirmationPage,
calculateDefaultReimbursable,
getInitialPerDiemTargetReport,
getIsWorkspacesOnlyForTransaction,
getReusableP2PReportID,
isParticipantP2P,
isSelfDMSoleDestination,
isLookingAroundSearchRoutingActive,
resolveOptimisticChatReportID,
resolveReportForMoneyRequest,
resolveEarlyReportID,
reportHasRealPolicy,
pickReportForPolicy,
getSelectedWorkspacePolicyID,
};