diff --git a/README.md b/README.md index 5f4ce72908..532e7a22b1 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ ionic serve -c staging ## Running unit tests - - Run `ng test` + - Run `npm run test` - Run `npm run test:no-parallel` to run tests without sharding (without parallel browsers). This is useful to avoid parallel execution and to prevent excessive CPU utilization and memory hogging. ## Viewing coverage report diff --git a/src/app/core/mock-data/transaction.data.ts b/src/app/core/mock-data/transaction.data.ts index a9e14eeb2b..736f7890e9 100644 --- a/src/app/core/mock-data/transaction.data.ts +++ b/src/app/core/mock-data/transaction.data.ts @@ -3,6 +3,9 @@ import { Transaction } from '../models/v1/transaction.model'; import { optionsData15, optionsData33 } from './merge-expenses-options-data.data'; import { expectedTxnCustomProperties, txnCustomPropertiesData } from './txn-custom-properties.data'; import { ExpenseTransactionStatus } from '../enums/platform/v1/expense-transaction-status.enum'; +import { Expense } from '../models/platform/v1/expense.model'; +import { MileageUnitEnum } from '../models/platform/platform-mileage-rates.model'; +import deepFreezeStrict from 'deep-freeze-strict'; export const txnList: Transaction[] = deepFreeze([ { @@ -2583,6 +2586,42 @@ export const txnAmount1: Transaction = deepFreeze({ categoryDisplayName: 'Bus', }); +export const transformedExpensePayload: Partial = deepFreezeStrict({ + id: txnAmount1.id, + spent_at: txnAmount1.txn_dt, + category_id: txnAmount1.org_category_id, + purpose: txnAmount1.purpose, + source_account_id: txnAmount1.source_account_id, + claim_amount: txnAmount1.amount, + merchant: txnAmount1.vendor, + project_id: txnAmount1.project_id, + cost_center_id: txnAmount1.cost_center_id, + foreign_currency: txnAmount1.orig_currency, + foreign_amount: txnAmount1.orig_amount, + source: txnAmount1.source, + is_reimbursable: !txnAmount1.skip_reimbursement, + tax_amount: txnAmount1.tax_amount, + tax_group_id: txnAmount1.tax_group_id, + is_billable: txnAmount1.billable, + distance: txnAmount1.distance, + distance_unit: txnAmount1.distance_unit as MileageUnitEnum, + started_at: txnAmount1.from_dt, + ended_at: txnAmount1.to_dt, + custom_fields: txnAmount1.custom_properties, + per_diem_rate_id: txnAmount1.per_diem_rate_id, + per_diem_num_days: 0, + mileage_rate_id: txnAmount1.mileage_rate_id, + advance_wallet_id: txnAmount1.advance_wallet_id, + report_id: txnAmount1.report_id, + travel_classes: [], + locations: [], + commute_deduction: undefined, + mileage_is_round_trip: null, + commute_details_id: undefined, + hotel_is_breakfast_provided: null, + file_ids: undefined, +}); + export const splitExpenseTxn2: Transaction = deepFreeze({ skip_reimbursement: false, source: 'MOBILE', diff --git a/src/app/core/mock-data/txn-custom-properties.data.ts b/src/app/core/mock-data/txn-custom-properties.data.ts index 8bf77ade39..a5f5ec30e9 100644 --- a/src/app/core/mock-data/txn-custom-properties.data.ts +++ b/src/app/core/mock-data/txn-custom-properties.data.ts @@ -317,7 +317,7 @@ export const txnCustomPropertiesData4: TxnCustomProperties[] = deepFreeze([ id: 210281, prefix: '', name: 'test', - value: '', + value: [], placeholder: '123test', type: 'MULTI_SELECT', mandatory: false, @@ -328,7 +328,7 @@ export const txnCustomPropertiesData4: TxnCustomProperties[] = deepFreeze([ id: 212819, prefix: '', name: 'category2', - value: '', + value: [], placeholder: 'category2', type: 'MULTI_SELECT', mandatory: false, @@ -339,7 +339,7 @@ export const txnCustomPropertiesData4: TxnCustomProperties[] = deepFreeze([ id: 206206, prefix: '', name: 'pub create hola 1', - value: null, + value: {}, placeholder: 'pub create hola 1', type: 'LOCATION', mandatory: false, @@ -350,7 +350,7 @@ export const txnCustomPropertiesData4: TxnCustomProperties[] = deepFreeze([ id: 211321, prefix: '', name: 'test 112', - value: null, + value: {}, placeholder: 'placeholder', type: 'LOCATION', mandatory: false, diff --git a/src/app/core/mock-data/unflattened-txn.data.ts b/src/app/core/mock-data/unflattened-txn.data.ts index 8f497bc79f..ee65d5177d 100644 --- a/src/app/core/mock-data/unflattened-txn.data.ts +++ b/src/app/core/mock-data/unflattened-txn.data.ts @@ -1906,7 +1906,7 @@ export const newExpFromFg: Partial = deepFreeze({ report_id: null, reported_at: null, state: 'COMPLETE', - num_files: 0, + num_files: 1, invoice_number: null, purpose: 'purpose', source: 'MOBILE', @@ -2095,8 +2095,8 @@ export const newExpFromFg2: Partial = deepFreeze({ vendor_id: 28860, platform_vendor: null, platform_vendor_id: null, - org_category: 'TRAVEL', - sub_category: 'TAXI', + org_category: 'Software', + sub_category: 'Software', fyle_category: 'Groceries', org_category_code: '117', org_category_id: 215481, @@ -2220,7 +2220,7 @@ export const newExpFromFg3: Partial = deepFreeze({ report_id: null, reported_at: null, state: 'DRAFT', - num_files: 0, + num_files: 1, invoice_number: null, purpose: 'purpose', source: 'MOBILE', @@ -2356,7 +2356,7 @@ export const newExpFromFg4: Partial = deepFreeze({ report_id: null, reported_at: null, state: 'DRAFT', - num_files: 0, + num_files: 1, invoice_number: null, purpose: 'purpose', source: 'MOBILE', @@ -3236,7 +3236,8 @@ export const newMileageExpFromForm: Partial = deepFreeze num_days: null, mileage_calculated_distance: 10, mileage_calculated_amount: 100, - mileage_vehicle_type: 'bicycle', + mileage_vehicle_type: null, + mileage_rate_id: 57035, mileage_rate: null, mileage_is_round_trip: true, hotel_is_breakfast_provided: null, @@ -3382,7 +3383,8 @@ export const newMileageExpFromForm2: Partial = deepFreez user_can_delete: true, billable: undefined, user_reason_for_duplicate_expenses: null, - mileage_vehicle_type: undefined, + mileage_vehicle_type: null, + mileage_rate_id: undefined, txn_dt: new Date('2023-02-13T01:00:00.000Z'), category: null, amount: 100, diff --git a/src/app/core/models/custom-input-options.model.ts b/src/app/core/models/custom-input-options.model.ts index 095005f361..4832db1372 100644 --- a/src/app/core/models/custom-input-options.model.ts +++ b/src/app/core/models/custom-input-options.model.ts @@ -1,4 +1,4 @@ export interface CustomInputOptions { label: string; - value: string | string[] | boolean | Date | number | { display: string }; + value: string | string[] | boolean | Date | number | { display?: string }; } diff --git a/src/app/core/models/custom-input.model.ts b/src/app/core/models/custom-input.model.ts index 0003f5d3dd..a91ff2526a 100644 --- a/src/app/core/models/custom-input.model.ts +++ b/src/app/core/models/custom-input.model.ts @@ -8,7 +8,7 @@ export interface CustomInput { placeholder: string; prefix: string; type: string; - value: string | string[] | boolean | Date | number | { display: string }; + value: string | string[] | boolean | Date | number | { display?: string }; parent_field_id: number; displayValue: string; areSameValues?: boolean; diff --git a/src/app/core/models/custom-inputs-option.model.ts b/src/app/core/models/custom-inputs-option.model.ts new file mode 100644 index 0000000000..df67994210 --- /dev/null +++ b/src/app/core/models/custom-inputs-option.model.ts @@ -0,0 +1,11 @@ +export interface CustomInputsOption { + id?: number; + mandatory?: boolean; + name?: string; + options?: string[]; + placeholder?: string; + prefix?: string; + type?: string; + label: string; + value: string; +} diff --git a/src/app/core/models/custom_field.model.ts b/src/app/core/models/custom_field.model.ts index 9b32cc5545..f2464bd666 100644 --- a/src/app/core/models/custom_field.model.ts +++ b/src/app/core/models/custom_field.model.ts @@ -3,9 +3,9 @@ import { AbstractControl } from '@angular/forms'; export interface CustomField { id?: number; name: string; - value: string | boolean | number | Date | string[] | { display: string }; + value: string | boolean | number | Date | string[] | { display?: string }; type?: string; - displayValue?: string | boolean | number | Date | string[] | { display: string }; + displayValue?: string | boolean | number | Date | string[] | { display?: string }; mandatory?: boolean; control?: AbstractControl; } diff --git a/src/app/core/models/receipt-detail.model.ts b/src/app/core/models/receipt-detail.model.ts new file mode 100644 index 0000000000..4c9fff6a6d --- /dev/null +++ b/src/app/core/models/receipt-detail.model.ts @@ -0,0 +1,5 @@ +export type ReceiptDetail = { + dataUrl: string; + type: string; + actionSource: string; +}; diff --git a/src/app/core/models/txn-custom-properties.model.ts b/src/app/core/models/txn-custom-properties.model.ts index 5c14575be9..bcfc9cb586 100644 --- a/src/app/core/models/txn-custom-properties.model.ts +++ b/src/app/core/models/txn-custom-properties.model.ts @@ -1,4 +1,5 @@ import { AbstractControl } from '@angular/forms'; +import { CustomInputsOption } from './custom-inputs-option.model'; export interface TxnCustomProperties { id?: number; @@ -8,21 +9,9 @@ export interface TxnCustomProperties { placeholder?: string; prefix?: string; type?: string; - value: string | string[] | boolean | Date | number | { display: string }; + value: string | string[] | boolean | Date | number | { display?: string }; parent_field_id?: number; label?: string; control?: AbstractControl; is_enabled?: boolean; } - -export interface CustomInputsOption { - id?: number; - mandatory?: boolean; - name?: string; - options?: string[]; - placeholder?: string; - prefix?: string; - type?: string; - label: string; - value: string; -} diff --git a/src/app/core/models/v1/transaction.model.ts b/src/app/core/models/v1/transaction.model.ts index ba9c1efd6e..4ee36a75f7 100644 --- a/src/app/core/models/v1/transaction.model.ts +++ b/src/app/core/models/v1/transaction.model.ts @@ -136,4 +136,5 @@ export interface Transaction { status: ExpenseTransactionStatus; corporate_card_nickname?: string; }[]; + file_ids?: string[]; } diff --git a/src/app/core/services/custom-fields.service.spec.ts b/src/app/core/services/custom-fields.service.spec.ts index 2e9db98db7..8073b05927 100644 --- a/src/app/core/services/custom-fields.service.spec.ts +++ b/src/app/core/services/custom-fields.service.spec.ts @@ -47,19 +47,13 @@ describe('CustomFieldsService', () => { expect(result.value).toBeFalse(); }); - it('should set default value to empty string for select type', () => { - const mockTxnCustomProperties = cloneDeep(txnCustomPropertiesData3[1]); - const result = customFieldsService.setDefaultValue(mockTxnCustomProperties, 'SELECT'); - expect(result.value).toBe(''); - }); - - it('should set default value to empty string for multi select type', () => { + it('should set default value to empty array for multi select type', () => { const mockTxnCustomProperties = cloneDeep(txnCustomPropertiesData3[2]); const result = customFieldsService.setDefaultValue(mockTxnCustomProperties, 'MULTI_SELECT'); - expect(result.value).toBe(''); + expect(result.value).toEqual([]); }); - it('should set default value to empty string for user select type', () => { + it('should set default value to empty array for user select type', () => { const mockTxnCustomProperties = cloneDeep(txnCustomPropertiesData[0]); const result = customFieldsService.setDefaultValue(mockTxnCustomProperties, 'USER_LIST'); expect(result.value).toEqual([]); diff --git a/src/app/core/services/custom-fields.service.ts b/src/app/core/services/custom-fields.service.ts index f63c032d52..ead89e18ad 100644 --- a/src/app/core/services/custom-fields.service.ts +++ b/src/app/core/services/custom-fields.service.ts @@ -1,14 +1,13 @@ import { Injectable } from '@angular/core'; import { CustomProperty } from '../models/custom-properties.model'; -import { CustomInputsOption, TxnCustomProperties } from '../models/txn-custom-properties.model'; +import { TxnCustomProperties } from '../models/txn-custom-properties.model'; import { ExpenseField } from '../models/v1/expense-field.model'; +import { CustomInputsOption } from '../models/custom-inputs-option.model'; @Injectable({ providedIn: 'root', }) export class CustomFieldsService { - constructor() {} - sortcustomFieldsByType(customField1: TxnCustomProperties, customField2: TxnCustomProperties): 1 | -1 | 0 { if (customField1.type > customField2.type) { return -1; @@ -23,15 +22,16 @@ export class CustomFieldsService { setDefaultValue(property: TxnCustomProperties, inputValue: string): TxnCustomProperties { if (inputValue === 'BOOLEAN') { property.value = false; - } else if (inputValue === 'SELECT' || inputValue === 'MULTI_SELECT') { - property.value = ''; - } else if (inputValue === 'USER_LIST') { + } else if (inputValue === 'USER_LIST' || inputValue === 'MULTI_SELECT') { property.value = []; + } else if (inputValue === 'LOCATION') { + property.value = {}; } return property; } + // eslint-disable-next-line @typescript-eslint/no-explicit-any setProperty(prefix: string, customInput: ExpenseField, customProperties: CustomProperty[]): TxnCustomProperties { /* Setting the name and mandatory based on the custom input key * Reason: Same method is used for expense custom fields and transport/advance request custom fields @@ -83,6 +83,7 @@ export class CustomFieldsService { } standardizeCustomFields( + // eslint-disable-next-line @typescript-eslint/no-explicit-any customProperties: CustomProperty[], customInputs: ExpenseField[] ): TxnCustomProperties[] { diff --git a/src/app/core/services/custom-inputs.service.spec.ts b/src/app/core/services/custom-inputs.service.spec.ts index 806f2b2620..d8dcd25b0d 100644 --- a/src/app/core/services/custom-inputs.service.spec.ts +++ b/src/app/core/services/custom-inputs.service.spec.ts @@ -234,7 +234,7 @@ describe('CustomInputsService', () => { options: null, }; - const expectedProperty = 'some,location'; + const expectedProperty = '-'; const result = customInputsService.getCustomPropertyDisplayValue(testProperty); expect(result).toEqual(expectedProperty); diff --git a/src/app/core/services/custom-inputs.service.ts b/src/app/core/services/custom-inputs.service.ts index 635b574228..5cea1ac4cd 100644 --- a/src/app/core/services/custom-inputs.service.ts +++ b/src/app/core/services/custom-inputs.service.ts @@ -218,8 +218,6 @@ export class CustomInputsService { if (customProperty.value) { if (customProperty.value.hasOwnProperty('display')) { displayValue = (<{ display: string }>customProperty.value).display || '-'; - } else { - displayValue = customProperty.value.toString(); } } return displayValue; diff --git a/src/app/core/services/platform/v1/spender/expenses.service.spec.ts b/src/app/core/services/platform/v1/spender/expenses.service.spec.ts index aeac61f1f6..e4a39ab618 100644 --- a/src/app/core/services/platform/v1/spender/expenses.service.spec.ts +++ b/src/app/core/services/platform/v1/spender/expenses.service.spec.ts @@ -29,6 +29,10 @@ import { } from 'src/app/core/mock-data/corporate-card-transaction-response.data'; import { cloneDeep } from 'lodash'; import { ExpenseTransactionStatus } from 'src/app/core/enums/platform/v1/expense-transaction-status.enum'; +import { Transaction } from 'src/app/core/models/v1/transaction.model'; +import { transformedExpensePayload, txnAmount1 } from 'src/app/core/mock-data/transaction.data'; +import { Expense } from 'src/app/core/models/platform/v1/expense.model'; +import { PlatformApiResponse } from 'src/app/core/models/platform/platform-api-response.model'; describe('ExpensesService', () => { let service: ExpensesService; @@ -390,6 +394,89 @@ describe('ExpensesService', () => { }); }); + describe('transformTo', () => { + let transaction: Partial; + let expensePayload: Partial; + + beforeEach(() => { + transaction = cloneDeep(txnAmount1); + expensePayload = { + ...transformedExpensePayload, + }; + }); + + it('should transform a Transaction to Expense payload', () => { + const result = service.transformTo(txnAmount1); + expect(result).toEqual(expensePayload); + }); + + it('should include flight travel classes if category is flight or airlines', () => { + transaction.fyle_category = 'Airlines'; + transaction.flight_journey_travel_class = 'Economy'; + transaction.flight_return_travel_class = 'Business'; + expensePayload.travel_classes = ['Economy', 'Business']; + + const result = service.transformTo(transaction); + expect(result.travel_classes).toEqual(['Economy', 'Business']); + }); + + it('should only include flight_journey_travel_class when return class is not present', () => { + transaction.fyle_category = 'Airlines'; + transaction.flight_journey_travel_class = 'Economy'; + transaction.flight_return_travel_class = null; + expensePayload.travel_classes = ['Economy']; + + const result = service.transformTo(transaction); + expect(result.travel_classes).toEqual(['Economy']); + }); + + it('should include bus travel class if category is bus', () => { + transaction.fyle_category = 'bus'; + transaction.bus_travel_class = 'Luxury'; + expensePayload.travel_classes = ['Luxury']; + + const result = service.transformTo(transaction); + expect(result.travel_classes).toEqual(['Luxury']); + }); + + it('should include train travel class if category is train', () => { + transaction.fyle_category = 'train'; + transaction.train_travel_class = 'First Class'; + expensePayload.travel_classes = ['First Class']; + + const result = service.transformTo(transaction); + expect(result.travel_classes).toEqual(['First Class']); + }); + + it('should not include any travel classes if category is not flight, bus, or train', () => { + transaction.fyle_category = 'taxi'; + expensePayload.travel_classes = []; + + const result = service.transformTo(transaction); + expect(result.travel_classes).toEqual([]); + }); + + it('should include multiple travel classes when both journey and return classes are present for flight', () => { + transaction.fyle_category = 'Airlines'; + transaction.flight_journey_travel_class = 'Premium Economy'; + transaction.flight_return_travel_class = 'Economy'; + expensePayload.travel_classes = ['Premium Economy', 'Economy']; + + const result = service.transformTo(transaction); + expect(result.travel_classes).toEqual(['Premium Economy', 'Economy']); + }); + + it('should not add travel class if travel class fields are undefined for flight', () => { + transaction.fyle_category = 'Airlines'; + transaction.flight_journey_travel_class = undefined; + transaction.flight_return_travel_class = undefined; + expensePayload.travel_classes = []; + + const result = service.transformTo(transaction); + expect(result.travel_classes).toEqual([]); + }); + }); + it('post(): should post expenses', (done) => { spenderService.post.and.returnValue(of({})); @@ -405,4 +492,23 @@ describe('ExpensesService', () => { done(); }); }); + + it('createFromFile(): should post fileId, source to create expense', (done) => { + spenderService.post.and.returnValue(of({})); + + const fileId = 'file123'; + const source = 'email'; + + service.createFromFile(fileId, source).subscribe((response) => { + expect(spenderService.post).toHaveBeenCalledOnceWith('/expenses/create_from_file/bulk', { + data: [ + { + file_id: fileId, + source, + }, + ], + }); + done(); + }); + }); }); diff --git a/src/app/core/services/platform/v1/spender/expenses.service.ts b/src/app/core/services/platform/v1/spender/expenses.service.ts index f860507fba..cb369daa6e 100644 --- a/src/app/core/services/platform/v1/spender/expenses.service.ts +++ b/src/app/core/services/platform/v1/spender/expenses.service.ts @@ -19,6 +19,9 @@ import { expensesCacheBuster$ } from 'src/app/core/cache-buster/expense-cache-bu import { CorporateCreditCardExpenseService } from '../../../corporate-credit-card-expense.service'; import { corporateCardTransaction } from 'src/app/core/models/platform/v1/cc-transaction.model'; import { MatchedCorporateCardTransaction } from 'src/app/core/models/platform/v1/matched-corpporate-card-transaction.model'; +import { MileageUnitEnum } from 'src/app/core/models/platform/platform-mileage-rates.model'; +import { Location } from 'src/app/core/models/location.model'; +import { CommuteDeduction } from 'src/app/core/enums/commute-deduction.enum'; @Injectable({ providedIn: 'root', @@ -287,9 +290,77 @@ export class ExpensesService { .pipe(map((res) => res.data)); } - post(expense: Partial): Observable { - return this.spenderService.post('/expenses', { + // transform public transaction to expense payload for /post expenses + transformTo(transaction: Partial): Partial { + const expense: Partial = { + id: transaction.id, + spent_at: transaction.txn_dt, + category_id: transaction.org_category_id, + purpose: transaction.purpose, + source_account_id: transaction.source_account_id, + claim_amount: transaction.amount, + merchant: transaction.vendor, + project_id: transaction.project_id, + cost_center_id: transaction.cost_center_id, + foreign_currency: transaction.orig_currency, + foreign_amount: transaction.orig_amount, + source: transaction.source, + is_reimbursable: !transaction.skip_reimbursement, + tax_amount: transaction.tax_amount, + tax_group_id: transaction.tax_group_id, + is_billable: transaction.billable, + distance: transaction.distance, + distance_unit: transaction.distance_unit as MileageUnitEnum, + started_at: transaction.from_dt, + ended_at: transaction.to_dt, + locations: transaction.locations as unknown as Location[], + custom_fields: transaction.custom_properties, + per_diem_rate_id: transaction.per_diem_rate_id, + per_diem_num_days: transaction.num_days || 0, + mileage_rate_id: transaction.mileage_rate_id, // @arjun check if this is present + commute_deduction: transaction.commute_deduction as CommuteDeduction, + mileage_is_round_trip: transaction.mileage_is_round_trip, + commute_details_id: transaction.commute_details_id, + hotel_is_breakfast_provided: transaction.hotel_is_breakfast_provided, + advance_wallet_id: transaction.advance_wallet_id, + file_ids: transaction.file_ids, + report_id: transaction.report_id, + travel_classes: [], + }; + + if ( + transaction.fyle_category?.toLowerCase() === 'flight' || + transaction.fyle_category?.toLowerCase() === 'airlines' + ) { + if (transaction.flight_journey_travel_class) { + expense.travel_classes.push(transaction.flight_journey_travel_class); + } + if (transaction.flight_return_travel_class) { + expense.travel_classes.push(transaction.flight_return_travel_class); + } + } else if (transaction.fyle_category?.toLowerCase() === 'bus' && transaction.bus_travel_class) { + expense.travel_classes.push(transaction.bus_travel_class); + } else if (transaction.fyle_category?.toLowerCase() === 'train' && transaction.train_travel_class) { + expense.travel_classes.push(transaction.train_travel_class); + } + + return expense; + } + + post(expense: Partial): Observable<{ data: Expense }> { + return this.spenderService.post<{ data: Expense }>('/expenses', { data: expense, }); } + + createFromFile(fileId: string, source: string): Observable<{ data: Expense[] }> { + return this.spenderService.post<{ data: Expense[] }>('/expenses/create_from_file/bulk', { + data: [ + { + file_id: fileId, + source, + }, + ], + }); + } } diff --git a/src/app/core/services/transaction.service.spec.ts b/src/app/core/services/transaction.service.spec.ts index 5e02d38157..234556808f 100644 --- a/src/app/core/services/transaction.service.spec.ts +++ b/src/app/core/services/transaction.service.spec.ts @@ -15,7 +15,6 @@ import { expenseData3, expenseList3, expenseList4, - expenseDataWithDateString, } from '../mock-data/expense.data'; import { UndoMergeData } from '../mock-data/undo-merge.data'; import { AccountsService } from './accounts.service'; @@ -36,12 +35,17 @@ import { TransactionService } from './transaction.service'; import { UserEventService } from './user-event.service'; import { UtilityService } from './utility.service'; import * as dayjs from 'dayjs'; -import { eouRes2 } from '../mock-data/extended-org-user.data'; -import { expenseV2Data, expenseV2DataMultiple } from '../mock-data/expense-v2.data'; import * as lodash from 'lodash'; -import { txnData, txnData2, txnData4, txnDataPayload, txnList, upsertTxnParam } from '../mock-data/transaction.data'; -import { unflattenedTxnData, unflattenedTxnDataWithSubCategory } from '../mock-data/unflattened-txn.data'; -import { fileObjectData, fileObjectData1, fileObjectData2 } from '../mock-data/file-object.data'; +import { + transformedExpensePayload, + txnData, + txnData2, + txnData4, + txnDataPayload, + txnList, + upsertTxnParam, +} from '../mock-data/transaction.data'; +import { fileObjectData1 } from '../mock-data/file-object.data'; import { AccountType } from '../enums/account-type.enum'; import { orgUserSettingsData, orgUserSettingsData2, orgUserSettingsData3 } from '../mock-data/org-user-settings.data'; import { orgSettingsData } from '../test-data/org-settings.service.spec.data'; @@ -107,7 +111,7 @@ describe('TransactionService', () => { const paymentModesServiceSpy = jasmine.createSpyObj('PaymentModesService', ['getDefaultAccount']); const orgSettingsServiceSpy = jasmine.createSpyObj('OrgSettingsService', ['get']); const accountsServiceSpy = jasmine.createSpyObj('AccountsService', ['getEMyAccounts']); - const expensesServiceSpy = jasmine.createSpyObj('ExpensesService', ['attachReceiptsToExpense']); + const expensesServiceSpy = jasmine.createSpyObj('ExpensesService', ['transformTo', 'post', 'createFromFile']); TestBed.configureTestingModule({ providers: [ @@ -1091,18 +1095,31 @@ describe('TransactionService', () => { }); }); - it('createTxnWithFiles(): should create transaction with files', (done) => { - spyOn(transactionService, 'upsert').and.returnValue(of(txnData2)); - expensesService.attachReceiptsToExpense.and.returnValue(of([expenseData])); - - const mockFileObject = cloneDeep(fileObjectData1); - transactionService.createTxnWithFiles(txnData, of(mockFileObject)).subscribe((res) => { - expect(res).toEqual(txnData2); - expect(transactionService.upsert).toHaveBeenCalledOnceWith(txnData); - expect(expensesService.attachReceiptsToExpense).toHaveBeenCalledOnceWith(mockFileObject[0].transaction_id, [ - mockFileObject[0].id, - ]); - done(); + describe('createTxnWithFiles():', () => { + it('should create transaction with files', (done) => { + const mockFileObject = cloneDeep(fileObjectData1); + + spyOn(transactionService, 'upsert').and.returnValue(of(txnData2)); + transactionService.createTxnWithFiles({ ...txnData }, of(mockFileObject)).subscribe((res) => { + expect(res).toEqual(txnData2); + expect(transactionService.upsert).toHaveBeenCalledOnceWith({ ...txnData, file_ids: [fileObjectData1[0].id] }); + done(); + }); + }); + + it('should create transaction from file when txn contains only source', (done) => { + const mockFileObject = cloneDeep(fileObjectData1); + const txnWithSourceOnly = { source: 'MOBILE_DASHCAM' }; + + expensesService.createFromFile.and.returnValue(of({ data: [expenseData] })); + spyOn(transactionService, 'transformExpense').and.returnValue({ tx: txnData2 }); + + transactionService.createTxnWithFiles(txnWithSourceOnly, of(mockFileObject)).subscribe((res) => { + expect(expensesService.createFromFile).toHaveBeenCalledOnceWith(mockFileObject[0].id, 'MOBILE_DASHCAM'); + expect(transactionService.transformExpense).toHaveBeenCalled(); + expect(res).toEqual(txnData2); + done(); + }); }); }); @@ -1113,14 +1130,17 @@ describe('TransactionService', () => { spyOn(transactionService, 'getTxnAccount').and.returnValue(of(txnAccountData)); // @ts-ignore spyOn(transactionService, 'getPersonalAccount').and.returnValue(of(personalAccountData)); + spyOn(transactionService, 'transformExpense').and.returnValue({ tx: txnData4 }); timezoneService.convertAllDatesToProperLocale.and.returnValue(txnCustomPropertiesData2); - apiService.post.and.returnValue(of(txnData4)); utilityService.discardRedundantCharacters.and.returnValue(txnDataPayload); + expensesService.transformTo.and.returnValue(transformedExpensePayload); + expensesService.post.and.returnValue(of({ data: expenseData })); const mockUpsertTxnParam = cloneDeep(upsertTxnParam); transactionService.upsert(mockUpsertTxnParam).subscribe((res) => { expect(res).toEqual(txnData4); - expect(apiService.post).toHaveBeenCalledOnceWith('/transactions', txnDataPayload); + expect(expensesService.transformTo).toHaveBeenCalledOnceWith(txnDataPayload); + expect(expensesService.post).toHaveBeenCalledOnceWith(transformedExpensePayload); expect(orgUserSettingsService.get).toHaveBeenCalledTimes(1); expect(timezoneService.convertAllDatesToProperLocale).toHaveBeenCalledOnceWith(txnCustomPropertiesData6, offset); expect(timezoneService.convertToUtc).toHaveBeenCalledTimes(2); @@ -1139,14 +1159,17 @@ describe('TransactionService', () => { spyOn(transactionService, 'getTxnAccount').and.returnValue(of(txnAccountData)); // @ts-ignore spyOn(transactionService, 'getPersonalAccount').and.returnValue(of(personalAccountData)); + spyOn(transactionService, 'transformExpense').and.returnValue({ tx: txnData4 }); timezoneService.convertAllDatesToProperLocale.and.returnValue(txnCustomPropertiesData2); - apiService.post.and.returnValue(of(txnData4)); utilityService.discardRedundantCharacters.and.returnValue(txnDataPayload); + expensesService.transformTo.and.returnValue(transformedExpensePayload); + expensesService.post.and.returnValue(of({ data: expenseData })); const mockUpsertTxnParam = cloneDeep(upsertTxnParam); transactionService.upsert(mockUpsertTxnParam).subscribe((res) => { expect(res).toEqual(txnData4); - expect(apiService.post).toHaveBeenCalledOnceWith('/transactions', txnDataPayload); + expect(expensesService.transformTo).toHaveBeenCalledOnceWith(txnDataPayload); + expect(expensesService.post).toHaveBeenCalledOnceWith(transformedExpensePayload); expect(orgUserSettingsService.get).toHaveBeenCalledTimes(1); expect(timezoneService.convertAllDatesToProperLocale).toHaveBeenCalledOnceWith(txnCustomPropertiesData6, offset); expect(timezoneService.convertToUtc).toHaveBeenCalledTimes(2); diff --git a/src/app/core/services/transaction.service.ts b/src/app/core/services/transaction.service.ts index 98d1ac90cb..19e676c614 100644 --- a/src/app/core/services/transaction.service.ts +++ b/src/app/core/services/transaction.service.ts @@ -180,7 +180,8 @@ export class TransactionService { const transactionCopy = this.utilityService.discardRedundantCharacters(transaction, fieldsToCheck); - return this.apiService.post('/transactions', transactionCopy); + const expensePayload = this.expensesService.transformTo(transactionCopy); + return this.expensesService.post(expensePayload).pipe(map((result) => this.transformExpense(result.data).tx)); }) ); } @@ -192,14 +193,20 @@ export class TransactionService { txn: Partial, fileUploads$: Observable ): Observable> { - const upsertTxn$ = this.upsert(txn); - return forkJoin([fileUploads$, upsertTxn$]).pipe( - switchMap(([fileObjs, transaction]) => { - const fileIds = fileObjs.map((fileObj) => fileObj.id); - if (fileIds.length > 0) { - return this.expensesService.attachReceiptsToExpense(transaction.id, fileIds).pipe(map(() => transaction)); + return fileUploads$.pipe( + switchMap((fileObjs) => { + // txn contains only source key when capturing receipt + if (txn.hasOwnProperty('source') && Object.keys(txn).length === 1) { + const fileIds = fileObjs.map((fileObj) => fileObj.id); + if (fileIds.length > 0) { + return this.expensesService + .createFromFile(fileIds[0], txn.source) + .pipe(map((result) => this.transformExpense(result.data[0]).tx)); + } } else { - return of(transaction); + const fileIds = fileObjs.map((fileObj) => fileObj.id); + txn.file_ids = fileIds; + return this.upsert(txn); } }) ); diff --git a/src/app/core/services/transactions-outbox.service.spec.ts b/src/app/core/services/transactions-outbox.service.spec.ts index 9cbd7d5e7c..0b6baa15a6 100644 --- a/src/app/core/services/transactions-outbox.service.spec.ts +++ b/src/app/core/services/transactions-outbox.service.spec.ts @@ -1,8 +1,7 @@ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { TestBed, fakeAsync, tick } from '@angular/core/testing'; import { expenseList2 } from '../mock-data/expense.data'; -import { fileObject4, fileObjectData1 } from '../mock-data/file-object.data'; -import { editUnflattenedTransaction, txnData2 } from '../mock-data/transaction.data'; +import { txnData2 } from '../mock-data/transaction.data'; import { DateService } from './date.service'; import { FileService } from './file.service'; import { OrgUserSettingsService } from './org-user-settings.service'; @@ -11,13 +10,10 @@ import { StorageService } from './storage.service'; import { TrackingService } from './tracking.service'; import { TransactionService } from './transaction.service'; import { ExpensesService } from './platform/v1/spender/expenses.service'; - import { TransactionsOutboxService } from './transactions-outbox.service'; import { outboxQueueData1 } from '../mock-data/outbox-queue.data'; import { cloneDeep } from 'lodash'; import { of } from 'rxjs'; -import { extractedData, parsedReceiptData1, parsedReceiptData2 } from '../mock-data/parsed-receipt.data'; -import { fileData1 } from '../mock-data/file.data'; import { SpenderReportsService } from './platform/v1/spender/reports.service'; import { parsedResponseData1 } from '../mock-data/parsed-response.data'; @@ -107,17 +103,6 @@ describe('TransactionsOutboxService', () => { expect(storageService.set).toHaveBeenCalledOnceWith('outbox', transactionsOutboxService.queue); }); - it('saveDataExtractionQueue(): should save data extraction queue', () => { - const mockQueue = cloneDeep(outboxQueueData1); - transactionsOutboxService.dataExtractionQueue = mockQueue; - storageService.set.and.resolveTo(null); - transactionsOutboxService.saveDataExtractionQueue(); - expect(storageService.set).toHaveBeenCalledOnceWith( - 'data_extraction_queue', - transactionsOutboxService.dataExtractionQueue - ); - }); - describe('isSyncInProgress', () => { it('should return true if sync is in progress', () => { transactionsOutboxService.syncInProgress = true; @@ -146,42 +131,6 @@ describe('TransactionsOutboxService', () => { }); }); - describe('isDataExtractionPending():', () => { - const txnId = 'tx3qHxFNgRcZ'; - it('should return true if data extraction is pending', () => { - const mockQueue = cloneDeep(outboxQueueData1); - transactionsOutboxService.dataExtractionQueue = mockQueue; - const res = transactionsOutboxService.isDataExtractionPending(txnId); - expect(res).toBeTrue(); - }); - - it('should return false if data extraction is not pending', () => { - transactionsOutboxService.dataExtractionQueue = []; - const res = transactionsOutboxService.isDataExtractionPending(txnId); - expect(res).toBeFalse(); - }); - }); - - it('removeDataExtractionEntry():', async () => { - const mockQueue = cloneDeep(outboxQueueData1); - transactionsOutboxService.dataExtractionQueue = mockQueue; - spyOn(transactionsOutboxService, 'saveDataExtractionQueue').and.resolveTo(); - await transactionsOutboxService.removeDataExtractionEntry(txnData2, [ - { url: '2023-02-08/orNVthTo2Zyo/receipts/fi6PQ6z4w6ET.000.jpeg', type: 'image/jpeg' }, - ]); - expect(transactionsOutboxService.saveDataExtractionQueue).toHaveBeenCalledTimes(1); - expect(transactionsOutboxService.dataExtractionQueue.length).toEqual(0); - }); - - it('addDataExtractionEntry():', () => { - spyOn(transactionsOutboxService, 'saveDataExtractionQueue').and.resolveTo(); - transactionsOutboxService.addDataExtractionEntry(txnData2, [ - { url: '2023-02-08/orNVthTo2Zyo/receipts/fi6PQ6z4w6ET.000.jpeg', type: 'image/jpeg' }, - ]); - expect(transactionsOutboxService.dataExtractionQueue.length).toEqual(1); - expect(transactionsOutboxService.saveDataExtractionQueue).toHaveBeenCalledTimes(1); - }); - it('uploadData(): should upload data', (done) => { const fileId = 'fiHPZUiichAS'; const uploadUrl = `${rootUrl}/files/${fileId}/upload_url`; @@ -230,15 +179,13 @@ describe('TransactionsOutboxService', () => { txnData2, [{ url: '2023-02-08/orNVthTo2Zyo/receipts/fi6PQ6z4w6ET.000.jpeg', type: 'image/jpeg' }], null, - null, - false + null ); expect(transactionsOutboxService.addEntry).toHaveBeenCalledOnceWith( txnData2, [{ url: '2023-02-08/orNVthTo2Zyo/receipts/fi6PQ6z4w6ET.000.jpeg', type: 'image/jpeg' }], null, - null, - false + null ); expect(transactionsOutboxService.syncEntry).toHaveBeenCalledOnceWith(outboxQueueData1[0]); expect(transactionsOutboxService.queue.length).toEqual(0); @@ -255,8 +202,7 @@ describe('TransactionsOutboxService', () => { txnData2, [{ url: '2023-02-08/orNVthTo2Zyo/receipts/fi6PQ6z4w6ET.000.jpeg', type: 'image/jpeg' }], null, - null, - false + null ); expect(transactionsOutboxService.syncEntry).toHaveBeenCalledOnceWith(outboxQueueData1[0]); expect(transactionsOutboxService.queue.length).toEqual(0); @@ -317,9 +263,8 @@ describe('TransactionsOutboxService', () => { tick(100); expect(storageService.get).toHaveBeenCalledWith('outbox'); - expect(storageService.get).toHaveBeenCalledWith('data_extraction_queue'); expect(transactionsOutboxService.queue).toEqual(mockQueue); - expect(dateService.fixDates).toHaveBeenCalledTimes(2); + expect(dateService.fixDates).toHaveBeenCalledTimes(1); expect(dateService.fixDates).toHaveBeenCalledWith(mockQueue[0].transaction); })); @@ -329,32 +274,11 @@ describe('TransactionsOutboxService', () => { tick(100); expect(storageService.get).toHaveBeenCalledWith('outbox'); - expect(storageService.get).toHaveBeenCalledWith('data_extraction_queue'); expect(transactionsOutboxService.queue).toEqual([]); expect(dateService.fixDates).not.toHaveBeenCalled(); })); }); - it('processDataExtractionEntry(): should process data extraction entry', fakeAsync(() => { - const mockQueue = cloneDeep([...outboxQueueData1, ...outboxQueueData1, ...outboxQueueData1]); - transactionsOutboxService.dataExtractionQueue = cloneDeep(mockQueue); - transactionService.upsert.and.returnValue(of(editUnflattenedTransaction)); - spyOn(transactionsOutboxService, 'parseReceipt').and.returnValues( - Promise.resolve(parsedReceiptData1), - Promise.resolve({ data: undefined }), - Promise.resolve(parsedReceiptData2) - ); - spyOn(transactionsOutboxService, 'removeDataExtractionEntry').and.resolveTo(); - spyOn(transactionsOutboxService, 'addEntryAndSync').and.resolveTo(outboxQueueData1[0]); - - transactionsOutboxService.processDataExtractionEntry(); - tick(100); - - expect(transactionsOutboxService.parseReceipt).toHaveBeenCalledTimes(3); - expect(transactionService.upsert).toHaveBeenCalledTimes(2); - expect(transactionsOutboxService.removeDataExtractionEntry).toHaveBeenCalledTimes(3); - })); - describe('getExpenseDate():', () => { it('should return transaction date if txn_dt is present', () => { const txnDate = new Date('2023-02-15T06:30:00.000Z'); diff --git a/src/app/core/services/transactions-outbox.service.ts b/src/app/core/services/transactions-outbox.service.ts index e2a68ad4b4..8ad12f30ca 100644 --- a/src/app/core/services/transactions-outbox.service.ts +++ b/src/app/core/services/transactions-outbox.service.ts @@ -2,23 +2,18 @@ import { Injectable } from '@angular/core'; import { StorageService } from './storage.service'; import { DateService } from './date.service'; import { Observable, from, noop } from 'rxjs'; -import { map } from 'rxjs/operators'; import { environment } from 'src/environments/environment'; import { HttpClient, HttpHeaders } from '@angular/common/http'; import { TransactionService } from './transaction.service'; -import { FileService } from './file.service'; import { StatusService } from './status.service'; -import { cloneDeep, indexOf } from 'lodash'; +import { indexOf } from 'lodash'; import { ParsedReceipt } from '../models/parsed_receipt.model'; import { TrackingService } from './tracking.service'; import { Expense } from '../models/expense.model'; import { CurrencyService } from './currency.service'; -import { OrgUserSettingsService } from './org-user-settings.service'; import { Transaction } from '../models/v1/transaction.model'; import { FileObject } from '../models/file-obj.model'; import { OutboxQueue } from '../models/outbox-queue.model'; -import { ExpensesService } from './platform/v1/spender/expenses.service'; -import { SpenderReportsService } from './platform/v1/spender/reports.service'; import { ParsedResponse } from '../models/parsed_response.model'; import { SpenderFileService } from './platform/v1/spender/file.service'; import { PlatformFile } from '../models/platform/platform-file.model'; @@ -33,10 +28,6 @@ export class TransactionsOutboxService { syncInProgress = false; - dataExtractionQueue: OutboxQueue[] = []; - - tempQueue; - ROOT_ENDPOINT: string; //Used for showing bulk mode prompt when instafyle is used more than thrice in the same session @@ -46,14 +37,10 @@ export class TransactionsOutboxService { private storageService: StorageService, private dateService: DateService, private transactionService: TransactionService, - private fileService: FileService, private statusService: StatusService, private httpClient: HttpClient, - private spenderReportsService: SpenderReportsService, private trackingService: TrackingService, private currencyService: CurrencyService, - private orgUserSettingsService: OrgUserSettingsService, - private expensesService: ExpensesService, private spenderFileService: SpenderFileService ) { this.ROOT_ENDPOINT = environment.ROOT_URL; @@ -76,60 +63,19 @@ export class TransactionsOutboxService { await this.storageService.set('outbox', this.queue); } - async saveDataExtractionQueue(): Promise { - await this.storageService.set('data_extraction_queue', this.dataExtractionQueue); - } - - async removeDataExtractionEntry( - expense: Partial, - dataUrls: { url: string; type: string }[] - ): Promise { - const entry = { - transaction: expense, - dataUrls, - }; - - const idx = this.dataExtractionQueue.indexOf(entry); - this.dataExtractionQueue.splice(idx, 1); - await this.saveDataExtractionQueue(); - } - async restoreQueue(): Promise { this.queue = await this.storageService.get('outbox'); - this.dataExtractionQueue = await this.storageService.get('data_extraction_queue'); if (!this.queue) { this.queue = []; } - if (!this.dataExtractionQueue) { - this.dataExtractionQueue = []; - } - // eslint-disable-next-line @typescript-eslint/prefer-for-of for (let i = 0; i < this.queue.length; i++) { const entry = this.queue[i]; // In localStorage the date objects are stored as string, have to convert them to date instance entry.transaction = this.dateService.fixDates(entry.transaction); } - - // eslint-disable-next-line @typescript-eslint/prefer-for-of - for (let i = 0; i < this.dataExtractionQueue.length; i++) { - const entry = this.dataExtractionQueue[i]; - // In localStorage the date objects are stored as string, have to convert them to date instance - entry.transaction = this.dateService.fixDates(entry.transaction); - } - } - - async addDataExtractionEntry( - transaction: Partial, - dataUrls: { url: string; type: string }[] - ): Promise { - this.dataExtractionQueue.push({ - transaction, - dataUrls, - }); - await this.saveDataExtractionQueue(); } getExpenseDate(entry: OutboxQueue, extractedData: ParsedResponse): Date { @@ -142,74 +88,6 @@ export class TransactionsOutboxService { } } - async processDataExtractionEntry(): Promise { - const that = this; - const clonedQueue = cloneDeep(this.dataExtractionQueue); - - if (clonedQueue.length > 0) { - // calling data_extraction serially for all expenses in queue. - // https://gist.github.com/steve-taylor/5075717 - const loop = (index: number): void => { - const entry = clonedQueue[index]; - - const base64Image = entry.dataUrls[0].url.replace('data:image/jpeg;base64,', ''); - - that - .parseReceipt(base64Image) - .then( - (response) => { - const parsedResponse = response.data; - - if (parsedResponse) { - const extractedData = { - amount: parsedResponse.amount, - currency: parsedResponse.currency, - category: parsedResponse.category, - date: parsedResponse.date ? new Date(parsedResponse.date) : null, - vendor: parsedResponse.vendor_name, - }; - - entry.transaction.extracted_data = extractedData; - entry.transaction.txn_dt = this.getExpenseDate(entry, parsedResponse); - - // TODO: add this to allow amout addtion to extracted expense - // let transactionUpsertPromise; - - // if (!entry.transaction.amount) { - // transactionUpsertPromise = that - // .getExtractedCurrencyData(extractedData, entry) - // .then((_) => that.transactionService.upsert(entry.transaction).toPromise()); - // } else { - // transactionUpsertPromise = that.transactionService.upsert(entry.transaction).toPromise(); - // } - - that.transactionService - .upsert(entry.transaction) - .toPromise() - .then(() => null) - .finally(() => { - this.removeDataExtractionEntry(entry.transaction, entry.dataUrls); - }); - } else { - this.removeDataExtractionEntry(entry.transaction, entry.dataUrls); - } - }, - () => { - this.removeDataExtractionEntry(entry.transaction, entry.dataUrls); - } - ) - .finally(() => { - // iterating to next item on list. - if (index < clonedQueue.length - 1) { - loop(index + 1); - } - }); - }; - - loop(0); - } - } - uploadData(uploadUrl: string, blob, contentType: string): Observable { return this.httpClient.put(uploadUrl, blob, { headers: new HttpHeaders({ 'Content-Type': contentType }), @@ -264,15 +142,13 @@ export class TransactionsOutboxService { transaction: Partial, dataUrls: { url: string; type: string }[], comments?: string[], - reportId?: string, - applyMagic = false + reportId?: string ): Promise { this.queue.push({ transaction, dataUrls, comments, reportId, - applyMagic: !!applyMagic, }); return this.saveQueue(); @@ -284,10 +160,9 @@ export class TransactionsOutboxService { transaction: Partial, dataUrls: { url: string; type: string }[], comments: string[], - reportId: string, - applyMagic = false + reportId: string ): Promise { - this.addEntry(transaction, dataUrls, comments, reportId, applyMagic); + this.addEntry(transaction, dataUrls, comments, reportId); return this.syncEntry(this.queue.pop()); } @@ -328,7 +203,6 @@ export class TransactionsOutboxService { syncEntry(entry: OutboxQueue): Promise { const that = this; const fileObjPromiseArray: Promise[] = []; - const reportId = entry.reportId; if (!entry.fileUploadCompleted) { if (entry.dataUrls && entry.dataUrls.length > 0) { @@ -360,76 +234,12 @@ export class TransactionsOutboxService { that.statusService.post('transactions', resp.id, { comment }, true).subscribe(noop); }); } - if (resp.id && entry.transaction.advance_wallet_id !== resp.advance_wallet_id) { - const expense = { - id: resp.id, - advance_wallet_id: entry.transaction.advance_wallet_id, - }; - that.expensesService.post(expense).subscribe(noop); - } - if (entry.dataUrls && entry.dataUrls.length > 0) { - that.expensesService - .getExpenseById(resp.id) - .toPromise() - .then((expense) => { - entry.dataUrls.forEach((dataUrl) => { - if (dataUrl.callBackUrl) { - const transformedExpense = that.transactionService.transformExpense(expense); - that.httpClient.post(dataUrl.callBackUrl, { - entered_data: { - amount: transformedExpense.tx.amount, - currency: transformedExpense.tx.currency, - orig_currency: transformedExpense.tx.orig_currency, - orig_amount: transformedExpense.tx.orig_amount, - date: transformedExpense.tx.txn_dt, - vendor: transformedExpense.tx.vendor, - category: transformedExpense.tx.fyle_category, - external_id: transformedExpense.tx.external_id, - transaction_id: transformedExpense.tx.id, - }, - }); - } - }); - }); - } - - if (entry.applyMagic) { - const isInstafyleEnabled$ = this.orgUserSettingsService - .get() - .pipe( - map( - (orgUserSettings) => - orgUserSettings.insta_fyle_settings.allowed && orgUserSettings.insta_fyle_settings.enabled - ) - ); - - isInstafyleEnabled$.subscribe((isInstafyleEnabled) => { - if (isInstafyleEnabled) { - that.addDataExtractionEntry(resp, entry.dataUrls); - } - }); - } that .matchIfRequired(resp.id, entry.transaction.matchCCCId) .then(() => { that.removeEntry(entry); - if (reportId) { - const txnIds = [resp.id]; - that.spenderReportsService - .addExpenses(reportId, txnIds) - .toPromise() - .then(() => { - this.trackingService.addToExistingReportAddEditExpense(); - resolve(entry); - }) - .catch((err: Error) => { - this.trackingService.syncError({ label: err }); - reject(err); - }); - } else { - resolve(entry); - } + resolve(entry); }) .catch((err: Error) => { this.trackingService.syncError({ label: err }); @@ -463,7 +273,6 @@ export class TransactionsOutboxService { // if (p.length > 0) { // TransactionService.deleteCache(); // } - that.processDataExtractionEntry(); resolve(); }) .catch((err) => { @@ -522,12 +331,6 @@ export class TransactionsOutboxService { ); } - isDataExtractionPending(txnId: string): boolean { - const txnIds = this.dataExtractionQueue.map((entry) => entry.transaction.id); - - return txnIds.indexOf(txnId) > -1; - } - isPDF(type: string): boolean { return ['application/pdf', 'pdf'].indexOf(type) > -1; } diff --git a/src/app/fyle/add-edit-expense/add-edit-expense-2.spec.ts b/src/app/fyle/add-edit-expense/add-edit-expense-2.spec.ts index 80f65948c3..05eabb96ee 100644 --- a/src/app/fyle/add-edit-expense/add-edit-expense-2.spec.ts +++ b/src/app/fyle/add-edit-expense/add-edit-expense-2.spec.ts @@ -8,21 +8,11 @@ import { ActionSheetController, ModalController, NavController, Platform, Popove import { Observable, Subscription, finalize, of, throwError } from 'rxjs'; import { AccountType } from 'src/app/core/enums/account-type.enum'; import { criticalPolicyViolation2 } from 'src/app/core/mock-data/crtical-policy-violations.data'; -import { - duplicateSetData1, - duplicateSetData4, - duplicateSetData5, - duplicateSetData6, -} from 'src/app/core/mock-data/duplicate-sets.data'; -import { - expenseData1, - expenseData2, - splitExpTransformedData, - transformedPlatformedExpense, -} from 'src/app/core/mock-data/expense.data'; +import { duplicateSetData1, duplicateSetData6 } from 'src/app/core/mock-data/duplicate-sets.data'; +import { expenseData1, expenseData2, transformedPlatformedExpense } from 'src/app/core/mock-data/expense.data'; import { fileObject7, fileObjectData } from 'src/app/core/mock-data/file-object.data'; import { individualExpPolicyStateData2 } from 'src/app/core/mock-data/individual-expense-policy-state.data'; -import { filterOrgCategoryParam, orgCategoryData } from 'src/app/core/mock-data/org-category.data'; +import { orgCategoryData } from 'src/app/core/mock-data/org-category.data'; import { orgSettingsCCCDisabled, orgSettingsCCCEnabled, @@ -42,12 +32,7 @@ import { } from 'src/app/core/mock-data/parsed-receipt.data'; import { splitPolicyExp4 } from 'src/app/core/mock-data/policy-violation.data'; import { editExpTxn, txnData2 } from 'src/app/core/mock-data/transaction.data'; -import { - expectedUnflattendedTxnData1, - unflattenedTxnData, - unflattenedTxnWithExtractedData, - unflattenedTxnWithExtractedData2, -} from 'src/app/core/mock-data/unflattened-txn.data'; +import { unflattenedTxnData } from 'src/app/core/mock-data/unflattened-txn.data'; import { AccountsService } from 'src/app/core/services/accounts.service'; import { AuthService } from 'src/app/core/services/auth.service'; import { CategoriesService } from 'src/app/core/services/categories.service'; @@ -396,18 +381,6 @@ export function TestCases2(getTestBed) { }); }); - it('getActiveCategories(): should get active categories', (done) => { - categoriesService.getAll.and.returnValue(of(filterOrgCategoryParam)); - categoriesService.filterRequired.and.returnValue([filterOrgCategoryParam[0]]); - - component.getActiveCategories().subscribe((res) => { - expect(res).toEqual([filterOrgCategoryParam[0]]); - expect(categoriesService.getAll).toHaveBeenCalledTimes(1); - expect(categoriesService.filterRequired).toHaveBeenCalledOnceWith(filterOrgCategoryParam); - done(); - }); - }); - describe('getInstaFyleImageData():', () => { it('should return image data if parsed from a receipt', (done) => { activatedRoute.snapshot.params.dataUrl = 'data-url'; @@ -881,7 +854,7 @@ export function TestCases2(getTestBed) { component.checkIfReceiptIsMissingAndMandatory('SAVE_EXPENSE').subscribe((isReceiptMissingAndMandatory) => { expect(isReceiptMissingAndMandatory).toBeTrue(); expect(component.getCustomFields).toHaveBeenCalledTimes(1); - expect(component.generateEtxnFromFg).toHaveBeenCalledOnceWith(component.etxn$, customFields$, true); + expect(component.generateEtxnFromFg).toHaveBeenCalledOnceWith(component.etxn$, customFields$); expect(policyService.getPlatformPolicyExpense).toHaveBeenCalledOnceWith( unflattenedTxnData as unknown as { tx: PublicPolicyExpense; dataUrls: Partial[] }, component.selectedCCCTransaction @@ -918,7 +891,7 @@ export function TestCases2(getTestBed) { component.checkIfReceiptIsMissingAndMandatory('SAVE_EXPENSE').subscribe((isReceiptMissingAndMandatory) => { expect(isReceiptMissingAndMandatory).toBeFalse(); expect(component.getCustomFields).toHaveBeenCalledTimes(1); - expect(component.generateEtxnFromFg).toHaveBeenCalledOnceWith(component.etxn$, customFields$, true); + expect(component.generateEtxnFromFg).toHaveBeenCalledOnceWith(component.etxn$, customFields$); expect(policyService.getPlatformPolicyExpense).toHaveBeenCalledOnceWith( unflattenedTxnData as unknown as { tx: PublicPolicyExpense; dataUrls: Partial[] }, component.selectedCCCTransaction @@ -960,7 +933,7 @@ export function TestCases2(getTestBed) { component.checkIfReceiptIsMissingAndMandatory('SAVE_EXPENSE').subscribe((isReceiptMissingAndMandatory) => { expect(isReceiptMissingAndMandatory).toBeFalse(); expect(component.getCustomFields).toHaveBeenCalledTimes(1); - expect(component.generateEtxnFromFg).toHaveBeenCalledOnceWith(component.etxn$, customFields$, true); + expect(component.generateEtxnFromFg).toHaveBeenCalledOnceWith(component.etxn$, customFields$); expect(policyService.getPlatformPolicyExpense).toHaveBeenCalledOnceWith( unflattenedTxnData as unknown as { tx: PublicPolicyExpense; dataUrls: Partial[] }, component.selectedCCCTransaction diff --git a/src/app/fyle/add-edit-expense/add-edit-expense-3.spec.ts b/src/app/fyle/add-edit-expense/add-edit-expense-3.spec.ts index d091ac2fe0..a7b77e417e 100644 --- a/src/app/fyle/add-edit-expense/add-edit-expense-3.spec.ts +++ b/src/app/fyle/add-edit-expense/add-edit-expense-3.spec.ts @@ -461,6 +461,10 @@ export function TestCases3(getTestBed) { }); describe('generateEtxnFromFg():', () => { + beforeEach(() => { + component.allCategories$ = of(orgCategoryData1); + }); + it('should generate expense object from input in the form', (done) => { orgSettingsService.get.and.returnValue(of(orgSettingsData)); @@ -724,10 +728,6 @@ export function TestCases3(getTestBed) { component.fg.controls.costCenter.setValue(costCenterApiRes1[0]); component.fg.controls.location_1.setValue(optionsData15.options[0].value); - component.fg.controls.category.setValue({ - name: 'TRAVEL', - sub_category: 'TAXI', - }); component.fg.controls.currencyObj.setValue({ amount: 100, currency: 'USD', @@ -735,7 +735,7 @@ export function TestCases3(getTestBed) { fixture.detectChanges(); const mockCustomFieldData1 = cloneDeep(customFieldData2); - component.generateEtxnFromFg(of(unflattenedTxnData2), of(mockCustomFieldData1), true).subscribe((res) => { + component.generateEtxnFromFg(of(unflattenedTxnData2), of(mockCustomFieldData1)).subscribe((res) => { expect(res).toEqual(newExpFromFg2); expect(component.getExpenseAttachments).toHaveBeenCalledOnceWith(component.mode); expect(dateService.getUTCDate).toHaveBeenCalledOnceWith(new Date('2023-02-23T16:24:01.335Z')); @@ -807,39 +807,37 @@ export function TestCases3(getTestBed) { fixture.detectChanges(); const mockCustomFields = cloneDeep(customFieldData1); - component - .generateEtxnFromFg(of(cloneDeep(draftUnflattendedTxn)), of(mockCustomFields), false) - .subscribe((res) => { - expect(res).toEqual(newExpFromFg3); - expect(component.getExpenseAttachments).toHaveBeenCalledOnceWith(component.mode); + component.generateEtxnFromFg(of(cloneDeep(draftUnflattendedTxn)), of(mockCustomFields)).subscribe((res) => { + expect(res).toEqual(newExpFromFg3); + expect(component.getExpenseAttachments).toHaveBeenCalledOnceWith(component.mode); - expect(component.getSourceAccID).toHaveBeenCalledTimes(1); - expect(component.getAdvanceWalletId).toHaveBeenCalledTimes(1); - expect(component.getBillable).toHaveBeenCalledTimes(1); - expect(component.getSkipRemibursement).toHaveBeenCalledTimes(1); - expect(component.getTxnDate).toHaveBeenCalledTimes(1); - expect(component.getCurrency).toHaveBeenCalledTimes(1); - expect(component.getOriginalCurrency).toHaveBeenCalledTimes(1); - expect(component.getOriginalAmount).toHaveBeenCalledTimes(1); - expect(component.getProjectID).toHaveBeenCalledTimes(1); - expect(component.getTaxAmount).toHaveBeenCalledTimes(1); - expect(component.getTaxGroupID).toHaveBeenCalledTimes(1); - expect(component.getOrgCategoryID).toHaveBeenCalledTimes(1); - expect(component.getFyleCategory).toHaveBeenCalledTimes(1); - expect(component.getDisplayName).toHaveBeenCalledTimes(1); - expect(component.getPurpose).toHaveBeenCalledTimes(1); - expect(component.getFromDt).toHaveBeenCalledTimes(1); - expect(component.getToDt).toHaveBeenCalledTimes(1); - expect(component.getFlightJourneyClass).toHaveBeenCalledTimes(1); - expect(component.getFlightReturnClass).toHaveBeenCalledTimes(1); - expect(component.getTrainTravelClass).toHaveBeenCalledTimes(1); - expect(component.getBusTravelClass).toHaveBeenCalledTimes(1); - expect(component.getDistance).toHaveBeenCalledTimes(1); - expect(component.getDistanceUnit).toHaveBeenCalledTimes(1); - expect(component.getBreakfastProvided).toHaveBeenCalledTimes(1); - expect(component.getAmount).toHaveBeenCalledTimes(1); - done(); - }); + expect(component.getSourceAccID).toHaveBeenCalledTimes(1); + expect(component.getAdvanceWalletId).toHaveBeenCalledTimes(1); + expect(component.getBillable).toHaveBeenCalledTimes(1); + expect(component.getSkipRemibursement).toHaveBeenCalledTimes(1); + expect(component.getTxnDate).toHaveBeenCalledTimes(1); + expect(component.getCurrency).toHaveBeenCalledTimes(1); + expect(component.getOriginalCurrency).toHaveBeenCalledTimes(1); + expect(component.getOriginalAmount).toHaveBeenCalledTimes(1); + expect(component.getProjectID).toHaveBeenCalledTimes(1); + expect(component.getTaxAmount).toHaveBeenCalledTimes(1); + expect(component.getTaxGroupID).toHaveBeenCalledTimes(1); + expect(component.getOrgCategoryID).toHaveBeenCalledTimes(1); + expect(component.getFyleCategory).toHaveBeenCalledTimes(1); + expect(component.getDisplayName).toHaveBeenCalledTimes(1); + expect(component.getPurpose).toHaveBeenCalledTimes(1); + expect(component.getFromDt).toHaveBeenCalledTimes(1); + expect(component.getToDt).toHaveBeenCalledTimes(1); + expect(component.getFlightJourneyClass).toHaveBeenCalledTimes(1); + expect(component.getFlightReturnClass).toHaveBeenCalledTimes(1); + expect(component.getTrainTravelClass).toHaveBeenCalledTimes(1); + expect(component.getBusTravelClass).toHaveBeenCalledTimes(1); + expect(component.getDistance).toHaveBeenCalledTimes(1); + expect(component.getDistanceUnit).toHaveBeenCalledTimes(1); + expect(component.getBreakfastProvided).toHaveBeenCalledTimes(1); + expect(component.getAmount).toHaveBeenCalledTimes(1); + done(); + }); }); it('should generate expense from form without cost center and location data in edit mode and is not a policy violation', (done) => { @@ -878,7 +876,7 @@ export function TestCases3(getTestBed) { const mockCustomFields = cloneDeep(customFieldData1); const mockEtxn = cloneDeep(draftUnflattendedTxn); - component.generateEtxnFromFg(of(cloneDeep(mockEtxn)), of(mockCustomFields), false).subscribe((res) => { + component.generateEtxnFromFg(of(cloneDeep(mockEtxn)), of(mockCustomFields)).subscribe((res) => { expect(res).toEqual(newExpFromFg4); expect(component.getExpenseAttachments).toHaveBeenCalledOnceWith(component.mode); expect(component.getSourceAccID).toHaveBeenCalledTimes(1); diff --git a/src/app/fyle/add-edit-expense/add-edit-expense-4.spec.ts b/src/app/fyle/add-edit-expense/add-edit-expense-4.spec.ts index f0b946afb6..fe697db0b1 100644 --- a/src/app/fyle/add-edit-expense/add-edit-expense-4.spec.ts +++ b/src/app/fyle/add-edit-expense/add-edit-expense-4.spec.ts @@ -465,7 +465,7 @@ export function TestCases4(getTestBed) { }); expect(component.getCustomFields).toHaveBeenCalledOnceWith(); expect(component.trackAddExpense).toHaveBeenCalledOnceWith(); - expect(component.generateEtxnFromFg).toHaveBeenCalledWith(component.etxn$, jasmine.any(Observable), true); + expect(component.generateEtxnFromFg).toHaveBeenCalledWith(component.etxn$, jasmine.any(Observable)); expect(component.generateEtxnFromFg).toHaveBeenCalledTimes(2); expect(component.checkPolicyViolation).toHaveBeenCalledTimes(1); expect(policyService.getCriticalPolicyRules).toHaveBeenCalledTimes(1); @@ -495,7 +495,7 @@ export function TestCases4(getTestBed) { expect(res).toBeNull(); expect(component.getCustomFields).toHaveBeenCalledOnceWith(); expect(component.trackAddExpense).toHaveBeenCalledTimes(1); - expect(component.generateEtxnFromFg).toHaveBeenCalledWith(component.etxn$, jasmine.any(Observable), true); + expect(component.generateEtxnFromFg).toHaveBeenCalledWith(component.etxn$, jasmine.any(Observable)); expect(component.generateEtxnFromFg).toHaveBeenCalledTimes(2); expect(authService.getEou).toHaveBeenCalledTimes(1); expect(transactionOutboxService.addEntry).toHaveBeenCalledOnceWith( @@ -528,7 +528,7 @@ export function TestCases4(getTestBed) { expect(res).toBeNull(); expect(component.getCustomFields).toHaveBeenCalledOnceWith(); expect(component.trackAddExpense).toHaveBeenCalledOnceWith(); - expect(component.generateEtxnFromFg).toHaveBeenCalledOnceWith(component.etxn$, jasmine.any(Observable), true); + expect(component.generateEtxnFromFg).toHaveBeenCalledOnceWith(component.etxn$, jasmine.any(Observable)); expect(component.checkPolicyViolation).toHaveBeenCalledTimes(1); expect(policyService.getCriticalPolicyRules).toHaveBeenCalledTimes(1); expect(component.criticalPolicyViolationErrorHandler).toHaveBeenCalledOnceWith( @@ -607,11 +607,7 @@ export function TestCases4(getTestBed) { component.addExpense('SAVE_EXPENSE').subscribe({ next: () => { expect(component.getCustomFields).toHaveBeenCalledOnceWith(); - expect(component.generateEtxnFromFg).toHaveBeenCalledOnceWith( - component.etxn$, - jasmine.any(Observable), - true - ); + expect(component.generateEtxnFromFg).toHaveBeenCalledOnceWith(component.etxn$, jasmine.any(Observable)); expect(component.trackAddExpense).toHaveBeenCalledOnceWith(); }, error: (err) => expect(err).toBeTruthy(), @@ -624,7 +620,7 @@ export function TestCases4(getTestBed) { it('should save an expense and match with personal card', () => { const generateEtxnSpy = spyOn(component, 'generateEtxnFromFg'); generateEtxnSpy - .withArgs(component.etxn$, jasmine.any(Observable), true) + .withArgs(component.etxn$, jasmine.any(Observable)) .and.returnValue(of({ ...expectedUnflattendedTxnData3, tx: unflattenedTransactionDataPersonalCard.tx })); generateEtxnSpy .withArgs(component.etxn$, jasmine.any(Observable)) @@ -648,7 +644,7 @@ export function TestCases4(getTestBed) { component.saveAndMatchWithPersonalCardTxn(); expect(component.getCustomFields).toHaveBeenCalledOnceWith(); - expect(component.generateEtxnFromFg).toHaveBeenCalledWith(component.etxn$, jasmine.any(Observable), true); + expect(component.generateEtxnFromFg).toHaveBeenCalledWith(component.etxn$, jasmine.any(Observable)); expect(component.generateEtxnFromFg).toHaveBeenCalledWith(component.etxn$, jasmine.any(Observable)); expect(component.generateEtxnFromFg).toHaveBeenCalledTimes(2); expect(component.checkPolicyViolation).toHaveBeenCalledTimes(1); @@ -696,7 +692,7 @@ export function TestCases4(getTestBed) { component.saveAndMatchWithPersonalCardTxn(); expect(component.getCustomFields).toHaveBeenCalledOnceWith(); - expect(component.generateEtxnFromFg).toHaveBeenCalledOnceWith(component.etxn$, jasmine.any(Observable), true); + expect(component.generateEtxnFromFg).toHaveBeenCalledOnceWith(component.etxn$, jasmine.any(Observable)); expect(policyService.getCriticalPolicyRules).toHaveBeenCalledTimes(1); expect(component.criticalPolicyViolationErrorHandler).toHaveBeenCalledOnceWith( { @@ -750,7 +746,7 @@ export function TestCases4(getTestBed) { component.saveAndMatchWithPersonalCardTxn(); expect(component.getCustomFields).toHaveBeenCalledOnceWith(); - expect(component.generateEtxnFromFg).toHaveBeenCalledOnceWith(component.etxn$, jasmine.any(Observable), true); + expect(component.generateEtxnFromFg).toHaveBeenCalledOnceWith(component.etxn$, jasmine.any(Observable)); expect(policyService.getCriticalPolicyRules).toHaveBeenCalledTimes(1); expect(policyService.getPolicyRules).toHaveBeenCalledTimes(1); expect(component.policyViolationErrorHandler).toHaveBeenCalledOnceWith( @@ -784,7 +780,7 @@ export function TestCases4(getTestBed) { it('should match an expense while offline', () => { const generateEtxnSpy = spyOn(component, 'generateEtxnFromFg'); generateEtxnSpy - .withArgs(component.etxn$, jasmine.any(Observable), true) + .withArgs(component.etxn$, jasmine.any(Observable)) .and.returnValue(of({ ...expectedUnflattendedTxnData3, tx: unflattenedTransactionDataPersonalCard.tx })); generateEtxnSpy .withArgs(component.etxn$, jasmine.any(Observable)) @@ -808,7 +804,7 @@ export function TestCases4(getTestBed) { component.saveAndMatchWithPersonalCardTxn(); expect(component.getCustomFields).toHaveBeenCalledOnceWith(); - expect(component.generateEtxnFromFg).toHaveBeenCalledWith(component.etxn$, jasmine.any(Observable), true); + expect(component.generateEtxnFromFg).toHaveBeenCalledWith(component.etxn$, jasmine.any(Observable)); expect(component.generateEtxnFromFg).toHaveBeenCalledWith(component.etxn$, jasmine.any(Observable)); expect(component.generateEtxnFromFg).toHaveBeenCalledTimes(2); expect(transactionService.upsert).toHaveBeenCalledTimes(1); @@ -831,7 +827,7 @@ export function TestCases4(getTestBed) { it('should generate an expense in offline mode and match with a card', () => { const generateEtxnSpy = spyOn(component, 'generateEtxnFromFg'); generateEtxnSpy - .withArgs(component.etxn$, jasmine.any(Observable), true) + .withArgs(component.etxn$, jasmine.any(Observable)) .and.returnValue(of({ ...expectedUnflattendedTxnData3, tx: unflattenedTransactionDataPersonalCard.tx })); generateEtxnSpy .withArgs(component.etxn$, jasmine.any(Observable)) @@ -855,7 +851,7 @@ export function TestCases4(getTestBed) { component.saveAndMatchWithPersonalCardTxn(); expect(component.getCustomFields).toHaveBeenCalledOnceWith(); - expect(component.generateEtxnFromFg).toHaveBeenCalledWith(component.etxn$, jasmine.any(Observable), true); + expect(component.generateEtxnFromFg).toHaveBeenCalledWith(component.etxn$, jasmine.any(Observable)); expect(component.generateEtxnFromFg).toHaveBeenCalledWith(component.etxn$, jasmine.any(Observable)); expect(component.generateEtxnFromFg).toHaveBeenCalledTimes(2); expect(transactionService.upsert).toHaveBeenCalledOnceWith(unflattenedTransactionDataPersonalCard.tx); @@ -935,16 +931,13 @@ export function TestCases4(getTestBed) { authService.getEou.and.resolveTo(apiEouRes); component.etxn$ = of(transformedExpenseData); transactionService.upsert.and.returnValue(of(transformedExpenseData.tx)); - expensesService.getExpenseById.and.returnValue(of(platformExpenseData)); - transactionService.transformExpense.and.returnValue(transformedExpenseData); component.fg.controls.report.setValue(expectedReportsPaginated[0]); spenderReportsService.addExpenses.and.returnValue(of(undefined)); - expensesService.post.and.returnValue(of(null)); fixture.detectChanges(); component.editExpense('SAVE_AND_NEW_EXPENSE').subscribe(() => { expect(component.getCustomFields).toHaveBeenCalledTimes(1); - expect(component.generateEtxnFromFg).toHaveBeenCalledWith(component.etxn$, jasmine.any(Observable), true); + expect(component.generateEtxnFromFg).toHaveBeenCalledWith(component.etxn$, jasmine.any(Observable)); expect(component.generateEtxnFromFg).toHaveBeenCalledWith(component.etxn$, jasmine.any(Observable)); expect(component.generateEtxnFromFg).toHaveBeenCalledTimes(2); expect(component.checkPolicyViolation).toHaveBeenCalledTimes(1); @@ -954,10 +947,7 @@ export function TestCases4(getTestBed) { expect(authService.getEou).toHaveBeenCalledTimes(1); expect(component.trackEditExpense).toHaveBeenCalledOnceWith(transformedExpenseData); expect(transactionService.upsert).toHaveBeenCalledOnceWith(transformedExpenseData.tx); - expect(expensesService.getExpenseById).toHaveBeenCalledOnceWith('txvslh8aQMbu'); - expect(transactionService.transformExpense).toHaveBeenCalledOnceWith(platformExpenseData); expect(spenderReportsService.addExpenses).toHaveBeenCalledOnceWith('rprAfNrce73O', ['txvslh8aQMbu']); - expect(expensesService.post).not.toHaveBeenCalled(); done(); }); }); @@ -976,15 +966,12 @@ export function TestCases4(getTestBed) { spenderReportsService.addExpenses.and.returnValue(of(undefined)); authService.getEou.and.resolveTo(apiEouRes); transactionService.upsert.and.returnValue(of(transformedExpenseDataWithReportId.tx)); - expensesService.getExpenseById.and.returnValue(of(platformExpenseDataWithReportId)); - transactionService.transformExpense.and.returnValue(transformedExpenseDataWithReportId); - expensesService.post.and.returnValue(of(null)); fixture.detectChanges(); component.editExpense('SAVE_AND_NEW_EXPENSE').subscribe((res) => { expect(res).toEqual(editUnflattenedTransactionPlatform2); expect(component.getCustomFields).toHaveBeenCalledTimes(1); - expect(component.generateEtxnFromFg).toHaveBeenCalledWith(component.etxn$, jasmine.any(Observable), true); + expect(component.generateEtxnFromFg).toHaveBeenCalledWith(component.etxn$, jasmine.any(Observable)); expect(component.generateEtxnFromFg).toHaveBeenCalledWith(component.etxn$, jasmine.any(Observable)); expect(component.generateEtxnFromFg).toHaveBeenCalledTimes(2); expect(component.checkPolicyViolation).toHaveBeenCalledTimes(1); @@ -994,11 +981,8 @@ export function TestCases4(getTestBed) { expect(authService.getEou).toHaveBeenCalledTimes(1); expect(component.trackEditExpense).toHaveBeenCalledOnceWith(transformedExpenseDataWithReportId); expect(transactionService.upsert).toHaveBeenCalledOnceWith(transformedExpenseDataWithReportId.tx); - expect(expensesService.getExpenseById).toHaveBeenCalledOnceWith('txD5hIQgLuR5'); - expect(transactionService.transformExpense).toHaveBeenCalledOnceWith(platformExpenseDataWithReportId); expect(spenderReportsService.addExpenses).toHaveBeenCalledOnceWith('rprAfNrce73O', ['txD5hIQgLuR5']); expect(spenderReportsService.ejectExpenses).toHaveBeenCalledOnceWith('rpbNc3kn5baq', 'txD5hIQgLuR5'); - expect(expensesService.post).not.toHaveBeenCalled(); done(); }); }); @@ -1017,15 +1001,12 @@ export function TestCases4(getTestBed) { spenderReportsService.addExpenses.and.returnValue(of(undefined)); authService.getEou.and.resolveTo(apiEouRes); transactionService.upsert.and.returnValue(of(transformedExpenseDataWithoutAdvanceWallet.tx)); - expensesService.getExpenseById.and.returnValue(of(platformExpenseDataForAdvanceWallet)); - transactionService.transformExpense.and.returnValue(transformedExpenseDataWithoutAdvanceWallet); - expensesService.post.and.returnValue(of(null)); fixture.detectChanges(); component.editExpense('SAVE_EXPENSE').subscribe((res) => { expect(res).toEqual(editUnflattenedTransactionPlatformWithAdvanceWallet); expect(component.getCustomFields).toHaveBeenCalledTimes(1); - expect(component.generateEtxnFromFg).toHaveBeenCalledWith(component.etxn$, jasmine.any(Observable), true); + expect(component.generateEtxnFromFg).toHaveBeenCalledWith(component.etxn$, jasmine.any(Observable)); expect(component.generateEtxnFromFg).toHaveBeenCalledWith(component.etxn$, jasmine.any(Observable)); expect(component.generateEtxnFromFg).toHaveBeenCalledTimes(2); expect(component.checkPolicyViolation).toHaveBeenCalledTimes(1); @@ -1035,19 +1016,11 @@ export function TestCases4(getTestBed) { expect(authService.getEou).toHaveBeenCalledTimes(1); expect(component.trackEditExpense).toHaveBeenCalledOnceWith(transformedExpenseDataWithAdvanceWallet); expect(transactionService.upsert).toHaveBeenCalledOnceWith(transformedExpenseDataWithAdvanceWallet.tx); - expect(expensesService.getExpenseById).toHaveBeenCalledOnceWith( - transformedExpenseDataWithAdvanceWallet.tx.id - ); - expect(transactionService.transformExpense).toHaveBeenCalledOnceWith(platformExpenseDataForAdvanceWallet); - expect(expensesService.post).toHaveBeenCalledOnceWith({ - id: transformedExpenseDataWithAdvanceWallet.tx.id, - advance_wallet_id: 'areq1234', - }); done(); }); }); - it('should remove expense from report while editing and and ask for review', (done) => { + it('should remove expense from report while editing and ask for review', (done) => { spyOn(component, 'getCustomFields').and.returnValue(of(txnCustomProperties)); spyOn(component, 'generateEtxnFromFg').and.returnValue(of(transformedExpenseDataWithReportId2)); spyOn(component, 'checkPolicyViolation').and.returnValue(of(expensePolicyData)); @@ -1060,15 +1033,12 @@ export function TestCases4(getTestBed) { spenderReportsService.ejectExpenses.and.returnValue(of(undefined)); authService.getEou.and.resolveTo(apiEouRes); transactionService.upsert.and.returnValue(of(transformedExpenseDataWithReportId2.tx)); - expensesService.getExpenseById.and.returnValue(of(platformExpenseDataWithReportId2)); - transactionService.transformExpense.and.returnValue(transformedExpenseDataWithReportId2); - expensesService.post.and.returnValue(of(null)); fixture.detectChanges(); component.editExpense('SAVE_AND_NEW_EXPENSE').subscribe((res) => { expect(res).toEqual(editUnflattenedTransactionPlatform3); expect(component.getCustomFields).toHaveBeenCalledTimes(1); - expect(component.generateEtxnFromFg).toHaveBeenCalledWith(component.etxn$, jasmine.any(Observable), true); + expect(component.generateEtxnFromFg).toHaveBeenCalledWith(component.etxn$, jasmine.any(Observable)); expect(component.generateEtxnFromFg).toHaveBeenCalledWith(component.etxn$, jasmine.any(Observable)); expect(component.generateEtxnFromFg).toHaveBeenCalledTimes(2); expect(component.checkPolicyViolation).toHaveBeenCalledTimes(1); @@ -1080,9 +1050,6 @@ export function TestCases4(getTestBed) { expect(spenderReportsService.ejectExpenses).toHaveBeenCalledOnceWith('rplD17WeBlha', 'txD5hIQgLuR5'); expect(trackingService.removeFromExistingReportEditExpense).toHaveBeenCalledTimes(1); expect(transactionService.upsert).toHaveBeenCalledOnceWith(transformedExpenseDataWithReportId2.tx); - expect(expensesService.getExpenseById).toHaveBeenCalledOnceWith('txD5hIQgLuR5'); - expect(transactionService.transformExpense).toHaveBeenCalledOnceWith(platformExpenseDataWithReportId2); - expect(expensesService.post).not.toHaveBeenCalled(); done(); }); }); @@ -1104,15 +1071,12 @@ export function TestCases4(getTestBed) { authService.getEou.and.resolveTo(apiEouRes); transactionService.upsert.and.returnValue(of(transformedExpenseDataWithSubCategory.tx)); - expensesService.getExpenseById.and.returnValue(of(platformExpenseDataWithSubCategory)); - transactionService.transformExpense.and.returnValue(transformedExpenseDataWithSubCategory); - expensesService.post.and.returnValue(of(null)); fixture.detectChanges(); component.editExpense('SAVE_AND_NEW_EXPENSE').subscribe((res) => { expect(res).toEqual(editUnflattenedTransactionPlatform); expect(component.getCustomFields).toHaveBeenCalledTimes(1); - expect(component.generateEtxnFromFg).toHaveBeenCalledWith(component.etxn$, jasmine.any(Observable), true); + expect(component.generateEtxnFromFg).toHaveBeenCalledWith(component.etxn$, jasmine.any(Observable)); expect(component.generateEtxnFromFg).toHaveBeenCalledTimes(1); expect(component.checkPolicyViolation).toHaveBeenCalledTimes(1); expect(policyService.getCriticalPolicyRules).toHaveBeenCalledTimes(1); @@ -1130,10 +1094,7 @@ export function TestCases4(getTestBed) { expect(authService.getEou).toHaveBeenCalledTimes(1); expect(component.trackEditExpense).toHaveBeenCalledOnceWith(transformedExpenseDataWithSubCategory); expect(transactionService.upsert).toHaveBeenCalledOnceWith(transformedExpenseDataWithSubCategory.tx); - expect(expensesService.getExpenseById).toHaveBeenCalledOnceWith('txD5hIQgLuR5'); - expect(transactionService.transformExpense).toHaveBeenCalledOnceWith(platformExpenseDataWithSubCategory); expect(component.getIsPolicyExpense).toHaveBeenCalledTimes(1); - expect(expensesService.post).not.toHaveBeenCalled(); done(); }); }); @@ -1158,15 +1119,12 @@ export function TestCases4(getTestBed) { transactionService.upsert.and.returnValue(of(transformedExpenseDataWithReportId.tx)); statusService.findLatestComment.and.returnValue(of('a comment')); statusService.post.and.returnValue(of(expenseStatusData)); - expensesService.getExpenseById.and.returnValue(of(platformExpenseDataWithReportId)); - transactionService.transformExpense.and.returnValue(transformedExpenseDataWithReportId); - expensesService.post.and.returnValue(of(null)); fixture.detectChanges(); component.editExpense('SAVE_AND_NEW_EXPENSE').subscribe((res) => { expect(res).toEqual(editUnflattenedTransactionPlatform2); expect(component.getCustomFields).toHaveBeenCalledTimes(1); - expect(component.generateEtxnFromFg).toHaveBeenCalledWith(component.etxn$, jasmine.any(Observable), true); + expect(component.generateEtxnFromFg).toHaveBeenCalledWith(component.etxn$, jasmine.any(Observable)); expect(component.generateEtxnFromFg).toHaveBeenCalledTimes(1); expect(component.checkPolicyViolation).toHaveBeenCalledTimes(1); expect(policyService.getCriticalPolicyRules).toHaveBeenCalledTimes(1); @@ -1186,8 +1144,6 @@ export function TestCases4(getTestBed) { expect(authService.getEou).toHaveBeenCalledTimes(1); expect(component.trackEditExpense).toHaveBeenCalledOnceWith(transformedExpenseDataWithReportId); expect(transactionService.upsert).toHaveBeenCalledOnceWith(transformedExpenseDataWithReportId.tx); - expect(expensesService.getExpenseById).toHaveBeenCalledOnceWith('txD5hIQgLuR5'); - expect(transactionService.transformExpense).toHaveBeenCalledOnceWith(platformExpenseDataWithReportId); expect(statusService.findLatestComment).toHaveBeenCalledOnceWith( transformedExpenseDataWithReportId.tx.id, @@ -1201,7 +1157,6 @@ export function TestCases4(getTestBed) { true ); expect(component.getIsPolicyExpense).toHaveBeenCalledTimes(1); - expect(expensesService.post).not.toHaveBeenCalled(); done(); }); }); @@ -1223,16 +1178,13 @@ export function TestCases4(getTestBed) { component.etxn$ = of(transformedExpenseDataWithReportId2); authService.getEou.and.resolveTo(apiEouRes); transactionService.upsert.and.returnValue(of(transformedExpenseDataWithReportId2.tx)); - expensesService.getExpenseById.and.returnValue(of(platformExpenseDataWithReportId2)); - transactionService.transformExpense.and.returnValue(transformedExpenseDataWithReportId2); statusService.findLatestComment.and.returnValue(of('comment')); - expensesService.post.and.returnValue(of(null)); fixture.detectChanges(); component.editExpense('SAVE_AND_NEW_EXPENSE').subscribe((res) => { expect(res).toEqual(editUnflattenedTransactionPlatform3); expect(component.getCustomFields).toHaveBeenCalledTimes(1); - expect(component.generateEtxnFromFg).toHaveBeenCalledWith(component.etxn$, jasmine.any(Observable), true); + expect(component.generateEtxnFromFg).toHaveBeenCalledWith(component.etxn$, jasmine.any(Observable)); expect(component.generateEtxnFromFg).toHaveBeenCalledTimes(1); expect(component.checkPolicyViolation).toHaveBeenCalledTimes(1); expect(policyService.getCriticalPolicyRules).toHaveBeenCalledTimes(1); @@ -1252,15 +1204,11 @@ export function TestCases4(getTestBed) { expect(authService.getEou).toHaveBeenCalledTimes(1); expect(component.trackEditExpense).toHaveBeenCalledOnceWith(transformedExpenseDataWithReportId2); expect(transactionService.upsert).toHaveBeenCalledOnceWith(transformedExpenseDataWithReportId2.tx); - expect(expensesService.getExpenseById).toHaveBeenCalledOnceWith('txD5hIQgLuR5'); - expect(transactionService.transformExpense).toHaveBeenCalledOnceWith(platformExpenseDataWithReportId2); expect(statusService.findLatestComment).toHaveBeenCalledOnceWith( transformedExpenseDataWithReportId2.tx.id, 'transactions', transformedExpenseDataWithReportId2.tx.org_user_id ); - expect(statusService.post).not.toHaveBeenCalled(); - expect(expensesService.post).not.toHaveBeenCalled(); done(); }); }); @@ -1288,18 +1236,15 @@ export function TestCases4(getTestBed) { component.etxn$ = of(transformedExpenseWithMatchCCCData2); authService.getEou.and.resolveTo(apiEouRes); transactionService.upsert.and.returnValue(of(transformedExpenseWithMatchCCCData2.tx)); - expensesService.getExpenseById.and.returnValue(of(platformExpenseWithMatchCCC2)); - transactionService.transformExpense.and.returnValue(transformedExpenseWithMatchCCCData2); component.selectedCCCTransaction = matchedCCTransactionData; component.matchedCCCTransaction = matchedCCTransactionData; transactionService.matchCCCExpense.and.returnValue(of(null)); - expensesService.post.and.returnValue(of(null)); fixture.detectChanges(); component.editExpense('SAVE_AND_NEW_EXPENSE').subscribe((res) => { expect(res).toBe(transformedExpenseWithMatchCCCData2.tx); expect(component.getCustomFields).toHaveBeenCalledTimes(1); - expect(component.generateEtxnFromFg).toHaveBeenCalledWith(component.etxn$, jasmine.any(Observable), true); + expect(component.generateEtxnFromFg).toHaveBeenCalledWith(component.etxn$, jasmine.any(Observable)); expect(component.generateEtxnFromFg).toHaveBeenCalledTimes(2); expect(component.checkPolicyViolation).toHaveBeenCalledTimes(1); expect(policyService.getCriticalPolicyRules).toHaveBeenCalledTimes(1); @@ -1307,10 +1252,7 @@ export function TestCases4(getTestBed) { expect(component.trackPolicyCorrections).toHaveBeenCalledTimes(1); expect(authService.getEou).toHaveBeenCalledTimes(1); expect(transactionService.upsert).toHaveBeenCalledOnceWith(transformedExpenseWithMatchCCCData2.tx); - expect(expensesService.getExpenseById).toHaveBeenCalledOnceWith('txmF3wgfj0Bs'); - expect(transactionService.transformExpense).toHaveBeenCalledOnceWith(platformExpenseWithMatchCCC2); expect(transactionService.matchCCCExpense).toHaveBeenCalledOnceWith('btxnSte7sVQCM8', 'txmF3wgfj0Bs'); - expect(expensesService.post).not.toHaveBeenCalled(); done(); }); }); @@ -1326,17 +1268,14 @@ export function TestCases4(getTestBed) { component.etxn$ = of(transformedExpenseWithMatchCCCData); authService.getEou.and.resolveTo(apiEouRes); transactionService.upsert.and.returnValue(of(transformedExpenseWithMatchCCCData.tx)); - expensesService.getExpenseById.and.returnValue(of(platformExpenseWithMatchCCC)); - transactionService.transformExpense.and.returnValue(transformedExpenseWithMatchCCCData); component.selectedCCCTransaction = null; component.matchedCCCTransaction = matchedCCTransactionData; transactionService.unmatchCCCExpense.and.returnValue(of(null)); - expensesService.post.and.returnValue(of(null)); fixture.detectChanges(); component.editExpense('SAVE_AND_NEW_EXPENSE').subscribe((res) => { expect(res).toBe(transformedExpenseWithMatchCCCData.tx); - expect(component.generateEtxnFromFg).toHaveBeenCalledWith(component.etxn$, jasmine.any(Observable), true); + expect(component.generateEtxnFromFg).toHaveBeenCalledWith(component.etxn$, jasmine.any(Observable)); expect(component.generateEtxnFromFg).toHaveBeenCalledTimes(2); expect(component.getCustomFields).toHaveBeenCalledTimes(1); expect(component.checkPolicyViolation).toHaveBeenCalledTimes(1); @@ -1344,10 +1283,7 @@ export function TestCases4(getTestBed) { expect(policyService.getPolicyRules).toHaveBeenCalledTimes(1); expect(authService.getEou).toHaveBeenCalledTimes(1); expect(transactionService.upsert).toHaveBeenCalledOnceWith(transformedExpenseWithMatchCCCData.tx); - expect(expensesService.getExpenseById).toHaveBeenCalledOnceWith('txmF3wgfj0Bs'); - expect(transactionService.transformExpense).toHaveBeenCalledOnceWith(platformExpenseWithMatchCCC); expect(transactionService.unmatchCCCExpense).toHaveBeenCalledOnceWith('btxnSte7sVQCM8', 'txmF3wgfj0Bs'); - expect(expensesService.post).not.toHaveBeenCalled(); done(); }); }); @@ -1363,18 +1299,15 @@ export function TestCases4(getTestBed) { component.etxn$ = of(transformedExpenseWithMatchCCCData); authService.getEou.and.resolveTo(apiEouRes); transactionService.upsert.and.returnValue(of(transformedExpenseWithMatchCCCData.tx)); - expensesService.getExpenseById.and.returnValue(of(platformExpenseWithMatchCCC)); - transactionService.transformExpense.and.returnValue(transformedExpenseWithMatchCCCData); component.selectedCCCTransaction = matchedCCTransactionData2; component.matchedCCCTransaction = matchedCCTransactionData2; transactionService.unmatchCCCExpense.and.returnValue(of(null)); transactionService.matchCCCExpense.and.returnValue(of(null)); - expensesService.post.and.returnValue(of(null)); fixture.detectChanges(); component.editExpense('SAVE_AND_NEW_EXPENSE').subscribe((res) => { expect(res).toBe(transformedExpenseWithMatchCCCData.tx); - expect(component.generateEtxnFromFg).toHaveBeenCalledWith(component.etxn$, jasmine.any(Observable), true); + expect(component.generateEtxnFromFg).toHaveBeenCalledWith(component.etxn$, jasmine.any(Observable)); expect(component.generateEtxnFromFg).toHaveBeenCalledTimes(2); expect(component.getCustomFields).toHaveBeenCalledTimes(1); expect(component.checkPolicyViolation).toHaveBeenCalledTimes(1); @@ -1382,11 +1315,8 @@ export function TestCases4(getTestBed) { expect(policyService.getPolicyRules).toHaveBeenCalledTimes(1); expect(authService.getEou).toHaveBeenCalledTimes(1); expect(transactionService.upsert).toHaveBeenCalledOnceWith(transformedExpenseWithMatchCCCData.tx); - expect(expensesService.getExpenseById).toHaveBeenCalledOnceWith('txmF3wgfj0Bs'); - expect(transactionService.transformExpense).toHaveBeenCalledOnceWith(platformExpenseWithMatchCCC); expect(transactionService.unmatchCCCExpense).toHaveBeenCalledOnceWith('btxnBdS2Kpvzhy', 'txmF3wgfj0Bs'); expect(transactionService.matchCCCExpense).toHaveBeenCalledOnceWith('btxnBdS2Kpvzhy', 'txmF3wgfj0Bs'); - expect(expensesService.post).not.toHaveBeenCalled(); done(); }); }); diff --git a/src/app/fyle/add-edit-expense/add-edit-expense-5.spec.ts b/src/app/fyle/add-edit-expense/add-edit-expense-5.spec.ts index 9c48f6a1f8..1f7f0b9fd9 100644 --- a/src/app/fyle/add-edit-expense/add-edit-expense-5.spec.ts +++ b/src/app/fyle/add-edit-expense/add-edit-expense-5.spec.ts @@ -22,6 +22,7 @@ import { categorieListRes, recentUsedCategoriesRes } from 'src/app/core/mock-dat import { filteredCategoriesData, orgCategoryData, + orgCategoryData1, sortedCategory, transformedOrgCategories, } from 'src/app/core/mock-data/org-category.data'; @@ -1464,6 +1465,10 @@ export function TestCases5(getTestBed) { }); describe('ionViewWillEnter():', () => { + beforeEach(() => { + categoriesService.getAll.and.returnValue(of(orgCategoryData1)); + }); + it('should setup class variables', (done) => { component.isConnected$ = of(true); component.txnFields$ = of(txnFieldsData2); @@ -1493,7 +1498,6 @@ export function TestCases5(getTestBed) { storageService.get.and.resolveTo(true); spyOn(component, 'setupBalanceFlag'); statusService.find.and.returnValue(of(getEstatusApiResponse)); - spyOn(component, 'getActiveCategories').and.returnValue(of(sortedCategory)); spyOn(component, 'getNewExpenseObservable').and.returnValue(of(expectedExpenseObservable)); spyOn(component, 'getEditExpenseObservable').and.returnValue(of(expectedUnflattendedTxnData1)); fileService.getReceiptsDetails.and.returnValue({ @@ -1705,7 +1709,6 @@ export function TestCases5(getTestBed) { storageService.get.and.resolveTo(true); spyOn(component, 'setupBalanceFlag'); statusService.find.and.returnValue(of(getEstatusApiResponse)); - spyOn(component, 'getActiveCategories').and.returnValue(of(sortedCategory)); spyOn(component, 'getNewExpenseObservable').and.returnValue(of(expectedExpenseObservable)); spyOn(component, 'getEditExpenseObservable').and.returnValue(of(expectedUnflattendedTxnData1)); fileService.findByTransactionId.and.returnValue(of(expectedFileData1)); @@ -1782,7 +1785,6 @@ export function TestCases5(getTestBed) { storageService.get.and.resolveTo(true); spyOn(component, 'setupBalanceFlag'); statusService.find.and.returnValue(of(getEstatusApiResponse)); - spyOn(component, 'getActiveCategories').and.returnValue(of(sortedCategory)); spyOn(component, 'getNewExpenseObservable').and.returnValue(of(expectedExpenseObservable)); spyOn(component, 'getEditExpenseObservable').and.returnValue(of(expectedUnflattendedTxnData1)); expensesService.getSplitExpenses.and.returnValue(of(splitExpensesData)); diff --git a/src/app/fyle/add-edit-expense/add-edit-expense.page.ts b/src/app/fyle/add-edit-expense/add-edit-expense.page.ts index e05d519206..bc11baa381 100644 --- a/src/app/fyle/add-edit-expense/add-edit-expense.page.ts +++ b/src/app/fyle/add-edit-expense/add-edit-expense.page.ts @@ -437,6 +437,8 @@ export class AddEditExpensePage implements OnInit { pendingTransactionAllowedToReportAndSplit = true; + allCategories$: Observable; + activeCategories$: Observable; selectedCategory$: Observable; @@ -1238,12 +1240,6 @@ export class AddEditExpensePage implements OnInit { ); } - getActiveCategories(): Observable { - const allCategories$ = this.categoriesService.getAll(); - - return allCategories$.pipe(map((catogories) => this.categoriesService.filterRequired(catogories))); - } - getInstaFyleImageData(): Observable> { if (this.activatedRoute.snapshot.params.dataUrl && this.activatedRoute.snapshot.params.canExtractData !== 'false') { const dataUrl = this.activatedRoute.snapshot.params.dataUrl as string; @@ -1820,7 +1816,7 @@ export class AddEditExpensePage implements OnInit { const customInputValues: { name: string; - value: string | number | string[] | number[] | Date | boolean | { display: string }; + value: string | number | string[] | number[] | Date | boolean | { display?: string }; }[] = customInputs .filter((customInput) => customInput.type !== 'DEPENDENT_SELECT') .map((customInput) => { @@ -2252,7 +2248,6 @@ export class AddEditExpensePage implements OnInit { const categoryControl = this.getFormControl('category'); const customInputsFeilds$: Observable = categoryControl.valueChanges.pipe( - filter((category) => !!category), startWith({}), distinctUntilChanged(), switchMap((category) => @@ -2262,6 +2257,21 @@ export class AddEditExpensePage implements OnInit { this.getCategoryOnEdit(category as OrgCategory) ) ), + switchMap((category: OrgCategory) => { + if (!category) { + // set to unspecified category if no category is selected + return this.allCategories$.pipe( + map((categories) => { + const unspecifiedCategory = categories.find( + (category) => category.fyle_category?.toLowerCase() === 'unspecified' + ); + return unspecifiedCategory; + }) + ); + } else { + return of(category); + } + }), switchMap((category: OrgCategory) => { const formValue = this.fg.value as { custom_inputs: CustomInput[]; @@ -2979,7 +2989,10 @@ export class AddEditExpensePage implements OnInit { } ionViewWillEnter(): void { - this.activeCategories$ = this.getActiveCategories().pipe(shareReplay(1)); + this.allCategories$ = this.categoriesService.getAll().pipe(shareReplay(1)); + this.activeCategories$ = this.allCategories$ + .pipe(map((catogories) => this.categoriesService.filterRequired(catogories))) + .pipe(shareReplay(1)); this.initClassObservables(); @@ -3490,8 +3503,7 @@ export class AddEditExpensePage implements OnInit { generateEtxnFromFg( etxn$: Observable>, - standardisedCustomProperties$: Observable, - isPolicyEtxn = false + standardisedCustomProperties$: Observable ): Observable> { const attachements$ = this.getExpenseAttachments(this.mode); return forkJoin({ @@ -3499,6 +3511,7 @@ export class AddEditExpensePage implements OnInit { customProperties: standardisedCustomProperties$, attachments: attachements$, orgSettings: this.orgSettingsService.get(), + allCategories: this.allCategories$, }).pipe( map((res) => { const etxn: Partial = res.etxn; @@ -3506,11 +3519,19 @@ export class AddEditExpensePage implements OnInit { let customProperties = res.customProperties; customProperties = customProperties.map((customProperty) => { if (customProperty.type === 'DATE') { - customProperty.value = - customProperty.value && this.dateService.getUTCDate(new Date(customProperty.value as string)); + customProperty.value = customProperty.value + ? this.dateService.getUTCDate(new Date(customProperty.value as string)) + : null; + } else if (customProperty.type === 'LOCATION' && !customProperty.value) { + customProperty.value = {}; + } else if (customProperty.type === 'BOOLEAN' && !customProperty.value) { + customProperty.value = false; } return customProperty; }); + const unspecifiedCategory = res.allCategories.find( + (category) => category.fyle_category?.toLowerCase() === 'unspecified' + ); const formValues = this.getFormValues(); @@ -3529,25 +3550,19 @@ export class AddEditExpensePage implements OnInit { costCenter.cost_center_code = formValues.costCenter.code; } - const policyProps: { org_category?: string; sub_category?: string } = {}; - - if (isPolicyEtxn) { - policyProps.org_category = formValues.category && formValues.category.name; - policyProps.sub_category = formValues.category && formValues.category.sub_category; - } - if (this.inpageExtractedData) { etxn.tx.extracted_data = this.inpageExtractedData; this.autoCodedData = this.inpageExtractedData; } - // If user has not edited the amount, then send user_amount to check_policies + // If user has not edited the amount, then send user_amount let amount = this.getAmount(); - if (isPolicyEtxn && amount === etxn.tx.amount && etxn.tx.user_amount) { + if (amount === etxn.tx.amount && etxn.tx.user_amount) { amount = etxn.tx.user_amount; } - //TODO: Add depenedent fields to custom_properties array once APIs are available + const category_id = this.getOrgCategoryID() || unspecifiedCategory.id; + //TODO: Add dependent fields to custom_properties array once APIs are available return { tx: { ...etxn.tx, @@ -3564,15 +3579,14 @@ export class AddEditExpensePage implements OnInit { project_id: this.getProjectID(), tax_amount: this.getTaxAmount(), tax_group_id: this.getTaxGroupID(), - org_category_id: this.getOrgCategoryID(), + org_category_id: category_id, fyle_category: this.getFyleCategory(), policy_amount: null, vendor: this.getDisplayName(), purpose: this.getPurpose(), locations: locations || [], custom_properties: customProperties || [], - num_files: isPolicyEtxn ? res.attachments?.length : this.activatedRoute.snapshot.params?.dataUrl ? 1 : 0, - ...policyProps, + num_files: res.attachments?.length, org_user_id: etxn.tx.org_user_id, from_dt: this.getFromDt(), to_dt: this.getToDt(), @@ -3634,7 +3648,7 @@ export class AddEditExpensePage implements OnInit { switchMap((isConnected) => { if (isConnected) { const customFields$ = this.getCustomFields(); - return this.generateEtxnFromFg(this.etxn$, customFields$, true).pipe( + return this.generateEtxnFromFg(this.etxn$, customFields$).pipe( switchMap((etxn) => // TODO: We should not use as unknown, this needs to be removed everywhere this.policyService.getPlatformPolicyExpense( @@ -3661,17 +3675,9 @@ export class AddEditExpensePage implements OnInit { } checkPolicyViolation(etxn: { tx: PublicPolicyExpense; dataUrls: Partial[] }): Observable { - return this.policyService.getPlatformPolicyExpense(etxn, this.selectedCCCTransaction).pipe( - switchMap((platformPolicyExpense) => - /* Expense creation has not moved to platform yet and since policy is moved to platform, - * it expects the expense object in terms of platform world. Until then, the method - * `transformTo` act as a bridge by translating the public expense object to platform - * expense. - */ - - this.transactionService.checkPolicy(platformPolicyExpense) - ) - ); + return this.policyService + .getPlatformPolicyExpense(etxn, this.selectedCCCTransaction) + .pipe(switchMap((platformPolicyExpense) => this.transactionService.checkPolicy(platformPolicyExpense))); } getProjectDependentFields(): TxnCustomProperties[] { @@ -3976,7 +3982,7 @@ export class AddEditExpensePage implements OnInit { this.trackPolicyCorrections(); const customFields$ = this.getCustomFields(); - return this.generateEtxnFromFg(this.etxn$, customFields$, true).pipe( + return this.generateEtxnFromFg(this.etxn$, customFields$).pipe( switchMap((etxn) => { const policyViolations$ = this.checkPolicyViolation( etxn as unknown as { tx: PublicPolicyExpense; dataUrls: Partial[] } @@ -4055,10 +4061,7 @@ export class AddEditExpensePage implements OnInit { report: Report; }; - // NOTE: This double call is done as certain fields will not be present in return of upsert call. policy_amount in this case. return this.transactionService.upsert(etxn.tx as Transaction).pipe( - switchMap((txn) => this.expensesService.getExpenseById(txn.id)), - map((expense) => this.transactionService.transformExpense(expense).tx), switchMap((tx) => { const selectedReportId = reportControl.report?.id; const criticalPolicyViolated = this.getIsPolicyExpense(etxn as unknown as Expense); @@ -4101,17 +4104,6 @@ export class AddEditExpensePage implements OnInit { } else { return of(txn); } - }), - switchMap((txn) => { - if (txn.id && txn.advance_wallet_id !== etxn.tx.advance_wallet_id) { - const expense = { - id: txn.id, - advance_wallet_id: etxn.tx.advance_wallet_id, - }; - return this.expensesService.post(expense).pipe(map(() => txn)); - } else { - return of(txn); - } }) ); }) @@ -4284,7 +4276,7 @@ export class AddEditExpensePage implements OnInit { this.trackAddExpense(); - return this.generateEtxnFromFg(this.etxn$, customFields$, true).pipe( + return this.generateEtxnFromFg(this.etxn$, customFields$).pipe( switchMap((etxn) => this.isConnected$.pipe( take(1), @@ -5001,7 +4993,7 @@ export class AddEditExpensePage implements OnInit { saveAndMatchWithPersonalCardTxn(): Subscription { this.saveExpenseLoader = true; const customFields$ = this.getCustomFields(); - return this.generateEtxnFromFg(this.etxn$, customFields$, true) + return this.generateEtxnFromFg(this.etxn$, customFields$) .pipe( switchMap((etxn) => this.isConnected$.pipe( diff --git a/src/app/fyle/add-edit-mileage/add-edit-mileage.page.ts b/src/app/fyle/add-edit-mileage/add-edit-mileage.page.ts index e4fae0cda2..79cb11fb1c 100644 --- a/src/app/fyle/add-edit-mileage/add-edit-mileage.page.ts +++ b/src/app/fyle/add-edit-mileage/add-edit-mileage.page.ts @@ -2331,7 +2331,7 @@ export class AddEditMileagePage implements OnInit { switchMap((rates) => { const transactionCopy = cloneDeep(etxn.tx) as PublicPolicyExpense; const selectedMileageRate = this.getMileageByVehicleType(rates, etxn.tx.mileage_vehicle_type); - transactionCopy.mileage_rate_id = selectedMileageRate.id; + transactionCopy.mileage_rate_id = selectedMileageRate?.id; /* Expense creation has not moved to platform yet and since policy is moved to platform, * it expects the expense object in terms of platform world. Until then, the method @@ -2432,7 +2432,7 @@ export class AddEditMileagePage implements OnInit { return { tx: { ...etxn.tx, - mileage_vehicle_type: formValue.mileage_rate_name?.vehicle_type, + mileage_rate_id: formValue.mileage_rate_name?.id, mileage_is_round_trip: formValue.route.roundTrip, mileage_rate: rate || etxn.tx.mileage_rate, source_account_id: formValue?.paymentMode?.acc?.id, diff --git a/src/app/fyle/dashboard/tasks/tasks.component.ts b/src/app/fyle/dashboard/tasks/tasks.component.ts index c6988135ac..4fd90e7558 100644 --- a/src/app/fyle/dashboard/tasks/tasks.component.ts +++ b/src/app/fyle/dashboard/tasks/tasks.component.ts @@ -604,7 +604,7 @@ export class TasksComponent implements OnInit { } addTransactionsToReport(report: Report, selectedExpensesId: string[]): Observable { - return from(this.loaderService.showLoader('Adding transaction to report')).pipe( + return from(this.loaderService.showLoader('Adding expense to report')).pipe( switchMap(() => this.spenderReportsService.addExpenses(report.id, selectedExpensesId).pipe(map(() => report))), finalize(() => this.loaderService.hideLoader()) ); 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 3f6c405b63..b1aca7d47a 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'; @@ -86,7 +87,6 @@ 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 { 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'; @@ -129,13 +129,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; @@ -171,7 +172,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', @@ -308,7 +308,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 }, @@ -435,7 +434,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; @@ -479,7 +477,7 @@ describe('MyExpensesPage', () => { describe('ionViewWillEnter(): ', () => { let backButtonSubscription: Subscription; - + const dEincompleteExpenseIds = ['txfCdl3TEZ7K', 'txfCdl3TEZ7l', 'txfCdl3TEZ7m']; beforeEach(() => { component.isConnected$ = of(true); backButtonSubscription = new Subscription(); @@ -494,6 +492,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)); @@ -962,12 +962,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(() => { @@ -2582,7 +2774,7 @@ describe('MyExpensesPage', () => { .addTransactionsToReport(expectedReportsSinglePage[0], ['tx5fBcPBAxLv']) .pipe( tap((updatedReport) => { - expect(loaderService.showLoader).toHaveBeenCalledOnceWith('Adding transaction to report'); + expect(loaderService.showLoader).toHaveBeenCalledOnceWith('Adding expense to report'); expect(spenderReportsService.addExpenses).toHaveBeenCalledOnceWith('rprAfNrce73O', ['tx5fBcPBAxLv']); expect(updatedReport).toEqual(expectedReportsSinglePage[0]); }), @@ -2921,7 +3113,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 25e0c43c2d..0426a56ab7 100644 --- a/src/app/fyle/my-expenses/my-expenses.page.ts +++ b/src/app/fyle/my-expenses/my-expenses.page.ts @@ -4,7 +4,7 @@ import { MatBottomSheet } from '@angular/material/bottom-sheet'; import { MatSnackBar } from '@angular/material/snack-bar'; import { ActivatedRoute, NavigationStart, Params, Router } from '@angular/router'; import { ActionSheetController, ModalController, NavController, PopoverController } from '@ionic/angular'; -import { cloneDeep, isEqual } from 'lodash'; +import { cloneDeep, isEqual, isNumber } from 'lodash'; import { BehaviorSubject, Observable, @@ -17,17 +17,22 @@ import { iif, noop, of, + timer, } from 'rxjs'; import { debounceTime, distinctUntilChanged, + exhaustMap, filter, finalize, map, shareReplay, + startWith, switchMap, take, takeUntil, + takeWhile, + timeout, } from 'rxjs/operators'; import { BackButtonActionPriority } from 'src/app/core/models/back-button-action-priority.enum'; import { Expense } from 'src/app/core/models/expense.model'; @@ -38,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'; @@ -78,6 +82,8 @@ import { PromoteOptInModalComponent } from 'src/app/shared/components/promote-op import { AuthService } from 'src/app/core/services/auth.service'; import { UtilityService } from 'src/app/core/services/utility.service'; import { FeatureConfigService } from 'src/app/core/services/platform/v1/spender/feature-config.service'; +import * as dayjs from 'dayjs'; +import { ExpensesQueryParams } from 'src/app/core/models/platform/v1/expenses-query-params.model'; @Component({ selector: 'app-my-expenses', @@ -210,7 +216,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, @@ -606,7 +611,24 @@ export class MyExpensesPage implements OnInit { }) ); - this.myExpenses$ = paginatedPipe.pipe(shareReplay(1)); + /** + * Observable that manages expenses, including polling for incomplete scans. + */ + this.myExpenses$ = paginatedPipe.pipe( + switchMap((expenses) => { + const dEincompleteExpenseIds = this.filterDEIncompleteExpenses(expenses); + + if (dEincompleteExpenseIds.length === 0) { + return of(expenses); // All scans are completed + } else { + return this.pollDEIncompleteExpenses(dEincompleteExpenseIds, expenses).pipe( + startWith(expenses), + timeout(30000) + ); + } + }), + shareReplay(1) + ); this.count$ = this.loadExpenses$.pipe( switchMap((params) => { @@ -1443,7 +1465,7 @@ export class MyExpensesPage implements OnInit { } addTransactionsToReport(report: Report, selectedExpensesId: string[]): Observable { - return from(this.loaderService.showLoader('Adding transaction to report')).pipe( + return from(this.loaderService.showLoader('Adding expense to report')).pipe( switchMap(() => this.spenderReportsService.addExpenses(report.id, selectedExpensesId).pipe(map(() => report))), finalize(() => this.loaderService.hideLoader()) ); @@ -1753,4 +1775,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$) + ); + } } diff --git a/src/app/shared/components/capture-receipt/capture-receipt.component.spec.ts b/src/app/shared/components/capture-receipt/capture-receipt.component.spec.ts index 8ff4e108ed..9a83fc6991 100644 --- a/src/app/shared/components/capture-receipt/capture-receipt.component.spec.ts +++ b/src/app/shared/components/capture-receipt/capture-receipt.component.spec.ts @@ -252,8 +252,7 @@ describe('CaptureReceiptComponent', () => { describe('addExpenseToQueue():', () => { it('should add entry to expense queue', (done) => { - authService.getEou.and.returnValue(Promise.resolve(apiEouRes)); - transactionsOutboxService.addEntry.and.returnValue(Promise.resolve(null)); + transactionsOutboxService.addEntry.and.resolveTo(null); fixture.detectChanges(); component @@ -262,15 +261,13 @@ describe('CaptureReceiptComponent', () => { base64Image: 'base64encodedcontent', }) .subscribe(() => { - expect(authService.getEou).toHaveBeenCalledTimes(1); expect(transactionsOutboxService.addEntry).toHaveBeenCalledTimes(1); done(); }); }); it('should add entry to expense queue if offline', (done) => { - authService.getEou.and.returnValue(Promise.resolve(null)); - transactionsOutboxService.addEntry.and.returnValue(Promise.resolve(null)); + transactionsOutboxService.addEntry.and.resolveTo(null); component.isOffline$ = of(true); fixture.detectChanges(); @@ -280,7 +277,6 @@ describe('CaptureReceiptComponent', () => { base64Image: 'base64encodedcontent', }) .subscribe(() => { - expect(authService.getEou).toHaveBeenCalledTimes(1); expect(transactionsOutboxService.addEntry).toHaveBeenCalledTimes(1); done(); }); @@ -485,12 +481,12 @@ describe('CaptureReceiptComponent', () => { component.openReceiptPreviewModal(); expect(component.showReceiptPreview).toHaveBeenCalledTimes(1); expect(component.setUpAndStartCamera).toHaveBeenCalledTimes(1); - expect(component.lastCapturedReceipt).toEqual(undefined); + expect(component.lastCapturedReceipt).toBeUndefined(); }); }); it('createReceiptPreviewModal(): should create receipt preview modal', () => { - modalController.create.and.returnValue(Promise.resolve(null)); + modalController.create.and.resolveTo(null); component.createReceiptPreviewModal('bulk'); expect(modalController.create).toHaveBeenCalledOnceWith({ @@ -503,15 +499,13 @@ describe('CaptureReceiptComponent', () => { }); it('showReceiptPreview(): should show receipt preview', (done) => { - spyOn(component, 'createReceiptPreviewModal').and.returnValue( - Promise.resolve({ - data: { - base64ImagesWithSource: images, - }, - onWillDismiss: () => Promise.resolve(true), - present: () => Promise.resolve(true), - } as any) - ); + spyOn(component, 'createReceiptPreviewModal').and.resolveTo({ + data: { + base64ImagesWithSource: images, + }, + onWillDismiss: () => Promise.resolve(true), + present: () => Promise.resolve(true), + } as any); component.showReceiptPreview().subscribe(() => { expect(component.createReceiptPreviewModal).toHaveBeenCalledOnceWith('bulk'); @@ -555,11 +549,9 @@ describe('CaptureReceiptComponent', () => { it('should capture receipt if bulk mode is disabled', async () => { spyOn(component, 'onSingleCapture').and.returnValue(null); spyOn(component, 'stopCamera').and.returnValue(null); - cameraPreviewService.capture.and.returnValue( - Promise.resolve({ - value: 'value', - }) - ); + cameraPreviewService.capture.and.resolveTo({ + value: 'value', + }); component.isBulkMode = false; const len = component.base64ImagesWithSource.length; @@ -575,11 +567,9 @@ describe('CaptureReceiptComponent', () => { it('should capture multiple receipts if bulk mode is enabled', async () => { component.isBulkMode = true; spyOn(component, 'onBulkCapture').and.returnValue(null); - cameraPreviewService.capture.and.returnValue( - Promise.resolve({ - value: 'value', - }) - ); + cameraPreviewService.capture.and.resolveTo({ + value: 'value', + }); const len = component.base64ImagesWithSource.length; fixture.detectChanges(); @@ -694,8 +684,8 @@ describe('CaptureReceiptComponent', () => { xdescribe('onGalleryUpload():', () => { it('should upload images to gallery if permission graneted', () => { - imagePicker.hasReadPermission.and.returnValue(Promise.resolve(true)); - imagePicker.getPictures.and.returnValue(Promise.resolve(['encodedcontent1', 'encodedcontent2'])); + imagePicker.hasReadPermission.and.resolveTo(true); + imagePicker.getPictures.and.resolveTo(['encodedcontent1', 'encodedcontent2']); fixture.detectChanges(); @@ -710,7 +700,7 @@ describe('CaptureReceiptComponent', () => { camera: 'denied', }); spyOn(component, 'showPermissionDeniedPopover'); - imagePicker.hasReadPermission.and.returnValue(Promise.resolve(false)); + imagePicker.hasReadPermission.and.resolveTo(false); fixture.detectChanges(); @@ -732,7 +722,7 @@ describe('CaptureReceiptComponent', () => { component.setUpAndStartCamera(); expect(component.cameraPreview.setUpAndStartCamera).toHaveBeenCalledTimes(1); expect(component.showBulkModeToastMessage).toHaveBeenCalledTimes(1); - expect(component.isBulkModePromptShown).toEqual(true); + expect(component.isBulkModePromptShown).toBeTrue(); }); describe('onSingleCapture(): ', () => { @@ -745,14 +735,12 @@ describe('CaptureReceiptComponent', () => { spyOn(component, 'createReceiptPreviewModal').and.returnValue( new Promise((resolve) => { const popOverSpy = jasmine.createSpyObj('receiptPreviewModal', ['present', 'onWillDismiss', 'onDidDismiss']); - popOverSpy.onWillDismiss.and.returnValue( - Promise.resolve({ - data: { - base64ImagesWithSource: images, - }, - }) - ); - popOverSpy.onDidDismiss.and.returnValue(Promise.resolve({ data: 'value' })); + popOverSpy.onWillDismiss.and.resolveTo({ + data: { + base64ImagesWithSource: images, + }, + }); + popOverSpy.onDidDismiss.and.resolveTo({ data: 'value' }); resolve(popOverSpy); }) ); diff --git a/src/app/shared/components/capture-receipt/capture-receipt.component.ts b/src/app/shared/components/capture-receipt/capture-receipt.component.ts index 9eabf5deb3..4c9df512f4 100644 --- a/src/app/shared/components/capture-receipt/capture-receipt.component.ts +++ b/src/app/shared/components/capture-receipt/capture-receipt.component.ts @@ -111,15 +111,13 @@ export class CaptureReceiptComponent implements OnInit, OnDestroy, AfterViewInit return forkJoin({ isOffline: this.isOffline$.pipe(take(1)), - eou: this.authService.getEou(), }).pipe( - switchMap(({ eou, isOffline }) => { + switchMap(({ isOffline }) => { if (isOffline) { source += '_OFFLINE'; } const transaction = { source, - currency: eou?.org?.currency, }; const attachmentUrls = [ @@ -129,7 +127,7 @@ export class CaptureReceiptComponent implements OnInit, OnDestroy, AfterViewInit url: base64ImagesWithSource.base64Image, }, ]; - return this.transactionsOutboxService.addEntry(transaction, attachmentUrls, null, null, true); + return this.transactionsOutboxService.addEntry(transaction, attachmentUrls, null, null); }) ); } diff --git a/src/app/shared/components/expenses-card-v2/expenses-card.component.spec.ts b/src/app/shared/components/expenses-card-v2/expenses-card.component.spec.ts index d08f6652de..519d95a720 100644 --- a/src/app/shared/components/expenses-card-v2/expenses-card.component.spec.ts +++ b/src/app/shared/components/expenses-card-v2/expenses-card.component.spec.ts @@ -35,7 +35,7 @@ import { CameraOptionsPopupComponent } from 'src/app/fyle/add-edit-expense/camer import { CaptureReceiptComponent } from 'src/app/shared/components/capture-receipt/capture-receipt.component'; import { ToastMessageComponent } from '../toast-message/toast-message.component'; import { DebugElement, EventEmitter } from '@angular/core'; -import { expenseData, expenseResponseData } from 'src/app/core/mock-data/platform/v1/expense.data'; +import { apiExpenses1, expenseData, expenseResponseData } from 'src/app/core/mock-data/platform/v1/expense.data'; import { AccountType } from 'src/app/core/models/platform/v1/account.model'; import { ExpensesService as SharedExpenseService } from 'src/app/core/services/platform/v1/shared/expenses.service'; import { PopupAlertComponent } from '../popup-alert/popup-alert.component'; @@ -88,7 +88,6 @@ describe('ExpensesCardComponent', () => { const popoverControllerSpy = jasmine.createSpyObj('PopoverController', ['create']); const networkServiceSpy = jasmine.createSpyObj('NetworkService', ['connectivityWatcher', 'isOnline']); const transactionsOutboxServiceSpy = jasmine.createSpyObj('TransactionsOutboxService', [ - 'isDataExtractionPending', 'isSyncInProgress', 'fileUpload', ]); @@ -155,7 +154,6 @@ describe('ExpensesCardComponent', () => { sharedExpenseService.isCriticalPolicyViolatedExpense.and.returnValue(false); platform.is.and.returnValue(true); fileService.getReceiptDetails.and.returnValue(fileObjectAdv[0].type); - transactionsOutboxService.isDataExtractionPending.and.returnValue(true); expensesService.getExpenseById.and.returnValue(of(platformExpenseData)); transactionService.transformExpense.and.returnValue(transformedExpenseData); networkService.isOnline.and.returnValue(of(true)); @@ -338,89 +336,36 @@ describe('ExpensesCardComponent', () => { }); }); - describe('pollDataExtractionStatus():', () => { - it('should call the callback when data extraction is not pending', fakeAsync(() => { - transactionsOutboxService.isDataExtractionPending.and.returnValue(false); - const callbackSpy = jasmine.createSpy('callback'); - component.pollDataExtractionStatus(callbackSpy); - tick(5000); - expect(callbackSpy).toHaveBeenCalledTimes(1); - })); - - it('should keep polling when data extraction is pending', fakeAsync(() => { - const callbackSpy = jasmine.createSpy('callback'); - - transactionsOutboxService.isDataExtractionPending.and.returnValue(true); - - component.pollDataExtractionStatus(callbackSpy); - tick(1000); // wait for the initial setTimeout call - - expect(transactionsOutboxService.isDataExtractionPending).toHaveBeenCalledTimes(1); - expect(callbackSpy).not.toHaveBeenCalledTimes(1); - - // simulate data extraction not pending - transactionsOutboxService.isDataExtractionPending.and.returnValue(false); - tick(5000); // wait for the next setTimeout call - - expect(transactionsOutboxService.isDataExtractionPending).toHaveBeenCalledTimes(2); - expect(callbackSpy).toHaveBeenCalledTimes(1); - })); - }); - describe('handleScanStatus():', () => { it('should handle status when the syncing is in progress and the extracted data is present', () => { component.isOutboxExpense = false; component.homeCurrency = 'INR'; - component.expense.id = 'txO6d6eiB4JF'; + component.expense = cloneDeep(apiExpenses1[0]); orgUserSettingsService.get.and.returnValue(of(orgUserSettingsData)); - const isScanCompletedSpy = spyOn(component, 'checkIfScanIsCompleted').and.returnValue(false); - expensesService.getExpenseById.and.returnValue(of(platformExpenseWithExtractedData)); - transactionService.transformExpense.and.returnValue(transformedExpenseWithExtractedData); - component.isScanInProgress = true; - spyOn(component, 'pollDataExtractionStatus').and.callFake((callback) => { - callback(); - }); - - transactionsOutboxService.isDataExtractionPending.and.returnValue(true); + const isScanCompletedSpy = spyOn(component, 'checkIfScanIsCompleted').and.returnValue(true); component.handleScanStatus(); expect(orgUserSettingsService.get).toHaveBeenCalledTimes(1); expect(isScanCompletedSpy).toHaveBeenCalledTimes(1); - expect(transactionsOutboxService.isDataExtractionPending).toHaveBeenCalledOnceWith('txO6d6eiB4JF'); - expect(component.pollDataExtractionStatus).toHaveBeenCalledTimes(1); - expect(expensesService.getExpenseById).toHaveBeenCalledOnceWith(component.expense.id); - expect(transactionService.transformExpense).toHaveBeenCalledOnceWith(platformExpenseWithExtractedData); expect(component.isScanCompleted).toBeTrue(); expect(component.isScanInProgress).toBeFalse(); - expect(component.expense.extracted_data).toEqual(transformedExpenseWithExtractedData.tx.extracted_data); }); it('should handle status when the sync is in progress and there is no extracted data present', () => { component.isOutboxExpense = false; - component.expense.id = 'txvslh8aQMbu'; + component.expense = cloneDeep(expenseData); orgUserSettingsService.get.and.returnValue(of(orgUserSettingsData)); const isScanCompletedSpy = spyOn(component, 'checkIfScanIsCompleted').and.returnValue(false); - expensesService.getExpenseById.and.returnValue(of(platformExpenseData)); - transactionService.transformExpense.and.returnValue(transformedExpenseData); component.isScanInProgress = true; - const pollDataSpy = spyOn(component, 'pollDataExtractionStatus').and.callFake((callback) => { - callback(); - }); - - transactionsOutboxService.isDataExtractionPending.and.returnValue(true); component.handleScanStatus(); expect(orgUserSettingsService.get).toHaveBeenCalledTimes(1); expect(component.checkIfScanIsCompleted).toHaveBeenCalledTimes(1); expect(isScanCompletedSpy).toHaveBeenCalledTimes(1); - expect(transactionsOutboxService.isDataExtractionPending).toHaveBeenCalledOnceWith('txvslh8aQMbu'); - expect(pollDataSpy).toHaveBeenCalledTimes(1); - expect(expensesService.getExpenseById).toHaveBeenCalledOnceWith(component.expense.id); - expect(transactionService.transformExpense).toHaveBeenCalledOnceWith(platformExpenseData); expect(component.isScanCompleted).toBeFalse(); - expect(component.isScanInProgress).toBeFalse(); + expect(component.isScanInProgress).toBeTrue(); }); it('should handle status when the scanning is not in progress', () => { diff --git a/src/app/shared/components/expenses-card-v2/expenses-card.component.ts b/src/app/shared/components/expenses-card-v2/expenses-card.component.ts index caed3e0067..4b13c09216 100644 --- a/src/app/shared/components/expenses-card-v2/expenses-card.component.ts +++ b/src/app/shared/components/expenses-card-v2/expenses-card.component.ts @@ -26,12 +26,8 @@ import { TrackingService } from '../../../core/services/tracking.service'; import { PopupAlertComponent } from '../popup-alert/popup-alert.component'; import { ExpensesService as SharedExpenseService } from 'src/app/core/services/platform/v1/shared/expenses.service'; import { ExpensesService } from 'src/app/core/services/platform/v1/spender/expenses.service'; +import { ReceiptDetail } from 'src/app/core/models/receipt-detail.model'; -type ReceiptDetail = { - dataUrl: string; - type: string; - actionSource: string; -}; @Component({ selector: 'app-expense-card-v2', templateUrl: './expenses-card.component.html', @@ -203,26 +199,6 @@ export class ExpensesCardComponent implements OnInit { return !!(hasUserManuallyEnteredData || isRequiredExtractedDataPresent || hasScanExpired); } - /** - * This is to check if the expense is currently in data extraction queue. If the item is not in data extraction queue anymore, - * a callback method is fired. - * - * The reasoning behind this is to check if scanning expenses have finished scanning - * - * @param callback Callback method to be fired when item has finished scanning - */ - pollDataExtractionStatus(callback: Function): void { - const that = this; - setTimeout(() => { - const isPresentInQueue = that.transactionOutboxService.isDataExtractionPending(that.expense.id); - if (!isPresentInQueue) { - callback(); - } else { - that.pollDataExtractionStatus(callback); - } - }, 1000); - } - handleScanStatus(): void { const that = this; that.isScanInProgress = false; @@ -236,24 +212,7 @@ export class ExpensesCardComponent implements OnInit { (that.homeCurrency === 'USD' || that.homeCurrency === 'INR') ) { that.isScanCompleted = that.checkIfScanIsCompleted(); - that.isScanInProgress = - !that.isScanCompleted && that.transactionOutboxService.isDataExtractionPending(that.expense.id); - if (that.isScanInProgress) { - that.pollDataExtractionStatus(function () { - that.expensesService.getExpenseById(that.expense.id).subscribe((expense) => { - const etxn = that.transactionService.transformExpense(expense); - const extractedData = etxn.tx.extracted_data; - if (!!extractedData) { - that.isScanCompleted = true; - that.isScanInProgress = false; - that.expense.extracted_data = extractedData; - } else { - that.isScanInProgress = false; - that.isScanCompleted = false; - } - }); - }); - } + that.isScanInProgress = !that.isScanCompleted && !this.expense.extracted_data; } else { that.isScanCompleted = true; that.isScanInProgress = false; @@ -271,14 +230,7 @@ export class ExpensesCardComponent implements OnInit { this.isPolicyViolated = this.expense.is_policy_flagged; } - ngOnInit(): void { - this.setupNetworkWatcher(); - const orgSettings$ = this.orgSettingsService.get().pipe(shareReplay(1)); - - this.isSycing$ = this.isConnected$.pipe( - map((isConnected) => isConnected && this.transactionOutboxService.isSyncInProgress() && this.isOutboxExpense) - ); - + setExpenseDetails(): void { this.category = this.expense?.category?.name && this.expense?.category?.name.toLowerCase(); this.isMileageExpense = this.category === 'mileage'; this.isPerDiem = this.category === 'per diem'; @@ -286,10 +238,36 @@ export class ExpensesCardComponent implements OnInit { this.isDraft = this.sharedExpenseService.isExpenseInDraft(this.expense); this.isCriticalPolicyViolated = this.sharedExpenseService.isCriticalPolicyViolatedExpense(this.expense); this.vendorDetails = this.sharedExpenseService.getVendorDetails(this.expense); + + this.setIsPolicyViolated(); + + if (!this.expense.id) { + this.showDt = !!this.isFirstOfflineExpense; + } else if (this.previousExpenseTxnDate || this.previousExpenseCreatedAt) { + const currentDate = (this.expense.spent_at || this.expense.created_at).toDateString(); + const previousDate = new Date( + (this.previousExpenseTxnDate || this.previousExpenseCreatedAt) as string + ).toDateString(); + this.showDt = currentDate !== previousDate; + } + + this.canShowPaymentModeIcon(); + + this.getReceipt(); + + this.setOtherData(); + } + + ngOnInit(): void { + this.setupNetworkWatcher(); + const orgSettings$ = this.orgSettingsService.get().pipe(shareReplay(1)); + + this.isSycing$ = this.isConnected$.pipe( + map((isConnected) => isConnected && this.transactionOutboxService.isSyncInProgress() && this.isOutboxExpense) + ); this.expenseFieldsService.getAllMap().subscribe((expenseFields) => { this.expenseFields = expenseFields; }); - this.setIsPolicyViolated(); this.currencyService .getHomeCurrency() @@ -305,24 +283,10 @@ export class ExpensesCardComponent implements OnInit { shareReplay(1) ); - if (!this.expense.id) { - this.showDt = !!this.isFirstOfflineExpense; - } else if (this.previousExpenseTxnDate || this.previousExpenseCreatedAt) { - const currentDate = (this.expense.spent_at || this.expense.created_at).toDateString(); - const previousDate = new Date( - (this.previousExpenseTxnDate || this.previousExpenseCreatedAt) as string - ).toDateString(); - this.showDt = currentDate !== previousDate; - } - - this.canShowPaymentModeIcon(); - - this.getReceipt(); + this.setExpenseDetails(); this.handleScanStatus(); - this.setOtherData(); - this.isIos = this.platform.is('ios'); } diff --git a/src/app/shared/components/expenses-card/expenses-card.component.spec.ts b/src/app/shared/components/expenses-card/expenses-card.component.spec.ts index e203098046..14c0bcb79a 100644 --- a/src/app/shared/components/expenses-card/expenses-card.component.spec.ts +++ b/src/app/shared/components/expenses-card/expenses-card.component.spec.ts @@ -21,7 +21,7 @@ import { FormsModule } from '@angular/forms'; import { ExpenseState } from '../../pipes/expense-state.pipe'; import { orgSettingsGetData } from 'src/app/core/test-data/org-settings.service.spec.data'; import { of, take } from 'rxjs'; -import { expenseData1 } from 'src/app/core/mock-data/expense.data'; +import { expenseData1, selectedExpense1 } from 'src/app/core/mock-data/expense.data'; import { apiExpenseRes } from 'src/app/core/mock-data/expense.data'; import { expenseFieldsMapResponse2 } from 'src/app/core/mock-data/expense-fields-map.data'; import { orgData1 } from 'src/app/core/mock-data/org.data'; @@ -85,7 +85,6 @@ describe('ExpensesCardComponent', () => { const popoverControllerSpy = jasmine.createSpyObj('PopoverController', ['create']); const networkServiceSpy = jasmine.createSpyObj('NetworkService', ['connectivityWatcher', 'isOnline']); const transactionsOutboxServiceSpy = jasmine.createSpyObj('TransactionsOutboxService', [ - 'isDataExtractionPending', 'isSyncInProgress', 'fileUpload', ]); @@ -150,7 +149,6 @@ describe('ExpensesCardComponent', () => { transactionService.getIsCriticalPolicyViolated.and.returnValue(false); platform.is.and.returnValue(true); fileService.getReceiptDetails.and.returnValue(fileObjectAdv[0].type); - transactionsOutboxService.isDataExtractionPending.and.returnValue(true); expensesService.getExpenseById.and.returnValue(of(platformExpenseData)); transactionService.transformExpense.and.returnValue(transformedExpenseData); networkService.isOnline.and.returnValue(of(true)); @@ -318,89 +316,37 @@ describe('ExpensesCardComponent', () => { }); }); - describe('pollDataExtractionStatus():', () => { - it('should call the callback when data extraction is not pending', fakeAsync(() => { - transactionsOutboxService.isDataExtractionPending.and.returnValue(false); - const callbackSpy = jasmine.createSpy('callback'); - component.pollDataExtractionStatus(callbackSpy); - tick(5000); - expect(callbackSpy).toHaveBeenCalledTimes(1); - })); - - it('should keep polling when data extraction is pending', fakeAsync(() => { - const callbackSpy = jasmine.createSpy('callback'); - - transactionsOutboxService.isDataExtractionPending.and.returnValue(true); - - component.pollDataExtractionStatus(callbackSpy); - tick(1000); // wait for the initial setTimeout call - - expect(transactionsOutboxService.isDataExtractionPending).toHaveBeenCalledTimes(1); - expect(callbackSpy).not.toHaveBeenCalledTimes(1); - - // simulate data extraction not pending - transactionsOutboxService.isDataExtractionPending.and.returnValue(false); - tick(5000); // wait for the next setTimeout call - - expect(transactionsOutboxService.isDataExtractionPending).toHaveBeenCalledTimes(2); - expect(callbackSpy).toHaveBeenCalledTimes(1); - })); - }); - describe('handleScanStatus():', () => { it('should handle status when the syncing is in progress and the extracted data is present', () => { component.isOutboxExpense = false; component.homeCurrency = 'INR'; - component.expense.tx_id = 'txO6d6eiB4JF'; + component.expense = selectedExpense1; orgUserSettingsService.get.and.returnValue(of(orgUserSettingsData)); const isScanCompletedSpy = spyOn(component, 'checkIfScanIsCompleted').and.returnValue(false); - expensesService.getExpenseById.and.returnValue(of(platformExpenseWithExtractedData)); - transactionService.transformExpense.and.returnValue(transformedExpenseWithExtractedData); component.isScanInProgress = true; - spyOn(component, 'pollDataExtractionStatus').and.callFake((callback) => { - callback(); - }); - - transactionsOutboxService.isDataExtractionPending.and.returnValue(true); component.handleScanStatus(); expect(orgUserSettingsService.get).toHaveBeenCalledTimes(1); expect(isScanCompletedSpy).toHaveBeenCalledTimes(1); - expect(transactionsOutboxService.isDataExtractionPending).toHaveBeenCalledOnceWith('txO6d6eiB4JF'); - expect(component.pollDataExtractionStatus).toHaveBeenCalledTimes(1); - expect(expensesService.getExpenseById).toHaveBeenCalledOnceWith(component.expense.tx_id); - expect(transactionService.transformExpense).toHaveBeenCalledOnceWith(platformExpenseWithExtractedData); - expect(component.isScanCompleted).toBeTrue(); + expect(component.isScanCompleted).toBeFalse(); expect(component.isScanInProgress).toBeFalse(); - expect(component.expense.tx_extracted_data).toEqual(transformedExpenseWithExtractedData.tx.extracted_data); }); it('should handle status when the sync is in progress and there is no extracted data present', () => { component.isOutboxExpense = false; - component.expense.tx_id = 'txvslh8aQMbu'; + component.expense = expenseData1; orgUserSettingsService.get.and.returnValue(of(orgUserSettingsData)); const isScanCompletedSpy = spyOn(component, 'checkIfScanIsCompleted').and.returnValue(false); - expensesService.getExpenseById.and.returnValue(of(platformExpenseData)); - transactionService.transformExpense.and.returnValue(transformedExpenseData); component.isScanInProgress = true; - const pollDataSpy = spyOn(component, 'pollDataExtractionStatus').and.callFake((callback) => { - callback(); - }); - - transactionsOutboxService.isDataExtractionPending.and.returnValue(true); component.handleScanStatus(); expect(orgUserSettingsService.get).toHaveBeenCalledTimes(1); expect(component.checkIfScanIsCompleted).toHaveBeenCalledTimes(1); expect(isScanCompletedSpy).toHaveBeenCalledTimes(1); - expect(transactionsOutboxService.isDataExtractionPending).toHaveBeenCalledOnceWith('txvslh8aQMbu'); - expect(pollDataSpy).toHaveBeenCalledTimes(1); - expect(expensesService.getExpenseById).toHaveBeenCalledOnceWith(component.expense.tx_id); - expect(transactionService.transformExpense).toHaveBeenCalledOnceWith(platformExpenseData); expect(component.isScanCompleted).toBeFalse(); - expect(component.isScanInProgress).toBeFalse(); + expect(component.isScanInProgress).toBeTrue(); }); it('should handle status when the scanning is not in progress', () => { diff --git a/src/app/shared/components/expenses-card/expenses-card.component.ts b/src/app/shared/components/expenses-card/expenses-card.component.ts index 727866f3f4..52b241e817 100644 --- a/src/app/shared/components/expenses-card/expenses-card.component.ts +++ b/src/app/shared/components/expenses-card/expenses-card.component.ts @@ -25,12 +25,8 @@ import { SnackbarPropertiesService } from '../../../core/services/snackbar-prope import { TrackingService } from '../../../core/services/tracking.service'; import { PopupAlertComponent } from '../popup-alert/popup-alert.component'; import { ExpensesService } from 'src/app/core/services/platform/v1/spender/expenses.service'; +import { ReceiptDetail } from 'src/app/core/models/receipt-detail.model'; -type ReceiptDetail = { - dataUrl: string; - type: string; - actionSource: string; -}; @Component({ selector: 'app-expense-card', templateUrl: './expenses-card.component.html', @@ -195,26 +191,6 @@ export class ExpensesCardComponent implements OnInit { return !!(hasUserManuallyEnteredData || isRequiredExtractedDataPresent || hasScanExpired); } - /** - * This is to check if the expense is currently in data extraction queue. If the item is not in data extraction queue anymore, - * a callback method is fired. - * - * The reasoning behind this is to check if scanning expenses have finished scanning - * - * @param callback Callback method to be fired when item has finished scanning - */ - pollDataExtractionStatus(callback: Function): void { - const that = this; - setTimeout(() => { - const isPresentInQueue = that.transactionOutboxService.isDataExtractionPending(that.expense.tx_id); - if (!isPresentInQueue) { - callback(); - } else { - that.pollDataExtractionStatus(callback); - } - }, 1000); - } - handleScanStatus(): void { const that = this; that.isScanInProgress = false; @@ -228,24 +204,7 @@ export class ExpensesCardComponent implements OnInit { (that.homeCurrency === 'USD' || that.homeCurrency === 'INR') ) { that.isScanCompleted = that.checkIfScanIsCompleted(); - that.isScanInProgress = - !that.isScanCompleted && that.transactionOutboxService.isDataExtractionPending(that.expense.tx_id); - if (that.isScanInProgress) { - that.pollDataExtractionStatus(function () { - that.expensesService.getExpenseById(that.expense.tx_id).subscribe((expense) => { - const etxn = that.transactionService.transformExpense(expense); - const extractedData = etxn.tx.extracted_data; - if (!!extractedData) { - that.isScanCompleted = true; - that.isScanInProgress = false; - that.expense.tx_extracted_data = extractedData; - } else { - that.isScanInProgress = false; - that.isScanCompleted = false; - } - }); - }); - } + that.isScanInProgress = !that.isScanCompleted && !this.expense.tx_extracted_data; } else { that.isScanCompleted = true; that.isScanInProgress = false;