From 6d4b961d4bd9bc4e10b538eef27b4d7109ce2ad1 Mon Sep 17 00:00:00 2001 From: Carl Nation Date: Tue, 4 Aug 2026 06:37:09 -0400 Subject: [PATCH] CASH-3108 Items to update on zip code change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CASH-3108 Items to update on zip code change When the zip code changes on the new scheduling page, we need to reprice the recycle and mobile fees for the new zip and add to supporting items. We need to add and remove these fees depending on the user selection. ie: mobile appointments. Set MSR flags, isMSRFeeApplicable & isMSRFeeCoveredByInsurance, when MSR applies and it’s a mobile appointment. --- src/layouts/scheduling/scheduling.spec.js | 293 +++++++++++++++++++++- src/layouts/scheduling/scheduling.vue | 198 ++++++++++++++- 2 files changed, 489 insertions(+), 2 deletions(-) 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(