Skip to content
32 changes: 25 additions & 7 deletions src/libs/Formula.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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();
}

/**
Expand Down Expand Up @@ -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<Policy>, report: Report, currentDate = new Date()): {startDate: Date | undefined; endDate: Date | undefined} {
function getAutoReportingDates(policy: OnyxEntry<Policy>, report: Report, currentDate = new Date(), context?: FormulaContext): {startDate: Date | undefined; endDate: Date | undefined} {
const frequency = policy?.autoReportingFrequency;
const offset = policy?.autoReportingOffset;

Expand Down Expand Up @@ -817,10 +833,11 @@ function getAutoReportingDates(policy: OnyxEntry<Policy>, 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;
}

Expand Down Expand Up @@ -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();
}

/**
Expand Down
40 changes: 32 additions & 8 deletions src/libs/actions/IOU/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,14 @@
buildOptimisticIOUReportAction,
buildOptimisticMoneyRequestEntities,
buildOptimisticReportPreview,
computeOptimisticReportName,
generateReportID,
getChatByParticipants,
getOutstandingChildRequest,
getParsedComment,
getReportNotificationPreference,
getReportOrDraftReport,
getReportTransactions,
hasOutstandingChildRequest,
hasViolations as hasViolationsReportUtils,
isDeprecatedGroupDM,
Expand All @@ -68,7 +70,6 @@
isSelectedManagerMcTest,
isSelfDM,
isTestTransactionReport,
populateOptimisticReportFormula,
shouldCreateNewMoneyRequestReport as shouldCreateNewMoneyRequestReportReportUtils,
updateReportPreview,
} from '@libs/ReportUtils';
Expand Down Expand Up @@ -378,7 +379,7 @@
};

let allPersonalDetails: OnyxTypes.PersonalDetailsList = {};
Onyx.connect({

Check warning on line 382 in src/libs/actions/IOU/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.PERSONAL_DETAILS_LIST,
callback: (value) => {
allPersonalDetails = value ?? {};
Expand Down Expand Up @@ -430,7 +431,7 @@
};

let allTransactions: NonNullable<OnyxCollection<OnyxTypes.Transaction>> = {};
Onyx.connect({

Check warning on line 434 in src/libs/actions/IOU/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.COLLECTION.TRANSACTION,
waitForCollectionCallback: true,
callback: (value) => {
Expand All @@ -444,7 +445,7 @@
});

let allTransactionDrafts: NonNullable<OnyxCollection<OnyxTypes.Transaction>> = {};
Onyx.connect({

Check warning on line 448 in src/libs/actions/IOU/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.COLLECTION.TRANSACTION_DRAFT,
waitForCollectionCallback: true,
callback: (value) => {
Expand All @@ -453,7 +454,7 @@
});

let allTransactionViolations: NonNullable<OnyxCollection<OnyxTypes.TransactionViolations>> = {};
Onyx.connect({

Check warning on line 457 in src/libs/actions/IOU/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS,
waitForCollectionCallback: true,
callback: (value) => {
Expand All @@ -467,7 +468,7 @@
});

let allPolicyTags: OnyxCollection<OnyxTypes.PolicyTagLists> = {};
Onyx.connect({

Check warning on line 471 in src/libs/actions/IOU/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.COLLECTION.POLICY_TAGS,
waitForCollectionCallback: true,
callback: (value) => {
Expand All @@ -480,7 +481,7 @@
});

let allReports: OnyxCollection<OnyxTypes.Report>;
Onyx.connect({

Check warning on line 484 in src/libs/actions/IOU/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.COLLECTION.REPORT,
waitForCollectionCallback: true,
callback: (value) => {
Expand All @@ -489,7 +490,7 @@
});

let allReportNameValuePairs: OnyxCollection<OnyxTypes.ReportNameValuePairs>;
Onyx.connect({

Check warning on line 493 in src/libs/actions/IOU/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS,
waitForCollectionCallback: true,
callback: (value) => {
Expand All @@ -499,7 +500,7 @@

let deprecatedUserAccountID = -1;
let deprecatedCurrentUserEmail = '';
Onyx.connect({

Check warning on line 503 in src/libs/actions/IOU/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.SESSION,
callback: (value) => {
deprecatedCurrentUserEmail = value?.email ?? '';
Expand All @@ -508,7 +509,7 @@
});

let deprecatedCurrentUserPersonalDetails: OnyxEntry<OnyxTypes.PersonalDetails>;
Onyx.connect({

Check warning on line 512 in src/libs/actions/IOU/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.PERSONAL_DETAILS_LIST,
callback: (value) => {
deprecatedCurrentUserPersonalDetails = value?.[deprecatedUserAccountID] ?? undefined;
Expand All @@ -516,7 +517,7 @@
});

let allReportActions: OnyxCollection<OnyxTypes.ReportActions>;
Onyx.connect({

Check warning on line 520 in src/libs/actions/IOU/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.COLLECTION.REPORT_ACTIONS,
waitForCollectionCallback: true,
callback: (actions) => {
Expand Down Expand Up @@ -2128,25 +2129,45 @@
* 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<OnyxTypes.Policy>): string | undefined {
function recalculateOptimisticReportName(iouReport: OnyxTypes.Report, policy: OnyxEntry<OnyxTypes.Policy>, newTransaction?: OnyxTypes.Transaction): string | undefined {
if (!policy?.fieldList?.[CONST.POLICY.FIELDS.FIELD_LIST_TITLE]) {
return undefined;
}
const titleFormula = policy.fieldList[CONST.POLICY.FIELDS.FIELD_LIST_TITLE]?.defaultValue ?? '';
if (!titleFormula) {
return undefined;
}
return populateOptimisticReportFormula(titleFormula, iouReport as Parameters<typeof populateOptimisticReportFormula>[1], policy);

// Gather existing transactions + the optimistic one not yet in Onyx.
const existingTransactions = getReportTransactions(iouReport.reportID);
const transactionsRecord: Record<string, OnyxTypes.Transaction> = {};
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);
Comment thread
TaduJR marked this conversation as resolved.
return computedName ?? undefined;
}

function maybeUpdateReportNameForFormulaTitle(iouReport: OnyxTypes.Report, policy: OnyxEntry<OnyxTypes.Policy>): OnyxTypes.Report {
function maybeUpdateReportNameForFormulaTitle(iouReport: OnyxTypes.Report, policy: OnyxEntry<OnyxTypes.Policy>, 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;
}
Expand Down Expand Up @@ -2331,8 +2352,6 @@
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
Expand Down Expand Up @@ -2434,6 +2453,11 @@
}
}

// 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
Expand Down
90 changes: 90 additions & 0 deletions tests/unit/FormulaTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});

Expand Down Expand Up @@ -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 = [
{
Expand Down
Loading