diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts index 80830b0579eb..e4b54580c40d 100644 --- a/src/libs/DebugUtils.ts +++ b/src/libs/DebugUtils.ts @@ -461,6 +461,7 @@ function validateReportDraftProperty(key: keyof Report | keyof ReportNameValuePa case 'unheldTotal': case 'nonReimbursableTotal': case 'unheldNonReimbursableTotal': + case 'transactionCount': return validateNumber(value); case 'chatType': return validateConstantEnum(value, CONST.REPORT.CHAT_TYPE); @@ -629,6 +630,7 @@ function validateReportDraftProperty(key: keyof Report | keyof ReportNameValuePa // eslint-disable-next-line @typescript-eslint/naming-convention expensify_text_title: CONST.RED_BRICK_ROAD_PENDING_ACTION, created: CONST.RED_BRICK_ROAD_PENDING_ACTION, + transactionCount: CONST.RED_BRICK_ROAD_PENDING_ACTION, }); case 'expensify_text_title': return validateObject>(value, { diff --git a/src/libs/Formula.ts b/src/libs/Formula.ts index 47f0fadd1590..b64fd91c7858 100644 --- a/src/libs/Formula.ts +++ b/src/libs/Formula.ts @@ -6,9 +6,10 @@ import type {Policy, Report, Transaction} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {getCurrencySymbol} from './CurrencyUtils'; import {formatDate} from './FormulaDatetime'; +import getBase62ReportID from './getBase62ReportID'; import {getAllReportActions} from './ReportActionsUtils'; -import {getMoneyRequestSpendBreakdown, getReportTransactions} from './ReportUtils'; -import {getCreated, isPartialTransaction} from './TransactionUtils'; +import {getHumanReadableStatus, getMoneyRequestSpendBreakdown, getReportTransactions} from './ReportUtils'; +import {getCreated, isPartialTransaction, isTransactionPendingDelete} from './TransactionUtils'; type FormulaPart = { /** The original definition from the formula */ @@ -28,6 +29,7 @@ type FormulaContext = { report: Report; policy: OnyxEntry; transaction?: Transaction; + allTransactions?: Record; }; type FieldList = Record; @@ -327,7 +329,7 @@ function computeAutoReportingInfo(part: FormulaPart, context: FormulaContext, su * Compute the value of a report formula part */ function computeReportPart(part: FormulaPart, context: FormulaContext): string { - const {report, policy} = context; + const {report, policy, allTransactions} = context; const [field, ...additionalPath] = part.fieldPath; // Reconstruct format string by joining additional path elements with ':' // This handles format strings with colons like 'HH:mm:ss' @@ -338,6 +340,12 @@ function computeReportPart(part: FormulaPart, context: FormulaContext): string { } switch (field.toLowerCase()) { + case 'id': + return getBase62ReportID(Number(report.reportID)); + case 'status': + return formatStatus(report.statusNum); + case 'expensescount': + return String(getExpensesCount(report, allTransactions)); case 'type': return formatType(report.type); case 'startdate': @@ -368,6 +376,35 @@ function computeReportPart(part: FormulaPart, context: FormulaContext): string { } } +/** + * Get the number of expenses in a report + * @param report - The report to get expenses for + * @param allTransactions - Optional map of all transactions. If provided, uses this instead of fetching from Onyx + */ +function getExpensesCount(report: Report, allTransactions?: Record): number { + if (!report.reportID) { + return 0; + } + + if (allTransactions) { + const transactions = Object.values(allTransactions).filter((transaction): transaction is Transaction => !!transaction && transaction.reportID === report.reportID); + return transactions?.filter((transaction) => !isTransactionPendingDelete(transaction))?.length ?? 0; + } + + return report.transactionCount ?? 0; +} + +/** + * Format a report status number to human-readable string + */ +function formatStatus(statusNum: number | undefined): string { + if (statusNum === undefined) { + return ''; + } + + return getHumanReadableStatus(statusNum); +} + /** * Compute the value of a field formula part */ diff --git a/src/libs/OptimisticReportNames.ts b/src/libs/OptimisticReportNames.ts index 422b411bef51..d15a1b0b138d 100644 --- a/src/libs/OptimisticReportNames.ts +++ b/src/libs/OptimisticReportNames.ts @@ -256,6 +256,7 @@ function computeReportNameIfNeeded(report: Report | undefined, incomingUpdate: O report: updatedReport, policy: updatedPolicy, transaction: updatedTransaction, + allTransactions: context.allTransactions, }; const newName = compute(formula, formulaContext); diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 442301a9a623..bdbdd3c6dc14 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -12827,6 +12827,7 @@ export { getOutstandingReportsForUser, isReportOutstanding, generateReportAttributes, + getHumanReadableStatus, getReportPersonalDetailsParticipants, isAllowedToSubmitDraftExpenseReport, isWorkspaceEligibleForReportChange, diff --git a/src/types/onyx/Report.ts b/src/types/onyx/Report.ts index 7df07278b7ad..5195d2c66ed5 100644 --- a/src/types/onyx/Report.ts +++ b/src/types/onyx/Report.ts @@ -146,6 +146,9 @@ type Report = OnyxCommon.OnyxValueWithOfflineFeedback< /** Invoice room receiver data */ invoiceReceiver?: InvoiceReceiver; + /** Number of transactions in the report */ + transactionCount?: number; + /** ID of the parent report of the current report, if it exists */ parentReportID?: string; diff --git a/src/types/utils/whitelistedReportKeys.ts b/src/types/utils/whitelistedReportKeys.ts index 2e90fb20d643..7aab8241de04 100644 --- a/src/types/utils/whitelistedReportKeys.ts +++ b/src/types/utils/whitelistedReportKeys.ts @@ -69,6 +69,7 @@ type WhitelistedReport = OnyxCommon.OnyxValueWithOfflineFeedback< private_isArchived: unknown; welcomeMessage: unknown; agentZeroProcessingRequestIndicator: unknown; + transactionCount: unknown; }, PolicyReportField['fieldID'] >; diff --git a/tests/unit/FormulaTest.ts b/tests/unit/FormulaTest.ts index ade2e73bfa80..97930088a033 100644 --- a/tests/unit/FormulaTest.ts +++ b/tests/unit/FormulaTest.ts @@ -223,6 +223,67 @@ describe('CustomFormula', () => { expect(result).toBe('Test Policy'); }); + test('should compute report ID in base62 format', () => { + const result = compute('{report:id}', mockContext); + expect(result).toBe('R0000000001z'); + }); + + test('should compute report status', () => { + const contextWithStatus: FormulaContext = { + ...mockContext, + report: { + ...mockContext.report, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + }, + }; + const result = compute('{report:status}', contextWithStatus); + expect(result).toBe('Submitted'); + }); + + test('should compute expenses count', () => { + const result = compute('{report:expensescount}', mockContext); + expect(result).toBe('0'); + }); + + test('should compute expenses count using allTransactions from context', () => { + const allTransactions = { + // eslint-disable-next-line @typescript-eslint/naming-convention + transactions_trans1: { + transactionID: 'trans1', + reportID: '123', + created: '2025-01-08T12:00:00Z', + amount: 5000, + merchant: 'ACME Ltd.', + } as Transaction, + // eslint-disable-next-line @typescript-eslint/naming-convention + transactions_trans2: { + transactionID: 'trans2', + reportID: '123', + created: '2025-01-14T16:45:00Z', + amount: 3000, + merchant: 'ACME Ltd.', + } as Transaction, + // eslint-disable-next-line @typescript-eslint/naming-convention + transactions_trans3: { + transactionID: 'trans3', + reportID: '123', + created: '2025-01-11T09:15:00Z', + amount: 2000, + merchant: 'ACME Ltd.', + } as Transaction, + }; + + const contextWithAllTransactions: FormulaContext = { + ...mockContext, + allTransactions, + }; + + const result = compute('{report:expensescount}', contextWithAllTransactions); + expect(result).toBe('3'); + // Verify that getReportTransactions was NOT called when allTransactions is provided + expect(mockReportUtils.getReportTransactions).not.toHaveBeenCalled(); + }); + test('should handle empty formula', () => { expect(compute('', mockContext)).toBe(''); }); @@ -251,78 +312,168 @@ describe('CustomFormula', () => { expect(result).toBe('Report with type after 4 spaces Expense Report-and no space after computed part'); }); - describe('Reimbursable Amount', () => { - const reimbursableContext: FormulaContext = { + test('should compute complex formula with multiple new parts', () => { + const contextWithStatus: FormulaContext = { + ...mockContext, report: { - reportID: '123', - reportName: '', - type: 'expense', - policyID: 'policy1', + ...mockContext.report, + transactionCount: 3, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, }, - policy: { - name: 'Test Policy', - } as Policy, }; + const result = compute('Report {report:id} has {report:expensescount} expenses and is {report:status}', contextWithStatus); + expect(result).toBe('Report R0000000001z has 3 expenses and is Approved'); + }); - const calculateExpectedReimbursable = (total: number, nonReimbursableTotal: number) => { - const reimbursableAmount = total - nonReimbursableTotal; - return Math.abs(reimbursableAmount) / 100; + test('should handle combination of new and existing formula parts', () => { + const contextWithStatus: FormulaContext = { + ...mockContext, + report: { + ...mockContext.report, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + transactionCount: 3, + }, }; + const result = compute('{report:type} {report:id} - {report:status} - Total: {report:total} ({report:expensescount} expenses)', contextWithStatus); + expect(result).toBe('Expense Report R0000000001z - Submitted - Total: $100.00 (3 expenses)'); + }); + + test('should handle different status numbers', () => { + const testCases = [ + {statusNum: CONST.REPORT.STATUS_NUM.OPEN, expected: 'Open'}, + {statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, expected: 'Submitted'}, + {statusNum: CONST.REPORT.STATUS_NUM.CLOSED, expected: 'Closed'}, + {statusNum: CONST.REPORT.STATUS_NUM.APPROVED, expected: 'Approved'}, + {statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED, expected: 'Reimbursed'}, + ]; - beforeEach(() => { - jest.clearAllMocks(); + testCases.forEach(({statusNum, expected}) => { + const contextWithStatus: FormulaContext = { + ...mockContext, + report: { + ...mockContext.report, + statusNum, + }, + }; + const result = compute('{report:status}', contextWithStatus); + expect(result).toBe(expected); }); + }); - test('should compute reimbursable amount', () => { - reimbursableContext.report.currency = 'USD'; - reimbursableContext.report.total = -10000; // -$100.00 - reimbursableContext.report.nonReimbursableTotal = -2500; // -$25.00 + test('should handle undefined status number', () => { + const contextWithUndefinedStatus: FormulaContext = { + ...mockContext, + report: { + ...mockContext.report, + statusNum: undefined, + }, + }; + const result = compute('{report:status}', contextWithUndefinedStatus); + expect(result).toBe('{report:status}'); + }); - const expectedReimbursable = calculateExpectedReimbursable(reimbursableContext.report.total, reimbursableContext.report.nonReimbursableTotal); - const result = compute('{report:reimbursable}', reimbursableContext); - expect(result).toBe(`$${expectedReimbursable.toFixed(2)}`); - }); + test('should return 0 for expensescount when no transactions exist', () => { + mockReportUtils.getReportTransactions.mockReturnValue([]); + const result = compute('{report:expensescount}', mockContext); + expect(result).toBe('0'); + }); - test('should compute reimbursable amount with different currency', () => { - reimbursableContext.report.currency = 'EUR'; - reimbursableContext.report.total = -8000; // -€80.00 - reimbursableContext.report.nonReimbursableTotal = -3000; // -€30.00 + test('should return 0 for expensescount when reportID is empty', () => { + const contextWithEmptyReportID: FormulaContext = { + ...mockContext, + report: { + ...mockContext.report, + reportID: '', + }, + }; + const result = compute('{report:expensescount}', contextWithEmptyReportID); + expect(result).toBe('0'); + }); - const expectedReimbursable = calculateExpectedReimbursable(reimbursableContext.report.total, reimbursableContext.report.nonReimbursableTotal); - const result = compute('{report:reimbursable}', reimbursableContext); + test('should compute report ID with different reportID values', () => { + const contextWithDifferentID: FormulaContext = { + ...mockContext, + report: { + ...mockContext.report, + reportID: '456789', + }, + }; + const result = compute('{report:id}', contextWithDifferentID); + expect(result).toBe('R00000001upZ'); + }); + }); - expect(result).toBe(`€${expectedReimbursable.toFixed(2)}`); - }); + describe('Reimbursable Amount', () => { + const reimbursableContext: FormulaContext = { + report: { + reportID: '123', + reportName: '', + type: 'expense', + policyID: 'policy1', + }, + policy: { + name: 'Test Policy', + } as Policy, + }; - test('should handle zero reimbursable amount', () => { - reimbursableContext.report.currency = 'USD'; - reimbursableContext.report.total = -10000; // -$100.00 - reimbursableContext.report.nonReimbursableTotal = -10000; // -$100.00 (all non-reimbursable) + const calculateExpectedReimbursable = (total: number, nonReimbursableTotal: number) => { + const reimbursableAmount = total - nonReimbursableTotal; + return Math.abs(reimbursableAmount) / 100; + }; - const expectedReimbursable = calculateExpectedReimbursable(reimbursableContext.report.total, reimbursableContext.report.nonReimbursableTotal); - const result = compute('{report:reimbursable}', reimbursableContext); - expect(result).toBe(`$${expectedReimbursable.toFixed(2)}`); - }); + beforeEach(() => { + jest.clearAllMocks(); + }); - test('should handle undefined reimbursable amount', () => { - reimbursableContext.report.currency = 'USD'; - reimbursableContext.report.total = undefined; - reimbursableContext.report.nonReimbursableTotal = undefined; + test('should compute reimbursable amount', () => { + reimbursableContext.report.currency = 'USD'; + reimbursableContext.report.total = -10000; // -$100.00 + reimbursableContext.report.nonReimbursableTotal = -2500; // -$25.00 - const result = compute('{report:reimbursable}', reimbursableContext); - expect(result).toBe('$0.00'); - }); + const expectedReimbursable = calculateExpectedReimbursable(reimbursableContext.report.total, reimbursableContext.report.nonReimbursableTotal); + const result = compute('{report:reimbursable}', reimbursableContext); + expect(result).toBe(`$${expectedReimbursable.toFixed(2)}`); + }); - test('should handle missing currency gracefully', () => { - reimbursableContext.report.currency = undefined; - reimbursableContext.report.total = -10000; // -100.00 - reimbursableContext.report.nonReimbursableTotal = -2500; // -25.00 + test('should compute reimbursable amount with different currency', () => { + reimbursableContext.report.currency = 'EUR'; + reimbursableContext.report.total = -8000; // -€80.00 + reimbursableContext.report.nonReimbursableTotal = -3000; // -€30.00 - const expectedReimbursable = calculateExpectedReimbursable(reimbursableContext.report.total, reimbursableContext.report.nonReimbursableTotal); - mockCurrencyUtils.getCurrencySymbol.mockReturnValue(undefined); - const result = compute('{report:reimbursable}', reimbursableContext); - expect(result).toBe(`${expectedReimbursable.toFixed(2)}`); - }); + const expectedReimbursable = calculateExpectedReimbursable(reimbursableContext.report.total, reimbursableContext.report.nonReimbursableTotal); + const result = compute('{report:reimbursable}', reimbursableContext); + + expect(result).toBe(`€${expectedReimbursable.toFixed(2)}`); + }); + + test('should handle zero reimbursable amount', () => { + reimbursableContext.report.currency = 'USD'; + reimbursableContext.report.total = -10000; // -$100.00 + reimbursableContext.report.nonReimbursableTotal = -10000; // -$100.00 (all non-reimbursable) + + const expectedReimbursable = calculateExpectedReimbursable(reimbursableContext.report.total, reimbursableContext.report.nonReimbursableTotal); + const result = compute('{report:reimbursable}', reimbursableContext); + expect(result).toBe(`$${expectedReimbursable.toFixed(2)}`); + }); + + test('should handle undefined reimbursable amount', () => { + reimbursableContext.report.currency = 'USD'; + reimbursableContext.report.total = undefined; + reimbursableContext.report.nonReimbursableTotal = undefined; + + const result = compute('{report:reimbursable}', reimbursableContext); + expect(result).toBe('$0.00'); + }); + + test('should handle missing currency gracefully', () => { + reimbursableContext.report.currency = undefined; + reimbursableContext.report.total = -10000; // -100.00 + reimbursableContext.report.nonReimbursableTotal = -2500; // -25.00 + + const expectedReimbursable = calculateExpectedReimbursable(reimbursableContext.report.total, reimbursableContext.report.nonReimbursableTotal); + mockCurrencyUtils.getCurrencySymbol.mockReturnValue(undefined); + const result = compute('{report:reimbursable}', reimbursableContext); + expect(result).toBe(`${expectedReimbursable.toFixed(2)}`); }); });