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
17 changes: 9 additions & 8 deletions src/components/Search/SearchSelectionFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,15 +60,16 @@ function getTransactionCount(transactionKeys: string[], transactions: SelectedTr
}, 0);
}

function getTransactionTotal(transactions: SelectedTransactionInfo[]): number {
return transactions.reduce((total, transaction) => total - (transaction.groupAmount ?? -Math.abs(transaction.amount)), 0);
}

// The live default-currency figure a row contributes to the footer total (also what the footer falls back to before a
// conversion arrives). The footer stamps each conversion against this value and compares it on every render, so an
// inline edit that moves it is detected and the cached conversion is fetched again.
// Sources are expense-signed (the negation of the displayed amount), so callers sum them with `total - source`.
function getEntrySource(entry: SelectedTransactionInfo): number {
return entry.groupAmount ?? -Math.abs(entry.amount);
return entry.groupAmount ?? -entry.displayAmount;
}

function getTransactionTotal(transactions: SelectedTransactionInfo[]): number {
return transactions.reduce((total, transaction) => total - getEntrySource(transaction), 0);
}

// Every selected row needs a fresh cached conversion for the target currency before the selected total can be shown
Expand Down Expand Up @@ -215,7 +216,7 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) {
}
const group: unknown = data[key];
if (group && typeof group === 'object' && 'total' in group && typeof group.total === 'number') {
sources[key] = -Math.abs(group.total);
sources[key] = -group.total;
}
}
return sources;
Expand Down Expand Up @@ -482,7 +483,7 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) {
convertedAmount = convertedTransactions?.[transaction.transaction.transactionID]?.[selectedCurrency];
}
}
return acc - (convertedAmount ?? transaction.groupAmount ?? -Math.abs(transaction.amount));
return acc - (convertedAmount ?? getEntrySource(transaction));
}, 0);
}

Expand All @@ -500,7 +501,7 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) {
} else if (transactionID) {
convertedAmount = convertedTransactions?.[transactionID]?.[selectedCurrency];
}
return total - (convertedAmount ?? transaction.groupAmount ?? -Math.abs(transaction.amount));
return total - (convertedAmount ?? getEntrySource(transaction));
}, 0)
: 0;
return {
Expand Down
3 changes: 3 additions & 0 deletions src/components/Search/selectionBuilders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ function mapTransactionItemToSelectedEntry({
reportID: item.reportID,
policyID: item.policyID,
amount: allowNegativeAmount ? amount : Math.abs(amount),
displayAmount: item.formattedTotal,
groupAmount: item.groupAmount,
currency: item.currency,
isFromOneTransactionReport: isOneTransactionReport(item.report),
Expand Down Expand Up @@ -121,6 +122,7 @@ function mapEmptyReportToSelectedEntry(item: TransactionReportGroupListItemType
reportID: item.reportID,
policyID: item.policyID ?? CONST.POLICY.ID_FAKE,
amount: item.totalDisplaySpend ?? item.total ?? 0,
displayAmount: item.totalDisplaySpend ?? 0,
currency,
...(currency ? {groupCurrency: currency} : {}),
},
Expand All @@ -145,6 +147,7 @@ function mapEmptyReportToSelectedEntry(item: TransactionReportGroupListItemType
reportID: item.reportID,
policyID: item.policyID ?? CONST.POLICY.ID_FAKE,
amount: item.total ?? 0,
displayAmount: item.total ?? 0,
currency,
...(currency ? {groupCurrency: currency} : {}),
},
Expand Down
5 changes: 4 additions & 1 deletion src/components/Search/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,12 @@ type SelectedTransactionInfo = {
/** The policyID tied to the report the transaction is reported on */
policyID: string | undefined;

/** The transaction amount */
/** The transaction amount as a magnitude, used for bulk pay. Signed only on the reconcile path. */
amount: number;

/** The signed amount the row displays */
displayAmount: number;

/** The transaction currency */
currency: string;

Expand Down
10 changes: 3 additions & 7 deletions src/components/TransactionItemRow/DataCells/TotalCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,11 @@ import useThemeStyles from '@hooks/useThemeStyles';
import {convertToBackendAmount, convertToFrontendAmountAsString, sanitizeCurrencyCode} from '@libs/CurrencyUtils';
import {formatToParts} from '@libs/NumberFormatUtils';
import {parseFloatAnyLocale, roundToTwoDecimalPlaces} from '@libs/NumberUtils';
import {isGroupPolicy} from '@libs/PolicyUtils';
import {isExpenseReport, isInvoiceReport, shouldEnableNegative} from '@libs/ReportUtils';
import {getAmount as getTransactionAmount, getCurrency as getTransactionCurrency, isDeletedTransaction, isExpenseUnreported, isScanning} from '@libs/TransactionUtils';
import {getTransactionDisplayAmount, isInvoiceReport, shouldEnableNegative} from '@libs/ReportUtils';
import {getCurrency as getTransactionCurrency, isExpenseUnreported, isScanning} from '@libs/TransactionUtils';

import CONST from '@src/CONST';
import type {Policy, Report} from '@src/types/onyx';
import {isEmptyObject} from '@src/types/utils/EmptyObject';

import React, {useRef, useState} from 'react';

Expand Down Expand Up @@ -51,9 +49,7 @@ function TotalCell({shouldShowTooltip, transactionItem, canEdit, onSave, report,

const effectiveReport = report ?? transactionItem.report;
const effectivePolicy = policy ?? transactionItem.policy;
const isDeleted = isDeletedTransaction(transactionItem);
const isFromExpenseReport = (!isEmptyObject(effectiveReport) && isExpenseReport(effectiveReport)) || (isEmptyObject(effectiveReport) && isGroupPolicy(effectivePolicy));
const amount = getTransactionAmount(transactionItem, isFromExpenseReport, transactionItem.reportID === CONST.REPORT.UNREPORTED_REPORT_ID, isDeleted);
const amount = getTransactionDisplayAmount(transactionItem, effectiveReport, effectivePolicy);
let amountToDisplay = convertToDisplayString(amount, currency);
if (isScanning(transactionItem)) {
amountToDisplay = translate('iou.receiptStatusTitle');
Expand Down
18 changes: 17 additions & 1 deletion src/libs/ReportUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@ import {
hasViolation,
hasWarningTypeViolation,
isManagedCardTransaction as isCardTransactionTransactionUtils,
isDeletedTransaction,
isDemoTransaction,
isDistanceRequest,
isFetchingWaypointsFromServer,
Expand Down Expand Up @@ -5075,6 +5076,20 @@ function getAvailableReportFields(report: OnyxEntry<Report>, policyReportFields:
return fields.filter(Boolean) as PolicyReportField[];
}

function isTransactionFromExpenseReport(report: OnyxInputOrEntry<Report>, policy: OnyxInputOrEntry<Policy>): boolean {
return isEmptyObject(report) ? isGroupPolicyPolicyUtils(policy) : isExpenseReport(report);
}

/**
* Returns a transaction's amount with the sign it is displayed with. A transaction on an expense report, on a group
* policy with no report of its own, unreported, or deleted is stored with the opposite sign, so its stored amount is
* negated. Any other transaction returns its magnitude.
*/
function getTransactionDisplayAmount(transaction: OnyxInputOrEntry<Transaction>, report: OnyxInputOrEntry<Report>, policy: OnyxInputOrEntry<Policy>): number {
Comment thread
mountiny marked this conversation as resolved.
const isFromTrackedExpense = transaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID;
return getTransactionAmount(transaction, isTransactionFromExpenseReport(report, policy), isFromTrackedExpense, !!transaction && isDeletedTransaction(transaction));
}

/**
* Gets transaction created, amount, currency, comment, and waypoints (for distance expense)
* into a flat object. Used for displaying transactions and sending them in API commands
Expand All @@ -5094,7 +5109,7 @@ function getTransactionDetails(
}

const report = getReportOrDraftReport(transaction?.reportID, undefined, 'report' in transaction ? transaction.report : undefined);
const isFromExpenseReport = (!isEmptyObject(report) && isExpenseReport(report)) || (isEmptyObject(report) && isGroupPolicyPolicyUtils(policy));
const isFromExpenseReport = isTransactionFromExpenseReport(report, policy);

return {
created: getFormattedCreated(transaction, createdDateFormat, dateFnsLocale),
Expand Down Expand Up @@ -14634,6 +14649,7 @@ export {
isTeachersUniteReport,
getTaskAssigneeChatOnyxData,
getTransactionDetails,
getTransactionDisplayAmount,
getTransactionReportName,
getDisplayedReportID,
getTransactionsWithReceipts,
Expand Down
8 changes: 3 additions & 5 deletions src/libs/SearchUIUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ import {
getReportOrDraftReport,
getReportStatusTooltipTranslation,
getReportStatusTranslation,
getTransactionDisplayAmount,
hasHeldExpenses,
hasInvoiceReports,
hasOnlyNonReimbursableTransactions,
Expand Down Expand Up @@ -221,7 +222,6 @@ import {
getTag,
getTaxAmount,
getTaxName,
getAmount as getTransactionAmount,
getCreated as getTransactionCreatedDate,
getMerchant as getTransactionMerchant,
getTransactionViolations,
Expand Down Expand Up @@ -1278,8 +1278,6 @@ function getTransactionItemCommonFormattedProperties(
report: OnyxTypes.Report | undefined,
translate: LocalizedTranslate,
): Pick<TransactionListItemType, 'formattedFrom' | 'formattedTo' | 'formattedTotal' | 'formattedMerchant' | 'date' | 'posted'> {
const isExpenseReport = report?.type === CONST.REPORT.TYPE.EXPENSE;

const formattedFrom = temporaryGetDisplayNameOrDefault({passedPersonalDetails: from, translate, formatPhoneNumber});

// Sometimes the search data personal detail for the 'to' account might not hold neither the display name nor the login
Expand All @@ -1289,8 +1287,8 @@ function getTransactionItemCommonFormattedProperties(
formattedTo = temporaryGetDisplayNameOrDefault({passedPersonalDetails: getPersonalDetailsForAccountID(to?.accountID), translate, formatPhoneNumber});
}

const isDeleted = isDeletedTransaction(transactionItem);
const formattedTotal = getTransactionAmount(transactionItem, isExpenseReport, false, isDeleted);
// formattedTotal is the Amount column's sort key and holds the same signed value the row displays.
const formattedTotal = getTransactionDisplayAmount(transactionItem, report, policy);
Comment thread
BartekObudzinski marked this conversation as resolved.
const date = transactionItem?.modifiedCreated ? transactionItem.modifiedCreated : transactionItem?.created;
const merchant = getTransactionMerchant(transactionItem);
const formattedMerchant = isInvalidMerchantValue(merchant) ? '' : merchant;
Expand Down
1 change: 1 addition & 0 deletions tests/unit/Search/SearchBulkActionsButtonTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ function makeTransaction(): SelectedTransactions[string] {
reportID: 'report1',
policyID: 'policy1',
amount: 100,
displayAmount: 100,
currency: 'USD',
};
}
Expand Down
37 changes: 37 additions & 0 deletions tests/unit/Search/SearchSelectionFooterTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ function buildSelectedTransaction(currency: string, groupCurrency?: string, grou
action: CONST.SEARCH.ACTION_TYPES.VIEW,
policyID: undefined,
amount: 100,
displayAmount: 100,
currency,
groupCurrency,
groupAmount,
Expand Down Expand Up @@ -166,6 +167,42 @@ describe('SearchSelectionFooter', () => {
expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({count: 10, total: 36000, currency: CONST.CURRENCY.USD}));
});

it('nets a selected credit against a selected expense instead of summing their magnitudes', async () => {
mockSelectedTransactions.current = {
transaction1: {...buildSelectedTransaction(CONST.CURRENCY.USD), displayAmount: 10000},
transaction2: {...buildSelectedTransaction(CONST.CURRENCY.USD), displayAmount: -10000},
};

render(<SearchSelectionFooter searchResults={buildSearchResults(CONST.CURRENCY.USD, 5)} />);
await waitForBatchedUpdates();

expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({count: 2, total: 0}));
});

it('nets a selected credit against a selected expense when the amounts differ', async () => {
mockSelectedTransactions.current = {
transaction1: {...buildSelectedTransaction(CONST.CURRENCY.USD), displayAmount: 10000},
transaction2: {...buildSelectedTransaction(CONST.CURRENCY.USD), displayAmount: -4000},
};

render(<SearchSelectionFooter searchResults={buildSearchResults(CONST.CURRENCY.USD, 5)} />);
await waitForBatchedUpdates();

expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({count: 2, total: 6000}));
});

it('adds back an excluded credit rather than subtracting it from the server total', async () => {
// The server total already counts the credit as -$100, so dropping it from the selection raises the total.
mockSelectedTransactions.current = {};
mockExcludedTransactions.current = {transaction1: {...buildSelectedTransaction(CONST.CURRENCY.USD), displayAmount: -10000}};
mockAreAllMatchingItemsSelected.current = true;

render(<SearchSelectionFooter searchResults={buildSearchResults(CONST.CURRENCY.USD, 172, 36000)} />);
await waitForBatchedUpdates();

expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({count: 171, total: 46000, currency: CONST.CURRENCY.USD}));
});

it("offers the user's live payment currency as the Reset target when there is no active workspace", async () => {
// A fresh no-workspace account: the active policy is the personal policy, and the only selected expense
// happens to be in a different currency (JPY) from the live payment currency (GBP).
Expand Down
1 change: 1 addition & 0 deletions tests/unit/Search/SearchSelectionProviderTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ function buildSelected(...keys: string[]): SelectedTransactions {
reportID: 'report_1',
policyID: 'policy_1',
amount: 100,
displayAmount: 100,
currency: 'USD',
},
]),
Expand Down
36 changes: 36 additions & 0 deletions tests/unit/Search/SearchUIUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6456,6 +6456,42 @@ describe('SearchUIUtils', () => {
const item = sections.find((s) => s.transactionID === filterTestTxID);
expect(item?.submitted).toBe('');
});

it('should keep the negative sign on formattedTotal for an unreported (tracked) credit', () => {
const data = makeFilterTestData({}, {reportID: CONST.REPORT.UNREPORTED_REPORT_ID, amount: 5000});
const [sections] = callGetTransactionsSections(data);
const item = sections.find((s) => s.transactionID === filterTestTxID);
expect(item?.formattedTotal).toBe(-5000);
});

// Amounts are stored with the opposite sign, so these three rows render +$80.00, -$40.00 and +$10.00.
function makeAmountSortData() {
const baseTransaction = searchResults.data[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`];
return makeFilterTestData(
{},
{reportID: CONST.REPORT.UNREPORTED_REPORT_ID, amount: -8000},
{
[`${ONYXKEYS.COLLECTION.TRANSACTION}sort-credit`]: {...baseTransaction, transactionID: 'sort-credit', reportID: CONST.REPORT.UNREPORTED_REPORT_ID, amount: 4000},
[`${ONYXKEYS.COLLECTION.TRANSACTION}sort-small`]: {...baseTransaction, transactionID: 'sort-small', reportID: CONST.REPORT.UNREPORTED_REPORT_ID, amount: -1000},
},
);
}

function getAmountSortedIDs(sortOrder: SortOrder) {
const [sections] = callGetTransactionsSections(makeAmountSortData());
const rows = sections.filter((section) => [filterTestTxID, 'sort-credit', 'sort-small'].includes(section.transactionID));
expect(rows).toHaveLength(3);
const sorted = SearchUIUtils.getSortedSections(CONST.SEARCH.DATA_TYPES.EXPENSE, rows, localeCompare, translateLocal, CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT, sortOrder);
return sorted.map((item) => ('transactionID' in item ? item.transactionID : undefined));
}

it('should rank a credit below every positive expense when sorting the Amount column descending', () => {
expect(getAmountSortedIDs(CONST.SEARCH.SORT_ORDER.DESC)).toEqual([filterTestTxID, 'sort-small', 'sort-credit']);
});

it('should rank a credit above every positive expense when sorting the Amount column ascending', () => {
expect(getAmountSortedIDs(CONST.SEARCH.SORT_ORDER.ASC)).toEqual(['sort-credit', 'sort-small', filterTestTxID]);
});
});

describe('getReportSections filtering and edge cases', () => {
Expand Down
43 changes: 43 additions & 0 deletions tests/unit/Search/selectionBuildersTest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type {TransactionGroupListItemType, TransactionReportGroupListItemType} from '@components/Search/SearchList/ListItem/types';
import {mapEmptyReportToSelectedEntry} from '@components/Search/selectionBuilders';

import CONST from '@src/CONST';

import createMock from '../../utils/createMock';

describe('selectionBuilders', () => {
describe('mapEmptyReportToSelectedEntry', () => {
it('takes displayAmount from the report-signed total for a report row', () => {
// totalDisplaySpend is already negated for expense reports, so a credit report keeps its negative sign.
const item = createMock<TransactionReportGroupListItemType>({
keyForList: 'report1',
reportID: 'report1',
policyID: 'policy1',
currency: CONST.CURRENCY.USD,
groupedBy: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT,
transactions: [],
total: 10000,
totalDisplaySpend: -10000,
});

const [, entry] = mapEmptyReportToSelectedEntry(item);

expect(entry.displayAmount).toBe(-10000);
});

it('takes displayAmount from the group total for a group row', () => {
const item = createMock<TransactionGroupListItemType>({
keyForList: `${CONST.SEARCH.GROUP_PREFIX}category1`,
reportID: undefined,
policyID: 'policy1',
currency: CONST.CURRENCY.USD,
transactions: [],
total: -4000,
});

const [, entry] = mapEmptyReportToSelectedEntry(item);

expect(entry.displayAmount).toBe(-4000);
});
});
});
1 change: 1 addition & 0 deletions tests/unit/Search/useRowSelectionTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ function buildSelected(...keys: string[]): SelectedTransactions {
reportID: 'report_1',
policyID: 'policy_1',
amount: 100,
displayAmount: 100,
currency: 'USD',
};
return acc;
Expand Down
1 change: 1 addition & 0 deletions tests/unit/Search/useSyncSelectedReportsTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ function buildSelected(...keys: string[]): SelectedTransactions {
reportID: 'report_1',
policyID: 'policy_1',
amount: 100,
displayAmount: 100,
currency: 'USD',
};
return acc;
Expand Down
1 change: 1 addition & 0 deletions tests/unit/SearchActionsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,7 @@ describe('getPayOption', () => {
reportID,
policyID: 'policy1',
amount: 100,
displayAmount: 100,
currency: 'USD',
isFromOneTransactionReport: false,
};
Expand Down
1 change: 1 addition & 0 deletions tests/unit/hooks/useSearchBulkActionsDeleteTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@ function makeSelectedTransaction(overrides: Partial<SelectedTransactions[string]
reportID: CONST.REPORT.UNREPORTED_REPORT_ID,
policyID: undefined,
amount: 100,
displayAmount: 100,
currency: 'USD',
isFromOneTransactionReport: false,
...overrides,
Expand Down
Loading
Loading