diff --git a/src/libs/Formula.ts b/src/libs/Formula.ts index 2804928f9b83..b3be063f5e6a 100644 --- a/src/libs/Formula.ts +++ b/src/libs/Formula.ts @@ -330,7 +330,7 @@ function computeAutoReportingInfo(part: FormulaPart, context: FormulaContext, su return part.definition; } - const {startDate, endDate} = getAutoReportingDates(policy, report); + const {startDate, endDate} = getAutoReportingDates(policy, report, new Date(), context); switch (subField.toLowerCase()) { case 'start': @@ -653,6 +653,21 @@ function getAllReportTransactionsWithContext(reportID: string, context?: Formula const transactions = [...getReportTransactions(reportID)]; const contextTransaction = context?.transaction; + // Merge optimistic transactions not yet in Onyx, passed via FormulaContext.allTransactions. + if (context?.allTransactions) { + for (const ctxTransaction of Object.values(context.allTransactions)) { + if (!ctxTransaction?.transactionID || ctxTransaction.reportID !== reportID) { + continue; + } + const existingIndex = transactions.findIndex((t) => t?.transactionID === ctxTransaction.transactionID); + if (existingIndex >= 0) { + transactions[existingIndex] = ctxTransaction; + } else { + transactions.push(ctxTransaction); + } + } + } + if (contextTransaction?.transactionID && contextTransaction.reportID === reportID) { const transactionIndex = transactions.findIndex((transaction) => transaction?.transactionID === contextTransaction.transactionID); if (transactionIndex >= 0) { @@ -698,7 +713,8 @@ function getOldestTransactionDate(reportID: string, context?: FormulaContext): s oldestDate = created; } - return oldestDate; + // Fall back to current date when all transactions were skipped (e.g. partial/scanning). + return oldestDate ?? new Date().toISOString(); } /** @@ -764,7 +780,7 @@ function getMonthlyLastBusinessDayPeriod(currentDate: Date): {startDate: Date; e /** * Calculate the start and end dates for auto-reporting based on the frequency and current date */ -function getAutoReportingDates(policy: OnyxEntry, report: Report, currentDate = new Date()): {startDate: Date | undefined; endDate: Date | undefined} { +function getAutoReportingDates(policy: OnyxEntry, report: Report, currentDate = new Date(), context?: FormulaContext): {startDate: Date | undefined; endDate: Date | undefined} { const frequency = policy?.autoReportingFrequency; const offset = policy?.autoReportingOffset; @@ -817,10 +833,11 @@ function getAutoReportingDates(policy: OnyxEntry, report: Report, curren } case CONST.POLICY.AUTO_REPORTING_FREQUENCIES.TRIP: { - // For trip-based, use oldest transaction as start - const oldestTransactionDateString = getOldestTransactionDate(report.reportID); + // For trip-based, use oldest transaction as start and newest transaction as end + const oldestTransactionDateString = getOldestTransactionDate(report.reportID, context); + const newestTransactionDateString = getNewestTransactionDate(report.reportID, context); startDate = oldestTransactionDateString ? new Date(oldestTransactionDateString) : currentDate; - endDate = currentDate; + endDate = newestTransactionDateString ? new Date(newestTransactionDateString) : currentDate; break; } @@ -867,7 +884,8 @@ function getNewestTransactionDate(reportID: string, context?: FormulaContext): s newestDate = created; } - return newestDate; + // Fall back to current date when all transactions were skipped (e.g. partial/scanning). + return newestDate ?? new Date().toISOString(); } /** diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 0f25e9d1fcbb..e1b5fcbb7c29 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -47,12 +47,14 @@ import { buildOptimisticIOUReportAction, buildOptimisticMoneyRequestEntities, buildOptimisticReportPreview, + computeOptimisticReportName, generateReportID, getChatByParticipants, getOutstandingChildRequest, getParsedComment, getReportNotificationPreference, getReportOrDraftReport, + getReportTransactions, hasOutstandingChildRequest, hasViolations as hasViolationsReportUtils, isDeprecatedGroupDM, @@ -68,7 +70,6 @@ import { isSelectedManagerMcTest, isSelfDM, isTestTransactionReport, - populateOptimisticReportFormula, shouldCreateNewMoneyRequestReport as shouldCreateNewMoneyRequestReportReportUtils, updateReportPreview, } from '@libs/ReportUtils'; @@ -2128,7 +2129,7 @@ function buildOnyxDataForMoneyRequest(moneyRequestParams: BuildOnyxDataForMoneyR * This is needed when report totals change (e.g., adding expenses or changing reimbursable status) * to ensure the report title reflects the updated values like {report:reimbursable}. */ -function recalculateOptimisticReportName(iouReport: OnyxTypes.Report, policy: OnyxEntry): string | undefined { +function recalculateOptimisticReportName(iouReport: OnyxTypes.Report, policy: OnyxEntry, newTransaction?: OnyxTypes.Transaction): string | undefined { if (!policy?.fieldList?.[CONST.POLICY.FIELDS.FIELD_LIST_TITLE]) { return undefined; } @@ -2136,17 +2137,37 @@ function recalculateOptimisticReportName(iouReport: OnyxTypes.Report, policy: On if (!titleFormula) { return undefined; } - return populateOptimisticReportFormula(titleFormula, iouReport as Parameters[1], policy); + + // Gather existing transactions + the optimistic one not yet in Onyx. + const existingTransactions = getReportTransactions(iouReport.reportID); + const transactionsRecord: Record = {}; + for (const transaction of existingTransactions) { + if (transaction?.transactionID) { + transactionsRecord[transaction.transactionID] = transaction; + } + } + if (newTransaction?.transactionID) { + transactionsRecord[newTransaction.transactionID] = newTransaction; + } + + const computedName = computeOptimisticReportName(iouReport, policy, iouReport.policyID, transactionsRecord); + return computedName ?? undefined; } -function maybeUpdateReportNameForFormulaTitle(iouReport: OnyxTypes.Report, policy: OnyxEntry): OnyxTypes.Report { +function maybeUpdateReportNameForFormulaTitle(iouReport: OnyxTypes.Report, policy: OnyxEntry, newTransaction?: OnyxTypes.Transaction): OnyxTypes.Report { const reportNameValuePairs = allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${iouReport.reportID}`]; const titleField = reportNameValuePairs?.expensify_text_title; - if (titleField?.type !== CONST.REPORT_FIELD_TYPES.FORMULA) { + + // Fall back to policy.fieldList when reportNameValuePairs doesn't exist yet (optimistic reports). + const isFormulaTitle = reportNameValuePairs + ? titleField?.type === CONST.REPORT_FIELD_TYPES.FORMULA + : policy?.fieldList?.[CONST.POLICY.FIELDS.FIELD_LIST_TITLE]?.type === CONST.REPORT_FIELD_TYPES.FORMULA; + + if (!isFormulaTitle) { return iouReport; } - const updatedReportName = recalculateOptimisticReportName(iouReport, policy); + const updatedReportName = recalculateOptimisticReportName(iouReport, policy, newTransaction); if (!updatedReportName) { return iouReport; } @@ -2331,8 +2352,6 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma iouReport.nonReimbursableTotal = (iouReport.nonReimbursableTotal ?? 0) - amount; } } - - iouReport = maybeUpdateReportNameForFormulaTitle(iouReport, policy); } if (typeof iouReport.unheldTotal === 'number') { // Use newReportTotal in scenarios where the total is based on more than just the current transaction amount, and we need to override it manually @@ -2434,6 +2453,11 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma } } + // Recalculate report name after STEP 3 so the optimistic transaction is included in formula computation. + if (!shouldCreateNewMoneyRequestReport && isPolicyExpenseChat) { + iouReport = maybeUpdateReportNameForFormulaTitle(iouReport, policy, optimisticTransaction); + } + // STEP 4: Build optimistic reportActions. We need: // 1. CREATED action for the chatReport // 2. CREATED action for the iouReport diff --git a/tests/unit/FormulaTest.ts b/tests/unit/FormulaTest.ts index 4a9bef24f847..8e911070b302 100644 --- a/tests/unit/FormulaTest.ts +++ b/tests/unit/FormulaTest.ts @@ -665,6 +665,71 @@ describe('CustomFormula', () => { const context = createMockContext(policy); expect(compute('{report:autoreporting:start}', context)).toBe('2025-01-08'); + expect(compute('{report:autoreporting:end}', context)).toBe('2025-01-14'); + }); + + test('should use context.transaction for trip end date when adding a new expense to existing report', () => { + // First transaction already in Onyx (oldest expense, dated Jan 8) + mockReportUtils.getReportTransactions.mockReturnValue([ + {transactionID: 'existing1', reportID: '123', created: '2025-01-08T12:00:00Z', merchant: 'Hotel', amount: 5000} as Transaction, + ]); + + const policy = {autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.TRIP} as Policy; + // Second transaction passed via context (newest expense, dated Jan 14 — not in Onyx yet) + const context: FormulaContext = { + report: mockReport, + policy, + transaction: {transactionID: 'optimistic1', reportID: '123', created: '2025-01-14T16:00:00Z', merchant: 'Restaurant', amount: 3000} as Transaction, + }; + + // Start should be oldest (Jan 8 from Onyx), end should be newest (Jan 14 from context) + expect(compute('{report:autoreporting:start}', context)).toBe('2025-01-08'); + expect(compute('{report:autoreporting:end}', context)).toBe('2025-01-14'); + }); + + test('should use allTransactions for trip dates when Onyx is empty (new report optimistic flow)', () => { + mockReportUtils.getReportTransactions.mockReturnValue([]); + + const policy = {autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.TRIP} as Policy; + const context: FormulaContext = { + report: mockReport, + policy, + allTransactions: { + trans1: {transactionID: 'trans1', reportID: '123', created: '2025-01-08T12:00:00Z', merchant: 'Hotel', amount: 5000} as Transaction, + }, + }; + + expect(compute('{report:autoreporting:start}', context)).toBe('2025-01-08'); + expect(compute('{report:autoreporting:end}', context)).toBe('2025-01-08'); + }); + + test('should use allTransactions to merge Onyx + optimistic transaction for trip date range', () => { + mockReportUtils.getReportTransactions.mockReturnValue([ + {transactionID: 'existing1', reportID: '123', created: '2025-01-08T12:00:00Z', merchant: 'Hotel', amount: 5000} as Transaction, + ]); + + const policy = {autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.TRIP} as Policy; + const context: FormulaContext = { + report: mockReport, + policy, + allTransactions: { + existing1: {transactionID: 'existing1', reportID: '123', created: '2025-01-08T12:00:00Z', merchant: 'Hotel', amount: 5000} as Transaction, + optimistic1: {transactionID: 'optimistic1', reportID: '123', created: '2025-01-14T16:00:00Z', merchant: 'Restaurant', amount: 3000} as Transaction, + }, + }; + + expect(compute('{report:autoreporting:start}', context)).toBe('2025-01-08'); + expect(compute('{report:autoreporting:end}', context)).toBe('2025-01-14'); + }); + + test('should fallback to current date for trip frequency when no transactions', () => { + mockReportUtils.getReportTransactions.mockReturnValue([]); + + const policy = {autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.TRIP} as Policy; + const context = createMockContext(policy); + + // Should fall back to current date (2025-01-19 from jest.setSystemTime) + expect(compute('{report:autoreporting:start}', context)).toBe('2025-01-19'); expect(compute('{report:autoreporting:end}', context)).toBe('2025-01-19'); }); @@ -787,6 +852,31 @@ describe('CustomFormula', () => { expect(endResult).toBe('2025-01-15'); }); + test('should fall back to current date when all transactions are partial (scan expense)', () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2025-01-19T12:00:00Z')); + + const mockTransactions = [ + { + transactionID: 'scan1', + created: '2025-01-15T12:00:00Z', + amount: 0, + merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, + }, + ] as Transaction[]; + + mockReportUtils.getReportTransactions.mockReturnValue(mockTransactions); + const context: FormulaContext = { + report: {reportID: 'test-report-123'} as Report, + policy: null as unknown as Policy, + }; + + expect(compute('{report:startdate}', context)).toBe('2025-01-19'); + expect(compute('{report:enddate}', context)).toBe('2025-01-19'); + + jest.useRealTimers(); + }); + test('should skip partial transactions (partial merchant)', () => { const mockTransactions = [ {