Merge pull request #3304 from Safelite/nation/CASH-3108

CASH-3108 Items to update on zip code change
This commit is contained in:
CarlNation 2026-08-04 09:04:32 -04:00 committed by GitHub
commit b81972f401
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 489 additions and 2 deletions

View file

@ -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)", () => {

View file

@ -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(