diff --git a/src/app/fyle/my-expenses/my-expenses.page.spec.ts b/src/app/fyle/my-expenses/my-expenses.page.spec.ts index 4f7ce7f288..873e89ee66 100644 --- a/src/app/fyle/my-expenses/my-expenses.page.spec.ts +++ b/src/app/fyle/my-expenses/my-expenses.page.spec.ts @@ -1,6 +1,7 @@ -import { ComponentFixture, TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; +import { ComponentFixture, TestBed, discardPeriodicTasks, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; import { ActionSheetController, IonicModule, ModalController, NavController, PopoverController } from '@ionic/angular'; +import * as dayjs from 'dayjs'; import { HttpClientTestingModule } from '@angular/common/http/testing'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { MatBottomSheet, MatBottomSheetRef } from '@angular/material/bottom-sheet'; @@ -18,22 +19,14 @@ import { } from 'src/app/core/mock-data/action-sheet-options.data'; import { allowedExpenseTypes } from 'src/app/core/mock-data/allowed-expense-types.data'; import { apiAuthRes } from 'src/app/core/mock-data/auth-reponse.data'; -import { cardAggregateStatParam } from 'src/app/core/mock-data/card-aggregate-stats.data'; import { expectedAssignedCCCStats } from 'src/app/core/mock-data/ccc-expense.details.data'; import { - expectedCriticalPolicyViolationPopoverParams, - expectedCriticalPolicyViolationPopoverParams2, - expectedCriticalPolicyViolationPopoverParams3, -} from 'src/app/core/mock-data/critical-policy-violation-popover.data'; -import { - expenseFiltersData1, expenseFiltersData2, expenseWithPotentialDuplicateFilterData, } from 'src/app/core/mock-data/expense-filters.data'; import { apiExpenseRes, expectedFormattedTransaction, - expenseData1, expenseData2, expenseList4, expenseListwithoutID, @@ -82,8 +75,6 @@ import { mileageExpenseWithDistance, perDiemExpenseWithSingleNumDays, } from 'src/app/core/mock-data/platform/v1/expense.data'; -import { reportUnflattenedData } from 'src/app/core/mock-data/report-v1.data'; -import { expectedReportSingleResponse } from 'src/app/core/mock-data/report.data'; import { selectedFilters1, selectedFilters2 } from 'src/app/core/mock-data/selected-filters.data'; import { snackbarPropertiesRes, @@ -92,15 +83,11 @@ import { snackbarPropertiesRes4, } from 'src/app/core/mock-data/snackbar-properties.data'; import { txnList } from 'src/app/core/mock-data/transaction.data'; -import { unflattenedTxnData } from 'src/app/core/mock-data/unflattened-txn.data'; import { unformattedTxnData } from 'src/app/core/mock-data/unformatted-transaction.data'; -import { expectedUniqueCardStats } from 'src/app/core/mock-data/unique-cards-stats.data'; import { uniqueCardsData } from 'src/app/core/mock-data/unique-cards.data'; import { AdvancesStates } from 'src/app/core/models/advances-states.model'; import { BackButtonActionPriority } from 'src/app/core/models/back-button-action-priority.enum'; -import { Expense } from 'src/app/core/models/expense.model'; import { ExtendedReport } from 'src/app/core/models/report.model'; -import { ApiV2Service } from 'src/app/core/services/api-v2.service'; import { CategoriesService } from 'src/app/core/services/categories.service'; import { CorporateCreditCardExpenseService } from 'src/app/core/services/corporate-credit-card-expense.service'; import { CurrencyService } from 'src/app/core/services/currency.service'; @@ -143,13 +130,14 @@ import { UtilityService } from 'src/app/core/services/utility.service'; import { AuthService } from 'src/app/core/services/auth.service'; import { apiEouRes } from 'src/app/core/mock-data/extended-org-user.data'; import { properties } from 'src/app/core/mock-data/modal-properties.data'; +import { ExpensesQueryParams } from 'src/app/core/models/platform/v1/expenses-query-params.model'; +import { Expense } from 'src/app/core/models/platform/v1/expense.model'; describe('MyExpensesPage', () => { let component: MyExpensesPage; let fixture: ComponentFixture; let tasksService: jasmine.SpyObj; let currencyService: jasmine.SpyObj; - let apiV2Service: jasmine.SpyObj; let transactionService: jasmine.SpyObj; let orgSettingsService: jasmine.SpyObj; let activatedRoute: jasmine.SpyObj; @@ -185,7 +173,6 @@ describe('MyExpensesPage', () => { beforeEach(waitForAsync(() => { const tasksServiceSpy = jasmine.createSpyObj('TasksService', ['getReportsTaskCount', 'getExpensesTaskCount']); const currencyServiceSpy = jasmine.createSpyObj('CurrencyService', ['getHomeCurrency']); - const apiV2ServiceSpy = jasmine.createSpyObj('ApiV2Service', ['extendQueryParamsForTextSearch']); const transactionServiceSpy = jasmine.createSpyObj('TransactionService', [ 'getMyExpensesCount', 'getMyExpenses', @@ -322,7 +309,6 @@ describe('MyExpensesPage', () => { providers: [ { provide: TasksService, useValue: tasksServiceSpy }, { provide: CurrencyService, useValue: currencyServiceSpy }, - { provide: ApiV2Service, useValue: apiV2ServiceSpy }, { provide: TransactionService, useValue: transactionServiceSpy }, { provide: OrgSettingsService, useValue: orgSettingsServiceSpy }, { provide: ActivatedRoute, useValue: activatedRouteSpy }, @@ -449,7 +435,6 @@ describe('MyExpensesPage', () => { tasksService = TestBed.inject(TasksService) as jasmine.SpyObj; orgSettingsService = TestBed.inject(OrgSettingsService) as jasmine.SpyObj; categoriesService = TestBed.inject(CategoriesService) as jasmine.SpyObj; - apiV2Service = TestBed.inject(ApiV2Service) as jasmine.SpyObj; transactionService = TestBed.inject(TransactionService) as jasmine.SpyObj; networkService = TestBed.inject(NetworkService) as jasmine.SpyObj; transactionOutboxService = TestBed.inject(TransactionsOutboxService) as jasmine.SpyObj; @@ -493,7 +478,7 @@ describe('MyExpensesPage', () => { describe('ionViewWillEnter(): ', () => { let backButtonSubscription: Subscription; - + const dEincompleteExpenseIds = ['txfCdl3TEZ7K', 'txfCdl3TEZ7l', 'txfCdl3TEZ7m']; beforeEach(() => { component.isConnected$ = of(true); backButtonSubscription = new Subscription(); @@ -508,6 +493,8 @@ describe('MyExpensesPage', () => { spyOn(component, 'setAllExpensesCountAndAmount'); spyOn(component, 'clearFilters'); spyOn(component, 'setupActionSheet'); + //@ts-ignore + spyOn(component, 'pollDEIncompleteExpenses').and.returnValue(of(apiExpenses1)); tokenService.getClusterDomain.and.resolveTo(apiAuthRes.cluster_domain); currencyService.getHomeCurrency.and.returnValue(of('USD')); expensesService.getExpenseStats.and.returnValue(of(completeStats)); @@ -988,12 +975,204 @@ describe('MyExpensesPage', () => { expect(expensesService.getExpenses).not.toHaveBeenCalled(); })); + + it('should call pollDEIncompleteExpenses if expenses not completed DE scan', () => { + //@ts-ignore + spyOn(component, 'filterDEIncompleteExpenses').and.returnValue(dEincompleteExpenseIds); + component.ionViewWillEnter(); + + //@ts-ignore + expect(component.pollDEIncompleteExpenses).toHaveBeenCalledWith(dEincompleteExpenseIds, apiExpenses1); + }); }); it('HeaderState(): should return the headerState', () => { expect(component.HeaderState).toEqual(HeaderState); }); + describe('pollDEIncompleteExpenses()', () => { + beforeEach(() => { + expensesService.getExpenses.and.returnValue(of(apiExpenses1)); + //@ts-ignore + spyOn(component, 'updateExpensesList').and.returnValue(apiExpenses1); + }); + + it('should call expenseService.getExpenses for dE incomplete expenses and return updated expenses', fakeAsync(() => { + const dEincompleteExpenseIds = ['txfCdl3TEZ7K', 'txfCdl3TEZ7l', 'txfCdl3TEZ7m']; + const dEincompleteExpenseIdParams: ExpensesQueryParams = { + queryParams: { id: `in.(${dEincompleteExpenseIds.join(',')})` }, + }; + + //@ts-ignore + component.pollDEIncompleteExpenses(dEincompleteExpenseIds, apiExpenses1).subscribe((result) => { + expect(expensesService.getExpenses).toHaveBeenCalledOnceWith({ ...dEincompleteExpenseIdParams.queryParams }); + expect(result).toEqual(apiExpenses1); + }); + tick(5000); + discardPeriodicTasks(); + })); + + it('should call expensesService.getExpenses 5 times and stop polling after 30 seconds', fakeAsync(() => { + const dEincompleteExpenseIds = ['txfCdl3TEZ7K', 'txfCdl3TEZ7l', 'txfCdl3TEZ7m']; + //@ts-ignore + spyOn(component, 'filterDEIncompleteExpenses').and.returnValue(dEincompleteExpenseIds); + //@ts-ignore + component.pollDEIncompleteExpenses(dEincompleteExpenseIds, apiExpenses1).subscribe(() => {}); + + // Simulate 30 seconds of time passing (the polling interval is 5 seconds) + tick(30000); + + // After 30 seconds, polling should stop, so no further calls to getAllExpenses + expect(expensesService.getExpenses).toHaveBeenCalledTimes(5); // If called every 5 seconds after the first 5 seconds + + // Cleanup + discardPeriodicTasks(); + })); + }); + + describe('updateExpensesList', () => { + beforeEach(() => { + //@ts-ignore + spyOn(component, 'isExpenseScanComplete').and.callThrough(); + }); + + it('should update expenses with completed scans', () => { + const updatedExpenses: Expense[] = [ + { ...apiExpenses1[0], extracted_data: { ...apiExpenses1[0].extracted_data, amount: 200 } }, + ]; + const dEincompleteExpenseIds = [apiExpenses1[0].id]; + + //@ts-ignore + component.isExpenseScanComplete.and.returnValue(true); + + //@ts-ignore + const result = component.updateExpensesList(apiExpenses1, updatedExpenses, dEincompleteExpenseIds); + + expect(result).toEqual([updatedExpenses[0], apiExpenses1[1]]); + }); + + it('should not update expenses if scan is incomplete', () => { + const updatedExpenses: Expense[] = [ + { ...apiExpenses1[0], extracted_data: { ...apiExpenses1[0].extracted_data, amount: 200 } }, + ]; + const dEincompleteExpenseIds = [apiExpenses1[0].id]; + + // Mock isExpenseScanComplete to return false for the updated expense + //@ts-ignore + (component.isExpenseScanComplete as jasmine.Spy).and.returnValue(false); + + //@ts-ignore + const result = component.updateExpensesList(apiExpenses1, updatedExpenses, dEincompleteExpenseIds); + + // Assert + expect(result).toEqual(apiExpenses1); // No changes should occur + }); + }); + + describe('checkIfScanIsCompleted():', () => { + it('should check if scan is complete and return true if the expense amount is not null and no other data is present', () => { + const expense = { + ...expenseData, + amount: 100, + claim_amount: null, + extracted_data: null, + }; + //@ts-ignore + const result = component.isExpenseScanComplete(expense); + expect(result).toBeTrue(); + }); + + it('should check if scan is complete and return true if the expense user amount is present and no extracted data is available', () => { + const expense = { + ...expenseData, + amount: null, + claim_amount: 7500, + extracted_data: null, + }; + //@ts-ignore + const result = component.isExpenseScanComplete(expense); + expect(result).toBeTrue(); + }); + + it('should check if scan is complete and return true if the required extracted data is present', () => { + const expense = { + ...expenseData, + amount: null, + claim_amount: null, + extracted_data: { + amount: 84.12, + currency: 'USD', + category: 'Professional Services', + date: null, + vendor_name: null, + invoice_dt: null, + }, + }; + //@ts-ignore + const result = component.isExpenseScanComplete(expense); + expect(result).toBeTrue(); + }); + + it('should return true if the scan has expired', () => { + const expense = { + ...expenseData, + amount: null, + claim_amount: null, + extracted_data: null, + }; + const oneDaysAfter = dayjs(expense.created_at).add(1, 'day').toDate(); + jasmine.clock().mockDate(oneDaysAfter); + + //@ts-ignore + const result = component.isExpenseScanComplete(expense); + expect(result).toBeTrue(); + }); + }); + + describe('isZeroAmountPerDiemOrMileage():', () => { + it('should check if scan is complete and return true if it is per diem expense with amount 0', () => { + const expense = { + ...cloneDeep(expenseData), + amount: 0, + }; + expense.category.name = 'Per Diem'; + //@ts-ignore + const result = component.isZeroAmountPerDiemOrMileage(expense); + expect(result).toBeTrue(); + }); + + it('should check if scan is complete and return true if it is per diem expense with user amount 0', () => { + const expense = { + ...cloneDeep(expenseData), + amount: null, + claim_amount: 0, + }; + expense.category.name = 'Per Diem'; + //@ts-ignore + const result = component.isZeroAmountPerDiemOrMileage(expense); + expect(result).toBeTrue(); + }); + + it('should check if scan is complete and return true if it is mileage expense with amount 0', () => { + const expense = { + ...cloneDeep(expenseData), + amount: 0, + }; + expense.category.name = 'Mileage'; + //@ts-ignore + const result = component.isZeroAmountPerDiemOrMileage(expense); + expect(result).toBeTrue(); + }); + + it('should return false if org category is null', () => { + const expense = cloneDeep(expenseData); + expense.category.name = null; + //@ts-ignore + const result = component.isZeroAmountPerDiemOrMileage(expense); + expect(result).toBeFalse(); + }); + }); + describe('clearText', () => { let dispatchEventSpy: jasmine.Spy; beforeEach(() => { @@ -2947,7 +3126,7 @@ describe('MyExpensesPage', () => { expect(component.isReportableExpensesSelected).toBeTrue(); }); - it('should update selectedElements, allExpensesCount and call apiV2Service if checked is true', () => { + it('should update selectedElements, allExpensesCount and call expensesService if checked is true', () => { expensesService.getAllExpenses.and.returnValue(of(cloneDeep(apiExpenses1))); component.outboxExpensesToBeDeleted = apiExpenseRes; component.pendingTransactions = cloneDeep([]); diff --git a/src/app/fyle/my-expenses/my-expenses.page.ts b/src/app/fyle/my-expenses/my-expenses.page.ts index 77ce4c1cd9..d2ec65e54d 100644 --- a/src/app/fyle/my-expenses/my-expenses.page.ts +++ b/src/app/fyle/my-expenses/my-expenses.page.ts @@ -43,7 +43,6 @@ import { Expense as PlatformExpense } from 'src/app/core/models/platform/v1/expe import { GetExpenseQueryParam } from 'src/app/core/models/platform/v1/get-expenses-query.model'; import { UniqueCards } from 'src/app/core/models/unique-cards.model'; import { Transaction } from 'src/app/core/models/v1/transaction.model'; -import { ApiV2Service } from 'src/app/core/services/api-v2.service'; import { CategoriesService } from 'src/app/core/services/categories.service'; import { CorporateCreditCardExpenseService } from 'src/app/core/services/corporate-credit-card-expense.service'; import { CurrencyService } from 'src/app/core/services/currency.service'; @@ -219,7 +218,6 @@ export class MyExpensesPage implements OnInit { private trackingService: TrackingService, private storageService: StorageService, private tokenService: TokenService, - private apiV2Service: ApiV2Service, private modalProperties: ModalPropertiesService, private matBottomSheet: MatBottomSheet, private matSnackBar: MatSnackBar, @@ -452,97 +450,6 @@ export class MyExpensesPage implements OnInit { } } - private isZeroAmountPerDiemOrMileage(expense: PlatformExpense): boolean { - return ( - (expense?.category?.name?.toLowerCase() === 'per diem' || expense?.category?.name?.toLowerCase() === 'mileage') && - (expense.amount === 0 || expense.claim_amount === 0) - ); - } - - /** - * Checks if the scan process for an expense has been completed. - * @param {PlatformExpense} expense - The expense to check. - * @returns {boolean} - True if the scan is complete or if data is manually entered. - */ - private isExpenseScanComplete(expense: PlatformExpense): boolean { - const isZeroAmountPerDiemOrMileage = this.isZeroAmountPerDiemOrMileage(expense); - - const hasUserManuallyEnteredData = - isZeroAmountPerDiemOrMileage || - ((expense.amount || expense.claim_amount) && isNumber(expense.amount || expense.claim_amount)); - const isDataExtracted = !!expense.extracted_data; - - // this is to prevent the scan failed from being shown from an indefinite amount of time. - const hasScanExpired = expense.created_at && dayjs(expense.created_at).diff(Date.now(), 'day') < 0; - return !!(hasUserManuallyEnteredData || isDataExtracted || hasScanExpired); - } - - /** - * Filters the list of expenses to get only those with incomplete scans. - * @param {PlatformExpense[]} expenses - The list of expenses to check. - * @returns {string[]} - Array of expense IDs that have incomplete scans. - */ - private filterIncompleteExpenses(expenses: PlatformExpense[]): string[] { - return expenses.filter((expense) => !this.isExpenseScanComplete(expense)).map((expense) => expense.id); - } - - /** - * Updates the expenses with polling results. - * @param {PlatformExpense[]} initialExpenses - The initial list of expenses. - * @param {PlatformExpense[]} updatedExpenses - The updated expenses after polling. - * @param {string[]} incompleteExpenseIds - Array of expense IDs with incomplete scans. - * @returns {PlatformExpense[]} - Updated list of expenses. - */ - private updateExpensesList( - initialExpenses: PlatformExpense[], - updatedExpenses: PlatformExpense[], - incompleteExpenseIds: string[] - ): PlatformExpense[] { - const updatedExpensesMap = new Map(updatedExpenses.map((expense) => [expense.id, expense])); - - const newExpensesList = initialExpenses.map((expense) => { - if (incompleteExpenseIds.includes(expense.id)) { - const updatedExpense = updatedExpensesMap.get(expense.id); - if (this.isExpenseScanComplete(updatedExpense)) { - return updatedExpense; - } - } - return expense; - }); - - return newExpensesList; - } - - /** - * Polls for expenses that have incomplete scan data. - * @param {string[]} incompleteExpenseIds - Array of expense IDs with incomplete scans. - * @param {PlatformExpense[]} initialExpenses - The initial list of expenses. - * @returns {Observable} - Observable that emits updated expenses. - */ - private pollIncompleteExpenses( - incompleteExpenseIds: string[], - expenses: PlatformExpense[] - ): Observable { - let updatedExpensesList = expenses; - // Create a stop signal that emits after 30 seconds - const stopPolling$ = timer(30000); - return timer(5000, 5000).pipe( - exhaustMap(() => { - const params: ExpensesQueryParams = { queryParams: { id: `in.(${incompleteExpenseIds.join(',')})` } }; - return this.expenseService.getExpenses({ ...params.queryParams }).pipe( - map((updatedExpenses) => { - updatedExpensesList = this.updateExpensesList(updatedExpensesList, updatedExpenses, incompleteExpenseIds); - incompleteExpenseIds = this.filterIncompleteExpenses(updatedExpenses); - return updatedExpensesList; - }) - ); - }), - takeWhile(() => incompleteExpenseIds.length > 0, true), - takeUntil(stopPolling$), - takeUntil(this.onPageExit$) - ); - } - ionViewWillEnter(): void { this.isNewReportsFlowEnabled = false; this.hardwareBackButton = this.platformHandlerService.registerBackButtonAction( @@ -715,12 +622,15 @@ export class MyExpensesPage implements OnInit { */ this.myExpenses$ = paginatedPipe.pipe( switchMap((expenses) => { - const incompleteExpenseIds = this.filterIncompleteExpenses(expenses); + const dEincompleteExpenseIds = this.filterDEIncompleteExpenses(expenses); - if (incompleteExpenseIds.length === 0) { + if (dEincompleteExpenseIds.length === 0) { return of(expenses); // All scans are completed } else { - return this.pollIncompleteExpenses(incompleteExpenseIds, expenses).pipe(startWith(expenses), timeout(30000)); + return this.pollDEIncompleteExpenses(dEincompleteExpenseIds, expenses).pipe( + startWith(expenses), + timeout(30000) + ); } }), shareReplay(1) @@ -1871,4 +1781,95 @@ export class MyExpensesPage implements OnInit { }) ); } + + private isZeroAmountPerDiemOrMileage(expense: PlatformExpense): boolean { + return ( + (expense.category.name?.toLowerCase() === 'per diem' || expense.category.name?.toLowerCase() === 'mileage') && + (expense.amount === 0 || expense.claim_amount === 0) + ); + } + + /** + * Checks if the scan process for an expense has been completed. + * @param {PlatformExpense} expense - The expense to check. + * @returns {boolean} - True if the scan is complete or if data is manually entered. + */ + private isExpenseScanComplete(expense: PlatformExpense): boolean { + const isZeroAmountPerDiemOrMileage = this.isZeroAmountPerDiemOrMileage(expense); + + const hasUserManuallyEnteredData = + isZeroAmountPerDiemOrMileage || + ((expense.amount || expense.claim_amount) && isNumber(expense.amount || expense.claim_amount)); + const isDataExtracted = !!expense.extracted_data; + + // this is to prevent the scan failed from being shown from an indefinite amount of time. + const hasScanExpired = expense.created_at && dayjs(expense.created_at).diff(Date.now(), 'day') < 0; + return !!(hasUserManuallyEnteredData || isDataExtracted || hasScanExpired); + } + + /** + * Filters the list of expenses to get only those with incomplete scans. + * @param {PlatformExpense[]} expenses - The list of expenses to check. + * @returns {string[]} - Array of expense IDs that have incomplete scans. + */ + private filterDEIncompleteExpenses(expenses: PlatformExpense[]): string[] { + return expenses.filter((expense) => !this.isExpenseScanComplete(expense)).map((expense) => expense.id); + } + + /** + * Updates the expenses with polling results. + * @param {PlatformExpense[]} initialExpenses - The initial list of expenses. + * @param {PlatformExpense[]} updatedExpenses - The updated expenses after polling. + * @param {string[]} dEincompleteExpenseIds - Array of expense IDs with incomplete scans. + * @returns {PlatformExpense[]} - Updated list of expenses. + */ + private updateExpensesList( + initialExpenses: PlatformExpense[], + updatedExpenses: PlatformExpense[], + dEincompleteExpenseIds: string[] + ): PlatformExpense[] { + const updatedExpensesMap = new Map(updatedExpenses.map((expense) => [expense.id, expense])); + + const newExpensesList = initialExpenses.map((expense) => { + if (dEincompleteExpenseIds.includes(expense.id)) { + const updatedExpense = updatedExpensesMap.get(expense.id); + if (this.isExpenseScanComplete(updatedExpense)) { + return updatedExpense; + } + } + return expense; + }); + + return newExpensesList; + } + + /** + * Polls for expenses that have incomplete scan data. + * @param dEincompleteExpenseIds - Array of expense IDs with incomplete scans. + * @param initialExpenses - The initial list of expenses. + * @returns - Observable that emits updated expenses. + */ + private pollDEIncompleteExpenses( + dEincompleteExpenseIds: string[], + expenses: PlatformExpense[] + ): Observable { + let updatedExpensesList = expenses; + // Create a stop signal that emits after 30 seconds + const stopPolling$ = timer(30000); + return timer(5000, 5000).pipe( + exhaustMap(() => { + const params: ExpensesQueryParams = { queryParams: { id: `in.(${dEincompleteExpenseIds.join(',')})` } }; + return this.expenseService.getExpenses({ ...params.queryParams }).pipe( + map((updatedExpenses) => { + updatedExpensesList = this.updateExpensesList(updatedExpensesList, updatedExpenses, dEincompleteExpenseIds); + dEincompleteExpenseIds = this.filterDEIncompleteExpenses(updatedExpenses); + return updatedExpensesList; + }) + ); + }), + takeWhile(() => dEincompleteExpenseIds.length > 0, true), + takeUntil(stopPolling$), + takeUntil(this.onPageExit$) + ); + } }