diff --git a/src/layouts/scheduling/scheduling.spec.js b/src/layouts/scheduling/scheduling.spec.js index 02e182056..5db9015cb 100644 --- a/src/layouts/scheduling/scheduling.spec.js +++ b/src/layouts/scheduling/scheduling.spec.js @@ -2,9 +2,16 @@ import { shallowMount } from "@vue/test-utils"; import scheduling from "./scheduling"; import { getMountOptions } from "@/helpers/unit-test-helper.js"; import { settleAllPromises } from "@/helpers/layout-helper"; -import { AppointmentTypeStrings } from "@/constants/schedule-constants"; +import { AppointmentTypeStrings, PREMIUM_FEE_PART_TYPE } from "@/constants/schedule-constants"; +import { partNumberStrings } from "@/constants/part-number-strings"; +import { experimentSettings } from "@/constants/experiments"; import { GaActions, GaCategories, GaLabels } from "@/constants/analytics"; import store from "@/store"; +import experimentMixin from "@/mixins/experiment-mixin.js"; +import { + getPricedMobileFeePart, + getPricedRecycleFeePart, +} from "@/layouts/service-location/helpers/service-location-helper/service-location-helper"; jest.mock("@/store", () => ({ dispatch: jest.fn().mockResolvedValue(null), @@ -61,6 +68,16 @@ jest.mock("@/helpers/page-prerequisites-helper.js", () => ({ hasInsuranceInfo: jest.fn(() => true), })); +jest.mock( + "@/layouts/service-location/helpers/service-location-helper/service-location-helper", + () => ({ + getPricedMobileFeePart: jest.fn().mockResolvedValue(null), + getPricedRecycleFeePart: jest.fn().mockResolvedValue(null), + getBillToAccountNumber: jest.fn().mockResolvedValue(null), + getZipCodeData: jest.fn().mockResolvedValue({}), + }) +); + function setupMocks({ isAppleBrowser = false } = {}) { const dispatchStoreAction = jest.fn().mockResolvedValue(null); const baseMixin = { @@ -79,6 +96,9 @@ function setupMocks({ isAppleBrowser = false } = {}) { SAVE_WAITLIST_REQUESTED: "saveWaitListRequested", SAVE_SUPPORTING_ITEMS_SUPPRESSING_STATE_RESETTING: "saveSupportingItemsSuppressingStateResetting", + SAVE_BILL_TO_ACCOUNT_NUMBER: "saveBillToAccountNumber", + SAVE_IS_MSR_FEE_APPLICABLE: "saveIsMSRFeeApplicable", + SAVE_IS_MSR_FEE_COVERED_BY_INSURANCE: "saveIsMSRFeeCoveredByInsurance", }; }, navigationScenarios() { @@ -468,6 +488,277 @@ describe("scheduling.vue", () => { ); wrapper.unmount(); }); + + test("updates recycle fee and adds mobile fee for mobile appointments", async () => { + const supportingItems = [ + { + partNumber: partNumberStrings.RECYCLE_FEE, + laborAmount: 1, + sellingPrice: 1, + kitPrice: 1, + }, + ]; + store.getters.lineItems.supportingItems = supportingItems; + + const { wrapper, dispatchStoreAction } = setupMocks(); + wrapper.vm.selectedDate = "2026-07-22"; + wrapper.vm.selectedScheduling = { + appointmentType: AppointmentTypeStrings.MOBILE, + providerNumber: "MOBILE1", + routeCodeId: "route-mobile", + isPremiumAppointment: false, + }; + wrapper.vm.mobileProviderAndTimeSlot = { + providerNumber: "MOBILE1", + timeSlots: { + days: [ + { + date: "2026-07-22", + timeSlots: [ + { id: "route-mobile", startTime: "08:00", endTime: "12:00" }, + ], + }, + ], + }, + }; + wrapper.vm.recycleFeePart = { + partNumber: partNumberStrings.RECYCLE_FEE, + laborAmount: 5, + sellingPrice: 6, + kitPrice: 7, + }; + wrapper.vm.mobileFeePart = { + partType: "MOBILE FEE", + laborAmount: 10, + sellingPrice: 20, + kitPrice: 0, + isInsurable: true, + }; + + await wrapper.vm.forwardButtonAction(); + + expect(supportingItems[0]).toEqual( + expect.objectContaining({ + partNumber: partNumberStrings.RECYCLE_FEE, + laborAmount: 5, + sellingPrice: 6, + kitPrice: 7, + }) + ); + expect(supportingItems).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + partType: "MOBILE FEE", + laborAmount: 10, + sellingPrice: 20, + isInsurable: true, + }), + ]) + ); + expect(dispatchStoreAction).toHaveBeenCalledWith( + "saveSupportingItemsSuppressingStateResetting", + supportingItems, + false + ); + + store.getters.lineItems.supportingItems = []; + wrapper.unmount(); + }); + + test("removes mobile fee for inshop appointments and refreshes recycle fee", async () => { + const mobileFee = { + partType: "MOBILE FEE", + laborAmount: 10, + sellingPrice: 20, + kitPrice: 0, + }; + const supportingItems = [ + { + partNumber: partNumberStrings.RECYCLE_FEE, + laborAmount: 1, + sellingPrice: 1, + kitPrice: 1, + }, + mobileFee, + { + partType: PREMIUM_FEE_PART_TYPE, + laborAmount: 2, + sellingPrice: 2, + kitPrice: 2, + }, + ]; + store.getters.lineItems.supportingItems = supportingItems; + + const { wrapper, dispatchStoreAction } = setupMocks(); + wrapper.vm.selectedDate = "2026-07-22"; + wrapper.vm.selectedScheduling = { + appointmentType: AppointmentTypeStrings.IN_SHOP, + providerNumber: "05018", + routeCodeId: "route-inshop", + }; + wrapper.vm.inshopProvidersAndTimeSlots = [ + { + provider: { providerNumber: "05018", address: { zipCodeCtu: "9999" } }, + timeSlots: { + days: [ + { + date: "2026-07-22", + timeSlots: [ + { id: "route-inshop", startTime: "09:00", endTime: "10:00" }, + ], + }, + ], + }, + }, + ]; + wrapper.vm.recycleFeePart = { + partNumber: partNumberStrings.RECYCLE_FEE, + laborAmount: 8, + sellingPrice: 9, + kitPrice: 10, + }; + wrapper.vm.mobileFeePart = mobileFee; + + await wrapper.vm.forwardButtonAction(); + + expect(supportingItems.find((item) => item.partType === "MOBILE FEE")).toBeUndefined(); + expect(supportingItems[0]).toEqual( + expect.objectContaining({ + partNumber: partNumberStrings.RECYCLE_FEE, + laborAmount: 8, + sellingPrice: 9, + kitPrice: 10, + }) + ); + expect(dispatchStoreAction).toHaveBeenCalledWith( + "saveSupportingItemsSuppressingStateResetting", + supportingItems, + false + ); + + store.getters.lineItems.supportingItems = []; + wrapper.unmount(); + }); + + test("saves MSR applicable true and covered-by-insurance for mobile MSR fee", async () => { + const getSettingValueSpy = jest + .spyOn(experimentMixin.methods, "getSettingValue") + .mockImplementation((settingName) => { + if (settingName === experimentSettings.DISPLAY_MSR) return "true"; + if (settingName === experimentSettings.ENABLE_MSR_SPLIT_PAY) return "true"; + return null; + }); + + const { wrapper, dispatchStoreAction } = setupMocks(); + wrapper.vm.selectedDate = "2026-07-22"; + wrapper.vm.selectedScheduling = { + appointmentType: AppointmentTypeStrings.MOBILE, + providerNumber: "MOBILE1", + routeCodeId: "route-mobile", + isPremiumAppointment: false, + }; + wrapper.vm.mobileProviderAndTimeSlot = { + providerNumber: "MOBILE1", + timeSlots: { + days: [ + { + date: "2026-07-22", + timeSlots: [ + { id: "route-mobile", startTime: "08:00", endTime: "12:00" }, + ], + }, + ], + }, + }; + wrapper.vm.mobileFeePart = { + partNumber: partNumberStrings.MOBILE_STATIC_RECAL_FEE, + partType: "MOBILE FEE", + laborAmount: 50, + sellingPrice: 0, + kitPrice: 0, + isInsurable: true, + }; + + await wrapper.vm.forwardButtonAction(); + + expect(dispatchStoreAction).toHaveBeenCalledWith("saveIsMSRFeeApplicable", true); + expect(dispatchStoreAction).toHaveBeenCalledWith( + "saveIsMSRFeeCoveredByInsurance", + true + ); + + getSettingValueSpy.mockRestore(); + wrapper.unmount(); + }); + + test("saves MSR applicable false for inshop appointments", async () => { + const getSettingValueSpy = jest + .spyOn(experimentMixin.methods, "getSettingValue") + .mockImplementation((settingName) => { + if (settingName === experimentSettings.DISPLAY_MSR) return "true"; + if (settingName === experimentSettings.ENABLE_MSR_SPLIT_PAY) return "true"; + return null; + }); + + const { wrapper, dispatchStoreAction } = setupMocks(); + wrapper.vm.selectedDate = "2026-07-22"; + wrapper.vm.selectedScheduling = { + appointmentType: AppointmentTypeStrings.IN_SHOP, + providerNumber: "05018", + routeCodeId: "route-inshop", + }; + wrapper.vm.inshopProvidersAndTimeSlots = [ + { + provider: { providerNumber: "05018", address: { zipCodeCtu: "9999" } }, + timeSlots: { + days: [ + { + date: "2026-07-22", + timeSlots: [ + { id: "route-inshop", startTime: "09:00", endTime: "10:00" }, + ], + }, + ], + }, + }, + ]; + wrapper.vm.mobileFeePart = { + partNumber: partNumberStrings.MOBILE_STATIC_RECAL_FEE, + partType: "MOBILE FEE", + isInsurable: false, + }; + + await wrapper.vm.forwardButtonAction(); + + expect(dispatchStoreAction).toHaveBeenCalledWith("saveIsMSRFeeApplicable", false); + expect(dispatchStoreAction).toHaveBeenCalledWith( + "saveIsMSRFeeCoveredByInsurance", + false + ); + + getSettingValueSpy.mockRestore(); + wrapper.unmount(); + }); + }); + + describe("loadFeeParts", () => { + test("loads priced mobile and recycle fee parts for the zip", async () => { + getPricedMobileFeePart.mockResolvedValueOnce({ partType: "MOBILE FEE" }); + getPricedRecycleFeePart.mockResolvedValueOnce({ + partNumber: partNumberStrings.RECYCLE_FEE, + }); + + const { wrapper } = setupMocks(); + await wrapper.vm.loadFeeParts("44101"); + + expect(getPricedMobileFeePart).toHaveBeenCalledWith("44101", "scheduling"); + expect(getPricedRecycleFeePart).toHaveBeenCalledWith("44101", "scheduling"); + expect(wrapper.vm.mobileFeePart).toEqual({ partType: "MOBILE FEE" }); + expect(wrapper.vm.recycleFeePart).toEqual({ + partNumber: partNumberStrings.RECYCLE_FEE, + }); + wrapper.unmount(); + }); }); describe("forwardButtonAction (recal acknowledgement)", () => { diff --git a/src/layouts/scheduling/scheduling.vue b/src/layouts/scheduling/scheduling.vue index f8f2da86b..d98bf9f25 100644 --- a/src/layouts/scheduling/scheduling.vue +++ b/src/layouts/scheduling/scheduling.vue @@ -107,6 +107,10 @@ import schedulingZipSearch from "@/layouts/scheduling/scheduling-zip-search/sche import recalAckModal from "@/layouts/schedule/recal-ack-modal/recal-ack-modal.vue"; import textLink from "@/ux-components/text-link/text-link"; import store from "@/store"; +import { partNumberStrings } from "@/constants/part-number-strings"; +import baseMixin from "@/mixins/base-mixin"; +import experimentMixin from "@/mixins/experiment-mixin.js"; +import { experimentSettings } from "@/constants/experiments"; import { storeActions } from "@/constants/store-actions"; import { AppointmentTypeStrings, @@ -114,6 +118,10 @@ import { RECAL_ACK_YES, } from "@/constants/schedule-constants"; import { isDropOffRouteCode } from "@/layouts/schedule/helpers/schedule-helper"; +import { + getPricedMobileFeePart, + getPricedRecycleFeePart, +} from "@/layouts/service-location/helpers/service-location-helper/service-location-helper"; import { fetchCmsContentForPage } from "@/helpers/cms-content-helper"; import { settleAllPromises } from "@/helpers/layout-helper"; import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper"; @@ -126,6 +134,8 @@ import { hasInsuranceInfo, } from "@/helpers/page-prerequisites-helper.js"; +const MOBILE_FEE_PART_TYPE = "MOBILE FEE"; + /** * Returns a YYYY-MM-DD date string offset by the given number of days from a base date. * @param {number} offsetDays - Number of days to offset (positive or negative). @@ -280,6 +290,24 @@ function fetchTimeSlotsBatch({ ]); } +/** + * Fetches serviceability details for a given zip code. + * @param {{ serviceZipCode: string, lineItems: any, pageNameToLog: string }} params + * @returns {Promise<{ isGlassServiceableInshop: boolean, isRecalibrationServiceableInshop: boolean, isGlassServiceableDropoff: boolean, isRecalibrationServiceableDropoff: boolean, isGlassServiceableMobile: boolean, isRecalibrationServiceableMobile: boolean }>} + */ +function getServiceabilityDetails(serviceZipCode, pageNameToLog) { + const lineItems = store.getters.lineItems; + return baseMixin.methods.dispatchStoreActionWithLogging( + storeActions.GET_SERVICEABILITY_DETAILS, + { + serviceZipCode: serviceZipCode, + lineItems: lineItems, + }, + pageNameToLog, + false + ); +} + export default { name: "scheduling", async beforeRouteEnter(to, from, next) { @@ -303,6 +331,14 @@ export default { pageNameToLog: to.name, }), }, + { + resultKey: "mobileFeePart", + promise: getPricedMobileFeePart(serviceZipCode, to.name), + }, + { + resultKey: "recycleFeePart", + promise: getPricedRecycleFeePart(serviceZipCode, to.name), + }, ]; const resultMap = await settleAllPromises(promiseResultMap); next(async (vm) => { @@ -312,6 +348,8 @@ export default { pageNameToLog: to.name, }); vm.mobilePremiumAppointmentFee = resultMap.mobilePremiumFee ?? null; + vm.mobileFeePart = resultMap.mobileFeePart ?? null; + vm.recycleFeePart = resultMap.recycleFeePart ?? null; vm.isLoadingDates = false; }); }, @@ -386,6 +424,45 @@ export default { const maxVisible = Math.min(MAX_INSHOP_PROVIDERS, this.allShopProviders.length); return this.inshopProvidersAndTimeSlots.length < maxVisible; }, + isInsurance() { + return store.getters.order.payment?.isInsurance; + }, + isITAC() { + return store.getters.order.policy?.isItac; + }, + isNoComp() { + return store.getters.order.policy?.isNoComp; + }, + isCashItacNoComp() { + return !this.isInsurance || this.isITAC || this.isNoComp; + }, + displayMSR() { + return ( + experimentMixin.methods + .getSettingValue(experimentSettings.DISPLAY_MSR) + ?.toLowerCase() === "true" + ); + }, + enableMSRSplitPay() { + return ( + experimentMixin.methods + .getSettingValue(experimentSettings.ENABLE_MSR_SPLIT_PAY) + ?.toLowerCase() === "true" + ); + }, + isMSRFeePartNumber() { + return ( + this.mobileFeePart?.partNumber === partNumberStrings.MOBILE_STATIC_RECAL_FEE || + this.mobileFeePart?.partNumber === partNumberStrings.MOBILE_DUAL_RECAL_FEE + ); + }, + isMobileStaticRecalibrationApplicable() { + return ( + this.displayMSR && + this.isMSRFeePartNumber && + (this.enableMSRSplitPay || this.isCashItacNoComp || this.mobileFeePart?.isInsurable) + ); + }, }, data() { return { @@ -400,6 +477,8 @@ export default { allShopProviders: [], mobileProviderAndTimeSlot: null, mobilePremiumAppointmentFee: null, + mobileFeePart: null, + recycleFeePart: null, selectedScheduling: null, isWaitlistRequested: false, isLoadingMoreShops: false, @@ -408,9 +487,25 @@ export default { billToAccountNumber: store.getters.order.payment?.billToAccountNumber ?? null, isRecalAcknowledgedForScheduling: store.getters.order.isRecalAcknowledgedForScheduling ?? "", + serviceabilityDetails: { + isGlassServiceableInshop: false, + isRecalibrationServiceableInshop: false, + isGlassServiceableDropoff: false, + isRecalibrationServiceableDropoff: false, + isGlassServiceableMobile: false, + isRecalibrationServiceableMobile: false, + }, }; }, methods: { + async loadFeeParts(serviceZipCode, pageNameToLog = this.pageName) { + const [mobileFeePart, recycleFeePart] = await Promise.all([ + getPricedMobileFeePart(serviceZipCode, pageNameToLog), + getPricedRecycleFeePart(serviceZipCode, pageNameToLog), + ]); + this.mobileFeePart = mobileFeePart ?? null; + this.recycleFeePart = recycleFeePart ?? null; + }, async loadSchedulingData( serviceZipCode, { providersResult = null, pageNameToLog = this.pageName } = {} @@ -486,6 +581,14 @@ export default { timeSlotsResultMap.mobileTimeSlots ?? null; } this.datesLoaded = true; + + const serviceabilityDetailsResultMap = await getServiceabilityDetails( + serviceZipCode, + pageNameToLog + ); + if (serviceabilityDetailsResultMap?.data) { + this.serviceabilityDetails = serviceabilityDetailsResultMap.data; + } }, getInshopTimeSlotsForSelectedDate(providerNumber) { if (!this.selectedDate) { @@ -537,6 +640,14 @@ export default { return; } + const serviceabilityDetailsResultMap = await getServiceabilityDetails( + zipCode, + this.pageName + ); + if (serviceabilityDetailsResultMap?.data) { + this.serviceabilityDetails = serviceabilityDetailsResultMap.data; + } + this.billToAccountNumber = billToAccountNumber; this.isLoadingDates = true; try { @@ -548,7 +659,7 @@ export default { this.datePickerKey += 1; this.datePickerStartDate = toDateString(0); this.datePickerEndDate = toDateString(SCHEDULE_FETCH_DAYS - 1); - await this.loadSchedulingData(zipCode); + await Promise.all([this.loadSchedulingData(zipCode), this.loadFeeParts(zipCode)]); } finally { this.isLoadingDates = false; } @@ -724,6 +835,88 @@ export default { maximum: providerEntry?.timeSlots?.estimatedServiceMinutesMaximum, }; }, + updateAndSaveSupportingItems(appointmentType) { + let supportingItems = store.getters.lineItems?.supportingItems; + let shouldSaveSupportingItems = false; + + supportingItems = + !supportingItems && this.isMobileStaticRecalibrationApplicable + ? [] + : supportingItems; + + // for insurance orders, fees can get removed causing supportingitems to be null or empty + if (!supportingItems) { + return; + } + + // update recycle fee price + if (this.recycleFeePart) { + const recycleFeeIndex = supportingItems.findIndex( + (item) => item.partNumber == partNumberStrings.RECYCLE_FEE + ); + if (recycleFeeIndex >= 0) { + supportingItems[recycleFeeIndex].laborAmount = this.recycleFeePart.laborAmount; + supportingItems[recycleFeeIndex].sellingPrice = + this.recycleFeePart.sellingPrice; + supportingItems[recycleFeeIndex].kitPrice = this.recycleFeePart.kitPrice; + + shouldSaveSupportingItems = true; + } + } + + // if we have a mobile fee, then save/update supporting items + if (appointmentType === AppointmentTypeStrings.MOBILE) { + if (this.mobileFeePart) { + const mobileFeeIndex = supportingItems.findIndex( + (item) => item.partType == MOBILE_FEE_PART_TYPE + ); + if (mobileFeeIndex >= 0) { + supportingItems[mobileFeeIndex].laborAmount = + this.mobileFeePart.laborAmount; + supportingItems[mobileFeeIndex].sellingPrice = + this.mobileFeePart.sellingPrice; + supportingItems[mobileFeeIndex].kitPrice = this.mobileFeePart.kitPrice; + supportingItems[mobileFeeIndex].isInsurable = + this.mobileFeePart.isInsurable; + } else { + supportingItems.push(this.mobileFeePart); + } + shouldSaveSupportingItems = true; + } + } else { + const removeMobileFeeIndex = supportingItems.findIndex( + (item) => item.partType == MOBILE_FEE_PART_TYPE + ); + if (removeMobileFeeIndex >= 0) { + supportingItems.splice(removeMobileFeeIndex, 1); + shouldSaveSupportingItems = true; + } + } + + if (shouldSaveSupportingItems) { + this.dispatchStoreAction( + this.storeActions.SAVE_SUPPORTING_ITEMS_SUPPRESSING_STATE_RESETTING, + supportingItems, + false + ); + } + }, + updateAndSaveIsMSRFeeApplicable(appointmentType) { + const isMSRFeeApplicable = + this.isMobileStaticRecalibrationApplicable && + appointmentType === AppointmentTypeStrings.MOBILE; + this.dispatchStoreAction( + this.storeActions.SAVE_IS_MSR_FEE_APPLICABLE, + isMSRFeeApplicable + ); + }, + updateAndSaveIsMSRFeeCoveredByInsurance() { + const isMSRFeeCoveredByInsurance = this.mobileFeePart?.isInsurable; + this.dispatchStoreAction( + this.storeActions.SAVE_IS_MSR_FEE_COVERED_BY_INSURANCE, + isMSRFeeCoveredByInsurance + ); + }, updateSupportingItems(selectedScheduling = this.selectedScheduling) { const supportingItems = store.getters.lineItems?.supportingItems; if (!supportingItems) { @@ -826,6 +1019,9 @@ export default { ); } + this.updateAndSaveSupportingItems(appointmentType); + this.updateAndSaveIsMSRFeeApplicable(appointmentType); + this.updateAndSaveIsMSRFeeCoveredByInsurance(); this.updateSupportingItems(selectedScheduling); const selectedTimeSlot = this.getSelectedTimeSlot(