Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 163 additions & 1 deletion src/libs/Formula.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
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';
Expand Down Expand Up @@ -233,6 +234,28 @@ function compute(formula?: string, context?: FormulaContext): string {
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
*/
Expand Down Expand Up @@ -265,6 +288,12 @@ function computeReportPart(part: FormulaPart, context: FormulaContext): string {
// 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 '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;
}
Expand Down Expand Up @@ -482,6 +511,139 @@ function getOldestTransactionDate(reportID: string, context?: FormulaContext): s
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<Policy>, 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
*/
Expand Down Expand Up @@ -518,6 +680,6 @@ function getNewestTransactionDate(reportID: string, context?: FormulaContext): s
return newestDate;
}

export {FORMULA_PART_TYPES, compute, extract, parse};
export {FORMULA_PART_TYPES, compute, extract, getAutoReportingDates, parse};

export type {FormulaContext, FormulaPart};
24 changes: 20 additions & 4 deletions src/pages/workspace/reports/ReportsDefaultTitle.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {Str} from 'expensify-common';
import React, {useCallback} from 'react';
import React, {useCallback, useRef, useState} from 'react';
import {View} from 'react-native';
import BulletList from '@components/BulletList';
import FormProvider from '@components/Form/FormProvider';
Expand All @@ -10,7 +10,7 @@ import OfflineWithFeedback from '@components/OfflineWithFeedback';
import RenderHTML from '@components/RenderHTML';
import ScreenWrapper from '@components/ScreenWrapper';
import TextInput from '@components/TextInput';
import useAutoFocusInput from '@hooks/useAutoFocusInput';
import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types';
import useBeforeRemove from '@hooks/useBeforeRemove';
import useLocalize from '@hooks/useLocalize';
import usePolicy from '@hooks/usePolicy';
Expand All @@ -19,7 +19,9 @@ import {getLatestErrorField} from '@libs/ErrorUtils';
import Navigation from '@libs/Navigation/Navigation';
import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
import updateMultilineInputRange from '@libs/updateMultilineInputRange';
import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper';
import variables from '@styles/variables';
import {clearPolicyTitleFieldError, setPolicyDefaultReportTitle} from '@userActions/Policy/Policy';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
Expand All @@ -34,7 +36,7 @@ function ReportsDefaultTitlePage({route}: RulesCustomNamePageProps) {

const {translate} = useLocalize();
const styles = useThemeStyles();
const {inputCallbackRef} = useAutoFocusInput();
const isInputInitializedRef = useRef(false);
const RULE_EXAMPLE_BULLET_POINTS = [
translate('workspace.reports.customNameEmailPhoneExample'),
translate('workspace.reports.customNameStartDateExample'),
Expand All @@ -45,6 +47,7 @@ function ReportsDefaultTitlePage({route}: RulesCustomNamePageProps) {

const fieldListItem = policy?.fieldList?.[CONST.POLICY.FIELDS.FIELD_LIST_TITLE];
const customNameDefaultValue = Str.htmlDecode(fieldListItem?.defaultValue ?? '');
const [reportTitle, setReportTitle] = useState(() => customNameDefaultValue);

const validateCustomName = useCallback(
({defaultTitle}: FormOnyxValues<typeof ONYXKEYS.FORMS.REPORTS_DEFAULT_TITLE_MODAL_FORM>) => {
Expand Down Expand Up @@ -114,11 +117,24 @@ function ReportsDefaultTitlePage({route}: RulesCustomNamePageProps) {
>
<InputWrapper
InputComponent={TextInput}
role={CONST.ROLE.PRESENTATION}
inputID={INPUT_IDS.DEFAULT_TITLE}
defaultValue={customNameDefaultValue}
label={translate('workspace.reports.customNameInputLabel')}
aria-label={translate('workspace.reports.customNameInputLabel')}
ref={inputCallbackRef}
maxAutoGrowHeight={variables.textInputAutoGrowMaxHeight}
value={reportTitle}
spellCheck={false}
autoFocus
onChangeText={setReportTitle}
autoGrowHeight
type="markdown"
ref={(el: BaseTextInputRef | null): void => {
if (!isInputInitializedRef.current) {
updateMultilineInputRange(el);
}
isInputInitializedRef.current = true;
}}
/>
</OfflineWithFeedback>
<BulletList
Expand Down
86 changes: 86 additions & 0 deletions tests/unit/FormulaTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,92 @@ describe('CustomFormula', () => {
});
});

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');
Expand Down
Loading