From 23d7175148c9ea16e897a375b4eb4afb16ed595c Mon Sep 17 00:00:00 2001 From: truph01 Date: Thu, 11 Dec 2025 10:46:16 +0700 Subject: [PATCH 01/14] fix: remove updateOptimisticReportNamesFromUpdates --- src/libs/API/index.ts | 25 +- src/libs/OptimisticReportNames.ts | 200 +------------ .../OptimisticReportNames.perf-test.ts | 192 +------------ tests/unit/OptimisticReportNamesTest.ts | 266 +----------------- 4 files changed, 7 insertions(+), 676 deletions(-) diff --git a/src/libs/API/index.ts b/src/libs/API/index.ts index 44aee2458702..35e9d1d6cdc3 100644 --- a/src/libs/API/index.ts +++ b/src/libs/API/index.ts @@ -8,8 +8,7 @@ import {handleDeletedAccount, HandleUnusedOptimisticID, Logging, Pagination, Rea import FraudMonitoring from '@libs/Middleware/FraudMonitoring'; import {isOffline} from '@libs/Network/NetworkStore'; import {push as pushToSequentialQueue, waitForIdle as waitForSequentialQueueIdle} from '@libs/Network/SequentialQueue'; -import * as OptimisticReportNames from '@libs/OptimisticReportNames'; -import {getUpdateContext, initialize as initializeOptimisticReportNamesContext} from '@libs/OptimisticReportNamesConnectionManager'; +import {initialize as initializeOptimisticReportNamesContext} from '@libs/OptimisticReportNamesConnectionManager'; import Pusher from '@libs/Pusher'; import {addMiddleware, processWithMiddleware} from '@libs/Request'; import {getAll, getLength as getPersistedRequestsLength} from '@userActions/PersistedRequests'; @@ -18,7 +17,7 @@ import type OnyxRequest from '@src/types/onyx/Request'; import type {PaginatedRequest, PaginationConfig, RequestConflictResolver} from '@src/types/onyx/Request'; import type Response from '@src/types/onyx/Response'; import type {ApiCommand, ApiRequestCommandParameters, ApiRequestType, CommandOfType, ReadCommand, SideEffectRequestCommand, WriteCommand} from './types'; -import {READ_COMMANDS, WRITE_COMMANDS} from './types'; +import {READ_COMMANDS} from './types'; // Setup API middlewares. Each request made will pass through a series of middleware functions that will get called in sequence (each one passing the result of the previous to the next). // Note: The ordering here is intentional as we want to Log, Recheck Connection, Reauthenticate, and Save the Response in Onyx. Errors thrown in one middleware will bubble to the next. @@ -92,25 +91,7 @@ function prepareRequest( if (optimisticData && shouldApplyOptimisticData) { Log.info('[API] Applying optimistic data', false, {command, type}); - - // Process optimistic data through report name middleware - // Skip for OpenReport command to avoid unnecessary processing - if (command === WRITE_COMMANDS.OPEN_REPORT) { - Onyx.update(optimisticData); - } else { - try { - const context = getUpdateContext(); - const processedData = OptimisticReportNames.updateOptimisticReportNamesFromUpdates(optimisticData, context, successData, failureData); - - Onyx.update(processedData.optimisticData); - processedSuccessData = processedData.successData; - processedFailureData = processedData.failureData; - } catch (error) { - Log.hmmm('[API] Failed to process optimistic report names', {error}); - // Fallback to original optimistic data if processing fails - Onyx.update(optimisticData); - } - } + Onyx.update(optimisticData); } const isWriteRequest = type === CONST.API_REQUEST_TYPE.WRITE; diff --git a/src/libs/OptimisticReportNames.ts b/src/libs/OptimisticReportNames.ts index 1c72f58ed40b..d10d3b71d219 100644 --- a/src/libs/OptimisticReportNames.ts +++ b/src/libs/OptimisticReportNames.ts @@ -1,7 +1,5 @@ import type {OnyxUpdate} from 'react-native-onyx'; -import Onyx from 'react-native-onyx'; import CONST from '@src/CONST'; -import type {OnyxKey} from '@src/ONYXKEYS'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Transaction} from '@src/types/onyx'; import type Policy from '@src/types/onyx/Policy'; @@ -10,35 +8,13 @@ import type {FormulaContext} from './Formula'; import {compute, FORMULA_PART_TYPES, parse, requiresBackendComputation} from './Formula'; import Log from './Log'; import type {UpdateContext} from './OptimisticReportNamesConnectionManager'; -import Permissions from './Permissions'; -import {getTitleReportField, isArchivedReport} from './ReportUtils'; +import {getTitleReportField} from './ReportUtils'; type ReportNameUpdate = { name: string | undefined; isPending?: boolean; }; -type ProcessedUpdates = { - optimisticData: OnyxUpdate[]; - successData?: OnyxUpdate[]; - failureData?: OnyxUpdate[]; -}; - -type AdditionalUpdates = { - optimisticData: OnyxUpdate[]; - successData: OnyxUpdate[]; - failureData: OnyxUpdate[]; -}; - -/** - * Get the title field from report name value pairs - */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -- this will be used in near future -function getTitleFieldFromRNVP(reportID: string, context: UpdateContext) { - const reportNameValuePairs = context.allReportNameValuePairs[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`]; - return reportNameValuePairs?.expensify_text_title; -} - /** * Temporary function to get the title field from a policy. Eventually we want to move this to report name value pairs. * @param policyId @@ -87,14 +63,6 @@ function getPolicyIDFromKey(key: string): string { return key.replace(ONYXKEYS.COLLECTION.POLICY, ''); } -/** - * Extract transaction ID from an Onyx key - */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -- this will be used in near future -function getTransactionIDFromKey(key: string): string { - return key.replace(ONYXKEYS.COLLECTION.TRANSACTION, ''); -} - /** * Get report by ID from the reports collection */ @@ -119,39 +87,6 @@ function getTransactionByID(transactionID: string, allTransactions: Record, context: UpdateContext): Report[] { - if (policyID === CONST.POLICY.ID_FAKE) { - return []; - } - return Object.values(allReports).filter((report) => { - if (report?.policyID !== policyID) { - return false; - } - - // Filter by type - only reports that support custom names - if (!isValidReportType(report.type)) { - return false; - } - - // Filter by state - exclude reports in high states (like approved or higher) - const stateThreshold = CONST.REPORT.STATE_NUM.APPROVED; - if (report.stateNum && report.stateNum > stateThreshold) { - return false; - } - - // Filter by isArchived - exclude archived reports - const reportNameValuePairs = context.allReportNameValuePairs[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`]; - if (isArchivedReport(reportNameValuePairs)) { - return false; - } - - return true; - }); -} - /** * Get the report associated with a transaction ID */ @@ -170,13 +105,6 @@ function getReportByTransactionID(transactionID: string, context: UpdateContext) return getReportByID(transaction.reportID, context.allReports); } -/** - * Generate the Onyx key for a report - */ -function getReportKey(reportID: string): OnyxKey { - return `${ONYXKEYS.COLLECTION.REPORT}${reportID}` as OnyxKey; -} - /** * Check if a report should have its name automatically computed */ @@ -307,129 +235,5 @@ function computeReportNameIfNeeded(report: Report | undefined, incomingUpdate: O return null; } -/** - * Add computed report name updates to the provided update buckets. - * Appends optimistic data and clears pending fields on success/failure if needed. - */ -function addAdditionalUpdates(reportID: string, reportNameUpdate: ReportNameUpdate, additionalUpdates: AdditionalUpdates) { - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - additionalUpdates.optimisticData.push({ - key: getReportKey(reportID), - onyxMethod: Onyx.METHOD.MERGE, - value: { - reportName: reportNameUpdate.name, - ...(reportNameUpdate.isPending && { - pendingFields: {reportName: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, - }), - }, - }); - - // If there's a pending field, add updates to clear it in success/failure data - if (reportNameUpdate.isPending) { - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - const clearPendingUpdate: OnyxUpdate = { - key: getReportKey(reportID), - onyxMethod: Onyx.METHOD.MERGE, - value: {pendingFields: {reportName: null}}, - }; - - additionalUpdates.successData.push(clearPendingUpdate); - additionalUpdates.failureData.push(clearPendingUpdate); - } -} - -/** - * Update optimistic report names based on incoming updates - * This is the main middleware function that processes optimistic data - */ -function updateOptimisticReportNamesFromUpdates(optimisticData: OnyxUpdate[], context: UpdateContext, successData?: OnyxUpdate[], failureData?: OnyxUpdate[]): ProcessedUpdates { - const {betas, allReports, betaConfiguration} = context; - - // Check if the feature is enabled - if (!Permissions.isBetaEnabled(CONST.BETAS.CUSTOM_REPORT_NAMES, betas, betaConfiguration)) { - return { - optimisticData, - successData, - failureData, - }; - } - - Log.info('[OptimisticReportNames] Processing optimistic updates for report names', false, { - updatesCount: optimisticData.length, - }); - - const additionalUpdates: AdditionalUpdates = { - optimisticData: [], - successData: [], - failureData: [], - }; - - for (const update of optimisticData) { - const objectType = determineObjectTypeByKey(update.key); - - switch (objectType) { - case 'report': { - const reportID = getReportIDFromKey(update.key); - const report = getReportByID(reportID, allReports); - - // Handle both existing and new reports with the same function - const reportNameUpdate = computeReportNameIfNeeded(report, update, context); - - if (reportNameUpdate) { - addAdditionalUpdates(reportID, reportNameUpdate, additionalUpdates); - } - break; - } - - case 'policy': { - const policyID = getPolicyIDFromKey(update.key); - const affectedReports = getReportsForNameComputation(policyID, allReports, context); - for (const report of affectedReports) { - const reportNameUpdate = computeReportNameIfNeeded(report, update, context); - - if (reportNameUpdate) { - addAdditionalUpdates(report.reportID, reportNameUpdate, additionalUpdates); - } - } - break; - } - - case 'transaction': { - let report: Report | undefined; - const transactionUpdate = update.value as Partial; - if (transactionUpdate.reportID) { - report = getReportByID(transactionUpdate.reportID, allReports); - } else { - report = getReportByTransactionID(getTransactionIDFromKey(update.key), context); - } - - if (report) { - const reportNameUpdate = computeReportNameIfNeeded(report, update, context); - - if (reportNameUpdate) { - addAdditionalUpdates(report.reportID, reportNameUpdate, additionalUpdates); - } - } - break; - } - - default: - continue; - } - } - - Log.info('[OptimisticReportNames] Processing completed', false, { - additionalOptimisticUpdatesCount: additionalUpdates.optimisticData.length, - additionalSuccessUpdatesCount: additionalUpdates.successData.length, - additionalFailureUpdatesCount: additionalUpdates.failureData.length, - }); - - return { - optimisticData: optimisticData.concat(additionalUpdates.optimisticData), - successData: successData ? successData.concat(additionalUpdates.successData) : additionalUpdates.successData, - failureData: failureData ? failureData.concat(additionalUpdates.failureData) : additionalUpdates.failureData, - }; -} - -export {computeReportNameIfNeeded, getReportByTransactionID, shouldComputeReportName, updateOptimisticReportNamesFromUpdates}; +export {computeReportNameIfNeeded, getReportByTransactionID, shouldComputeReportName}; export type {UpdateContext}; diff --git a/tests/perf-test/OptimisticReportNames.perf-test.ts b/tests/perf-test/OptimisticReportNames.perf-test.ts index e40cca17aabb..1b50f220f4b7 100644 --- a/tests/perf-test/OptimisticReportNames.perf-test.ts +++ b/tests/perf-test/OptimisticReportNames.perf-test.ts @@ -1,7 +1,7 @@ import Onyx from 'react-native-onyx'; import {measureFunction} from 'reassure'; import type {UpdateContext} from '@libs/OptimisticReportNames'; -import {computeReportNameIfNeeded, updateOptimisticReportNamesFromUpdates} from '@libs/OptimisticReportNames'; +import {computeReportNameIfNeeded} from '@libs/OptimisticReportNames'; // eslint-disable-next-line no-restricted-syntax -- disabled because we need ReportUtils to mock import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; @@ -98,194 +98,4 @@ describe('[OptimisticReportNames] Performance Tests', () => { await measureFunction(() => computeReportNameIfNeeded(report, update, mockContext)); }); }); - - describe('Batch Processing Performance', () => { - test('[OptimisticReportNames] updateOptimisticReportNamesFromUpdates() with 10 new reports', async () => { - const updates = Array.from({length: 10}, (_, i) => ({ - key: `report_new${i}` as OnyxKey, - onyxMethod: Onyx.METHOD.SET, - value: { - reportID: `new${i}`, - policyID: `policy${i % 10}`, - total: -(Math.random() * 50000), - currency: 'USD', - lastVisibleActionCreated: new Date().toISOString(), - }, - })); - - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - await measureFunction(() => updateOptimisticReportNamesFromUpdates(updates, mockContext)); - }); - - test('[OptimisticReportNames] updateOptimisticReportNamesFromUpdates() with 50 existing report updates', async () => { - const reportKeys = Object.keys(mockReports).slice(0, 50) as OnyxKey[]; - const updates = reportKeys.map((key) => ({ - key, - onyxMethod: Onyx.METHOD.MERGE, - value: {total: -(Math.random() * 100000)}, - })); - - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - await measureFunction(() => updateOptimisticReportNamesFromUpdates(updates, mockContext)); - }); - - test('[OptimisticReportNames] updateOptimisticReportNamesFromUpdates() with 100 mixed updates', async () => { - const newReportUpdates = Array.from({length: 50}, (_, i) => ({ - key: `report_batch${i}` as OnyxKey, - onyxMethod: Onyx.METHOD.SET, - value: { - reportID: `batch${i}`, - policyID: `policy${i % 20}`, - total: -(Math.random() * 75000), - currency: 'USD', - lastVisibleActionCreated: new Date().toISOString(), - }, - })); - - const existingReportUpdates = Object.keys(mockReports) - .slice(0, 50) - .map((key) => ({ - key: key as OnyxKey, - onyxMethod: Onyx.METHOD.MERGE, - value: {total: -(Math.random() * 125000)}, - })); - - const allUpdates = [...newReportUpdates, ...existingReportUpdates]; - - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - await measureFunction(() => updateOptimisticReportNamesFromUpdates(allUpdates, mockContext)); - }); - }); - - describe('Policy Update Impact Performance', () => { - test('[OptimisticReportNames] policy update affecting multiple reports', async () => { - const policyUpdate = { - key: 'policy_policy1' as OnyxKey, - onyxMethod: Onyx.METHOD.MERGE, - value: {name: 'Updated Policy Name'}, - }; - - // This should trigger name computation for all reports using policy1 - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - await measureFunction(() => updateOptimisticReportNamesFromUpdates([policyUpdate], mockContext)); - }); - - test('[OptimisticReportNames] multiple policy updates', async () => { - const policyUpdates = Array.from({length: 10}, (_, i) => ({ - key: `policy_policy${i}` as OnyxKey, - onyxMethod: Onyx.METHOD.MERGE, - value: {name: `Bulk Updated Policy ${i}`}, - })); - - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - await measureFunction(() => updateOptimisticReportNamesFromUpdates(policyUpdates, mockContext)); - }); - }); - - describe('Large Dataset Performance', () => { - test('[OptimisticReportNames] processing with large context (1000 reports)', async () => { - const updates = Array.from({length: 1000}, (_, i) => ({ - key: `report_large${i}` as OnyxKey, - onyxMethod: Onyx.METHOD.SET, - value: { - reportID: `large${i}`, - policyID: `policy${i % 50}`, - total: -(Math.random() * 200000), - currency: 'USD', - lastVisibleActionCreated: new Date().toISOString(), - }, - })); - - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - await measureFunction(() => updateOptimisticReportNamesFromUpdates(updates, mockContext)); - }); - - test('[OptimisticReportNames] worst case: many irrelevant updates', async () => { - // Create updates that won't trigger name computation to test filtering performance - const irrelevantUpdates = Array.from({length: 100}, (_, i) => ({ - key: `transaction_${i}` as OnyxKey, - onyxMethod: Onyx.METHOD.MERGE, - value: {description: `Updated transaction ${i}`}, - })); - - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - await measureFunction(() => updateOptimisticReportNamesFromUpdates(irrelevantUpdates, mockContext)); - }); - }); - - describe('Edge Cases Performance', () => { - test('[OptimisticReportNames] reports without formulas', async () => { - // Mock reports with policies that don't have formulas - const contextWithoutFormulas: UpdateContext = { - ...mockContext, - allPolicies: createCollection( - (item) => `policy_${item.id}`, - (index) => - ({ - id: `policy${index}`, - name: `Policy ${index}`, - fieldList: { - // eslint-disable-next-line @typescript-eslint/naming-convention - text_title: { - name: 'Title', - defaultValue: 'Static Title', - fieldID: 'text_title', - orderWeight: 0, - type: 'text' as const, - deletable: true, - values: [], - keys: [], - externalIDs: [], - disabledOptions: [], - isTax: false, - }, - }, - }) as unknown as Policy, - 50, - ), - allReportNameValuePairs: {}, - isOffline: false, - }; - - const updates = Array.from({length: 20}, (_, i) => ({ - key: `report_static${i}` as OnyxKey, - onyxMethod: Onyx.METHOD.SET, - value: { - reportID: `static${i}`, - policyID: `policy${i % 10}`, - total: -10000, - currency: 'USD', - }, - })); - - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - await measureFunction(() => updateOptimisticReportNamesFromUpdates(updates, contextWithoutFormulas)); - }); - - test('[OptimisticReportNames] missing policies and reports', async () => { - const contextWithMissingData: UpdateContext = { - betas: [CONST.BETAS.CUSTOM_REPORT_NAMES], - betaConfiguration: {}, - allReports: {}, - allPolicies: {}, - allReportNameValuePairs: {}, - allTransactions: {}, - isOffline: false, - }; - - const updates = Array.from({length: 10}, (_, i) => ({ - key: `report_missing${i}` as OnyxKey, - onyxMethod: Onyx.METHOD.SET, - value: { - reportID: `missing${i}`, - policyID: 'nonexistent', - total: -10000, - currency: 'USD', - }, - })); - - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - await measureFunction(() => updateOptimisticReportNamesFromUpdates(updates, contextWithMissingData)); - }); - }); }); diff --git a/tests/unit/OptimisticReportNamesTest.ts b/tests/unit/OptimisticReportNamesTest.ts index bd005f1003e2..36121446bf31 100644 --- a/tests/unit/OptimisticReportNamesTest.ts +++ b/tests/unit/OptimisticReportNamesTest.ts @@ -2,7 +2,7 @@ import Onyx from 'react-native-onyx'; // eslint-disable-next-line no-restricted-syntax -- disabled because we need ReportUtils to mock import type * as CurrencyUtils from '@libs/CurrencyUtils'; import type {UpdateContext} from '@libs/OptimisticReportNames'; -import {computeReportNameIfNeeded, getReportByTransactionID, shouldComputeReportName, updateOptimisticReportNamesFromUpdates} from '@libs/OptimisticReportNames'; +import {computeReportNameIfNeeded, getReportByTransactionID, shouldComputeReportName} from '@libs/OptimisticReportNames'; // eslint-disable-next-line no-restricted-syntax -- disabled because we need ReportUtils to mock import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; @@ -143,135 +143,6 @@ describe('OptimisticReportNames', () => { }); }); - describe('updateOptimisticReportNamesFromUpdates()', () => { - test('should detect new report creation and add name update', () => { - const updates = [ - { - key: 'report_456' as OnyxKey, - onyxMethod: Onyx.METHOD.SET, - value: { - reportID: '456', - policyID: 'policy1', - total: -15000, - currency: 'USD', - type: 'expense', - }, - }, - ]; - - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - const result = updateOptimisticReportNamesFromUpdates(updates, mockContext); - expect(result.optimisticData).toHaveLength(2); // Original + name update - expect(result.optimisticData.at(1)).toEqual({ - key: 'report_456', - onyxMethod: Onyx.METHOD.MERGE, - value: {reportName: 'Expense Report - $150.00'}, - }); - }); - - test('should handle existing report updates', () => { - const updates = [ - { - key: 'report_123' as OnyxKey, - onyxMethod: Onyx.METHOD.MERGE, - value: {total: -25000}, - }, - ]; - - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - const result = updateOptimisticReportNamesFromUpdates(updates, mockContext); - expect(result.optimisticData).toHaveLength(2); // Original + name update - expect(result.optimisticData.at(1)?.value).toEqual({reportName: 'Expense Report - $250.00'}); - }); - - test('should handle policy updates affecting multiple reports', () => { - const contextWithMultipleReports = { - ...mockContext, - allReports: { - // eslint-disable-next-line @typescript-eslint/naming-convention - report_123: {...mockReport, reportID: '123'}, - // eslint-disable-next-line @typescript-eslint/naming-convention - report_456: {...mockReport, reportID: '456'}, - // eslint-disable-next-line @typescript-eslint/naming-convention - report_789: {...mockReport, reportID: '789'}, - }, - allPolicies: { - // eslint-disable-next-line @typescript-eslint/naming-convention - policy_policy1: { - id: 'policy1', - fieldList: { - [CONST.REPORT_FIELD_TITLE_FIELD_ID]: { - fieldID: CONST.REPORT_FIELD_TITLE_FIELD_ID, - defaultValue: 'Policy: {report:policyname}', - }, - }, - } as unknown as Policy, - }, - allReportNameValuePairs: { - // eslint-disable-next-line @typescript-eslint/naming-convention - reportNameValuePairs_123: {private_isArchived: '', expensify_text_title: {defaultValue: 'Policy: {report:policyname}'}} as unknown as ReportNameValuePairs, - // eslint-disable-next-line @typescript-eslint/naming-convention - reportNameValuePairs_456: {private_isArchived: '', expensify_text_title: {defaultValue: 'Policy: {report:policyname}'}} as unknown as ReportNameValuePairs, - // eslint-disable-next-line @typescript-eslint/naming-convention - reportNameValuePairs_789: {private_isArchived: '', expensify_text_title: {defaultValue: 'Policy: {report:policyname}'}} as unknown as ReportNameValuePairs, - }, - }; - - const updates = [ - { - key: 'policy_policy1' as OnyxKey, - onyxMethod: Onyx.METHOD.MERGE, - value: {name: 'Updated Policy Name'}, - }, - ]; - - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - const result = updateOptimisticReportNamesFromUpdates(updates, contextWithMultipleReports); - - expect(result.optimisticData).toHaveLength(4); - - // Assert the original policy update - expect(result.optimisticData.at(0)).toEqual({ - key: 'policy_policy1', - onyxMethod: Onyx.METHOD.MERGE, - value: {name: 'Updated Policy Name'}, - }); - - // Assert individual report name updates - expect(result.optimisticData.at(1)).toEqual({ - key: 'report_123', - onyxMethod: Onyx.METHOD.MERGE, - value: {reportName: 'Policy: Updated Policy Name'}, - }); - - expect(result.optimisticData.at(2)).toEqual({ - key: 'report_456', - onyxMethod: Onyx.METHOD.MERGE, - value: {reportName: 'Policy: Updated Policy Name'}, - }); - - expect(result.optimisticData.at(3)).toEqual({ - key: 'report_789', - onyxMethod: Onyx.METHOD.MERGE, - value: {reportName: 'Policy: Updated Policy Name'}, - }); - }); - - test('should handle unknown object types gracefully', () => { - const updates = [ - { - key: 'unknown_123' as OnyxKey, - onyxMethod: Onyx.METHOD.MERGE, - value: {someData: 'value'}, - }, - ]; - - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - const result = updateOptimisticReportNamesFromUpdates(updates, mockContext); - expect(result.optimisticData).toEqual(updates); // Unchanged - }); - }); - describe('Edge Cases', () => { test('should handle missing report gracefully', () => { const update = { @@ -287,40 +158,6 @@ describe('OptimisticReportNames', () => { }); describe('Transaction Updates', () => { - test('should process transaction updates and trigger report name updates', () => { - const contextWithTransaction = { - ...mockContext, - allTransactions: { - // eslint-disable-next-line @typescript-eslint/naming-convention - transactions_txn123: { - transactionID: 'txn123', - reportID: '123', - created: '2024-01-01', - amount: -5000, - currency: 'USD', - merchant: 'Original Merchant', - }, - }, - }; - - const update = { - key: 'transactions_txn123' as OnyxKey, - onyxMethod: Onyx.METHOD.MERGE, - value: { - created: '2024-02-15', // Updated date - reportID: '123', - }, - }; - - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - const result = updateOptimisticReportNamesFromUpdates([update], contextWithTransaction); - - // Should include original update + new report name update - expect(result.optimisticData).toHaveLength(2); - expect(result.optimisticData.at(0)).toEqual(update); // Original transaction update - expect(result.optimisticData.at(1)?.key).toBe('report_123'); // New report update - }); - test('getReportByTransactionID should find report from transaction', () => { const contextWithTransaction = { ...mockContext, @@ -366,106 +203,5 @@ describe('OptimisticReportNames', () => { const result = getReportByTransactionID('incomplete', contextWithIncompleteTransaction); expect(result).toBeUndefined(); }); - - test('should handle transaction updates that rely on context lookup', () => { - const contextWithTransaction = { - ...mockContext, - allTransactions: { - // eslint-disable-next-line @typescript-eslint/naming-convention - transactions_xyz789: { - transactionID: 'xyz789', - reportID: '123', - created: '2024-01-01', - amount: -3000, - currency: 'EUR', - merchant: 'Context Store', - }, - }, - }; - - // Transaction update without reportID in the value - const update = { - key: 'transactions_xyz789' as OnyxKey, - onyxMethod: Onyx.METHOD.MERGE, - value: { - amount: -4000, // Updated amount - // No reportID provided in update - }, - }; - - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - const result = updateOptimisticReportNamesFromUpdates([update], contextWithTransaction); - - // Should still find the report through context lookup and generate update - expect(result.optimisticData).toHaveLength(2); - expect(result.optimisticData.at(1)?.key).toBe('report_123'); - }); - - test('should use optimistic transaction data in formula computation', () => { - const contextWithTransaction = { - ...mockContext, - allReportNameValuePairs: { - // eslint-disable-next-line @typescript-eslint/naming-convention - reportNameValuePairs_123: { - // eslint-disable-next-line @typescript-eslint/naming-convention - expensify_text_title: { - defaultValue: 'Report from {report:startdate}', - }, - } as unknown as ReportNameValuePairs, - }, - allPolicies: { - // eslint-disable-next-line @typescript-eslint/naming-convention - policy_policy1: { - id: 'policy1', - fieldList: { - [CONST.REPORT_FIELD_TITLE_FIELD_ID]: { - fieldID: CONST.REPORT_FIELD_TITLE_FIELD_ID, - defaultValue: 'Report from {report:startdate}', - }, - }, - } as unknown as Policy, - }, - allTransactions: { - // eslint-disable-next-line @typescript-eslint/naming-convention - transactions_formula123: { - transactionID: 'formula123', - reportID: '123', - created: '2024-01-01', // Original date - amount: -5000, - currency: 'USD', - merchant: 'Original Store', - }, - }, - }; - - // Mock getReportTransactions to return the original transaction - // eslint-disable-next-line @typescript-eslint/dot-notation - mockReportUtils.getReportTransactions.mockReturnValue([contextWithTransaction.allTransactions['transactions_formula123']]); - - const update = { - key: 'transactions_formula123' as OnyxKey, - onyxMethod: Onyx.METHOD.MERGE, - value: { - transactionID: 'formula123', - created: '2024-03-15', // Updated date that should be used in formula - modifiedCreated: '2024-03-15', - }, - }; - - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - const result = updateOptimisticReportNamesFromUpdates([update], contextWithTransaction); - - expect(result.optimisticData).toHaveLength(2); - - // The key test: verify exact report name with optimistic date - const reportUpdate = result.optimisticData.at(1); - expect(reportUpdate).toEqual({ - key: 'report_123', - onyxMethod: Onyx.METHOD.MERGE, - value: { - reportName: 'Report from 2024-03-15', // Exact expected result with updated date - }, - }); - }); }); }); From c1ade0b3e446d635ff0d660f24a3cf0632a5fafa Mon Sep 17 00:00:00 2001 From: truph01 Date: Thu, 11 Dec 2025 11:04:52 +0700 Subject: [PATCH 02/14] fix: lint --- src/libs/API/index.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/libs/API/index.ts b/src/libs/API/index.ts index 35e9d1d6cdc3..26a7da670a30 100644 --- a/src/libs/API/index.ts +++ b/src/libs/API/index.ts @@ -86,9 +86,6 @@ function prepareRequest( const {optimisticData, successData, failureData, ...onyxDataWithoutOptimisticData} = onyxData; - let processedSuccessData = successData; - let processedFailureData = failureData; - if (optimisticData && shouldApplyOptimisticData) { Log.info('[API] Applying optimistic data', false, {command, type}); Onyx.update(optimisticData); @@ -118,8 +115,8 @@ function prepareRequest( initiatedOffline: isOffline(), requestID: requestIndex++, ...onyxDataWithoutOptimisticData, - successData: processedSuccessData, - failureData: processedFailureData, + successData, + failureData, ...conflictResolver, }; From 9d8eeaefb0503d361105e64872c1e5aa9882cc04 Mon Sep 17 00:00:00 2001 From: truph01 Date: Mon, 15 Dec 2025 09:11:09 +0700 Subject: [PATCH 03/14] fix: remove all related files --- src/libs/OptimisticReportNames.ts | 239 ------------------ .../OptimisticReportNames.perf-test.ts | 101 -------- tests/unit/OptimisticReportNamesTest.ts | 207 --------------- 3 files changed, 547 deletions(-) delete mode 100644 src/libs/OptimisticReportNames.ts delete mode 100644 tests/perf-test/OptimisticReportNames.perf-test.ts delete mode 100644 tests/unit/OptimisticReportNamesTest.ts diff --git a/src/libs/OptimisticReportNames.ts b/src/libs/OptimisticReportNames.ts deleted file mode 100644 index d10d3b71d219..000000000000 --- a/src/libs/OptimisticReportNames.ts +++ /dev/null @@ -1,239 +0,0 @@ -import type {OnyxUpdate} from 'react-native-onyx'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {Transaction} from '@src/types/onyx'; -import type Policy from '@src/types/onyx/Policy'; -import type Report from '@src/types/onyx/Report'; -import type {FormulaContext} from './Formula'; -import {compute, FORMULA_PART_TYPES, parse, requiresBackendComputation} from './Formula'; -import Log from './Log'; -import type {UpdateContext} from './OptimisticReportNamesConnectionManager'; -import {getTitleReportField} from './ReportUtils'; - -type ReportNameUpdate = { - name: string | undefined; - isPending?: boolean; -}; - -/** - * Temporary function to get the title field from a policy. Eventually we want to move this to report name value pairs. - * @param policyId - * @param context - */ -function getTitleFieldFromPolicy(policyId: string | undefined, context: UpdateContext) { - if (!policyId) { - return; - } - - const policy = context.allPolicies[`${ONYXKEYS.COLLECTION.POLICY}${policyId}`]; - if (!policy || !policy.fieldList) { - return; - } - - return getTitleReportField(policy.fieldList); -} - -/** - * Get the object type from an Onyx key - */ -function determineObjectTypeByKey(key: string): 'report' | 'policy' | 'transaction' | 'unknown' { - if (key.startsWith(ONYXKEYS.COLLECTION.REPORT)) { - return 'report'; - } - if (key.startsWith(ONYXKEYS.COLLECTION.POLICY)) { - return 'policy'; - } - if (key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION)) { - return 'transaction'; - } - return 'unknown'; -} - -/** - * Extract report ID from an Onyx key - */ -function getReportIDFromKey(key: string): string { - return key.replace(ONYXKEYS.COLLECTION.REPORT, ''); -} - -/** - * Extract policy ID from an Onyx key - */ -function getPolicyIDFromKey(key: string): string { - return key.replace(ONYXKEYS.COLLECTION.POLICY, ''); -} - -/** - * Get report by ID from the reports collection - */ -function getReportByID(reportID: string, allReports: Record): Report | undefined { - return allReports[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; -} - -/** - * Get policy by ID from the policies collection - */ -function getPolicyByID(policyID: string | undefined, allPolicies: Record): Policy | undefined { - if (!policyID) { - return; - } - return allPolicies[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; -} - -/** - * Get transaction by ID from the transactions collection - */ -function getTransactionByID(transactionID: string, allTransactions: Record): Transaction | undefined { - return allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; -} - -/** - * Get the report associated with a transaction ID - */ -function getReportByTransactionID(transactionID: string, context: UpdateContext): Report | undefined { - if (!transactionID) { - return undefined; - } - - const transaction = getTransactionByID(transactionID, context.allTransactions); - - if (!transaction?.reportID) { - return undefined; - } - - // Get the report using the transaction's reportID from context - return getReportByID(transaction.reportID, context.allReports); -} - -/** - * Check if a report should have its name automatically computed - */ -function shouldComputeReportName(report: Report, context: UpdateContext): boolean { - if (!report) { - return false; - } - - if (!isValidReportType(report.type)) { - return false; - } - - // Only compute names for expense reports with policies that have title fields - // Check if the report has a title field with a formula in policy - const policyTitleField = getTitleFieldFromPolicy(report.policyID, context); - if (!policyTitleField?.defaultValue) { - return false; - } - return true; -} - -function isValidReportType(reportType?: string): boolean { - if (!reportType) { - return false; - } - return ( - reportType === CONST.REPORT.TYPE.EXPENSE || - reportType === CONST.REPORT.TYPE.INVOICE || - reportType === CONST.REPORT.UNSUPPORTED_TYPE.BILL || - reportType === CONST.REPORT.UNSUPPORTED_TYPE.PAYCHECK || - reportType === 'trip' - ); -} - -/** - * Compute a new report name if needed based on an optimistic update - */ -function computeReportNameIfNeeded(report: Report | undefined, incomingUpdate: OnyxUpdate, context: UpdateContext): ReportNameUpdate | null { - const {allPolicies, isOffline} = context; - - // If no report is provided, extract it from the update (for new reports) - const targetReport = report ?? (incomingUpdate.value as Report); - - if (!targetReport) { - return null; - } - - const policy = getPolicyByID(targetReport.policyID, allPolicies); - - if (!shouldComputeReportName(targetReport, context)) { - return null; - } - - const titleField = getTitleFieldFromPolicy(targetReport.policyID, context); - - // Quick check: see if the update might affect the report name - const updateType = determineObjectTypeByKey(incomingUpdate.key); - const formula = titleField?.defaultValue; - const formulaParts = parse(formula); - - let transaction: Transaction | undefined; - if (updateType === 'transaction') { - transaction = getTransactionByID((incomingUpdate.value as Transaction).transactionID, context.allTransactions); - } - - // Check if any formula part might be affected by this update - const isAffected = formulaParts.some((part) => { - if (part.type === FORMULA_PART_TYPES.REPORT) { - // Checking if the formula part is affected in this manner works, but it could certainly be more precise. - // For example, a policy update only affects the part if the formula in the policy changed, or if the report part references a field on the policy. - // However, if we run into performance problems, this would be a good place to optimize. - return updateType === 'report' || updateType === 'transaction' || updateType === 'policy'; - } - if (part.type === FORMULA_PART_TYPES.FIELD) { - return updateType === 'report'; - } - return false; - }); - - if (!isAffected) { - return null; - } - - // Build context with the updated data - const updatedReport = - updateType === 'report' && targetReport.reportID === getReportIDFromKey(incomingUpdate.key) ? {...targetReport, ...(incomingUpdate.value as Partial)} : targetReport; - - const updatedPolicy = updateType === 'policy' && targetReport.policyID === getPolicyIDFromKey(incomingUpdate.key) ? {...(policy ?? {}), ...(incomingUpdate.value as Policy)} : policy; - - const updatedTransaction = updateType === 'transaction' ? {...(transaction ?? {}), ...(incomingUpdate.value as Transaction)} : undefined; - - // Compute the new name - const formulaContext: FormulaContext = { - report: updatedReport, - policy: updatedPolicy, - transaction: updatedTransaction, - allTransactions: context.allTransactions, - }; - - // When we cannot properly compute the formula (e.g., currency conversion requires exchange rates), - // computing while online causes flickering between incorrect optimistic values and correct backend values. - // Return null to skip optimistic updates and let the backend provide the accurate result. - if (requiresBackendComputation(formulaParts, formulaContext)) { - if (!isOffline) { - return null; - } - - return { - name: targetReport.reportName, - isPending: true, - }; - } - - const newName = compute(formula, formulaContext); - - // Only return an update if the name actually changed - if (newName && newName !== targetReport.reportName) { - Log.info('[OptimisticReportNames] Report name computed', false, { - updateType, - isNewReport: !report, - }); - - return { - name: newName, - }; - } - - return null; -} - -export {computeReportNameIfNeeded, getReportByTransactionID, shouldComputeReportName}; -export type {UpdateContext}; diff --git a/tests/perf-test/OptimisticReportNames.perf-test.ts b/tests/perf-test/OptimisticReportNames.perf-test.ts deleted file mode 100644 index 1b50f220f4b7..000000000000 --- a/tests/perf-test/OptimisticReportNames.perf-test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import Onyx from 'react-native-onyx'; -import {measureFunction} from 'reassure'; -import type {UpdateContext} from '@libs/OptimisticReportNames'; -import {computeReportNameIfNeeded} from '@libs/OptimisticReportNames'; -// eslint-disable-next-line no-restricted-syntax -- disabled because we need ReportUtils to mock -import * as ReportUtils from '@libs/ReportUtils'; -import CONST from '@src/CONST'; -import type {OnyxKey} from '@src/ONYXKEYS'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy, Report} from '@src/types/onyx'; -import createCollection from '../utils/collections/createCollection'; -import {createRandomReport} from '../utils/collections/reports'; -import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; - -// Mock dependencies -jest.mock('@libs/ReportUtils', () => ({ - // jest.requireActual is necessary to include multi-layered module imports (eg. Report.ts has processReportIDDeeplink() which uses parseReportRouteParams() imported from getReportIDFromUrl.ts) - // Without jest.requireActual, parseReportRouteParams would be undefined, causing the test to fail. - ...jest.requireActual('@libs/ReportUtils'), - // These methods are mocked below in the beforeAll function to return specific values - isExpenseReport: jest.fn(), - getTitleReportField: jest.fn(), -})); -jest.mock('@libs/Log', () => ({ - info: jest.fn(), -})); - -const mockReportUtils = ReportUtils as jest.Mocked; - -describe('[OptimisticReportNames] Performance Tests', () => { - const REPORTS_COUNT = 1000; - const POLICIES_COUNT = 100; - - const mockPolicy = { - id: 'policy1', - name: 'Test Policy', - fieldList: { - // eslint-disable-next-line @typescript-eslint/naming-convention - text_title: { - defaultValue: '{report:type} - {report:startdate} - {report:total} {report:currency}', - }, - }, - } as unknown as Policy; - - const mockPolicies = createCollection( - (item) => `policy_${item.id}`, - (index) => ({ - ...mockPolicy, - id: `policy${index}`, - name: `Policy ${index}`, - }), - POLICIES_COUNT, - ); - - const mockReports = createCollection( - (item) => `${ONYXKEYS.COLLECTION.REPORT}${item.reportID}`, - (index) => ({ - ...createRandomReport(index, undefined), - policyID: `policy${index % POLICIES_COUNT}`, - total: -(Math.random() * 100000), // Random negative amount - currency: 'USD', - lastVisibleActionCreated: new Date().toISOString(), - }), - REPORTS_COUNT, - ); - - const mockContext: UpdateContext = { - betas: [CONST.BETAS.CUSTOM_REPORT_NAMES], - betaConfiguration: {}, - allReports: mockReports, - allPolicies: mockPolicies, - allReportNameValuePairs: {}, - allTransactions: {}, - isOffline: false, - }; - - beforeAll(async () => { - Onyx.init({keys: ONYXKEYS}); - mockReportUtils.isExpenseReport.mockReturnValue(true); - mockReportUtils.getTitleReportField.mockReturnValue(mockPolicy.fieldList?.text_title); - await waitForBatchedUpdates(); - }); - - afterAll(() => { - Onyx.clear(); - }); - - describe('Single Report Name Computation', () => { - test('[OptimisticReportNames] computeReportNameIfNeeded() single report', async () => { - const report = Object.values(mockReports).at(0); - const update = { - key: `report_${report?.reportID}` as OnyxKey, - onyxMethod: Onyx.METHOD.MERGE, - value: {total: -20000}, - }; - - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - await measureFunction(() => computeReportNameIfNeeded(report, update, mockContext)); - }); - }); -}); diff --git a/tests/unit/OptimisticReportNamesTest.ts b/tests/unit/OptimisticReportNamesTest.ts deleted file mode 100644 index 36121446bf31..000000000000 --- a/tests/unit/OptimisticReportNamesTest.ts +++ /dev/null @@ -1,207 +0,0 @@ -import Onyx from 'react-native-onyx'; -// eslint-disable-next-line no-restricted-syntax -- disabled because we need ReportUtils to mock -import type * as CurrencyUtils from '@libs/CurrencyUtils'; -import type {UpdateContext} from '@libs/OptimisticReportNames'; -import {computeReportNameIfNeeded, getReportByTransactionID, shouldComputeReportName} from '@libs/OptimisticReportNames'; -// eslint-disable-next-line no-restricted-syntax -- disabled because we need ReportUtils to mock -import * as ReportUtils from '@libs/ReportUtils'; -import CONST from '@src/CONST'; -import type {OnyxKey} from '@src/ONYXKEYS'; -import type {Policy, Report, ReportNameValuePairs, Transaction} from '@src/types/onyx'; - -// Mock dependencies -jest.mock('@libs/ReportUtils', () => ({ - ...jest.requireActual('@libs/ReportUtils'), - isExpenseReport: jest.fn(), - getReportTransactions: jest.fn(), -})); - -jest.mock('@libs/CurrencyUtils', () => ({ - ...jest.requireActual('@libs/CurrencyUtils'), - isValidCurrencyCode: jest.fn().mockImplementation((code: string) => ['USD'].includes(code)), -})); - -const mockReportUtils = ReportUtils as jest.Mocked; - -describe('OptimisticReportNames', () => { - const mockPolicy = { - id: 'policy1', - fieldList: { - [CONST.REPORT_FIELD_TITLE_FIELD_ID]: { - fieldID: CONST.REPORT_FIELD_TITLE_FIELD_ID, - defaultValue: '{report:type} - {report:total}', - }, - }, - } as unknown as Policy; - - const mockReport = { - reportID: '123', - reportName: 'Old Name', - policyID: 'policy1', - total: -10000, - currency: 'USD', - lastVisibleActionCreated: '2025-01-15T10:30:00Z', - type: 'expense', - } as Report; - - const mockContext: UpdateContext = { - betas: [CONST.BETAS.CUSTOM_REPORT_NAMES], - betaConfiguration: {}, - allReports: { - // eslint-disable-next-line @typescript-eslint/naming-convention - report_123: mockReport, - }, - allPolicies: { - // eslint-disable-next-line @typescript-eslint/naming-convention - policy_policy1: mockPolicy, - }, - allReportNameValuePairs: { - // eslint-disable-next-line @typescript-eslint/naming-convention - reportNameValuePairs_123: { - private_isArchived: '', - // eslint-disable-next-line @typescript-eslint/naming-convention - expensify_text_title: { - defaultValue: '{report:type} - {report:total}', - }, - } as unknown as ReportNameValuePairs, - }, - allTransactions: {}, - isOffline: false, - }; - - beforeEach(() => { - jest.clearAllMocks(); - mockReportUtils.isExpenseReport.mockReturnValue(true); - }); - - describe('shouldComputeReportName()', () => { - test('should return true for report with title field formula', () => { - const result = shouldComputeReportName(mockReport, mockContext); - expect(result).toBe(true); - }); - - test('should return false when no title field', () => { - const context = { - ...mockContext, - allReportNameValuePairs: { - ...mockContext.allReportNameValuePairs, - // eslint-disable-next-line @typescript-eslint/naming-convention - reportNameValuePairs_123: { - private_isArchived: '', - }, - }, - allPolicies: {}, - }; - const result = shouldComputeReportName(mockReport, context); - expect(result).toBe(false); - }); - - test('should return false for reports with unsupported type', () => { - mockReportUtils.isExpenseReport.mockReturnValue(false); - - const result = shouldComputeReportName( - { - ...mockReport, - type: 'iou', - } as Report, - mockContext, - ); - expect(result).toBe(false); - }); - }); - - describe('computeReportNameIfNeeded()', () => { - test('should compute name when report data changes', () => { - const update = { - key: 'report_123' as OnyxKey, - onyxMethod: Onyx.METHOD.MERGE, - value: {total: -20000}, - }; - - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - const result = computeReportNameIfNeeded(mockReport, update, mockContext); - expect(result?.name).toEqual('Expense Report - $200.00'); - }); - - test('should return null when name would not change', () => { - const update = { - key: 'report_456' as OnyxKey, - onyxMethod: Onyx.METHOD.MERGE, - value: {description: 'Updated description'}, - }; - - const result = computeReportNameIfNeeded( - { - ...mockReport, - reportName: 'Expense Report - $100.00', - }, - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - update, - mockContext, - ); - expect(result).toBeNull(); - }); - }); - - describe('Edge Cases', () => { - test('should handle missing report gracefully', () => { - const update = { - key: 'report_999' as OnyxKey, - onyxMethod: Onyx.METHOD.MERGE, - value: {total: -10000}, - }; - - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - const result = computeReportNameIfNeeded(undefined, update, mockContext); - expect(result).toBeNull(); - }); - }); - - describe('Transaction Updates', () => { - test('getReportByTransactionID should find report from transaction', () => { - const contextWithTransaction = { - ...mockContext, - allTransactions: { - // eslint-disable-next-line @typescript-eslint/naming-convention - transactions_abc123: { - transactionID: 'abc123', - reportID: '123', - amount: -7500, - created: '2024-01-15', - currency: 'USD', - merchant: 'Test Store', - }, - }, - }; - - const result = getReportByTransactionID('abc123', contextWithTransaction); - - expect(result).toEqual(mockReport); - expect(result?.reportID).toBe('123'); - }); - - test('getReportByTransactionID should return undefined for missing transaction', () => { - const result = getReportByTransactionID('nonexistent', mockContext); - expect(result).toBeUndefined(); - }); - - test('getReportByTransactionID should return undefined for transaction without reportID', () => { - const contextWithIncompleteTransaction = { - ...mockContext, - allTransactions: { - // eslint-disable-next-line @typescript-eslint/naming-convention - transactions_incomplete: { - transactionID: 'incomplete' as OnyxKey, - amount: -1000, - currency: 'USD', - merchant: 'Store', - // Missing reportID - } as unknown as Transaction, - }, - }; - - const result = getReportByTransactionID('incomplete', contextWithIncompleteTransaction); - expect(result).toBeUndefined(); - }); - }); -}); From 1f4182d04890d0ca401f7b4a739558cfe58f5a05 Mon Sep 17 00:00:00 2001 From: truph01 Date: Tue, 23 Dec 2025 17:16:43 +0700 Subject: [PATCH 04/14] fix: remove OptimisticReportNamesConnectionManager --- src/libs/API/index.ts | 6 - .../OptimisticReportNamesConnectionManager.ts | 149 ------------------ 2 files changed, 155 deletions(-) delete mode 100644 src/libs/OptimisticReportNamesConnectionManager.ts diff --git a/src/libs/API/index.ts b/src/libs/API/index.ts index d6ddb9798332..996fa7e9479f 100644 --- a/src/libs/API/index.ts +++ b/src/libs/API/index.ts @@ -8,7 +8,6 @@ import {handleDeletedAccount, HandleUnusedOptimisticID, Logging, Pagination, Rea import FraudMonitoring from '@libs/Middleware/FraudMonitoring'; import {isOffline} from '@libs/Network/NetworkStore'; import {push as pushToSequentialQueue, waitForIdle as waitForSequentialQueueIdle} from '@libs/Network/SequentialQueue'; -import {initialize as initializeOptimisticReportNamesContext} from '@libs/OptimisticReportNamesConnectionManager'; import Pusher from '@libs/Pusher'; import {addMiddleware, processWithMiddleware} from '@libs/Request'; import {getAll, getLength as getPersistedRequestsLength} from '@userActions/PersistedRequests'; @@ -50,11 +49,6 @@ addMiddleware(SaveResponseInOnyx); // FraudMonitoring - Tags the request with the appropriate Fraud Protection event. addMiddleware(FraudMonitoring); -// Initialize OptimisticReportNames context on module load -initializeOptimisticReportNamesContext().catch(() => { - Log.warn('Failed to initialize OptimisticReportNames context'); -}); - let requestIndex = 0; type OnyxData = { diff --git a/src/libs/OptimisticReportNamesConnectionManager.ts b/src/libs/OptimisticReportNamesConnectionManager.ts deleted file mode 100644 index eb932e0f569c..000000000000 --- a/src/libs/OptimisticReportNamesConnectionManager.ts +++ /dev/null @@ -1,149 +0,0 @@ -import type {OnyxEntry} from 'react-native-onyx'; -import Onyx from 'react-native-onyx'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {Beta, BetaConfiguration, Policy, Report, Transaction} from '@src/types/onyx'; -import type ReportNameValuePairs from '@src/types/onyx/ReportNameValuePairs'; - -type UpdateContext = { - betas: OnyxEntry; - betaConfiguration: OnyxEntry; - allReports: Record; - allPolicies: Record; - allReportNameValuePairs: Record; - allTransactions: Record; - isOffline: boolean; -}; - -let betas: OnyxEntry; -let betaConfiguration: OnyxEntry; -let allReports: Record; -let allPolicies: Record; -let allReportNameValuePairs: Record; -let allTransactions: Record; -let isOffline: OnyxEntry; -let isInitialized = false; -let connectionsInitializedCount = 0; -const totalConnections = 7; -let initializationPromise: Promise | null = null; - -/** - * Initialize persistent connections to Onyx data needed for OptimisticReportNames - * This is called lazily when OptimisticReportNames functionality is first used - * Returns a Promise that resolves when all connections have received their initial data - * - * We use Onyx.connectWithoutView because we do not use this in React components and this logic is not tied directly to the UI. - * This is a centralized system that needs access to all objects of several types, so that when any updates affect - * the computed report names, we can compute the new names according to the formula and add the necessary updates. - * It wouldn't be possible to do this without connecting to all the data. - * - */ -function initialize(): Promise { - if (isInitialized) { - return Promise.resolve(); - } - - if (initializationPromise) { - return initializationPromise; - } - - initializationPromise = new Promise((resolve) => { - const incrementInitialization = () => { - connectionsInitializedCount++; - if (connectionsInitializedCount === totalConnections) { - isInitialized = true; - resolve(); - } - }; - - // Connect to BETAS - Onyx.connectWithoutView({ - key: ONYXKEYS.BETAS, - callback: (val) => { - betas = val; - incrementInitialization(); - }, - }); - - // Connect to BETA_CONFIGURATION - Onyx.connectWithoutView({ - key: ONYXKEYS.BETA_CONFIGURATION, - callback: (val) => { - betaConfiguration = val; - incrementInitialization(); - }, - }); - - // Connect to all REPORTS - Onyx.connectWithoutView({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (val) => { - allReports = (val as Record) ?? {}; - incrementInitialization(); - }, - }); - - // Connect to all POLICIES - Onyx.connectWithoutView({ - key: ONYXKEYS.COLLECTION.POLICY, - waitForCollectionCallback: true, - callback: (val) => { - allPolicies = (val as Record) ?? {}; - incrementInitialization(); - }, - }); - - // Connect to all REPORT_NAME_VALUE_PAIRS - Onyx.connectWithoutView({ - key: ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, - waitForCollectionCallback: true, - callback: (val) => { - allReportNameValuePairs = (val as Record) ?? {}; - incrementInitialization(); - }, - }); - - // Connect to all TRANSACTIONS - Onyx.connectWithoutView({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (val) => { - allTransactions = (val as Record) ?? {}; - incrementInitialization(); - }, - }); - - // Connect to NETWORK to track offline status - Onyx.connectWithoutView({ - key: ONYXKEYS.NETWORK, - callback: (val) => { - isOffline = val?.isOffline; - incrementInitialization(); - }, - }); - }); - - return initializationPromise; -} - -/** - * Get the current update context synchronously - * Must be called after initialize() has completed - */ -function getUpdateContext(): UpdateContext { - if (!isInitialized) { - throw new Error('OptimisticReportNamesConnectionManager not initialized. Call initialize() first.'); - } - - return { - betas, - betaConfiguration, - allReports: allReports ?? {}, - allPolicies: allPolicies ?? {}, - allReportNameValuePairs: allReportNameValuePairs ?? {}, - allTransactions: allTransactions ?? {}, - isOffline: isOffline ?? false, - }; -} -export {initialize, getUpdateContext}; -export type {UpdateContext}; From b6b6ec4a20cfc6c4e5103aa5c5c0dd35b3214e07 Mon Sep 17 00:00:00 2001 From: truph01 Date: Tue, 23 Dec 2025 17:20:05 +0700 Subject: [PATCH 05/14] fix: remove unsed CONST --- src/CONST/index.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 9780d4cb607a..7948f77f8123 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1658,8 +1658,6 @@ const CONST = { APPLY_AIRSHIP_UPDATES: 'apply_airship_updates', APPLY_PUSHER_UPDATES: 'apply_pusher_updates', APPLY_HTTPS_UPDATES: 'apply_https_updates', - COMPUTE_REPORT_NAME: 'compute_report_name', - COMPUTE_REPORT_NAME_FOR_NEW_REPORT: 'compute_report_name_for_new_report', COLD: 'cold', WARM: 'warm', REPORT_ACTION_ITEM_LAYOUT_DEBOUNCE_TIME: 1500, From 246a08b51ff4db0a8ba93b94a4bd8e96ba16a4db Mon Sep 17 00:00:00 2001 From: truph01 Date: Tue, 23 Dec 2025 17:45:03 +0700 Subject: [PATCH 06/14] remove more functions --- src/libs/Formula.ts | 93 +-- tests/unit/FormulaTest.ts | 1337 +------------------------------------ 2 files changed, 3 insertions(+), 1427 deletions(-) diff --git a/src/libs/Formula.ts b/src/libs/Formula.ts index 1859abd2ded3..448b2fe04dcb 100644 --- a/src/libs/Formula.ts +++ b/src/libs/Formula.ts @@ -198,40 +198,6 @@ function parsePart(definition: string): FormulaPart { return part; } -/** - * Check if a formula requires backend computation (e.g., currency conversion with exchange rates) - * This is used by OptimisticReportNames to skip optimistic updates when online and backend is needed - */ -function requiresBackendComputation(parts: FormulaPart[], context?: FormulaContext): boolean { - if (!context) { - return false; - } - - const {report} = context; - - for (const part of parts) { - if (part.type === FORMULA_PART_TYPES.REPORT) { - const [field, ...additionalPath] = part.fieldPath; - // Reconstruct format string by joining additional path elements with ':' - // This handles format strings with colons like 'HH:mm:ss' - const format = additionalPath.length > 0 ? additionalPath.join(':') : undefined; - const fieldName = field?.toLowerCase(); - - if (fieldName === 'total' || fieldName === 'reimbursable') { - // Use formatAmount to check whether a currency conversion is needed. - // A null return means the backend must handle the conversion. - // We rely on report.total because zero values can be computed optimistically. - const result = formatAmount(report.total, report.currency, format); - if (result === null) { - return true; - } - } - } - } - - return false; -} - /** * Check if the report field formula value is containing circular references, e.g example: A -> A, A->B->A, A->B->C->A, etc */ @@ -294,61 +260,6 @@ function hasCircularReferences(fieldValue: string, fieldName: string, fieldList? return hasCircularReferencesRecursive(fieldValue, fieldName); } -/** - * Check if a formula part is a submission info part (report:submit:*) - */ -function isSubmissionInfoPart(part: FormulaPart): boolean { - return part.type === FORMULA_PART_TYPES.REPORT && part.fieldPath.at(0)?.toLowerCase() === 'submit'; -} - -/** - * Compute the value of a formula given a context - */ -function compute(formula?: string, context?: FormulaContext): string { - if (!formula || typeof formula !== 'string') { - return ''; - } - if (!context) { - return ''; - } - - const parts = parse(formula); - let result = ''; - - for (const part of parts) { - let value = ''; - - switch (part.type) { - case FORMULA_PART_TYPES.REPORT: - value = computeReportPart(part, context); - // Apply fallback to formula definition for empty values, except for submission info - // Submission info explicitly returns empty strings when data is missing (matches backend) - if (value === '' && !isSubmissionInfoPart(part)) { - value = part.definition; - } - break; - case FORMULA_PART_TYPES.FIELD: - value = computeFieldPart(part); - break; - case FORMULA_PART_TYPES.USER: - value = computeUserPart(part); - break; - case FORMULA_PART_TYPES.FREETEXT: - value = part.definition; - break; - default: - // If we don't recognize the part type, use the original definition - value = part.definition; - } - - // Apply any functions to the computed value - value = applyFunctions(value, part.functions); - result += value; - } - - return result; -} - /** * Compute auto-reporting info for a report formula part */ @@ -942,6 +853,6 @@ function computePersonalDetailsField(path: string[], personalDetails: PersonalDe } } -export {FORMULA_PART_TYPES, compute, extract, getAutoReportingDates, parse, hasCircularReferences, requiresBackendComputation}; +export {FORMULA_PART_TYPES, parse, hasCircularReferences}; -export type {FormulaContext, FormulaPart, FieldList}; +export type {FieldList}; diff --git a/tests/unit/FormulaTest.ts b/tests/unit/FormulaTest.ts index d4dda90765b6..e2f270d819ec 100644 --- a/tests/unit/FormulaTest.ts +++ b/tests/unit/FormulaTest.ts @@ -1,13 +1,8 @@ // eslint-disable-next-line no-restricted-syntax -- disabled because we need CurrencyUtils to mock import * as CurrencyUtils from '@libs/CurrencyUtils'; -import type {FormulaContext} from '@libs/Formula'; -import {compute, extract, hasCircularReferences, parse, requiresBackendComputation} from '@libs/Formula'; -// eslint-disable-next-line no-restricted-syntax -- disabled because we need ReportActionsUtils to mock -import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +import {hasCircularReferences, parse} from '@libs/Formula'; // eslint-disable-next-line no-restricted-syntax -- disabled because we need ReportUtils to mock import * as ReportUtils from '@libs/ReportUtils'; -import CONST from '@src/CONST'; -import type {PersonalDetails, Policy, Report, ReportActions, Transaction} from '@src/types/onyx'; jest.mock('@libs/ReportActionsUtils', () => ({ getAllReportActions: jest.fn(), @@ -23,33 +18,7 @@ jest.mock('@libs/CurrencyUtils', () => ({ isValidCurrencyCode: jest.fn(), })); -const mockReportActionsUtils = ReportActionsUtils as jest.Mocked; -const mockReportUtils = ReportUtils as jest.Mocked; -const mockCurrencyUtils = CurrencyUtils as jest.Mocked; - describe('CustomFormula', () => { - describe('extract()', () => { - test('should extract formula parts with default braces', () => { - expect(extract('{report:type} - {report:total}')).toEqual(['{report:type}', '{report:total}']); - }); - - test('should handle nested braces', () => { - expect(extract('{report:{report:submit:from:firstName|substr:2}}')).toEqual(['{report:{report:submit:from:firstName|substr:2}}']); - }); - - test('should handle escaped braces', () => { - expect(extract('\\{not-formula} {report:type}')).toEqual(['{report:type}']); - }); - - test('should handle empty formula', () => { - expect(extract('')).toEqual([]); - }); - - test('should handle formula without braces', () => { - expect(extract('no braces here')).toEqual([]); - }); - }); - describe('parse()', () => { test('should parse report formula parts', () => { const parts = parse('{report:type} {report:startdate}'); @@ -99,1315 +68,11 @@ describe('CustomFormula', () => { }); }); - describe('compute()', () => { - const mockContext: FormulaContext = { - report: { - reportID: '123', - reportName: '', - type: 'expense', - total: -10000, // -$100.00 - currency: 'USD', - lastVisibleActionCreated: '2025-01-15T10:30:00Z', - policyID: 'policy1', - } as Report, - policy: { - name: 'Test Policy', - } as Policy, - }; - - beforeEach(() => { - jest.clearAllMocks(); - - mockCurrencyUtils.isValidCurrencyCode.mockImplementation((code: string) => ['USD', 'EUR', 'JPY', 'NPR'].includes(code)); - - const mockReportActions = { - // eslint-disable-next-line @typescript-eslint/naming-convention - '1': { - reportActionID: '1', - created: '2025-01-10T08:00:00Z', // Oldest action - actionName: 'CREATED', - }, - // eslint-disable-next-line @typescript-eslint/naming-convention - '2': { - reportActionID: '2', - created: '2025-01-15T10:30:00Z', // Later action - actionName: 'IOU', - }, - // eslint-disable-next-line @typescript-eslint/naming-convention - '3': { - reportActionID: '3', - created: '2025-01-12T14:20:00Z', // Middle action - actionName: 'COMMENT', - }, - } as unknown as ReportActions; - - const mockTransactions = [ - { - transactionID: 'trans1', - created: '2025-01-08T12:00:00Z', // Oldest transaction - amount: 5000, - merchant: 'ACME Ltd.', - }, - { - transactionID: 'trans2', - created: '2025-01-14T16:45:00Z', // Later transaction - amount: 3000, - merchant: 'ACME Ltd.', - }, - { - transactionID: 'trans3', - created: '2025-01-11T09:15:00Z', // Middle transaction - amount: 2000, - merchant: 'ACME Ltd.', - }, - ] as Transaction[]; - - mockReportActionsUtils.getAllReportActions.mockReturnValue(mockReportActions); - mockReportUtils.getReportTransactions.mockReturnValue(mockTransactions); - }); - - test('should compute basic report formula', () => { - const result = compute('{report:type} {report:total}', mockContext); - expect(result).toBe('Expense Report $100.00'); // No space between parts - }); - - test('should compute startdate formula using transactions', () => { - const result = compute('{report:startdate}', mockContext); - expect(result).toBe('2025-01-08'); // Should use oldest transaction date (2025-01-08) - }); - - test('should compute enddate formula using transactions', () => { - const result = compute('{report:enddate}', mockContext); - expect(result).toBe('2025-01-14'); // Should use newest transaction date (2025-01-14) - }); - - test('should compute created formula using report actions', () => { - const result = compute('{report:created}', mockContext); - expect(result).toBe('2025-01-10'); // Should use oldest report action date (2025-01-10) - }); - - test('should compute startdate with custom format', () => { - const result = compute('{report:startdate:MM/dd/yyyy}', mockContext); - expect(result).toBe('01/08/2025'); // Should use oldest transaction date with yyyy-MM-dd format - }); - - test('should compute enddate with custom format', () => { - const result = compute('{report:enddate:MM/dd/yyyy}', mockContext); - expect(result).toBe('01/14/2025'); // Should use newest transaction date with MM/dd/yyyy format - }); - - test('should compute created with custom format', () => { - const result = compute('{report:created:MMMM dd, yyyy}', mockContext); - expect(result).toBe('January 10, 2025'); // Should use oldest report action date with MMMM dd, yyyy format - }); - - test('should compute startdate with short month format', () => { - const result = compute('{report:startdate:dd MMM yyyy}', mockContext); - expect(result).toBe('08 Jan 2025'); // Should use oldest transaction date with dd MMM yyyy format - }); - - test('should compute enddate with short month format', () => { - const result = compute('{report:enddate:dd MMM yyyy}', mockContext); - expect(result).toBe('14 Jan 2025'); // Should use newest transaction date with dd MMM yyyy format - }); - - test('should compute policy name', () => { - const result = compute('{report:policyname}', mockContext); - 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(''); - }); - - test('should handle unknown formula parts', () => { - const result = compute('{report:unknown}', mockContext); - expect(result).toBe('{report:unknown}'); - }); - - test('should handle missing report data gracefully', () => { - const contextWithMissingData: FormulaContext = { - report: {} as unknown as Report, - policy: null as unknown as Policy, - }; - const result = compute('{report:total} {report:policyname}', contextWithMissingData); - expect(result).toBe('{report:total} {report:policyname}'); // Empty data is replaced with definition - }); - - test('should preserve free text', () => { - const result = compute('Expense Report - {report:total}', mockContext); - expect(result).toBe('Expense Report - $100.00'); - }); - - test('should preserve exact spacing around formula parts', () => { - const result = compute('Report with type after 4 spaces {report:type}-and no space after computed part', mockContext); - expect(result).toBe('Report with type after 4 spaces Expense Report-and no space after computed part'); - }); - - test('should compute complex formula with multiple new parts', () => { - const contextWithStatus: FormulaContext = { - ...mockContext, - report: { - ...mockContext.report, - transactionCount: 3, - statusNum: CONST.REPORT.STATUS_NUM.APPROVED, - }, - }; - 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'); - }); - - 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'}, - ]; - - for (const {statusNum, expected} of testCases) { - const contextWithStatus: FormulaContext = { - ...mockContext, - report: { - ...mockContext.report, - statusNum, - }, - }; - const result = compute('{report:status}', contextWithStatus); - expect(result).toBe(expected); - } - }); - - 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}'); - }); - - 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 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'); - }); - - 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'); - }); - }); - - describe('Reimbursable Amount', () => { - const reimbursableContext: FormulaContext = { - report: { - reportID: '123', - reportName: '', - type: 'expense', - policyID: 'policy1', - }, - policy: { - name: 'Test Policy', - } as Policy, - }; - - const calculateExpectedReimbursable = (total: number, nonReimbursableTotal: number) => { - const reimbursableAmount = total - nonReimbursableTotal; - return Math.abs(reimbursableAmount) / 100; - }; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - test('should compute reimbursable amount', () => { - reimbursableContext.report.currency = 'USD'; - reimbursableContext.report.total = -10000; // -$100.00 - reimbursableContext.report.nonReimbursableTotal = -2500; // -$25.00 - - const expectedReimbursable = calculateExpectedReimbursable(reimbursableContext.report.total, reimbursableContext.report.nonReimbursableTotal); - const result = compute('{report:reimbursable}', reimbursableContext); - expect(result).toBe(`$${expectedReimbursable.toFixed(2)}`); - }); - - 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); - 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); - const result = compute('{report:reimbursable}', reimbursableContext); - expect(result).toBe(`${expectedReimbursable.toFixed(2)}`); - }); - - describe('Currency Formatting & Conversion', () => { - const currencyContext: FormulaContext = { - report: { - reportID: '123', - total: -10000, - currency: 'USD', - } as Report, - policy: {} as Policy, - }; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('Format options', () => { - test('nosymbol - should format without currency symbol', () => { - const result = compute('{report:total:nosymbol}', currencyContext); - expect(result).toBe('100.00'); - - // Should not require backend computation - const parts = parse('{report:total:nosymbol}'); - expect(requiresBackendComputation(parts, currencyContext)).toBe(false); - }); - - test('same currency - should format normally (case insensitive)', () => { - currencyContext.report.currency = 'EUR'; - expect(compute('{report:total:EUR}', currencyContext)).toBe('€100.00'); - expect(compute('{report:total:eur}', currencyContext)).toBe('€100.00'); - - // Should not require backend computation when currencies match - const parts = parse('{report:total:EUR}'); - expect(requiresBackendComputation(parts, currencyContext)).toBe(false); - }); - - test('default (no format) - should use report currency', () => { - currencyContext.report.currency = 'NPR'; - const result = compute('{report:total}', currencyContext); - expect(result).toBe('NPR\u00A0100.00'); - - // Should not require backend computation - const parts = parse('{report:total}'); - expect(requiresBackendComputation(parts, currencyContext)).toBe(false); - }); - }); - - describe('Currency conversion (requires backend)', () => { - test('different valid currencies - should return placeholder', () => { - currencyContext.report.currency = 'USD'; - - // Various currencies requiring conversion - expect(compute('{report:total:EUR}', currencyContext)).toBe('{report:total:EUR}'); - expect(compute('{report:total:JPY}', currencyContext)).toBe('{report:total:JPY}'); - - // Should require backend computation - const parts = parse('{report:total:EUR}'); - expect(requiresBackendComputation(parts, currencyContext)).toBe(true); - }); - - test('case and whitespace handling - should normalize and detect conversion', () => { - currencyContext.report.currency = 'USD'; - - // Mixed case and whitespace - expect(compute('{report:total:EuR}', currencyContext)).toBe('{report:total:EuR}'); - expect(compute('{report:total: EUR }', currencyContext)).toBe('{report:total: EUR }'); - expect(compute('{report:total:eur }', currencyContext)).toBe('{report:total:eur }'); - }); - - test('in complex formulas - should detect conversion need', () => { - currencyContext.report.currency = 'USD'; - - // Multiple parts where one needs conversion - const parts = parse('Report: {report:type} Total: {report:total:EUR}'); - expect(requiresBackendComputation(parts, currencyContext)).toBe(true); - - // Non-total fields don't need backend - const simpleParts = parse('{report:type} {report:policyname}'); - expect(requiresBackendComputation(simpleParts, currencyContext)).toBe(false); - }); - }); - - describe('Edge cases', () => { - test('undefined currency - should format without symbol', () => { - currencyContext.report.currency = undefined; - const result = compute('{report:total}', currencyContext); - expect(result).toBe('100.00'); - }); - - test('invalid source currency - should return placeholder', () => { - currencyContext.report.currency = 'UNKNOWN'; - const result = compute('{report:total}', currencyContext); - expect(result).toBe('{report:total}'); - }); - - test('invalid format currency - should return placeholder', () => { - currencyContext.report.currency = 'EUR'; - const result = compute('{report:total:UNKNOWN}', currencyContext); - expect(result).toBe('{report:total:UNKNOWN}'); - }); - }); - }); - }); - - describe('Function Modifiers', () => { - const mockContext: FormulaContext = { - report: { - reportID: 'report123456789', - reportName: '', - type: 'expense', - total: -10000, // -$100.00 - currency: 'USD', - lastVisibleActionCreated: '2025-01-15T10:30:00Z', - policyID: 'policy1', - } as Report, - policy: { - name: 'Engineering Department Rules', - } as Policy, - }; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('frontpart modifier', () => { - test('should extract first word from non-email text', () => { - const result = compute('{report:policyname|frontpart}', mockContext); - expect(result).toBe('Engineering'); // First word of "Engineering Department Rules" - }); - - test('should handle empty strings', () => { - const contextWithEmpty: FormulaContext = { - report: {} as Report, - policy: {name: ''} as Policy, - }; - const result = compute('{report:policyname|frontpart}', contextWithEmpty); - expect(result).toBe('{report:policyname|frontpart}'); // Falls back to formula definition - }); - }); - - describe('domain modifier', () => { - test('should extract domain from email', () => { - const result = compute('{report:submit:from:email|domain}', mockContext); - // Submit part extraction not implemented yet; for now, it returns the definition - // Once implemented, this should return 'domain' of the email - expect(result).toBe(''); - }); - - test('should return empty for non-email text', () => { - const result = compute('{report:policyname|domain}', mockContext); - expect(result).toBe(''); // "Engineering Department Rules" has no @ symbol - }); - - test('should handle empty strings', () => { - const contextWithEmpty: FormulaContext = { - report: {} as Report, - policy: {name: ''} as Policy, - }; - const result = compute('{report:policyname|domain}', contextWithEmpty); - expect(result).toBe(''); // Empty policy name - }); - }); - - describe('substr modifier', () => { - test('should extract substring with start and length', () => { - const result = compute('{report:policyname|substr:0:11}', mockContext); - expect(result).toBe('Engineering'); // First 11 characters of "Engineering Department Rules" - }); - - test('should extract substring with only start position', () => { - const result = compute('{report:policyname|substr:12}', mockContext); - expect(result).toBe('Department Rules'); // From position 12 to end - }); - - test('should handle start position beyond string length', () => { - const result = compute('{report:policyname|substr:50:10}', mockContext); - expect(result).toBe(''); // Start position 50 is beyond string length - }); - - test('should handle length larger than remaining string', () => { - const result = compute('{report:policyname|substr:23:50}', mockContext); - expect(result).toBe('Rules'); // Only remaining characters - }); - - test('should handle invalid length parameter', () => { - const result = compute('{report:policyname|substr:0:abc}', mockContext); - expect(result).toBe(''); // Invalid length, returns empty - }); - }); - }); - - describe('Auto-reporting Frequency', () => { - const mockReport = {reportID: '123'} as Report; - const createMockContext = (policy: Policy): FormulaContext => ({report: mockReport, policy}); - - beforeEach(() => { - jest.clearAllMocks(); - jest.useFakeTimers(); - jest.setSystemTime(new Date('2025-01-19T14:23:45Z')); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - test('should compute weekly frequency dates', () => { - const policy = {autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.WEEKLY} as Policy; - const context = createMockContext(policy); - - expect(compute('{report:autoreporting:start}', context)).toBe('2025-01-13'); - expect(compute('{report:autoreporting:end}', context)).toBe('2025-01-19'); - }); - - test('should compute semi-monthly frequency dates', () => { - jest.setSystemTime(new Date('2025-01-10T12:00:00Z')); - const policy = {autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.SEMI_MONTHLY} as Policy; - const context = createMockContext(policy); - - expect(compute('{report:autoreporting:start}', context)).toBe('2025-01-01'); - expect(compute('{report:autoreporting:end}', context)).toBe('2025-01-15'); - - jest.setSystemTime(new Date('2025-01-20T12:00:00Z')); - expect(compute('{report:autoreporting:start}', context)).toBe('2025-01-16'); - expect(compute('{report:autoreporting:end}', context)).toBe('2025-01-31'); - }); - - test('should compute monthly frequency with specific offset', () => { - const policy = { - autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MONTHLY, - autoReportingOffset: 25, - } as Policy; - const context = createMockContext(policy); - - expect(compute('{report:autoreporting:start}', context)).toBe('2024-12-26'); - expect(compute('{report:autoreporting:end}', context)).toBe('2025-01-25'); - }); - - test('should compute monthly frequency with last business day', () => { - const policy = { - autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MONTHLY, - autoReportingOffset: CONST.POLICY.AUTO_REPORTING_OFFSET.LAST_BUSINESS_DAY_OF_MONTH, - } as Policy; - const context = createMockContext(policy); - - expect(compute('{report:autoreporting:start}', context)).toBe('2025-01-01'); - expect(compute('{report:autoreporting:end}', context)).toBe('2025-01-31'); - }); - - test('should compute trip frequency dates', () => { - const mockTransactions = [ - {transactionID: 'trans1', created: '2025-01-08T12:00:00Z', merchant: 'Hotel', amount: 5000} as Transaction, - {transactionID: 'trans2', created: '2025-01-14T16:45:00Z', merchant: 'Restaurant', amount: 3000} as Transaction, - ]; - - mockReportUtils.getReportTransactions.mockReturnValue(mockTransactions); - - const policy = {autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.TRIP} as Policy; - const context = createMockContext(policy); - - expect(compute('{report:autoreporting:start}', context)).toBe('2025-01-08'); - expect(compute('{report:autoreporting:end}', context)).toBe('2025-01-19'); - }); - - test('should apply custom date formats', () => { - const policy = {autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.WEEKLY} as Policy; - const context = createMockContext(policy); - - expect(compute('{report:autoreporting:start:MMMM dd, yyyy}', context)).toBe('January 13, 2025'); - expect(compute('{report:autoreporting:end:MM/dd/yyyy}', context)).toBe('01/19/2025'); - }); - - test('should return formula definition when policy or frequency is missing', () => { - expect(compute('{report:autoreporting:start}', {report: mockReport, policy: undefined})).toBe('{report:autoreporting:start}'); - expect(compute('{report:autoreporting:end}', createMockContext({} as Policy))).toBe('{report:autoreporting:end}'); - }); - }); - describe('Edge Cases', () => { test('should handle malformed braces', () => { const parts = parse('{incomplete'); expect(parts.at(0)?.type).toBe('freetext'); }); - - test('should handle undefined amounts', () => { - const context: FormulaContext = { - report: {total: undefined} as Report, - policy: null as unknown as Policy, - }; - const result = compute('{report:total}', context); - expect(result).toBe('{report:total}'); - }); - - test('should handle missing report actions for created', () => { - mockReportActionsUtils.getAllReportActions.mockReturnValue({}); - const context: FormulaContext = { - report: {reportID: '123'} as Report, - policy: null as unknown as Policy, - }; - - const result = compute('{report:created}', context); - expect(result).toBe('{report:created}'); - }); - - test('should handle missing transactions for startdate', () => { - mockReportUtils.getReportTransactions.mockReturnValue([]); - const context: FormulaContext = { - report: {reportID: '123'} as Report, - policy: null as unknown as Policy, - }; - const today = new Date(); - const expected = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; - const result = compute('{report:startdate}', context); - expect(result).toBe(expected); - }); - - test('should handle missing transactions for enddate', () => { - mockReportUtils.getReportTransactions.mockReturnValue([]); - const context: FormulaContext = { - report: {reportID: '123'} as Report, - policy: null as unknown as Policy, - }; - const today = new Date(); - const expected = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; - const result = compute('{report:enddate}', context); - expect(result).toBe(expected); - }); - - test('should call getReportTransactions with correct reportID for startdate', () => { - const context: FormulaContext = { - report: {reportID: 'test-report-123'} as Report, - policy: null as unknown as Policy, - }; - - compute('{report:startdate}', context); - expect(mockReportUtils.getReportTransactions).toHaveBeenCalledWith('test-report-123'); - }); - - test('should call getAllReportActions with correct reportID for created', () => { - const context: FormulaContext = { - report: {reportID: 'test-report-456'} as Report, - policy: null as unknown as Policy, - }; - - compute('{report:created}', context); - expect(mockReportActionsUtils.getAllReportActions).toHaveBeenCalledWith('test-report-456'); - }); - - test('should skip partial transactions (empty merchant)', () => { - const mockTransactions = [ - { - transactionID: 'trans1', - created: '2025-01-15T12:00:00Z', - amount: 5000, - merchant: 'ACME Ltd.', - }, - { - transactionID: 'trans2', - created: '2025-01-08T16:45:00Z', // Older but partial - amount: 3000, - merchant: '', // Empty merchant = partial - }, - { - transactionID: 'trans3', - created: '2025-01-12T09:15:00Z', // Should be oldest valid - amount: 2000, - merchant: 'Gamma Inc.', - }, - ] as Transaction[]; - - mockReportUtils.getReportTransactions.mockReturnValue(mockTransactions); - const context: FormulaContext = { - report: {reportID: 'test-report-123'} as Report, - policy: null as unknown as Policy, - }; - - const result = compute('{report:startdate}', context); - expect(result).toBe('2025-01-12'); - - const endResult = compute('{report:enddate}', context); - expect(endResult).toBe('2025-01-15'); - }); - - test('should skip partial transactions (zero amount)', () => { - const mockTransactions = [ - { - transactionID: 'trans1', - created: '2025-01-15T12:00:00Z', - amount: 5000, - merchant: 'ACME Ltd.', - }, - { - transactionID: 'trans2', - created: '2025-01-08T16:45:00Z', // Older but partial - amount: 0, // Zero amount = partial - merchant: 'Beta Corp.', - iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, - }, - { - transactionID: 'trans3', - created: '2025-01-12T09:15:00Z', // Should be oldest valid - amount: 2000, - merchant: 'Gamma Inc.', - }, - ] as Transaction[]; - - mockReportUtils.getReportTransactions.mockReturnValue(mockTransactions); - const context: FormulaContext = { - report: {reportID: 'test-report-123'} as Report, - policy: null as unknown as Policy, - }; - - const result = compute('{report:startdate}', context); - expect(result).toBe('2025-01-12'); - - const endResult = compute('{report:enddate}', context); - expect(endResult).toBe('2025-01-15'); - }); - }); - - describe('Date Format Tokens', () => { - // Test date: Wednesday, January 8, 2025, 3:30:45 PM (15:30:45) UTC - const testDate = '2025-01-08T15:30:45.123Z'; - const morningDate = '2025-01-08T09:05:02.123Z'; // 9:05:02 AM for leading zero tests - - const mockContextWithDate: FormulaContext = { - report: {reportID: '123'} as Report, - policy: null as unknown as Policy, - }; - - const setupMockDate = (date: string) => { - const mockTransaction = { - transactionID: 'trans1', - created: date, - amount: -5000, - merchant: 'Test Store', - } as Transaction; - mockReportUtils.getReportTransactions.mockReturnValue([mockTransaction]); - - const mockReportAction = { - created: date, - actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, - } as unknown as ReportActions[string]; - mockReportActionsUtils.getAllReportActions.mockReturnValue({action1: mockReportAction}); - }; - - beforeEach(() => setupMockDate(testDate)); - - test('year formats - yyyy/yy/Y/y/YYYY', () => { - expect(compute('{report:startdate:yyyy}', mockContextWithDate)).toBe('2025'); - expect(compute('{report:startdate:YYYY}', mockContextWithDate)).toBe('2025'); - expect(compute('{report:startdate:Y}', mockContextWithDate)).toBe('2025'); - expect(compute('{report:startdate:yy}', mockContextWithDate)).toBe('25'); - expect(compute('{report:startdate:y}', mockContextWithDate)).toBe('25'); - }); - - test('month formats - names and numbers', () => { - expect(compute('{report:startdate:MMMM}', mockContextWithDate)).toBe('January'); - expect(compute('{report:startdate:F}', mockContextWithDate)).toBe('January'); - expect(compute('{report:startdate:MMM}', mockContextWithDate)).toBe('Jan'); - expect(compute('{report:startdate:M}', mockContextWithDate)).toBe('Jan'); - expect(compute('{report:startdate:MM}', mockContextWithDate)).toBe('01'); - expect(compute('{report:startdate:n}', mockContextWithDate)).toBe('1'); - expect(compute('{report:startdate:t}', mockContextWithDate)).toBe('31'); - }); - - test('day formats - numbers and names', () => { - expect(compute('{report:startdate:dd}', mockContextWithDate)).toBe('08'); - expect(compute('{report:startdate:d}', mockContextWithDate)).toBe('08'); - expect(compute('{report:startdate:j}', mockContextWithDate)).toBe('8'); - expect(compute('{report:startdate:S}', mockContextWithDate)).toBe('th'); - expect(compute('{report:startdate:jS}', mockContextWithDate)).toBe('8th'); - expect(compute('{report:startdate:dddd}', mockContextWithDate)).toBe('Wednesday'); - expect(compute('{report:startdate:l}', mockContextWithDate)).toBe('Wednesday'); - expect(compute('{report:startdate:ddd}', mockContextWithDate)).toBe('Wed'); - expect(compute('{report:startdate:D}', mockContextWithDate)).toBe('Wed'); - expect(compute('{report:startdate:w}', mockContextWithDate)).toBe('3'); - expect(compute('{report:startdate:N}', mockContextWithDate)).toBe('3'); - expect(compute('{report:startdate:z}', mockContextWithDate)).toBe('7'); - expect(compute('{report:startdate:W}', mockContextWithDate)).toBe('02'); - }); - - test('ISO week number - first Thursday rule', () => { - // January 1, 2021 is a Friday - // Week 1 of 2021 contains the first Thursday (Jan 7) - // So Jan 1-3 (Fri, Sat, Sun) belong to week 53 of 2020 - setupMockDate('2021-01-01T12:00:00Z'); // Friday, Jan 1, 2021 - expect(compute('{report:startdate:W}', mockContextWithDate)).toBe('53'); - - // January 4 is Monday, which is in week 1 - setupMockDate('2021-01-04T12:00:00Z'); // Monday, Jan 4, 2021 - expect(compute('{report:startdate:W}', mockContextWithDate)).toBe('01'); - - // December 31, 2020 is Thursday, should be week 53 - setupMockDate('2020-12-31T12:00:00Z'); // Thursday, Dec 31, 2020 - expect(compute('{report:startdate:W}', mockContextWithDate)).toBe('53'); - }); - - test('complex date formats', () => { - expect(compute('{report:startdate:MMMM dd, yyyy}', mockContextWithDate)).toBe('January 08, 2025'); - expect(compute('{report:startdate:dd MMM yyyy}', mockContextWithDate)).toBe('08 Jan 2025'); - expect(compute('{report:startdate:yyyy-MM-dd}', mockContextWithDate)).toBe('2025-01-08'); - }); - - test('time formats - hours', () => { - // 24-hour format: 15:30:45 (afternoon) and 09:05:02 (morning with leading zero) - expect(compute('{report:startdate:HH}', mockContextWithDate)).toBe('15'); - expect(compute('{report:startdate:H}', mockContextWithDate)).toBe('15'); - expect(compute('{report:startdate:G}', mockContextWithDate)).toBe('15'); - - setupMockDate(morningDate); - expect(compute('{report:startdate:HH}', mockContextWithDate)).toBe('09'); - expect(compute('{report:startdate:H}', mockContextWithDate)).toBe('09'); // H has leading zeros per spec - expect(compute('{report:startdate:G}', mockContextWithDate)).toBe('9'); // G has NO leading zeros - - // 12-hour format: 3:30 PM (afternoon) and 9:05 AM (morning) - setupMockDate(testDate); - expect(compute('{report:startdate:hh}', mockContextWithDate)).toBe('03'); - expect(compute('{report:startdate:h}', mockContextWithDate)).toBe('03'); // h has leading zeros per spec - expect(compute('{report:startdate:g}', mockContextWithDate)).toBe('3'); // g has NO leading zeros - - setupMockDate(morningDate); - expect(compute('{report:startdate:hh}', mockContextWithDate)).toBe('09'); - expect(compute('{report:startdate:h}', mockContextWithDate)).toBe('09'); // h has leading zeros per spec - expect(compute('{report:startdate:g}', mockContextWithDate)).toBe('9'); // g has NO leading zeros - }); - - test('time formats - minutes and seconds', () => { - // Minutes: 30 (double digit) and 05 (single digit with leading zero) - setupMockDate(testDate); - expect(compute('{report:startdate:mm}', mockContextWithDate)).toBe('30'); - expect(compute('{report:startdate:i}', mockContextWithDate)).toBe('30'); - - setupMockDate(morningDate); - expect(compute('{report:startdate:mm}', mockContextWithDate)).toBe('05'); - expect(compute('{report:startdate:i}', mockContextWithDate)).toBe('05'); - - // Seconds: 45 (double digit) and 02 (single digit with leading zero) - setupMockDate(testDate); - expect(compute('{report:startdate:ss}', mockContextWithDate)).toBe('45'); - expect(compute('{report:startdate:s}', mockContextWithDate)).toBe('45'); - - setupMockDate(morningDate); - expect(compute('{report:startdate:ss}', mockContextWithDate)).toBe('02'); - expect(compute('{report:startdate:s}', mockContextWithDate)).toBe('02'); - }); - - test('time formats - AM/PM', () => { - expect(compute('{report:startdate:tt}', mockContextWithDate)).toBe('PM'); - expect(compute('{report:startdate:A}', mockContextWithDate)).toBe('PM'); - expect(compute('{report:startdate:a}', mockContextWithDate)).toBe('pm'); - }); - - test('full date/time formats - c, r, U', () => { - // ISO 8601 format (c token) - const cResult = compute('{report:startdate:c}', mockContextWithDate); - expect(cResult).toBe('2025-01-08T15:30:45.123Z'); - - // RFC 2822 format (r token) - const rResult = compute('{report:startdate:r}', mockContextWithDate); - expect(rResult).toMatch(/^Wed, 08 Jan 2025 \d{2}:\d{2}:\d{2} [+-]\d{4}$/); - - // Unix timestamp (U token) - const uResult = compute('{report:startdate:U}', mockContextWithDate); - const expectedTimestamp = Math.floor(new Date(testDate).getTime() / 1000).toString(); - expect(uResult).toBe(expectedTimestamp); - }); - - test('format strings with colons', () => { - expect(compute('{report:startdate:HH:mm}', mockContextWithDate)).toBe('15:30'); - expect(compute('{report:startdate:HH:mm:ss}', mockContextWithDate)).toBe('15:30:45'); - expect(compute('{report:startdate:hh:mm tt}', mockContextWithDate)).toBe('03:30 PM'); - expect(compute('{report:startdate:yyyy-MM-dd HH:mm:ss}', mockContextWithDate)).toBe('2025-01-08 15:30:45'); - expect(compute('{report:created:HH:mm:ss}', mockContextWithDate)).toBe('15:30:45'); - expect(compute('{report:startdate:g:i a}', mockContextWithDate)).toBe('3:30 pm'); - }); - }); - - describe('Submission Info', () => { - const mockSubmitter: PersonalDetails = { - accountID: 12345, - firstName: 'John', - lastName: 'Doe', - displayName: 'John Doe', - login: 'john.doe@company.com', - }; - - const mockManager: PersonalDetails = { - accountID: 67890, - firstName: 'Jane', - lastName: 'Smith', - displayName: 'Jane Smith', - login: 'jane.smith@company.com', - }; - - const mockContextWithSubmissionInfo: FormulaContext = { - report: { - reportID: '123', - reportName: '', - type: 'expense', - ownerAccountID: 12345, - managerID: 67890, - stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, - statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, - created: '2025-01-15T10:30:00Z', - } as Report, - policy: { - name: 'Test Policy', - employeeList: { - // eslint-disable-next-line @typescript-eslint/naming-convention - 'john.doe@company.com': { - email: 'john.doe@company.com', - employeeUserID: 'EMP001', - employeePayrollID: 'PAY123', - }, - // eslint-disable-next-line @typescript-eslint/naming-convention - 'jane.smith@company.com': { - email: 'jane.smith@company.com', - employeeUserID: 'EMP002', - employeePayrollID: 'PAY456', - }, - }, - } as unknown as Policy, - submitterPersonalDetails: mockSubmitter, - managerPersonalDetails: mockManager, - }; - - beforeEach(() => { - jest.clearAllMocks(); - - const mockReportActions = { - // eslint-disable-next-line @typescript-eslint/naming-convention - '1': { - reportActionID: '1', - created: '2025-01-10T08:00:00Z', - actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, - }, - // eslint-disable-next-line @typescript-eslint/naming-convention - '2': { - reportActionID: '2', - created: '2025-01-15T10:30:00Z', - actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, - }, - } as unknown as ReportActions; - - mockReportActionsUtils.getAllReportActions.mockReturnValue(mockReportActions); - }); - - describe('Submitter information', () => { - test('firstname - basic submitter first name', () => { - expect(compute('{report:submit:from:firstname}', mockContextWithSubmissionInfo)).toBe('John'); - }); - - test('lastname - basic submitter last name', () => { - expect(compute('{report:submit:from:lastname}', mockContextWithSubmissionInfo)).toBe('Doe'); - }); - - test('fullname - basic submitter full name', () => { - expect(compute('{report:submit:from:fullname}', mockContextWithSubmissionInfo)).toBe('John Doe'); - }); - - test('email - basic submitter email', () => { - expect(compute('{report:submit:from:email}', mockContextWithSubmissionInfo)).toBe('john.doe@company.com'); - }); - - test('userid - submitter employee user ID (alias for customfield1)', () => { - expect(compute('{report:submit:from:userid}', mockContextWithSubmissionInfo)).toBe('EMP001'); - }); - - test('customfield1 - submitter employee user ID from policy', () => { - expect(compute('{report:submit:from:customfield1}', mockContextWithSubmissionInfo)).toBe('EMP001'); - }); - - test('customfield2 - submitter employee payroll ID from policy', () => { - expect(compute('{report:submit:from:customfield2}', mockContextWithSubmissionInfo)).toBe('PAY123'); - }); - - test('payrollid - submitter employee payroll ID (alias for customfield2)', () => { - expect(compute('{report:submit:from:payrollid}', mockContextWithSubmissionInfo)).toBe('PAY123'); - }); - - test('name fields fall back to email when name missing', () => { - const contextWithPartialDetails: FormulaContext = { - report: {reportID: '123'} as Report, - policy: null as unknown as Policy, - submitterPersonalDetails: { - accountID: 111, - login: 'fallback@email.com', - } as PersonalDetails, - }; - - expect(compute('{report:submit:from:firstname}', contextWithPartialDetails)).toBe('fallback@email.com'); - expect(compute('{report:submit:from:lastname}', contextWithPartialDetails)).toBe('fallback@email.com'); - expect(compute('{report:submit:from:fullname}', contextWithPartialDetails)).toBe('fallback@email.com'); - }); - - test('customfield1 - return empty when employeeList missing', () => { - const contextWithoutEmployeeList: FormulaContext = { - report: {reportID: '123'} as Report, - policy: { - name: 'Test Policy', - } as Policy, - submitterPersonalDetails: mockSubmitter, - }; - - expect(compute('{report:submit:from:customfield1}', contextWithoutEmployeeList)).toBe(''); - }); - - test('customfield2 - return empty when employeeList missing', () => { - const contextWithoutEmployeeList: FormulaContext = { - report: {reportID: '123'} as Report, - policy: { - name: 'Test Policy', - } as Policy, - submitterPersonalDetails: mockSubmitter, - }; - - expect(compute('{report:submit:from:customfield2}', contextWithoutEmployeeList)).toBe(''); - }); - - test('customfield1 - return empty when user not in employeeList', () => { - const contextWithDifferentEmployee: FormulaContext = { - report: {reportID: '123'} as Report, - policy: { - name: 'Test Policy', - employeeList: { - // eslint-disable-next-line @typescript-eslint/naming-convention - 'other.user@company.com': { - email: 'other.user@company.com', - employeeUserID: 'EMP999', - }, - }, - } as unknown as Policy, - submitterPersonalDetails: mockSubmitter, - }; - - expect(compute('{report:submit:from:customfield1}', contextWithDifferentEmployee)).toBe(''); - }); - }); - - describe('Manager information', () => { - test('firstname - basic manager first name', () => { - expect(compute('{report:submit:to:firstname}', mockContextWithSubmissionInfo)).toBe('Jane'); - }); - - test('lastname - basic manager last name', () => { - expect(compute('{report:submit:to:lastname}', mockContextWithSubmissionInfo)).toBe('Smith'); - }); - - test('fullname - basic manager full name', () => { - expect(compute('{report:submit:to:fullname}', mockContextWithSubmissionInfo)).toBe('Jane Smith'); - }); - - test('email - basic manager email', () => { - expect(compute('{report:submit:to:email}', mockContextWithSubmissionInfo)).toBe('jane.smith@company.com'); - }); - - test('userid - manager employee user ID (alias for customfield1)', () => { - expect(compute('{report:submit:to:userid}', mockContextWithSubmissionInfo)).toBe('EMP002'); - }); - - test('customfield1 - manager employee user ID from policy', () => { - expect(compute('{report:submit:to:customfield1}', mockContextWithSubmissionInfo)).toBe('EMP002'); - }); - - test('customfield2 - manager employee payroll ID from policy', () => { - expect(compute('{report:submit:to:customfield2}', mockContextWithSubmissionInfo)).toBe('PAY456'); - }); - - test('payrollid - manager employee payroll ID (alias for customfield2)', () => { - expect(compute('{report:submit:to:payrollid}', mockContextWithSubmissionInfo)).toBe('PAY456'); - }); - - test('firstname - fall back to email when manager name missing', () => { - const contextWithPartialManagerDetails: FormulaContext = { - report: {reportID: '123'} as Report, - policy: null as unknown as Policy, - managerPersonalDetails: { - accountID: 222, - login: 'manager@email.com', - } as PersonalDetails, - }; - - expect(compute('{report:submit:to:firstname}', contextWithPartialManagerDetails)).toBe('manager@email.com'); - }); - - test('fullname - fall back to email when manager displayName missing', () => { - const contextWithPartialManagerDetails: FormulaContext = { - report: {reportID: '123'} as Report, - policy: null as unknown as Policy, - managerPersonalDetails: { - accountID: 222, - login: 'manager@email.com', - } as PersonalDetails, - }; - - expect(compute('{report:submit:to:fullname}', contextWithPartialManagerDetails)).toBe('manager@email.com'); - }); - }); - - describe('Submission date', () => { - test('default format - yyyy-MM-dd', () => { - expect(compute('{report:submit:date}', mockContextWithSubmissionInfo)).toBe('2025-01-15'); - }); - - test('custom format - verifies date formatting works', () => { - expect(compute('{report:submit:date:MMMM dd, yyyy}', mockContextWithSubmissionInfo)).toBe('January 15, 2025'); - }); - }); - - describe('Function modifiers', () => { - test('frontpart - extract username from submitter email', () => { - expect(compute('{report:submit:from:email|frontpart}', mockContextWithSubmissionInfo)).toBe('john.doe'); - }); - - test('domain - extract domain from submitter email', () => { - expect(compute('{report:submit:from:email|domain}', mockContextWithSubmissionInfo)).toBe('company.com'); - }); - - test('frontpart - extract username from manager email', () => { - expect(compute('{report:submit:to:email|frontpart}', mockContextWithSubmissionInfo)).toBe('jane.smith'); - }); - - test('substr - extract first 4 characters from fullname', () => { - expect(compute('{report:submit:from:fullname|substr:0:4}', mockContextWithSubmissionInfo)).toBe('John'); - }); - - test('chained modifiers - frontpart then substr on email', () => { - expect(compute('{report:submit:from:email|frontpart|substr:0:4}', mockContextWithSubmissionInfo)).toBe('john'); - }); - }); - - describe('Combined formulas', () => { - test('submitter and manager names together', () => { - expect(compute('{report:submit:from:firstname} -> {report:submit:to:firstname}', mockContextWithSubmissionInfo)).toBe('John -> Jane'); - }); - - test('transaction date range with submission date', () => { - const mockTransactions = [ - { - transactionID: 'trans1', - created: '2025-01-08T12:00:00Z', - amount: 5000, - merchant: 'Store', - }, - ] as Transaction[]; - - mockReportUtils.getReportTransactions.mockReturnValue(mockTransactions); - - expect(compute('{report:startdate} to {report:submit:date}', mockContextWithSubmissionInfo)).toBe('2025-01-08 to 2025-01-15'); - }); - }); - - describe('Edge cases', () => { - test('empty email - return empty when email empty', () => { - const contextWithEmptyEmail: FormulaContext = { - report: {reportID: '123'} as Report, - policy: null as unknown as Policy, - submitterPersonalDetails: { - accountID: 123, - login: '', - } as PersonalDetails, - }; - - expect(compute('{report:submit:from:email}', contextWithEmptyEmail)).toBe(''); - }); - - test('empty email with name - return empty when name also empty', () => { - const contextWithEmptyEmail: FormulaContext = { - report: {reportID: '123'} as Report, - policy: null as unknown as Policy, - submitterPersonalDetails: { - accountID: 123, - login: '', - } as PersonalDetails, - }; - - expect(compute('{report:submit:from:firstname}', contextWithEmptyEmail)).toBe(''); - }); - - test('empty firstname - fallback to email when firstname is empty string', () => { - const contextWithEmptyFirstName: FormulaContext = { - report: {reportID: '123'} as Report, - policy: null as unknown as Policy, - submitterPersonalDetails: { - accountID: 123, - firstName: '', - login: 'user@test.com', - } as PersonalDetails, - }; - - expect(compute('{report:submit:from:firstname}', contextWithEmptyFirstName)).toBe('user@test.com'); - }); - - test('empty lastname - fallback to email when lastname is empty string', () => { - const contextWithEmptyLastName: FormulaContext = { - report: {reportID: '123'} as Report, - policy: null as unknown as Policy, - submitterPersonalDetails: { - accountID: 123, - lastName: '', - login: 'user@test.com', - } as PersonalDetails, - }; - - expect(compute('{report:submit:from:lastname}', contextWithEmptyLastName)).toBe('user@test.com'); - }); - - test('empty displayName - fallback to email when displayName is empty string', () => { - const contextWithEmptyDisplayName: FormulaContext = { - report: {reportID: '123'} as Report, - policy: null as unknown as Policy, - submitterPersonalDetails: { - accountID: 123, - displayName: '', - login: 'user@test.com', - } as PersonalDetails, - }; - - expect(compute('{report:submit:from:fullname}', contextWithEmptyDisplayName)).toBe('user@test.com'); - }); - - test('empty email with frontpart - return empty for empty email modifier', () => { - const contextWithEmptyEmail: FormulaContext = { - report: {reportID: '123'} as Report, - policy: null as unknown as Policy, - submitterPersonalDetails: { - accountID: 123, - login: '', - } as PersonalDetails, - }; - - expect(compute('{report:submit:from:email|frontpart}', contextWithEmptyEmail)).toBe(''); - }); - - test('unknown field - return empty for invalid field name', () => { - expect(compute('{report:submit:from:unknown}', mockContextWithSubmissionInfo)).toBe(''); - }); - - test('invalid direction - return empty for invalid from/to', () => { - expect(compute('{report:submit:invalid:email}', mockContextWithSubmissionInfo)).toBe(''); - }); - }); }); describe('hasCircularReferences()', () => { From ceb52b552e34f0f4da4d3f89de958e110362c6a1 Mon Sep 17 00:00:00 2001 From: truph01 Date: Tue, 23 Dec 2025 18:11:33 +0700 Subject: [PATCH 07/14] fix: cleanup fomular file --- src/libs/Formula.ts | 613 -------------------------------------------- 1 file changed, 613 deletions(-) diff --git a/src/libs/Formula.ts b/src/libs/Formula.ts index 448b2fe04dcb..b54dac751a82 100644 --- a/src/libs/Formula.ts +++ b/src/libs/Formula.ts @@ -1,16 +1,5 @@ -import {endOfDay, endOfMonth, endOfWeek, getDay, lastDayOfMonth, set, startOfMonth, startOfWeek, subDays} from 'date-fns'; -import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; -import CONST from '@src/CONST'; -import type {PersonalDetails, Policy, Report, Transaction} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import {convertToDisplayString, convertToDisplayStringWithoutCurrency, isValidCurrencyCode} from './CurrencyUtils'; -import {formatDate} from './FormulaDatetime'; -import getBase62ReportID from './getBase62ReportID'; -import Log from './Log'; -import {getAllReportActions} from './ReportActionsUtils'; -import {getHumanReadableStatus, getMoneyRequestSpendBreakdown, getReportTransactions} from './ReportUtils'; -import {getCreated, isPartialTransaction, isTransactionPendingDelete} from './TransactionUtils'; type FormulaPart = { /** The original definition from the formula */ @@ -26,15 +15,6 @@ type FormulaPart = { functions: string[]; }; -type FormulaContext = { - report: Report; - policy: OnyxEntry; - transaction?: Transaction; - submitterPersonalDetails?: PersonalDetails; - managerPersonalDetails?: PersonalDetails; - allTransactions?: Record; -}; - type FieldList = Record; const FORMULA_PART_TYPES = { @@ -260,599 +240,6 @@ function hasCircularReferences(fieldValue: string, fieldName: string, fieldList? return hasCircularReferencesRecursive(fieldValue, fieldName); } -/** - * Compute auto-reporting info for a report formula part - */ -function computeAutoReportingInfo(part: FormulaPart, context: FormulaContext, subField: string | undefined, format: string | undefined): string { - const {report, policy} = context; - - if (!subField) { - return part.definition; - } - - const {startDate, endDate} = getAutoReportingDates(policy, report); - - switch (subField.toLowerCase()) { - case 'start': - return formatDate(startDate?.toISOString(), format); - case 'end': - return formatDate(endDate?.toISOString(), format); - default: - return part.definition; - } -} - -/** - * Compute the value of a report formula part - */ -function computeReportPart(part: FormulaPart, context: FormulaContext): string { - 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' - const format = additionalPath.length > 0 ? additionalPath.join(':') : undefined; - - if (!field) { - return part.definition; - } - - 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': - return formatDate(getOldestTransactionDate(report.reportID, context), format); - case 'enddate': - return formatDate(getNewestTransactionDate(report.reportID, context), format); - case 'total': { - const formattedAmount = formatAmount(report.total, report.currency, format); - // Return empty string when conversion needed (formatAmount returns null for unavailable conversions) - return formattedAmount ?? ''; - } - case 'reimbursable': { - const formattedAmount = formatAmount(getMoneyRequestSpendBreakdown(report).reimbursableSpend, report.currency, format); - return formattedAmount ?? ''; - } - case 'currency': - return report.currency ?? ''; - case 'policyname': - case 'workspacename': - return policy?.name ?? ''; - case 'created': - // Backend will always return at least one report action (of type created) and its date is equal to report's creation date - // We can make it slightly more efficient in the future by ensuring report.created is always present in backend's responses - return formatDate(getOldestReportActionDate(report.reportID), format); - case 'submit': { - return computeSubmitPart(additionalPath, context); - } - case 'autoreporting': { - const subField = additionalPath.at(0); - // For multi-part formulas, format is everything after the subfield - const autoReportingFormat = additionalPath.length > 1 ? additionalPath.slice(1).join(':') : undefined; - return computeAutoReportingInfo(part, context, subField, autoReportingFormat); - } - default: - return part.definition; - } -} - -/** - * 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 - */ -function computeFieldPart(part: FormulaPart): string { - // Field computation will be implemented later - return part.definition; -} - -/** - * Compute the value of a user formula part - */ -function computeUserPart(part: FormulaPart): string { - // User computation will be implemented later - return part.definition; -} - -/** - * Apply functions to a computed value - */ -function applyFunctions(value: string, functions: string[]): string { - let result = value; - - for (const func of functions) { - const [functionName, ...args] = func.split(':'); - - switch (functionName.toLowerCase()) { - case 'frontpart': - result = getFrontPart(result); - break; - case 'substr': - result = getSubstring(result, args); - break; - case 'domain': - result = getDomainName(result); - break; - default: - // Unknown function, leave value as is - break; - } - } - - return result; -} - -/** - * Get the front part of an email or first word of a string - */ -function getFrontPart(value: string): string { - const trimmed = value.trim(); - - // If it's an email, return the part before @ - if (trimmed.includes('@')) { - return trimmed.split('@').at(0) ?? ''; - } - - // Otherwise, return the first word - return trimmed.split(' ').at(0) ?? ''; -} - -/** - * Get the domain name of an email or URL - */ -function getDomainName(value: string): string { - const trimmed = value.trim(); - - // If it's an email, return the part after @ - if (trimmed.includes('@')) { - return trimmed.split('@').at(1) ?? ''; - } - - return ''; -} - -/** - * Get substring of a value - */ -function getSubstring(value: string, args: string[]): string { - const start = parseInt(args.at(0) ?? '', 10) || 0; - const length = args.at(1) ? parseInt(args.at(1) ?? '', 10) : undefined; - - if (length !== undefined) { - return value.substring(start, start + length); - } - - return value.substring(start); -} - -/** - * Format an amount value - * @returns The formatted amount string, or null if currency conversion is needed (unavailable on frontend) - */ -function formatAmount(amount: number | undefined, currency: string | undefined, displayCurrency?: string): string | null { - if (amount === undefined) { - return ''; - } - - const absoluteAmount = Math.abs(amount); - - try { - const trimmedDisplayCurrency = displayCurrency?.trim().toUpperCase(); - if (trimmedDisplayCurrency) { - if (trimmedDisplayCurrency === 'NOSYMBOL') { - return convertToDisplayStringWithoutCurrency(absoluteAmount, currency); - } - - // Check if format is a valid currency code (e.g., USD, EUR, eur) - if (!isValidCurrencyCode(trimmedDisplayCurrency)) { - return ''; - } - - // If a currency conversion is needed (displayCurrency differs from the source), - // return null so the backend can compute it. - // We can only compute the value optimistically when the amount is 0. - if (absoluteAmount !== 0 && currency !== trimmedDisplayCurrency) { - return null; - } - - return convertToDisplayString(absoluteAmount, trimmedDisplayCurrency); - } - - if (currency && isValidCurrencyCode(currency)) { - return convertToDisplayString(absoluteAmount, currency); - } - - return convertToDisplayStringWithoutCurrency(absoluteAmount, currency); - } catch (error) { - Log.hmmm('[Formula] formatAmount failed', {error, amount, currency, displayCurrency}); - return ''; - } -} - -/** - * Get the date of the oldest report action for a given report - */ -function getOldestReportActionDate(reportID: string): string | undefined { - if (!reportID) { - return undefined; - } - - const reportActions = getAllReportActions(reportID); - if (!reportActions || Object.keys(reportActions).length === 0) { - return undefined; - } - - let oldestDate: string | undefined; - - for (const action of Object.values(reportActions)) { - if (!action?.created) { - continue; - } - - if (oldestDate && action.created > oldestDate) { - continue; - } - oldestDate = action.created; - } - - return oldestDate; -} - -/** - * Format a report type to its human-readable string - */ -function formatType(type: string | undefined): string { - if (!type) { - return ''; - } - - const typeMapping: Record = { - [CONST.REPORT.TYPE.EXPENSE]: 'Expense Report', - [CONST.REPORT.TYPE.INVOICE]: 'Invoice', - [CONST.REPORT.TYPE.CHAT]: 'Chat', - [CONST.REPORT.UNSUPPORTED_TYPE.BILL]: 'Bill', - [CONST.REPORT.UNSUPPORTED_TYPE.PAYCHECK]: 'Paycheck', - [CONST.REPORT.TYPE.IOU]: 'IOU', - [CONST.REPORT.TYPE.TASK]: 'Task', - trip: 'Trip', - }; - - return typeMapping[type.toLowerCase()] || type; -} - -/** - * Get all transactions for a report, including any context transaction. - * Updates an existing transaction if it matches the context or adds it if new. - */ -function getAllReportTransactionsWithContext(reportID: string, context?: FormulaContext): Transaction[] { - const transactions = [...getReportTransactions(reportID)]; - const contextTransaction = context?.transaction; - - if (contextTransaction?.transactionID && contextTransaction.reportID === reportID) { - const transactionIndex = transactions.findIndex((transaction) => transaction?.transactionID === contextTransaction.transactionID); - if (transactionIndex >= 0) { - transactions[transactionIndex] = contextTransaction; - } else { - transactions.push(contextTransaction); - } - } - - return transactions; -} - -/** - * Get the date of the oldest transaction for a given report - */ -function getOldestTransactionDate(reportID: string, context?: FormulaContext): string | undefined { - if (!reportID) { - return undefined; - } - - const transactions = getAllReportTransactionsWithContext(reportID, context); - if (!transactions || transactions.length === 0) { - return new Date().toISOString(); - } - - let oldestDate: string | undefined; - - for (const transaction of transactions) { - const created = getCreated(transaction); - if (!created) { - continue; - } - // Skip transactions with pending deletion (offline deletes) to calculate dates properly. - if (transaction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { - continue; - } - if (oldestDate && created >= oldestDate) { - continue; - } - if (isPartialTransaction(transaction)) { - continue; - } - oldestDate = created; - } - - return oldestDate; -} - -/** - * Calculate monthly reporting period for a specific day offset - */ -function getMonthlyReportingPeriod(currentDate: Date, offsetDay: number): {startDate: Date; endDate: Date} { - const currentDay = currentDate.getDate(); - const currentYear = currentDate.getFullYear(); - const currentMonth = currentDate.getMonth(); - - if (currentDay <= offsetDay) { - // We haven't reached the reporting day yet - period is from last month's offset+1 to this month's offset - const prevMonth = currentMonth - 1; - const prevYear = prevMonth < 0 ? currentYear - 1 : currentYear; - const adjustedPrevMonth = prevMonth < 0 ? 11 : prevMonth; - - const prevMonthDays = lastDayOfMonth(new Date(prevYear, adjustedPrevMonth, 1)).getDate(); - const prevOffsetDay = Math.min(offsetDay, prevMonthDays); - - const currentMonthDays = lastDayOfMonth(currentDate).getDate(); - const currentOffsetDay = Math.min(offsetDay, currentMonthDays); - - return { - startDate: new Date(prevYear, adjustedPrevMonth, prevOffsetDay + 1, 0, 0, 0, 0), - endDate: new Date(currentYear, currentMonth, currentOffsetDay, 23, 59, 59, 999), - }; - } - - // We've passed the reporting day - period is from this month's offset+1 to next month's offset - const nextMonth = currentMonth + 1; - const nextYear = nextMonth > 11 ? currentYear + 1 : currentYear; - const adjustedNextMonth = nextMonth > 11 ? 0 : nextMonth; - - const currentMonthDays = lastDayOfMonth(currentDate).getDate(); - const currentOffsetDay = Math.min(offsetDay, currentMonthDays); - - const nextMonthDays = lastDayOfMonth(new Date(nextYear, adjustedNextMonth, 1)).getDate(); - const nextOffsetDay = Math.min(offsetDay, nextMonthDays); - - return { - startDate: new Date(currentYear, currentMonth, currentOffsetDay + 1, 0, 0, 0, 0), - endDate: new Date(nextYear, adjustedNextMonth, nextOffsetDay, 23, 59, 59, 999), - }; -} - -/** - * Calculate monthly reporting period for last business day - */ -function getMonthlyLastBusinessDayPeriod(currentDate: Date): {startDate: Date; endDate: Date} { - let endDate = endOfMonth(currentDate); - - // Move backward to find last business day (Mon-Fri) - while (getDay(endDate) === 0 || getDay(endDate) === 6) { - endDate = subDays(endDate, 1); - } - - return { - startDate: startOfMonth(currentDate), - endDate: endOfDay(endDate), - }; -} - -/** - * 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} { - const frequency = policy?.autoReportingFrequency; - const offset = policy?.autoReportingOffset; - - // Return undefined if no frequency is set - if (!frequency || !policy) { - return {startDate: undefined, endDate: undefined}; - } - - let startDate: Date; - let endDate: Date; - - switch (frequency) { - case CONST.POLICY.AUTO_REPORTING_FREQUENCIES.WEEKLY: { - // Weekly: use the app's configured week start convention (Monday) - const weekStartsOn = CONST.WEEK_STARTS_ON; - startDate = startOfWeek(currentDate, {weekStartsOn}); - endDate = endOfWeek(currentDate, {weekStartsOn}); - break; - } - - case CONST.POLICY.AUTO_REPORTING_FREQUENCIES.SEMI_MONTHLY: { - // Semi-monthly: 1st-15th or 16th-end of month - const dayOfMonth = currentDate.getDate(); - if (dayOfMonth <= 15) { - startDate = startOfMonth(currentDate); - endDate = set(currentDate, {date: 15, hours: 23, minutes: 59, seconds: 59, milliseconds: 999}); - } else { - startDate = set(currentDate, {date: 16, hours: 0, minutes: 0, seconds: 0, milliseconds: 0}); - endDate = endOfMonth(currentDate); - } - break; - } - - case CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MONTHLY: { - // Monthly reporting with different offset configurations - if (offset === CONST.POLICY.AUTO_REPORTING_OFFSET.LAST_BUSINESS_DAY_OF_MONTH) { - const period = getMonthlyLastBusinessDayPeriod(currentDate); - startDate = period.startDate; - endDate = period.endDate; - } else if (typeof offset === 'number') { - const period = getMonthlyReportingPeriod(currentDate, offset); - startDate = period.startDate; - endDate = period.endDate; - } else { - // Default to full month - startDate = startOfMonth(currentDate); - endDate = endOfMonth(currentDate); - } - break; - } - - case CONST.POLICY.AUTO_REPORTING_FREQUENCIES.TRIP: { - // For trip-based, use oldest transaction as start - const oldestTransactionDateString = getOldestTransactionDate(report.reportID); - startDate = oldestTransactionDateString ? new Date(oldestTransactionDateString) : currentDate; - endDate = currentDate; - break; - } - - default: - // For any other frequency, use current date as both start and end - startDate = currentDate; - endDate = currentDate; - break; - } - - return {startDate, endDate}; -} - -/** - * Get the date of the newest transaction for a given report - */ -function getNewestTransactionDate(reportID: string, context?: FormulaContext): string | undefined { - if (!reportID) { - return undefined; - } - - const transactions = getAllReportTransactionsWithContext(reportID, context); - if (!transactions || transactions.length === 0) { - return new Date().toISOString(); - } - - let newestDate: string | undefined; - - for (const transaction of transactions) { - const created = getCreated(transaction); - if (!created) { - continue; - } - // Skip transactions with pending deletion (offline deletes) to calculate dates properly. - if (transaction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { - continue; - } - if (newestDate && created <= newestDate) { - continue; - } - if (isPartialTransaction(transaction)) { - continue; - } - newestDate = created; - } - - return newestDate; -} - -/** - * Compute the value of a report:submit:* formula part - * Handles nested paths like submit:from:firstname, submit:to:email, submit:date - */ -function computeSubmitPart(path: string[], context: FormulaContext): string { - const [direction, ...subPath] = path; - - if (!direction) { - return ''; - } - - switch (direction.toLowerCase()) { - case 'from': - return computePersonalDetailsField(subPath, context.submitterPersonalDetails, context.policy); - case 'to': - return computePersonalDetailsField(subPath, context.managerPersonalDetails, context.policy); - case 'date': { - // TODO: Use report.submitted once backend adds it (issue #568267) - // Using report.created as placeholder until then - const submittedDate = context.report.created; - const format = subPath.length > 0 ? subPath.join(':') : undefined; - return formatDate(submittedDate, format); - } - default: - return ''; - } -} - -/** - * Compute personal details information for either submitter (from) or manager (to) - */ -function computePersonalDetailsField(path: string[], personalDetails: PersonalDetails | undefined, policy: OnyxEntry): string { - const [field] = path; - - if (!personalDetails || !field) { - return ''; - } - - switch (field.toLowerCase()) { - case 'firstname': - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - return personalDetails.firstName || personalDetails.login || ''; - case 'lastname': - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - return personalDetails.lastName || personalDetails.login || ''; - case 'fullname': - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - return personalDetails.displayName || personalDetails.login || ''; - case 'email': - return personalDetails.login ?? ''; - // userid/customfield1 returns employeeUserID from policy.employeeList - // TODO: Check policy.glCodes once backend adds it (issue #568268) - case 'userid': - case 'customfield1': { - const email = personalDetails.login; - if (!email || !policy?.employeeList) { - return ''; - } - // eslint-disable-next-line rulesdir/no-default-id-values - return policy.employeeList[email]?.employeeUserID ?? ''; - } - // payrollid/customfield2 returns employeePayrollID from policy.employeeList - case 'payrollid': - case 'customfield2': { - const email = personalDetails.login; - if (!email || !policy?.employeeList) { - return ''; - } - // eslint-disable-next-line rulesdir/no-default-id-values - return policy.employeeList[email]?.employeePayrollID ?? ''; - } - default: - return ''; - } -} - export {FORMULA_PART_TYPES, parse, hasCircularReferences}; export type {FieldList}; From 936eca368943cb2231bb52b85677768c99f70002 Mon Sep 17 00:00:00 2001 From: truph01 Date: Tue, 23 Dec 2025 18:16:26 +0700 Subject: [PATCH 08/14] fix: lint --- tests/unit/FormulaTest.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit/FormulaTest.ts b/tests/unit/FormulaTest.ts index e2f270d819ec..432009a72188 100644 --- a/tests/unit/FormulaTest.ts +++ b/tests/unit/FormulaTest.ts @@ -9,11 +9,13 @@ jest.mock('@libs/ReportActionsUtils', () => ({ })); jest.mock('@libs/ReportUtils', () => ({ + // eslint-disable-next-line @typescript-eslint/consistent-type-imports ...jest.requireActual('@libs/ReportUtils'), getReportTransactions: jest.fn(), })); jest.mock('@libs/CurrencyUtils', () => ({ + // eslint-disable-next-line @typescript-eslint/consistent-type-imports ...jest.requireActual('@libs/CurrencyUtils'), isValidCurrencyCode: jest.fn(), })); From 9a8264657628e63bd70ae28c6b903e4cf14de17b Mon Sep 17 00:00:00 2001 From: truph01 Date: Tue, 23 Dec 2025 18:19:26 +0700 Subject: [PATCH 09/14] fix: lint --- tests/unit/FormulaTest.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/unit/FormulaTest.ts b/tests/unit/FormulaTest.ts index 432009a72188..39bc3089d15f 100644 --- a/tests/unit/FormulaTest.ts +++ b/tests/unit/FormulaTest.ts @@ -1,7 +1,7 @@ -// eslint-disable-next-line no-restricted-syntax -- disabled because we need CurrencyUtils to mock +// eslint-disable-next-line no-restricted-syntax @typescript-eslint/consistent-type-imports -- disabled because we need CurrencyUtils to mock import * as CurrencyUtils from '@libs/CurrencyUtils'; import {hasCircularReferences, parse} from '@libs/Formula'; -// eslint-disable-next-line no-restricted-syntax -- disabled because we need ReportUtils to mock +// eslint-disable-next-line no-restricted-syntax @typescript-eslint/consistent-type-imports -- disabled because we need ReportUtils to mock import * as ReportUtils from '@libs/ReportUtils'; jest.mock('@libs/ReportActionsUtils', () => ({ @@ -9,13 +9,11 @@ jest.mock('@libs/ReportActionsUtils', () => ({ })); jest.mock('@libs/ReportUtils', () => ({ - // eslint-disable-next-line @typescript-eslint/consistent-type-imports ...jest.requireActual('@libs/ReportUtils'), getReportTransactions: jest.fn(), })); jest.mock('@libs/CurrencyUtils', () => ({ - // eslint-disable-next-line @typescript-eslint/consistent-type-imports ...jest.requireActual('@libs/CurrencyUtils'), isValidCurrencyCode: jest.fn(), })); From c9d9f023238f838580ed4e12b35f5e01cde33b9d Mon Sep 17 00:00:00 2001 From: truph01 Date: Tue, 23 Dec 2025 18:24:05 +0700 Subject: [PATCH 10/14] fix: lint --- tests/unit/FormulaTest.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/FormulaTest.ts b/tests/unit/FormulaTest.ts index 39bc3089d15f..fbe6b27c82f9 100644 --- a/tests/unit/FormulaTest.ts +++ b/tests/unit/FormulaTest.ts @@ -1,7 +1,7 @@ -// eslint-disable-next-line no-restricted-syntax @typescript-eslint/consistent-type-imports -- disabled because we need CurrencyUtils to mock +// eslint-disable-next-line @typescript-eslint/consistent-type-imports import * as CurrencyUtils from '@libs/CurrencyUtils'; import {hasCircularReferences, parse} from '@libs/Formula'; -// eslint-disable-next-line no-restricted-syntax @typescript-eslint/consistent-type-imports -- disabled because we need ReportUtils to mock +// eslint-disable-next-line @typescript-eslint/consistent-type-imports import * as ReportUtils from '@libs/ReportUtils'; jest.mock('@libs/ReportActionsUtils', () => ({ From d36acfddb776ea227416a3aa3d9c1446e1d77fdf Mon Sep 17 00:00:00 2001 From: truph01 Date: Tue, 23 Dec 2025 18:32:35 +0700 Subject: [PATCH 11/14] fix: lint --- tests/unit/FormulaTest.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit/FormulaTest.ts b/tests/unit/FormulaTest.ts index fbe6b27c82f9..8d78a23cc635 100644 --- a/tests/unit/FormulaTest.ts +++ b/tests/unit/FormulaTest.ts @@ -1,8 +1,8 @@ -// eslint-disable-next-line @typescript-eslint/consistent-type-imports -import * as CurrencyUtils from '@libs/CurrencyUtils'; +// eslint-disable-next-line no-restricted-syntax +import type * as CurrencyUtils from '@libs/CurrencyUtils'; import {hasCircularReferences, parse} from '@libs/Formula'; -// eslint-disable-next-line @typescript-eslint/consistent-type-imports -import * as ReportUtils from '@libs/ReportUtils'; +// eslint-disable-next-line no-restricted-syntax +import type * as ReportUtils from '@libs/ReportUtils'; jest.mock('@libs/ReportActionsUtils', () => ({ getAllReportActions: jest.fn(), From 294c480e719b19c453b1bbae1c44ec844a1d0861 Mon Sep 17 00:00:00 2001 From: truph01 Date: Thu, 25 Dec 2025 17:02:19 +0700 Subject: [PATCH 12/14] fix: Revert unnecessary changes --- src/libs/Formula.ts | 673 +++++++++++++++++++- tests/unit/FormulaTest.ts | 1257 ++++++++++++++++++++++++++++++++++++- 2 files changed, 1923 insertions(+), 7 deletions(-) diff --git a/src/libs/Formula.ts b/src/libs/Formula.ts index b54dac751a82..abf11d2c499e 100644 --- a/src/libs/Formula.ts +++ b/src/libs/Formula.ts @@ -1,5 +1,16 @@ +import {endOfDay, endOfMonth, endOfWeek, getDay, lastDayOfMonth, set, startOfMonth, startOfWeek, subDays} from 'date-fns'; +import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; +import CONST from '@src/CONST'; +import type {PersonalDetails, Policy, Report, Transaction} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import {convertToDisplayString, convertToDisplayStringWithoutCurrency, isValidCurrencyCode} from './CurrencyUtils'; +import {formatDate} from './FormulaDatetime'; +import getBase62ReportID from './getBase62ReportID'; +import Log from './Log'; +import {getAllReportActions} from './ReportActionsUtils'; +import {getHumanReadableStatus, getMoneyRequestSpendBreakdown, getReportTransactions} from './ReportUtils'; +import {getCreated, isPartialTransaction, isTransactionPendingDelete} from './TransactionUtils'; type FormulaPart = { /** The original definition from the formula */ @@ -15,6 +26,15 @@ type FormulaPart = { functions: string[]; }; +type FormulaContext = { + report: Report; + policy: OnyxEntry; + transaction?: Transaction; + submitterPersonalDetails?: PersonalDetails; + managerPersonalDetails?: PersonalDetails; + allTransactions?: Record; +}; + type FieldList = Record; const FORMULA_PART_TYPES = { @@ -178,6 +198,7 @@ function parsePart(definition: string): FormulaPart { return part; } + /** * Check if the report field formula value is containing circular references, e.g example: A -> A, A->B->A, A->B->C->A, etc */ @@ -240,6 +261,654 @@ function hasCircularReferences(fieldValue: string, fieldName: string, fieldList? return hasCircularReferencesRecursive(fieldValue, fieldName); } -export {FORMULA_PART_TYPES, parse, hasCircularReferences}; +/** + * Check if a formula part is a submission info part (report:submit:*) + */ +function isSubmissionInfoPart(part: FormulaPart): boolean { + return part.type === FORMULA_PART_TYPES.REPORT && part.fieldPath.at(0)?.toLowerCase() === 'submit'; +} + +/** + * Compute the value of a formula given a context + */ +function compute(formula?: string, context?: FormulaContext): string { + if (!formula || typeof formula !== 'string') { + return ''; + } + if (!context) { + return ''; + } + + const parts = parse(formula); + let result = ''; + + for (const part of parts) { + let value = ''; + + switch (part.type) { + case FORMULA_PART_TYPES.REPORT: + value = computeReportPart(part, context); + // Apply fallback to formula definition for empty values, except for submission info + // Submission info explicitly returns empty strings when data is missing (matches backend) + if (value === '' && !isSubmissionInfoPart(part)) { + value = part.definition; + } + break; + case FORMULA_PART_TYPES.FIELD: + value = computeFieldPart(part); + break; + case FORMULA_PART_TYPES.USER: + value = computeUserPart(part); + break; + case FORMULA_PART_TYPES.FREETEXT: + value = part.definition; + break; + default: + // If we don't recognize the part type, use the original definition + value = part.definition; + } + + // Apply any functions to the computed value + value = applyFunctions(value, part.functions); + result += value; + } + + return result; +} + +/** + * Compute auto-reporting info for a report formula part + */ +function computeAutoReportingInfo(part: FormulaPart, context: FormulaContext, subField: string | undefined, format: string | undefined): string { + const {report, policy} = context; + + if (!subField) { + return part.definition; + } + + const {startDate, endDate} = getAutoReportingDates(policy, report); + + switch (subField.toLowerCase()) { + case 'start': + return formatDate(startDate?.toISOString(), format); + case 'end': + return formatDate(endDate?.toISOString(), format); + default: + return part.definition; + } +} + +/** + * Compute the value of a report formula part + */ +function computeReportPart(part: FormulaPart, context: FormulaContext): string { + 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' + const format = additionalPath.length > 0 ? additionalPath.join(':') : undefined; + + if (!field) { + return part.definition; + } + + 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': + return formatDate(getOldestTransactionDate(report.reportID, context), format); + case 'enddate': + return formatDate(getNewestTransactionDate(report.reportID, context), format); + case 'total': { + const formattedAmount = formatAmount(report.total, report.currency, format); + // Return empty string when conversion needed (formatAmount returns null for unavailable conversions) + return formattedAmount ?? ''; + } + case 'reimbursable': { + const formattedAmount = formatAmount(getMoneyRequestSpendBreakdown(report).reimbursableSpend, report.currency, format); + return formattedAmount ?? ''; + } + case 'currency': + return report.currency ?? ''; + case 'policyname': + case 'workspacename': + return policy?.name ?? ''; + case 'created': + // Backend will always return at least one report action (of type created) and its date is equal to report's creation date + // We can make it slightly more efficient in the future by ensuring report.created is always present in backend's responses + return formatDate(getOldestReportActionDate(report.reportID), format); + case 'submit': { + return computeSubmitPart(additionalPath, context); + } + case 'autoreporting': { + const subField = additionalPath.at(0); + // For multi-part formulas, format is everything after the subfield + const autoReportingFormat = additionalPath.length > 1 ? additionalPath.slice(1).join(':') : undefined; + return computeAutoReportingInfo(part, context, subField, autoReportingFormat); + } + default: + return part.definition; + } +} + +/** + * 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 + */ +function computeFieldPart(part: FormulaPart): string { + // Field computation will be implemented later + return part.definition; +} + +/** + * Compute the value of a user formula part + */ +function computeUserPart(part: FormulaPart): string { + // User computation will be implemented later + return part.definition; +} + +/** + * Apply functions to a computed value + */ +function applyFunctions(value: string, functions: string[]): string { + let result = value; + + for (const func of functions) { + const [functionName, ...args] = func.split(':'); + + switch (functionName.toLowerCase()) { + case 'frontpart': + result = getFrontPart(result); + break; + case 'substr': + result = getSubstring(result, args); + break; + case 'domain': + result = getDomainName(result); + break; + default: + // Unknown function, leave value as is + break; + } + } + + return result; +} + +/** + * Get the front part of an email or first word of a string + */ +function getFrontPart(value: string): string { + const trimmed = value.trim(); + + // If it's an email, return the part before @ + if (trimmed.includes('@')) { + return trimmed.split('@').at(0) ?? ''; + } + + // Otherwise, return the first word + return trimmed.split(' ').at(0) ?? ''; +} + +/** + * Get the domain name of an email or URL + */ +function getDomainName(value: string): string { + const trimmed = value.trim(); + + // If it's an email, return the part after @ + if (trimmed.includes('@')) { + return trimmed.split('@').at(1) ?? ''; + } + + return ''; +} + +/** + * Get substring of a value + */ +function getSubstring(value: string, args: string[]): string { + const start = parseInt(args.at(0) ?? '', 10) || 0; + const length = args.at(1) ? parseInt(args.at(1) ?? '', 10) : undefined; + + if (length !== undefined) { + return value.substring(start, start + length); + } + + return value.substring(start); +} + +/** + * Format an amount value + * @returns The formatted amount string, or null if currency conversion is needed (unavailable on frontend) + */ +function formatAmount(amount: number | undefined, currency: string | undefined, displayCurrency?: string): string | null { + if (amount === undefined) { + return ''; + } + + const absoluteAmount = Math.abs(amount); + + try { + const trimmedDisplayCurrency = displayCurrency?.trim().toUpperCase(); + if (trimmedDisplayCurrency) { + if (trimmedDisplayCurrency === 'NOSYMBOL') { + return convertToDisplayStringWithoutCurrency(absoluteAmount, currency); + } + + // Check if format is a valid currency code (e.g., USD, EUR, eur) + if (!isValidCurrencyCode(trimmedDisplayCurrency)) { + return ''; + } + + // If a currency conversion is needed (displayCurrency differs from the source), + // return null so the backend can compute it. + // We can only compute the value optimistically when the amount is 0. + if (absoluteAmount !== 0 && currency !== trimmedDisplayCurrency) { + return null; + } + + return convertToDisplayString(absoluteAmount, trimmedDisplayCurrency); + } + + if (currency && isValidCurrencyCode(currency)) { + return convertToDisplayString(absoluteAmount, currency); + } + + return convertToDisplayStringWithoutCurrency(absoluteAmount, currency); + } catch (error) { + Log.hmmm('[Formula] formatAmount failed', {error, amount, currency, displayCurrency}); + return ''; + } +} + +/** + * Get the date of the oldest report action for a given report + */ +function getOldestReportActionDate(reportID: string): string | undefined { + if (!reportID) { + return undefined; + } + + const reportActions = getAllReportActions(reportID); + if (!reportActions || Object.keys(reportActions).length === 0) { + return undefined; + } + + let oldestDate: string | undefined; + + for (const action of Object.values(reportActions)) { + if (!action?.created) { + continue; + } + + if (oldestDate && action.created > oldestDate) { + continue; + } + oldestDate = action.created; + } + + return oldestDate; +} + +/** + * Format a report type to its human-readable string + */ +function formatType(type: string | undefined): string { + if (!type) { + return ''; + } + + const typeMapping: Record = { + [CONST.REPORT.TYPE.EXPENSE]: 'Expense Report', + [CONST.REPORT.TYPE.INVOICE]: 'Invoice', + [CONST.REPORT.TYPE.CHAT]: 'Chat', + [CONST.REPORT.UNSUPPORTED_TYPE.BILL]: 'Bill', + [CONST.REPORT.UNSUPPORTED_TYPE.PAYCHECK]: 'Paycheck', + [CONST.REPORT.TYPE.IOU]: 'IOU', + [CONST.REPORT.TYPE.TASK]: 'Task', + trip: 'Trip', + }; + + return typeMapping[type.toLowerCase()] || type; +} + +/** + * Get all transactions for a report, including any context transaction. + * Updates an existing transaction if it matches the context or adds it if new. + */ +function getAllReportTransactionsWithContext(reportID: string, context?: FormulaContext): Transaction[] { + const transactions = [...getReportTransactions(reportID)]; + const contextTransaction = context?.transaction; + + if (contextTransaction?.transactionID && contextTransaction.reportID === reportID) { + const transactionIndex = transactions.findIndex((transaction) => transaction?.transactionID === contextTransaction.transactionID); + if (transactionIndex >= 0) { + transactions[transactionIndex] = contextTransaction; + } else { + transactions.push(contextTransaction); + } + } + + return transactions; +} + +/** + * Get the date of the oldest transaction for a given report + */ +function getOldestTransactionDate(reportID: string, context?: FormulaContext): string | undefined { + if (!reportID) { + return undefined; + } + + const transactions = getAllReportTransactionsWithContext(reportID, context); + if (!transactions || transactions.length === 0) { + return new Date().toISOString(); + } + + let oldestDate: string | undefined; + + for (const transaction of transactions) { + const created = getCreated(transaction); + if (!created) { + continue; + } + // Skip transactions with pending deletion (offline deletes) to calculate dates properly. + if (transaction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + continue; + } + if (oldestDate && created >= oldestDate) { + continue; + } + if (isPartialTransaction(transaction)) { + continue; + } + oldestDate = created; + } + + return oldestDate; +} + +/** + * Calculate monthly reporting period for a specific day offset + */ +function getMonthlyReportingPeriod(currentDate: Date, offsetDay: number): {startDate: Date; endDate: Date} { + const currentDay = currentDate.getDate(); + const currentYear = currentDate.getFullYear(); + const currentMonth = currentDate.getMonth(); + + if (currentDay <= offsetDay) { + // We haven't reached the reporting day yet - period is from last month's offset+1 to this month's offset + const prevMonth = currentMonth - 1; + const prevYear = prevMonth < 0 ? currentYear - 1 : currentYear; + const adjustedPrevMonth = prevMonth < 0 ? 11 : prevMonth; + + const prevMonthDays = lastDayOfMonth(new Date(prevYear, adjustedPrevMonth, 1)).getDate(); + const prevOffsetDay = Math.min(offsetDay, prevMonthDays); + + const currentMonthDays = lastDayOfMonth(currentDate).getDate(); + const currentOffsetDay = Math.min(offsetDay, currentMonthDays); + + return { + startDate: new Date(prevYear, adjustedPrevMonth, prevOffsetDay + 1, 0, 0, 0, 0), + endDate: new Date(currentYear, currentMonth, currentOffsetDay, 23, 59, 59, 999), + }; + } + + // We've passed the reporting day - period is from this month's offset+1 to next month's offset + const nextMonth = currentMonth + 1; + const nextYear = nextMonth > 11 ? currentYear + 1 : currentYear; + const adjustedNextMonth = nextMonth > 11 ? 0 : nextMonth; + + const currentMonthDays = lastDayOfMonth(currentDate).getDate(); + const currentOffsetDay = Math.min(offsetDay, currentMonthDays); + + const nextMonthDays = lastDayOfMonth(new Date(nextYear, adjustedNextMonth, 1)).getDate(); + const nextOffsetDay = Math.min(offsetDay, nextMonthDays); + + return { + startDate: new Date(currentYear, currentMonth, currentOffsetDay + 1, 0, 0, 0, 0), + endDate: new Date(nextYear, adjustedNextMonth, nextOffsetDay, 23, 59, 59, 999), + }; +} + +/** + * Calculate monthly reporting period for last business day + */ +function getMonthlyLastBusinessDayPeriod(currentDate: Date): {startDate: Date; endDate: Date} { + let endDate = endOfMonth(currentDate); + + // Move backward to find last business day (Mon-Fri) + while (getDay(endDate) === 0 || getDay(endDate) === 6) { + endDate = subDays(endDate, 1); + } + + return { + startDate: startOfMonth(currentDate), + endDate: endOfDay(endDate), + }; +} + +/** + * 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} { + const frequency = policy?.autoReportingFrequency; + const offset = policy?.autoReportingOffset; + + // Return undefined if no frequency is set + if (!frequency || !policy) { + return {startDate: undefined, endDate: undefined}; + } + + let startDate: Date; + let endDate: Date; + + switch (frequency) { + case CONST.POLICY.AUTO_REPORTING_FREQUENCIES.WEEKLY: { + // Weekly: use the app's configured week start convention (Monday) + const weekStartsOn = CONST.WEEK_STARTS_ON; + startDate = startOfWeek(currentDate, {weekStartsOn}); + endDate = endOfWeek(currentDate, {weekStartsOn}); + break; + } + + case CONST.POLICY.AUTO_REPORTING_FREQUENCIES.SEMI_MONTHLY: { + // Semi-monthly: 1st-15th or 16th-end of month + const dayOfMonth = currentDate.getDate(); + if (dayOfMonth <= 15) { + startDate = startOfMonth(currentDate); + endDate = set(currentDate, {date: 15, hours: 23, minutes: 59, seconds: 59, milliseconds: 999}); + } else { + startDate = set(currentDate, {date: 16, hours: 0, minutes: 0, seconds: 0, milliseconds: 0}); + endDate = endOfMonth(currentDate); + } + break; + } + + case CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MONTHLY: { + // Monthly reporting with different offset configurations + if (offset === CONST.POLICY.AUTO_REPORTING_OFFSET.LAST_BUSINESS_DAY_OF_MONTH) { + const period = getMonthlyLastBusinessDayPeriod(currentDate); + startDate = period.startDate; + endDate = period.endDate; + } else if (typeof offset === 'number') { + const period = getMonthlyReportingPeriod(currentDate, offset); + startDate = period.startDate; + endDate = period.endDate; + } else { + // Default to full month + startDate = startOfMonth(currentDate); + endDate = endOfMonth(currentDate); + } + break; + } + + case CONST.POLICY.AUTO_REPORTING_FREQUENCIES.TRIP: { + // For trip-based, use oldest transaction as start + const oldestTransactionDateString = getOldestTransactionDate(report.reportID); + startDate = oldestTransactionDateString ? new Date(oldestTransactionDateString) : currentDate; + endDate = currentDate; + break; + } + + default: + // For any other frequency, use current date as both start and end + startDate = currentDate; + endDate = currentDate; + break; + } + + return {startDate, endDate}; +} + +/** + * Get the date of the newest transaction for a given report + */ +function getNewestTransactionDate(reportID: string, context?: FormulaContext): string | undefined { + if (!reportID) { + return undefined; + } + + const transactions = getAllReportTransactionsWithContext(reportID, context); + if (!transactions || transactions.length === 0) { + return new Date().toISOString(); + } + + let newestDate: string | undefined; + + for (const transaction of transactions) { + const created = getCreated(transaction); + if (!created) { + continue; + } + // Skip transactions with pending deletion (offline deletes) to calculate dates properly. + if (transaction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + continue; + } + if (newestDate && created <= newestDate) { + continue; + } + if (isPartialTransaction(transaction)) { + continue; + } + newestDate = created; + } + + return newestDate; +} + +/** + * Compute the value of a report:submit:* formula part + * Handles nested paths like submit:from:firstname, submit:to:email, submit:date + */ +function computeSubmitPart(path: string[], context: FormulaContext): string { + const [direction, ...subPath] = path; + + if (!direction) { + return ''; + } + + switch (direction.toLowerCase()) { + case 'from': + return computePersonalDetailsField(subPath, context.submitterPersonalDetails, context.policy); + case 'to': + return computePersonalDetailsField(subPath, context.managerPersonalDetails, context.policy); + case 'date': { + // TODO: Use report.submitted once backend adds it (issue #568267) + // Using report.created as placeholder until then + const submittedDate = context.report.created; + const format = subPath.length > 0 ? subPath.join(':') : undefined; + return formatDate(submittedDate, format); + } + default: + return ''; + } +} + +/** + * Compute personal details information for either submitter (from) or manager (to) + */ +function computePersonalDetailsField(path: string[], personalDetails: PersonalDetails | undefined, policy: OnyxEntry): string { + const [field] = path; + + if (!personalDetails || !field) { + return ''; + } + + switch (field.toLowerCase()) { + case 'firstname': + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + return personalDetails.firstName || personalDetails.login || ''; + case 'lastname': + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + return personalDetails.lastName || personalDetails.login || ''; + case 'fullname': + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + return personalDetails.displayName || personalDetails.login || ''; + case 'email': + return personalDetails.login ?? ''; + // userid/customfield1 returns employeeUserID from policy.employeeList + // TODO: Check policy.glCodes once backend adds it (issue #568268) + case 'userid': + case 'customfield1': { + const email = personalDetails.login; + if (!email || !policy?.employeeList) { + return ''; + } + // eslint-disable-next-line rulesdir/no-default-id-values + return policy.employeeList[email]?.employeeUserID ?? ''; + } + // payrollid/customfield2 returns employeePayrollID from policy.employeeList + case 'payrollid': + case 'customfield2': { + const email = personalDetails.login; + if (!email || !policy?.employeeList) { + return ''; + } + // eslint-disable-next-line rulesdir/no-default-id-values + return policy.employeeList[email]?.employeePayrollID ?? ''; + } + default: + return ''; + } +} + +export {FORMULA_PART_TYPES, compute, parse, hasCircularReferences}; -export type {FieldList}; +export type {FormulaContext, FieldList}; diff --git a/tests/unit/FormulaTest.ts b/tests/unit/FormulaTest.ts index 8d78a23cc635..375b49c63f11 100644 --- a/tests/unit/FormulaTest.ts +++ b/tests/unit/FormulaTest.ts @@ -1,8 +1,13 @@ -// eslint-disable-next-line no-restricted-syntax -import type * as CurrencyUtils from '@libs/CurrencyUtils'; -import {hasCircularReferences, parse} from '@libs/Formula'; -// eslint-disable-next-line no-restricted-syntax -import type * as ReportUtils from '@libs/ReportUtils'; +// eslint-disable-next-line no-restricted-syntax -- disabled because we need CurrencyUtils to mock +import * as CurrencyUtils from '@libs/CurrencyUtils'; +import type {FormulaContext} from '@libs/Formula'; +import {compute, hasCircularReferences, parse} from '@libs/Formula'; +// eslint-disable-next-line no-restricted-syntax -- disabled because we need ReportActionsUtils to mock +import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +// eslint-disable-next-line no-restricted-syntax -- disabled because we need ReportUtils to mock +import * as ReportUtils from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import type {PersonalDetails, Policy, Report, ReportActions, Transaction} from '@src/types/onyx'; jest.mock('@libs/ReportActionsUtils', () => ({ getAllReportActions: jest.fn(), @@ -18,6 +23,10 @@ jest.mock('@libs/CurrencyUtils', () => ({ isValidCurrencyCode: jest.fn(), })); +const mockReportActionsUtils = ReportActionsUtils as jest.Mocked; +const mockReportUtils = ReportUtils as jest.Mocked; +const mockCurrencyUtils = CurrencyUtils as jest.Mocked; + describe('CustomFormula', () => { describe('parse()', () => { test('should parse report formula parts', () => { @@ -68,11 +77,1249 @@ describe('CustomFormula', () => { }); }); + describe('compute()', () => { + const mockContext: FormulaContext = { + report: { + reportID: '123', + reportName: '', + type: 'expense', + total: -10000, // -$100.00 + currency: 'USD', + lastVisibleActionCreated: '2025-01-15T10:30:00Z', + policyID: 'policy1', + } as Report, + policy: { + name: 'Test Policy', + } as Policy, + }; + + beforeEach(() => { + jest.clearAllMocks(); + + mockCurrencyUtils.isValidCurrencyCode.mockImplementation((code: string) => ['USD', 'EUR', 'JPY', 'NPR'].includes(code)); + + const mockReportActions = { + // eslint-disable-next-line @typescript-eslint/naming-convention + '1': { + reportActionID: '1', + created: '2025-01-10T08:00:00Z', // Oldest action + actionName: 'CREATED', + }, + // eslint-disable-next-line @typescript-eslint/naming-convention + '2': { + reportActionID: '2', + created: '2025-01-15T10:30:00Z', // Later action + actionName: 'IOU', + }, + // eslint-disable-next-line @typescript-eslint/naming-convention + '3': { + reportActionID: '3', + created: '2025-01-12T14:20:00Z', // Middle action + actionName: 'COMMENT', + }, + } as unknown as ReportActions; + + const mockTransactions = [ + { + transactionID: 'trans1', + created: '2025-01-08T12:00:00Z', // Oldest transaction + amount: 5000, + merchant: 'ACME Ltd.', + }, + { + transactionID: 'trans2', + created: '2025-01-14T16:45:00Z', // Later transaction + amount: 3000, + merchant: 'ACME Ltd.', + }, + { + transactionID: 'trans3', + created: '2025-01-11T09:15:00Z', // Middle transaction + amount: 2000, + merchant: 'ACME Ltd.', + }, + ] as Transaction[]; + + mockReportActionsUtils.getAllReportActions.mockReturnValue(mockReportActions); + mockReportUtils.getReportTransactions.mockReturnValue(mockTransactions); + }); + + test('should compute basic report formula', () => { + const result = compute('{report:type} {report:total}', mockContext); + expect(result).toBe('Expense Report $100.00'); // No space between parts + }); + + test('should compute startdate formula using transactions', () => { + const result = compute('{report:startdate}', mockContext); + expect(result).toBe('2025-01-08'); // Should use oldest transaction date (2025-01-08) + }); + + test('should compute enddate formula using transactions', () => { + const result = compute('{report:enddate}', mockContext); + expect(result).toBe('2025-01-14'); // Should use newest transaction date (2025-01-14) + }); + + test('should compute created formula using report actions', () => { + const result = compute('{report:created}', mockContext); + expect(result).toBe('2025-01-10'); // Should use oldest report action date (2025-01-10) + }); + + test('should compute startdate with custom format', () => { + const result = compute('{report:startdate:MM/dd/yyyy}', mockContext); + expect(result).toBe('01/08/2025'); // Should use oldest transaction date with yyyy-MM-dd format + }); + + test('should compute enddate with custom format', () => { + const result = compute('{report:enddate:MM/dd/yyyy}', mockContext); + expect(result).toBe('01/14/2025'); // Should use newest transaction date with MM/dd/yyyy format + }); + + test('should compute created with custom format', () => { + const result = compute('{report:created:MMMM dd, yyyy}', mockContext); + expect(result).toBe('January 10, 2025'); // Should use oldest report action date with MMMM dd, yyyy format + }); + + test('should compute startdate with short month format', () => { + const result = compute('{report:startdate:dd MMM yyyy}', mockContext); + expect(result).toBe('08 Jan 2025'); // Should use oldest transaction date with dd MMM yyyy format + }); + + test('should compute enddate with short month format', () => { + const result = compute('{report:enddate:dd MMM yyyy}', mockContext); + expect(result).toBe('14 Jan 2025'); // Should use newest transaction date with dd MMM yyyy format + }); + + test('should compute policy name', () => { + const result = compute('{report:policyname}', mockContext); + 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(''); + }); + + test('should handle unknown formula parts', () => { + const result = compute('{report:unknown}', mockContext); + expect(result).toBe('{report:unknown}'); + }); + + test('should handle missing report data gracefully', () => { + const contextWithMissingData: FormulaContext = { + report: {} as unknown as Report, + policy: null as unknown as Policy, + }; + const result = compute('{report:total} {report:policyname}', contextWithMissingData); + expect(result).toBe('{report:total} {report:policyname}'); // Empty data is replaced with definition + }); + + test('should preserve free text', () => { + const result = compute('Expense Report - {report:total}', mockContext); + expect(result).toBe('Expense Report - $100.00'); + }); + + test('should preserve exact spacing around formula parts', () => { + const result = compute('Report with type after 4 spaces {report:type}-and no space after computed part', mockContext); + expect(result).toBe('Report with type after 4 spaces Expense Report-and no space after computed part'); + }); + + test('should compute complex formula with multiple new parts', () => { + const contextWithStatus: FormulaContext = { + ...mockContext, + report: { + ...mockContext.report, + transactionCount: 3, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + }, + }; + 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'); + }); + + 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'}, + ]; + + for (const {statusNum, expected} of testCases) { + const contextWithStatus: FormulaContext = { + ...mockContext, + report: { + ...mockContext.report, + statusNum, + }, + }; + const result = compute('{report:status}', contextWithStatus); + expect(result).toBe(expected); + } + }); + + 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}'); + }); + + 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 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'); + }); + + 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'); + }); + }); + + describe('Reimbursable Amount', () => { + const reimbursableContext: FormulaContext = { + report: { + reportID: '123', + reportName: '', + type: 'expense', + policyID: 'policy1', + }, + policy: { + name: 'Test Policy', + } as Policy, + }; + + const calculateExpectedReimbursable = (total: number, nonReimbursableTotal: number) => { + const reimbursableAmount = total - nonReimbursableTotal; + return Math.abs(reimbursableAmount) / 100; + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should compute reimbursable amount', () => { + reimbursableContext.report.currency = 'USD'; + reimbursableContext.report.total = -10000; // -$100.00 + reimbursableContext.report.nonReimbursableTotal = -2500; // -$25.00 + + const expectedReimbursable = calculateExpectedReimbursable(reimbursableContext.report.total, reimbursableContext.report.nonReimbursableTotal); + const result = compute('{report:reimbursable}', reimbursableContext); + expect(result).toBe(`$${expectedReimbursable.toFixed(2)}`); + }); + + 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); + 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); + const result = compute('{report:reimbursable}', reimbursableContext); + expect(result).toBe(`${expectedReimbursable.toFixed(2)}`); + }); + + describe('Currency Formatting & Conversion', () => { + const currencyContext: FormulaContext = { + report: { + reportID: '123', + total: -10000, + currency: 'USD', + } as Report, + policy: {} as Policy, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Edge cases', () => { + test('undefined currency - should format without symbol', () => { + currencyContext.report.currency = undefined; + const result = compute('{report:total}', currencyContext); + expect(result).toBe('100.00'); + }); + + test('invalid source currency - should return placeholder', () => { + currencyContext.report.currency = 'UNKNOWN'; + const result = compute('{report:total}', currencyContext); + expect(result).toBe('{report:total}'); + }); + + test('invalid format currency - should return placeholder', () => { + currencyContext.report.currency = 'EUR'; + const result = compute('{report:total:UNKNOWN}', currencyContext); + expect(result).toBe('{report:total:UNKNOWN}'); + }); + }); + }); + }); + + describe('Function Modifiers', () => { + const mockContext: FormulaContext = { + report: { + reportID: 'report123456789', + reportName: '', + type: 'expense', + total: -10000, // -$100.00 + currency: 'USD', + lastVisibleActionCreated: '2025-01-15T10:30:00Z', + policyID: 'policy1', + } as Report, + policy: { + name: 'Engineering Department Rules', + } as Policy, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('frontpart modifier', () => { + test('should extract first word from non-email text', () => { + const result = compute('{report:policyname|frontpart}', mockContext); + expect(result).toBe('Engineering'); // First word of "Engineering Department Rules" + }); + + test('should handle empty strings', () => { + const contextWithEmpty: FormulaContext = { + report: {} as Report, + policy: {name: ''} as Policy, + }; + const result = compute('{report:policyname|frontpart}', contextWithEmpty); + expect(result).toBe('{report:policyname|frontpart}'); // Falls back to formula definition + }); + }); + + describe('domain modifier', () => { + test('should extract domain from email', () => { + const result = compute('{report:submit:from:email|domain}', mockContext); + // Submit part extraction not implemented yet; for now, it returns the definition + // Once implemented, this should return 'domain' of the email + expect(result).toBe(''); + }); + + test('should return empty for non-email text', () => { + const result = compute('{report:policyname|domain}', mockContext); + expect(result).toBe(''); // "Engineering Department Rules" has no @ symbol + }); + + test('should handle empty strings', () => { + const contextWithEmpty: FormulaContext = { + report: {} as Report, + policy: {name: ''} as Policy, + }; + const result = compute('{report:policyname|domain}', contextWithEmpty); + expect(result).toBe(''); // Empty policy name + }); + }); + + describe('substr modifier', () => { + test('should extract substring with start and length', () => { + const result = compute('{report:policyname|substr:0:11}', mockContext); + expect(result).toBe('Engineering'); // First 11 characters of "Engineering Department Rules" + }); + + test('should extract substring with only start position', () => { + const result = compute('{report:policyname|substr:12}', mockContext); + expect(result).toBe('Department Rules'); // From position 12 to end + }); + + test('should handle start position beyond string length', () => { + const result = compute('{report:policyname|substr:50:10}', mockContext); + expect(result).toBe(''); // Start position 50 is beyond string length + }); + + test('should handle length larger than remaining string', () => { + const result = compute('{report:policyname|substr:23:50}', mockContext); + expect(result).toBe('Rules'); // Only remaining characters + }); + + test('should handle invalid length parameter', () => { + const result = compute('{report:policyname|substr:0:abc}', mockContext); + expect(result).toBe(''); // Invalid length, returns empty + }); + }); + }); + + describe('Auto-reporting Frequency', () => { + const mockReport = {reportID: '123'} as Report; + const createMockContext = (policy: Policy): FormulaContext => ({report: mockReport, policy}); + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + jest.setSystemTime(new Date('2025-01-19T14:23:45Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test('should compute weekly frequency dates', () => { + const policy = {autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.WEEKLY} as Policy; + const context = createMockContext(policy); + + expect(compute('{report:autoreporting:start}', context)).toBe('2025-01-13'); + expect(compute('{report:autoreporting:end}', context)).toBe('2025-01-19'); + }); + + test('should compute semi-monthly frequency dates', () => { + jest.setSystemTime(new Date('2025-01-10T12:00:00Z')); + const policy = {autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.SEMI_MONTHLY} as Policy; + const context = createMockContext(policy); + + expect(compute('{report:autoreporting:start}', context)).toBe('2025-01-01'); + expect(compute('{report:autoreporting:end}', context)).toBe('2025-01-15'); + + jest.setSystemTime(new Date('2025-01-20T12:00:00Z')); + expect(compute('{report:autoreporting:start}', context)).toBe('2025-01-16'); + expect(compute('{report:autoreporting:end}', context)).toBe('2025-01-31'); + }); + + test('should compute monthly frequency with specific offset', () => { + const policy = { + autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MONTHLY, + autoReportingOffset: 25, + } as Policy; + const context = createMockContext(policy); + + expect(compute('{report:autoreporting:start}', context)).toBe('2024-12-26'); + expect(compute('{report:autoreporting:end}', context)).toBe('2025-01-25'); + }); + + test('should compute monthly frequency with last business day', () => { + const policy = { + autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MONTHLY, + autoReportingOffset: CONST.POLICY.AUTO_REPORTING_OFFSET.LAST_BUSINESS_DAY_OF_MONTH, + } as Policy; + const context = createMockContext(policy); + + expect(compute('{report:autoreporting:start}', context)).toBe('2025-01-01'); + expect(compute('{report:autoreporting:end}', context)).toBe('2025-01-31'); + }); + + test('should compute trip frequency dates', () => { + const mockTransactions = [ + {transactionID: 'trans1', created: '2025-01-08T12:00:00Z', merchant: 'Hotel', amount: 5000} as Transaction, + {transactionID: 'trans2', created: '2025-01-14T16:45:00Z', merchant: 'Restaurant', amount: 3000} as Transaction, + ]; + + mockReportUtils.getReportTransactions.mockReturnValue(mockTransactions); + + const policy = {autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.TRIP} as Policy; + const context = createMockContext(policy); + + expect(compute('{report:autoreporting:start}', context)).toBe('2025-01-08'); + expect(compute('{report:autoreporting:end}', context)).toBe('2025-01-19'); + }); + + test('should apply custom date formats', () => { + const policy = {autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.WEEKLY} as Policy; + const context = createMockContext(policy); + + expect(compute('{report:autoreporting:start:MMMM dd, yyyy}', context)).toBe('January 13, 2025'); + expect(compute('{report:autoreporting:end:MM/dd/yyyy}', context)).toBe('01/19/2025'); + }); + + test('should return formula definition when policy or frequency is missing', () => { + expect(compute('{report:autoreporting:start}', {report: mockReport, policy: undefined})).toBe('{report:autoreporting:start}'); + expect(compute('{report:autoreporting:end}', createMockContext({} as Policy))).toBe('{report:autoreporting:end}'); + }); + }); + describe('Edge Cases', () => { test('should handle malformed braces', () => { const parts = parse('{incomplete'); expect(parts.at(0)?.type).toBe('freetext'); }); + + test('should handle undefined amounts', () => { + const context: FormulaContext = { + report: {total: undefined} as Report, + policy: null as unknown as Policy, + }; + const result = compute('{report:total}', context); + expect(result).toBe('{report:total}'); + }); + + test('should handle missing report actions for created', () => { + mockReportActionsUtils.getAllReportActions.mockReturnValue({}); + const context: FormulaContext = { + report: {reportID: '123'} as Report, + policy: null as unknown as Policy, + }; + + const result = compute('{report:created}', context); + expect(result).toBe('{report:created}'); + }); + + test('should handle missing transactions for startdate', () => { + mockReportUtils.getReportTransactions.mockReturnValue([]); + const context: FormulaContext = { + report: {reportID: '123'} as Report, + policy: null as unknown as Policy, + }; + const today = new Date(); + const expected = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; + const result = compute('{report:startdate}', context); + expect(result).toBe(expected); + }); + + test('should handle missing transactions for enddate', () => { + mockReportUtils.getReportTransactions.mockReturnValue([]); + const context: FormulaContext = { + report: {reportID: '123'} as Report, + policy: null as unknown as Policy, + }; + const today = new Date(); + const expected = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; + const result = compute('{report:enddate}', context); + expect(result).toBe(expected); + }); + + test('should call getReportTransactions with correct reportID for startdate', () => { + const context: FormulaContext = { + report: {reportID: 'test-report-123'} as Report, + policy: null as unknown as Policy, + }; + + compute('{report:startdate}', context); + expect(mockReportUtils.getReportTransactions).toHaveBeenCalledWith('test-report-123'); + }); + + test('should call getAllReportActions with correct reportID for created', () => { + const context: FormulaContext = { + report: {reportID: 'test-report-456'} as Report, + policy: null as unknown as Policy, + }; + + compute('{report:created}', context); + expect(mockReportActionsUtils.getAllReportActions).toHaveBeenCalledWith('test-report-456'); + }); + + test('should skip partial transactions (empty merchant)', () => { + const mockTransactions = [ + { + transactionID: 'trans1', + created: '2025-01-15T12:00:00Z', + amount: 5000, + merchant: 'ACME Ltd.', + }, + { + transactionID: 'trans2', + created: '2025-01-08T16:45:00Z', // Older but partial + amount: 3000, + merchant: '', // Empty merchant = partial + }, + { + transactionID: 'trans3', + created: '2025-01-12T09:15:00Z', // Should be oldest valid + amount: 2000, + merchant: 'Gamma Inc.', + }, + ] as Transaction[]; + + mockReportUtils.getReportTransactions.mockReturnValue(mockTransactions); + const context: FormulaContext = { + report: {reportID: 'test-report-123'} as Report, + policy: null as unknown as Policy, + }; + + const result = compute('{report:startdate}', context); + expect(result).toBe('2025-01-12'); + + const endResult = compute('{report:enddate}', context); + expect(endResult).toBe('2025-01-15'); + }); + + test('should skip partial transactions (zero amount)', () => { + const mockTransactions = [ + { + transactionID: 'trans1', + created: '2025-01-15T12:00:00Z', + amount: 5000, + merchant: 'ACME Ltd.', + }, + { + transactionID: 'trans2', + created: '2025-01-08T16:45:00Z', // Older but partial + amount: 0, // Zero amount = partial + merchant: 'Beta Corp.', + iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + }, + { + transactionID: 'trans3', + created: '2025-01-12T09:15:00Z', // Should be oldest valid + amount: 2000, + merchant: 'Gamma Inc.', + }, + ] as Transaction[]; + + mockReportUtils.getReportTransactions.mockReturnValue(mockTransactions); + const context: FormulaContext = { + report: {reportID: 'test-report-123'} as Report, + policy: null as unknown as Policy, + }; + + const result = compute('{report:startdate}', context); + expect(result).toBe('2025-01-12'); + + const endResult = compute('{report:enddate}', context); + expect(endResult).toBe('2025-01-15'); + }); + }); + + describe('Date Format Tokens', () => { + // Test date: Wednesday, January 8, 2025, 3:30:45 PM (15:30:45) UTC + const testDate = '2025-01-08T15:30:45.123Z'; + const morningDate = '2025-01-08T09:05:02.123Z'; // 9:05:02 AM for leading zero tests + + const mockContextWithDate: FormulaContext = { + report: {reportID: '123'} as Report, + policy: null as unknown as Policy, + }; + + const setupMockDate = (date: string) => { + const mockTransaction = { + transactionID: 'trans1', + created: date, + amount: -5000, + merchant: 'Test Store', + } as Transaction; + mockReportUtils.getReportTransactions.mockReturnValue([mockTransaction]); + + const mockReportAction = { + created: date, + actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, + } as unknown as ReportActions[string]; + mockReportActionsUtils.getAllReportActions.mockReturnValue({action1: mockReportAction}); + }; + + beforeEach(() => setupMockDate(testDate)); + + test('year formats - yyyy/yy/Y/y/YYYY', () => { + expect(compute('{report:startdate:yyyy}', mockContextWithDate)).toBe('2025'); + expect(compute('{report:startdate:YYYY}', mockContextWithDate)).toBe('2025'); + expect(compute('{report:startdate:Y}', mockContextWithDate)).toBe('2025'); + expect(compute('{report:startdate:yy}', mockContextWithDate)).toBe('25'); + expect(compute('{report:startdate:y}', mockContextWithDate)).toBe('25'); + }); + + test('month formats - names and numbers', () => { + expect(compute('{report:startdate:MMMM}', mockContextWithDate)).toBe('January'); + expect(compute('{report:startdate:F}', mockContextWithDate)).toBe('January'); + expect(compute('{report:startdate:MMM}', mockContextWithDate)).toBe('Jan'); + expect(compute('{report:startdate:M}', mockContextWithDate)).toBe('Jan'); + expect(compute('{report:startdate:MM}', mockContextWithDate)).toBe('01'); + expect(compute('{report:startdate:n}', mockContextWithDate)).toBe('1'); + expect(compute('{report:startdate:t}', mockContextWithDate)).toBe('31'); + }); + + test('day formats - numbers and names', () => { + expect(compute('{report:startdate:dd}', mockContextWithDate)).toBe('08'); + expect(compute('{report:startdate:d}', mockContextWithDate)).toBe('08'); + expect(compute('{report:startdate:j}', mockContextWithDate)).toBe('8'); + expect(compute('{report:startdate:S}', mockContextWithDate)).toBe('th'); + expect(compute('{report:startdate:jS}', mockContextWithDate)).toBe('8th'); + expect(compute('{report:startdate:dddd}', mockContextWithDate)).toBe('Wednesday'); + expect(compute('{report:startdate:l}', mockContextWithDate)).toBe('Wednesday'); + expect(compute('{report:startdate:ddd}', mockContextWithDate)).toBe('Wed'); + expect(compute('{report:startdate:D}', mockContextWithDate)).toBe('Wed'); + expect(compute('{report:startdate:w}', mockContextWithDate)).toBe('3'); + expect(compute('{report:startdate:N}', mockContextWithDate)).toBe('3'); + expect(compute('{report:startdate:z}', mockContextWithDate)).toBe('7'); + expect(compute('{report:startdate:W}', mockContextWithDate)).toBe('02'); + }); + + test('ISO week number - first Thursday rule', () => { + // January 1, 2021 is a Friday + // Week 1 of 2021 contains the first Thursday (Jan 7) + // So Jan 1-3 (Fri, Sat, Sun) belong to week 53 of 2020 + setupMockDate('2021-01-01T12:00:00Z'); // Friday, Jan 1, 2021 + expect(compute('{report:startdate:W}', mockContextWithDate)).toBe('53'); + + // January 4 is Monday, which is in week 1 + setupMockDate('2021-01-04T12:00:00Z'); // Monday, Jan 4, 2021 + expect(compute('{report:startdate:W}', mockContextWithDate)).toBe('01'); + + // December 31, 2020 is Thursday, should be week 53 + setupMockDate('2020-12-31T12:00:00Z'); // Thursday, Dec 31, 2020 + expect(compute('{report:startdate:W}', mockContextWithDate)).toBe('53'); + }); + + test('complex date formats', () => { + expect(compute('{report:startdate:MMMM dd, yyyy}', mockContextWithDate)).toBe('January 08, 2025'); + expect(compute('{report:startdate:dd MMM yyyy}', mockContextWithDate)).toBe('08 Jan 2025'); + expect(compute('{report:startdate:yyyy-MM-dd}', mockContextWithDate)).toBe('2025-01-08'); + }); + + test('time formats - hours', () => { + // 24-hour format: 15:30:45 (afternoon) and 09:05:02 (morning with leading zero) + expect(compute('{report:startdate:HH}', mockContextWithDate)).toBe('15'); + expect(compute('{report:startdate:H}', mockContextWithDate)).toBe('15'); + expect(compute('{report:startdate:G}', mockContextWithDate)).toBe('15'); + + setupMockDate(morningDate); + expect(compute('{report:startdate:HH}', mockContextWithDate)).toBe('09'); + expect(compute('{report:startdate:H}', mockContextWithDate)).toBe('09'); // H has leading zeros per spec + expect(compute('{report:startdate:G}', mockContextWithDate)).toBe('9'); // G has NO leading zeros + + // 12-hour format: 3:30 PM (afternoon) and 9:05 AM (morning) + setupMockDate(testDate); + expect(compute('{report:startdate:hh}', mockContextWithDate)).toBe('03'); + expect(compute('{report:startdate:h}', mockContextWithDate)).toBe('03'); // h has leading zeros per spec + expect(compute('{report:startdate:g}', mockContextWithDate)).toBe('3'); // g has NO leading zeros + + setupMockDate(morningDate); + expect(compute('{report:startdate:hh}', mockContextWithDate)).toBe('09'); + expect(compute('{report:startdate:h}', mockContextWithDate)).toBe('09'); // h has leading zeros per spec + expect(compute('{report:startdate:g}', mockContextWithDate)).toBe('9'); // g has NO leading zeros + }); + + test('time formats - minutes and seconds', () => { + // Minutes: 30 (double digit) and 05 (single digit with leading zero) + setupMockDate(testDate); + expect(compute('{report:startdate:mm}', mockContextWithDate)).toBe('30'); + expect(compute('{report:startdate:i}', mockContextWithDate)).toBe('30'); + + setupMockDate(morningDate); + expect(compute('{report:startdate:mm}', mockContextWithDate)).toBe('05'); + expect(compute('{report:startdate:i}', mockContextWithDate)).toBe('05'); + + // Seconds: 45 (double digit) and 02 (single digit with leading zero) + setupMockDate(testDate); + expect(compute('{report:startdate:ss}', mockContextWithDate)).toBe('45'); + expect(compute('{report:startdate:s}', mockContextWithDate)).toBe('45'); + + setupMockDate(morningDate); + expect(compute('{report:startdate:ss}', mockContextWithDate)).toBe('02'); + expect(compute('{report:startdate:s}', mockContextWithDate)).toBe('02'); + }); + + test('time formats - AM/PM', () => { + expect(compute('{report:startdate:tt}', mockContextWithDate)).toBe('PM'); + expect(compute('{report:startdate:A}', mockContextWithDate)).toBe('PM'); + expect(compute('{report:startdate:a}', mockContextWithDate)).toBe('pm'); + }); + + test('full date/time formats - c, r, U', () => { + // ISO 8601 format (c token) + const cResult = compute('{report:startdate:c}', mockContextWithDate); + expect(cResult).toBe('2025-01-08T15:30:45.123Z'); + + // RFC 2822 format (r token) + const rResult = compute('{report:startdate:r}', mockContextWithDate); + expect(rResult).toMatch(/^Wed, 08 Jan 2025 \d{2}:\d{2}:\d{2} [+-]\d{4}$/); + + // Unix timestamp (U token) + const uResult = compute('{report:startdate:U}', mockContextWithDate); + const expectedTimestamp = Math.floor(new Date(testDate).getTime() / 1000).toString(); + expect(uResult).toBe(expectedTimestamp); + }); + + test('format strings with colons', () => { + expect(compute('{report:startdate:HH:mm}', mockContextWithDate)).toBe('15:30'); + expect(compute('{report:startdate:HH:mm:ss}', mockContextWithDate)).toBe('15:30:45'); + expect(compute('{report:startdate:hh:mm tt}', mockContextWithDate)).toBe('03:30 PM'); + expect(compute('{report:startdate:yyyy-MM-dd HH:mm:ss}', mockContextWithDate)).toBe('2025-01-08 15:30:45'); + expect(compute('{report:created:HH:mm:ss}', mockContextWithDate)).toBe('15:30:45'); + expect(compute('{report:startdate:g:i a}', mockContextWithDate)).toBe('3:30 pm'); + }); + }); + + describe('Submission Info', () => { + const mockSubmitter: PersonalDetails = { + accountID: 12345, + firstName: 'John', + lastName: 'Doe', + displayName: 'John Doe', + login: 'john.doe@company.com', + }; + + const mockManager: PersonalDetails = { + accountID: 67890, + firstName: 'Jane', + lastName: 'Smith', + displayName: 'Jane Smith', + login: 'jane.smith@company.com', + }; + + const mockContextWithSubmissionInfo: FormulaContext = { + report: { + reportID: '123', + reportName: '', + type: 'expense', + ownerAccountID: 12345, + managerID: 67890, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + created: '2025-01-15T10:30:00Z', + } as Report, + policy: { + name: 'Test Policy', + employeeList: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'john.doe@company.com': { + email: 'john.doe@company.com', + employeeUserID: 'EMP001', + employeePayrollID: 'PAY123', + }, + // eslint-disable-next-line @typescript-eslint/naming-convention + 'jane.smith@company.com': { + email: 'jane.smith@company.com', + employeeUserID: 'EMP002', + employeePayrollID: 'PAY456', + }, + }, + } as unknown as Policy, + submitterPersonalDetails: mockSubmitter, + managerPersonalDetails: mockManager, + }; + + beforeEach(() => { + jest.clearAllMocks(); + + const mockReportActions = { + // eslint-disable-next-line @typescript-eslint/naming-convention + '1': { + reportActionID: '1', + created: '2025-01-10T08:00:00Z', + actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, + }, + // eslint-disable-next-line @typescript-eslint/naming-convention + '2': { + reportActionID: '2', + created: '2025-01-15T10:30:00Z', + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + }, + } as unknown as ReportActions; + + mockReportActionsUtils.getAllReportActions.mockReturnValue(mockReportActions); + }); + + describe('Submitter information', () => { + test('firstname - basic submitter first name', () => { + expect(compute('{report:submit:from:firstname}', mockContextWithSubmissionInfo)).toBe('John'); + }); + + test('lastname - basic submitter last name', () => { + expect(compute('{report:submit:from:lastname}', mockContextWithSubmissionInfo)).toBe('Doe'); + }); + + test('fullname - basic submitter full name', () => { + expect(compute('{report:submit:from:fullname}', mockContextWithSubmissionInfo)).toBe('John Doe'); + }); + + test('email - basic submitter email', () => { + expect(compute('{report:submit:from:email}', mockContextWithSubmissionInfo)).toBe('john.doe@company.com'); + }); + + test('userid - submitter employee user ID (alias for customfield1)', () => { + expect(compute('{report:submit:from:userid}', mockContextWithSubmissionInfo)).toBe('EMP001'); + }); + + test('customfield1 - submitter employee user ID from policy', () => { + expect(compute('{report:submit:from:customfield1}', mockContextWithSubmissionInfo)).toBe('EMP001'); + }); + + test('customfield2 - submitter employee payroll ID from policy', () => { + expect(compute('{report:submit:from:customfield2}', mockContextWithSubmissionInfo)).toBe('PAY123'); + }); + + test('payrollid - submitter employee payroll ID (alias for customfield2)', () => { + expect(compute('{report:submit:from:payrollid}', mockContextWithSubmissionInfo)).toBe('PAY123'); + }); + + test('name fields fall back to email when name missing', () => { + const contextWithPartialDetails: FormulaContext = { + report: {reportID: '123'} as Report, + policy: null as unknown as Policy, + submitterPersonalDetails: { + accountID: 111, + login: 'fallback@email.com', + } as PersonalDetails, + }; + + expect(compute('{report:submit:from:firstname}', contextWithPartialDetails)).toBe('fallback@email.com'); + expect(compute('{report:submit:from:lastname}', contextWithPartialDetails)).toBe('fallback@email.com'); + expect(compute('{report:submit:from:fullname}', contextWithPartialDetails)).toBe('fallback@email.com'); + }); + + test('customfield1 - return empty when employeeList missing', () => { + const contextWithoutEmployeeList: FormulaContext = { + report: {reportID: '123'} as Report, + policy: { + name: 'Test Policy', + } as Policy, + submitterPersonalDetails: mockSubmitter, + }; + + expect(compute('{report:submit:from:customfield1}', contextWithoutEmployeeList)).toBe(''); + }); + + test('customfield2 - return empty when employeeList missing', () => { + const contextWithoutEmployeeList: FormulaContext = { + report: {reportID: '123'} as Report, + policy: { + name: 'Test Policy', + } as Policy, + submitterPersonalDetails: mockSubmitter, + }; + + expect(compute('{report:submit:from:customfield2}', contextWithoutEmployeeList)).toBe(''); + }); + + test('customfield1 - return empty when user not in employeeList', () => { + const contextWithDifferentEmployee: FormulaContext = { + report: {reportID: '123'} as Report, + policy: { + name: 'Test Policy', + employeeList: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'other.user@company.com': { + email: 'other.user@company.com', + employeeUserID: 'EMP999', + }, + }, + } as unknown as Policy, + submitterPersonalDetails: mockSubmitter, + }; + + expect(compute('{report:submit:from:customfield1}', contextWithDifferentEmployee)).toBe(''); + }); + }); + + describe('Manager information', () => { + test('firstname - basic manager first name', () => { + expect(compute('{report:submit:to:firstname}', mockContextWithSubmissionInfo)).toBe('Jane'); + }); + + test('lastname - basic manager last name', () => { + expect(compute('{report:submit:to:lastname}', mockContextWithSubmissionInfo)).toBe('Smith'); + }); + + test('fullname - basic manager full name', () => { + expect(compute('{report:submit:to:fullname}', mockContextWithSubmissionInfo)).toBe('Jane Smith'); + }); + + test('email - basic manager email', () => { + expect(compute('{report:submit:to:email}', mockContextWithSubmissionInfo)).toBe('jane.smith@company.com'); + }); + + test('userid - manager employee user ID (alias for customfield1)', () => { + expect(compute('{report:submit:to:userid}', mockContextWithSubmissionInfo)).toBe('EMP002'); + }); + + test('customfield1 - manager employee user ID from policy', () => { + expect(compute('{report:submit:to:customfield1}', mockContextWithSubmissionInfo)).toBe('EMP002'); + }); + + test('customfield2 - manager employee payroll ID from policy', () => { + expect(compute('{report:submit:to:customfield2}', mockContextWithSubmissionInfo)).toBe('PAY456'); + }); + + test('payrollid - manager employee payroll ID (alias for customfield2)', () => { + expect(compute('{report:submit:to:payrollid}', mockContextWithSubmissionInfo)).toBe('PAY456'); + }); + + test('firstname - fall back to email when manager name missing', () => { + const contextWithPartialManagerDetails: FormulaContext = { + report: {reportID: '123'} as Report, + policy: null as unknown as Policy, + managerPersonalDetails: { + accountID: 222, + login: 'manager@email.com', + } as PersonalDetails, + }; + + expect(compute('{report:submit:to:firstname}', contextWithPartialManagerDetails)).toBe('manager@email.com'); + }); + + test('fullname - fall back to email when manager displayName missing', () => { + const contextWithPartialManagerDetails: FormulaContext = { + report: {reportID: '123'} as Report, + policy: null as unknown as Policy, + managerPersonalDetails: { + accountID: 222, + login: 'manager@email.com', + } as PersonalDetails, + }; + + expect(compute('{report:submit:to:fullname}', contextWithPartialManagerDetails)).toBe('manager@email.com'); + }); + }); + + describe('Submission date', () => { + test('default format - yyyy-MM-dd', () => { + expect(compute('{report:submit:date}', mockContextWithSubmissionInfo)).toBe('2025-01-15'); + }); + + test('custom format - verifies date formatting works', () => { + expect(compute('{report:submit:date:MMMM dd, yyyy}', mockContextWithSubmissionInfo)).toBe('January 15, 2025'); + }); + }); + + describe('Function modifiers', () => { + test('frontpart - extract username from submitter email', () => { + expect(compute('{report:submit:from:email|frontpart}', mockContextWithSubmissionInfo)).toBe('john.doe'); + }); + + test('domain - extract domain from submitter email', () => { + expect(compute('{report:submit:from:email|domain}', mockContextWithSubmissionInfo)).toBe('company.com'); + }); + + test('frontpart - extract username from manager email', () => { + expect(compute('{report:submit:to:email|frontpart}', mockContextWithSubmissionInfo)).toBe('jane.smith'); + }); + + test('substr - extract first 4 characters from fullname', () => { + expect(compute('{report:submit:from:fullname|substr:0:4}', mockContextWithSubmissionInfo)).toBe('John'); + }); + + test('chained modifiers - frontpart then substr on email', () => { + expect(compute('{report:submit:from:email|frontpart|substr:0:4}', mockContextWithSubmissionInfo)).toBe('john'); + }); + }); + + describe('Combined formulas', () => { + test('submitter and manager names together', () => { + expect(compute('{report:submit:from:firstname} -> {report:submit:to:firstname}', mockContextWithSubmissionInfo)).toBe('John -> Jane'); + }); + + test('transaction date range with submission date', () => { + const mockTransactions = [ + { + transactionID: 'trans1', + created: '2025-01-08T12:00:00Z', + amount: 5000, + merchant: 'Store', + }, + ] as Transaction[]; + + mockReportUtils.getReportTransactions.mockReturnValue(mockTransactions); + + expect(compute('{report:startdate} to {report:submit:date}', mockContextWithSubmissionInfo)).toBe('2025-01-08 to 2025-01-15'); + }); + }); + + describe('Edge cases', () => { + test('empty email - return empty when email empty', () => { + const contextWithEmptyEmail: FormulaContext = { + report: {reportID: '123'} as Report, + policy: null as unknown as Policy, + submitterPersonalDetails: { + accountID: 123, + login: '', + } as PersonalDetails, + }; + + expect(compute('{report:submit:from:email}', contextWithEmptyEmail)).toBe(''); + }); + + test('empty email with name - return empty when name also empty', () => { + const contextWithEmptyEmail: FormulaContext = { + report: {reportID: '123'} as Report, + policy: null as unknown as Policy, + submitterPersonalDetails: { + accountID: 123, + login: '', + } as PersonalDetails, + }; + + expect(compute('{report:submit:from:firstname}', contextWithEmptyEmail)).toBe(''); + }); + + test('empty firstname - fallback to email when firstname is empty string', () => { + const contextWithEmptyFirstName: FormulaContext = { + report: {reportID: '123'} as Report, + policy: null as unknown as Policy, + submitterPersonalDetails: { + accountID: 123, + firstName: '', + login: 'user@test.com', + } as PersonalDetails, + }; + + expect(compute('{report:submit:from:firstname}', contextWithEmptyFirstName)).toBe('user@test.com'); + }); + + test('empty lastname - fallback to email when lastname is empty string', () => { + const contextWithEmptyLastName: FormulaContext = { + report: {reportID: '123'} as Report, + policy: null as unknown as Policy, + submitterPersonalDetails: { + accountID: 123, + lastName: '', + login: 'user@test.com', + } as PersonalDetails, + }; + + expect(compute('{report:submit:from:lastname}', contextWithEmptyLastName)).toBe('user@test.com'); + }); + + test('empty displayName - fallback to email when displayName is empty string', () => { + const contextWithEmptyDisplayName: FormulaContext = { + report: {reportID: '123'} as Report, + policy: null as unknown as Policy, + submitterPersonalDetails: { + accountID: 123, + displayName: '', + login: 'user@test.com', + } as PersonalDetails, + }; + + expect(compute('{report:submit:from:fullname}', contextWithEmptyDisplayName)).toBe('user@test.com'); + }); + + test('empty email with frontpart - return empty for empty email modifier', () => { + const contextWithEmptyEmail: FormulaContext = { + report: {reportID: '123'} as Report, + policy: null as unknown as Policy, + submitterPersonalDetails: { + accountID: 123, + login: '', + } as PersonalDetails, + }; + + expect(compute('{report:submit:from:email|frontpart}', contextWithEmptyEmail)).toBe(''); + }); + + test('unknown field - return empty for invalid field name', () => { + expect(compute('{report:submit:from:unknown}', mockContextWithSubmissionInfo)).toBe(''); + }); + + test('invalid direction - return empty for invalid from/to', () => { + expect(compute('{report:submit:invalid:email}', mockContextWithSubmissionInfo)).toBe(''); + }); + }); }); describe('hasCircularReferences()', () => { From 9a136ac7cf687d7c984b7b8e1a37330eaa7eafc7 Mon Sep 17 00:00:00 2001 From: truph01 Date: Thu, 25 Dec 2025 17:06:26 +0700 Subject: [PATCH 13/14] fix: prettier --- src/libs/Formula.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/libs/Formula.ts b/src/libs/Formula.ts index abf11d2c499e..ad4e1c812d56 100644 --- a/src/libs/Formula.ts +++ b/src/libs/Formula.ts @@ -198,7 +198,6 @@ function parsePart(definition: string): FormulaPart { return part; } - /** * Check if the report field formula value is containing circular references, e.g example: A -> A, A->B->A, A->B->C->A, etc */ From 6859b1cdb2eac19ab93a4b75f4da9710f63bd15f Mon Sep 17 00:00:00 2001 From: truph01 Date: Tue, 30 Dec 2025 15:50:47 +0700 Subject: [PATCH 14/14] fix: bring back format test --- tests/unit/FormulaTest.ts | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/unit/FormulaTest.ts b/tests/unit/FormulaTest.ts index 375b49c63f11..c782fbb26397 100644 --- a/tests/unit/FormulaTest.ts +++ b/tests/unit/FormulaTest.ts @@ -460,6 +460,43 @@ describe('CustomFormula', () => { jest.clearAllMocks(); }); + describe('Format options', () => { + test('nosymbol - should format without currency symbol', () => { + const result = compute('{report:total:nosymbol}', currencyContext); + expect(result).toBe('100.00'); + }); + test('same currency - should format normally (case insensitive)', () => { + currencyContext.report.currency = 'EUR'; + expect(compute('{report:total:EUR}', currencyContext)).toBe('€100.00'); + expect(compute('{report:total:eur}', currencyContext)).toBe('€100.00'); + }); + + test('default (no format) - should use report currency', () => { + currencyContext.report.currency = 'NPR'; + const result = compute('{report:total}', currencyContext); + expect(result).toBe('NPR\u00A0100.00'); + }); + }); + + describe('Currency conversion (requires backend)', () => { + test('different valid currencies - should return placeholder', () => { + currencyContext.report.currency = 'USD'; + + // Various currencies requiring conversion + expect(compute('{report:total:EUR}', currencyContext)).toBe('{report:total:EUR}'); + expect(compute('{report:total:JPY}', currencyContext)).toBe('{report:total:JPY}'); + }); + + test('case and whitespace handling - should normalize and detect conversion', () => { + currencyContext.report.currency = 'USD'; + + // Mixed case and whitespace + expect(compute('{report:total:EuR}', currencyContext)).toBe('{report:total:EuR}'); + expect(compute('{report:total: EUR }', currencyContext)).toBe('{report:total: EUR }'); + expect(compute('{report:total:eur }', currencyContext)).toBe('{report:total:eur }'); + }); + }); + describe('Edge cases', () => { test('undefined currency - should format without symbol', () => { currencyContext.report.currency = undefined;