CASH-3036 add additional policy fields

CASH-3036 add additional policy fields
This commit is contained in:
Carl Nation 2026-07-14 06:35:56 -04:00
parent 7d1cfe935a
commit 70c1c6f4b9
8 changed files with 384 additions and 6 deletions

View file

@ -42,6 +42,7 @@ const errorMessages = {
INSURANCE_COMPANY_NAME_REQUIRED: "Please enter your insurance company name",
POLICY_NUMBER_REQUIRED: "Please enter your policy number",
POLICY_NUMBER_FORMAT: "Please enter a valid policy number",
CLAIM_NUMBER_REQUIRED: "Please enter your claim number",
CLAIM_NUMBER_VALID: "Please enter a valid claim number",
CLAIM_NUMBER_FORMAT: "Claim number must be 6 to 8 digits",
DATE_OF_LOSS_REQUIRED: "Please enter your date of damage",

View file

@ -0,0 +1,54 @@
// CMS answer `Name` values that can appear in MorePolicyQuestionsWidget /
// MorePolicyQuestionsOverrideWidget's Answers array (CMS content is a superset
// across both widget variants).
export const morePolicyQuestionAnswers = {
ADDITIONAL_DAMAGE: "AdditionalDamage",
RENTAL: "Rental",
OTHER_RESPONSIBLE_PARTY: "OtherResponsibleParty",
THIRD_PARTY_VEHICLE: "ThirdPartyVehicle",
INJURIES: "Injuries",
};
// Maps each possible CMS answer Name to the boolean flag persisted in the store
// and sent to the save-session API, regardless of which widget variant (and
// therefore which subset of answers) is shown to the user.
export const morePolicyQuestionAnswerToFlag = {
[morePolicyQuestionAnswers.ADDITIONAL_DAMAGE]: "additionalDamage",
[morePolicyQuestionAnswers.RENTAL]: "rental",
[morePolicyQuestionAnswers.OTHER_RESPONSIBLE_PARTY]: "otherResponsibleParty",
[morePolicyQuestionAnswers.THIRD_PARTY_VEHICLE]: "thirdPartyVehicle",
[morePolicyQuestionAnswers.INJURIES]: "injuries",
};
export function getDefaultMorePolicyQuestions() {
return Object.values(morePolicyQuestionAnswerToFlag).reduce((flags, flagName) => {
flags[flagName] = false;
return flags;
}, {});
}
// Converts the array of selected CMS answer Names (as emitted by the
// checkbox-based buttonQuestion component) into the boolean-flag object shape
// that is persisted to the store and sent to the backend.
export function morePolicyQuestionAnswerNamesToFlags(selectedAnswerNames) {
const flags = getDefaultMorePolicyQuestions();
for (const answerName of Array.isArray(selectedAnswerNames) ? selectedAnswerNames : []) {
const flagName = morePolicyQuestionAnswerToFlag[answerName];
if (flagName) {
flags[flagName] = true;
}
}
return flags;
}
// Reverses morePolicyQuestionAnswerNamesToFlags, so the checkbox-based
// buttonQuestion component (which expects an array of selected values) can be
// pre-populated from previously persisted flags.
export function morePolicyQuestionFlagsToAnswerNames(flags) {
if (!flags) {
return [];
}
return Object.entries(morePolicyQuestionAnswerToFlag)
.filter(([, flagName]) => flags[flagName])
.map(([answerName]) => answerName);
}

View file

@ -0,0 +1,88 @@
import { shallowMount } from "@vue/test-utils";
import morePolicyQuestions from "./more-policy-questions";
const answersFromCms = [
{ Name: "AdditionalDamage", Text: "Additional damage occurred" },
{ Name: "Rental", Text: "The vehicle was a rental" },
{ Name: "OtherResponsibleParty", Text: "Another party is responsible" },
];
const mockMixin = {
methods: {
getCmsContent: jest.fn().mockImplementation((widgetName, property) => {
if (property === "Answers") {
return answersFromCms;
}
return "Please select all that apply";
}),
},
};
function mountComponent(modelValue) {
return shallowMount(morePolicyQuestions, {
props: {
modelValue,
groupName: "MorePolicyQuestionsQuestion",
cmsWidgetName: "MorePolicyQuestionsWidget",
},
mixins: [mockMixin],
});
}
describe("more-policy-questions.vue", () => {
it("converts previously selected boolean flags into the array of CMS answer Names for the checkbox group", () => {
// Act
const wrapper = mountComponent({
additionalDamage: true,
rental: false,
otherResponsibleParty: true,
thirdPartyVehicle: false,
injuries: false,
});
// Assert
expect(wrapper.vm.selectedValues).toEqual(["AdditionalDamage", "OtherResponsibleParty"]);
});
it("treats a null/undefined modelValue as no answers selected", () => {
// Act
const wrapper = mountComponent(undefined);
// Assert
expect(wrapper.vm.selectedValues).toEqual([]);
});
it("emits a full set of boolean flags reflecting which CMS answer Names were checked", () => {
// Arrange
const wrapper = mountComponent({});
// Act
wrapper.vm.selectedValues = ["Rental", "Injuries"];
// Assert
expect(wrapper.emitted("update:modelValue")[0][0]).toEqual({
additionalDamage: false,
rental: true,
otherResponsibleParty: false,
thirdPartyVehicle: false,
injuries: true,
});
});
it("emits every flag as false when no answers are checked", () => {
// Arrange
const wrapper = mountComponent({ rental: true });
// Act
wrapper.vm.selectedValues = [];
// Assert
expect(wrapper.emitted("update:modelValue")[0][0]).toEqual({
additionalDamage: false,
rental: false,
otherResponsibleParty: false,
thirdPartyVehicle: false,
injuries: false,
});
});
});

View file

@ -13,11 +13,18 @@
<script>
import buttonQuestion from "@/digital-components/button-question/button-question";
import {
morePolicyQuestionAnswerNamesToFlags,
morePolicyQuestionFlagsToAnswerNames,
} from "@/constants/more-policy-questions";
export default {
name: "morePolicyQuestions",
props: {
modelValue: Array,
// Boolean flags keyed by the camelCase name of each possible policy
// question (e.g. { additionalDamage, rental, otherResponsibleParty,
// thirdPartyVehicle, injuries }), see @/constants/more-policy-questions.
modelValue: Object,
groupName: String,
cmsWidgetName: String,
},
@ -33,12 +40,18 @@ export default {
if (!Array.isArray(this.answersFromCms)) return [];
return this.answersFromCms;
},
// The checkbox-based buttonQuestion component works with an array of
// selected CMS answer Names; translate to/from the boolean-flag object
// that this component's own v-model exposes to its parent.
selectedValues: {
get() {
return this.modelValue;
return morePolicyQuestionFlagsToAnswerNames(this.modelValue);
},
set(newValue) {
this.$emit("update:modelValue", newValue);
set(newSelectedAnswerNames) {
this.$emit(
"update:modelValue",
morePolicyQuestionAnswerNamesToFlags(newSelectedAnswerNames)
);
},
},
},

View file

@ -47,6 +47,82 @@ describe("policy-info.vue", () => {
});
});
describe("displayClaimNumber", () => {
test("is false when no morePolicyQuestions flags are set", async () => {
const { wrapper } = setupMocks();
wrapper.vm.morePolicyQuestions = {
additionalDamage: false,
rental: false,
otherResponsibleParty: false,
thirdPartyVehicle: false,
injuries: false,
};
expect(wrapper.vm.displayClaimNumber).toBe(false);
});
test("is true when additionalDamage is checked", async () => {
const { wrapper } = setupMocks();
wrapper.vm.morePolicyQuestions = {
additionalDamage: true,
rental: false,
otherResponsibleParty: false,
thirdPartyVehicle: false,
injuries: false,
};
expect(wrapper.vm.displayClaimNumber).toBe(true);
});
test("is true when rental is checked", async () => {
const { wrapper } = setupMocks();
wrapper.vm.morePolicyQuestions = {
additionalDamage: false,
rental: true,
otherResponsibleParty: false,
thirdPartyVehicle: false,
injuries: false,
};
expect(wrapper.vm.displayClaimNumber).toBe(true);
});
test("is false when only unrelated flags (e.g. otherResponsibleParty, thirdPartyVehicle, injuries) are checked", async () => {
const { wrapper } = setupMocks();
wrapper.vm.morePolicyQuestions = {
additionalDamage: false,
rental: false,
otherResponsibleParty: true,
thirdPartyVehicle: true,
injuries: true,
};
expect(wrapper.vm.displayClaimNumber).toBe(false);
});
test("is false when morePolicyQuestions is not yet populated", async () => {
const { wrapper } = setupMocks();
wrapper.vm.morePolicyQuestions = null;
expect(wrapper.vm.displayClaimNumber).toBe(false);
});
});
describe("claim-number-required validation rule", () => {
test("fails when value is empty", async () => {
const result = await validate("", "claim-number-required");
expect(result.valid).toBe(false);
expect(result.errors).toContain(errorMessages.CLAIM_NUMBER_REQUIRED);
});
test("passes when value is provided", async () => {
const result = await validate("12345678", "claim-number-required");
expect(result.valid).toBe(true);
});
});
describe("forwardButtonAction", () => {
// Regression coverage: clicking Continue must explicitly (re)validate the form
// rather than assuming meta.valid/isForwardActionDisabled already caught everything,

View file

@ -180,10 +180,20 @@
<morePolicyQuestions
ref="morePolicyQuestions"
class="mb-4"
:cmsWidgetName="morePolicyQuestionsWidgetName"
v-model="morePolicyQuestions"
groupName="MorePolicyQuestionsQuestion" />
<textboxQuestion
v-if="displayClaimNumber"
isRequired
class="mb-4"
cmsWidgetName="ClaimNumberWidget"
v-model="claimNumber"
inputId="claimNumber"
validationRules="claim-number-required" />
<insuranceNavBar
cmsWidgetName="FunnelFooterWidget"
ref="navbar"
@ -212,6 +222,10 @@ import dropdownQuestion from "@/digital-components/dropdown-question/dropdown-qu
import morePolicyQuestions from "@/layouts/policy-info/more-policy-questions/more-policy-questions";
import buttonQuestion from "@/digital-components/button-question/button-question";
import { stateOptions } from "@/constants/state-options";
import {
morePolicyQuestionAnswers,
morePolicyQuestionAnswerToFlag,
} from "@/constants/more-policy-questions";
import { Form, defineRule } from "vee-validate";
import { required } from "@/helpers/validation-rules";
import { errorMessages } from "@/constants/error-messages";
@ -317,6 +331,7 @@ defineRule("street-address-required", required(errorMessages.STREET_ADDRESS_REQU
defineRule("loss-time-required", required(errorMessages.LOSS_TIME_REQUIRED));
defineRule("loss-location-required", required(errorMessages.LOSS_LOCATION_REQUIRED));
defineRule("subrogation-required", required(errorMessages.SUBROGATION_REQUIRED));
defineRule("claim-number-required", required(errorMessages.CLAIM_NUMBER_REQUIRED));
export default {
name: "policy-info",
@ -339,6 +354,7 @@ export default {
morePolicyQuestions: this.getMorePolicyQuestionsFromStore(),
subrogationSelectedValue: this.getSubrogationSelectedValueFromStore(),
lossLocation: this.getLossLocationFromStore(),
claimNumber: this.getClaimNumberFromStore(),
};
},
components: {
@ -471,6 +487,17 @@ export default {
? "MorePolicyQuestionsOverrideWidget"
: "MorePolicyQuestionsWidget";
},
// Only ask for a claim number if the user indicated additional damage
// occurred or the vehicle was a rental, per the morePolicyQuestions answers.
displayClaimNumber() {
const additionalDamageFlag =
morePolicyQuestionAnswerToFlag[morePolicyQuestionAnswers.ADDITIONAL_DAMAGE];
const rentalFlag = morePolicyQuestionAnswerToFlag[morePolicyQuestionAnswers.RENTAL];
return Boolean(
this.morePolicyQuestions?.[additionalDamageFlag] ||
this.morePolicyQuestions?.[rentalFlag]
);
},
},
// Ensure CMS content is fetched during route navigation without depending on `to`/`from`
async beforeRouteEnter(to, from, next) {
@ -560,11 +587,14 @@ export default {
return store.getters.order.policy.subrogation;
},
getMorePolicyQuestionsFromStore() {
return store.getters.order.policy.morePolicyQuestions ?? [];
return store.getters.order.policy.morePolicyQuestions;
},
getLossLocationFromStore() {
return store.getters.order.policy.lossLocation;
},
getClaimNumberFromStore() {
return store.getters.order.payment.insuranceCoverage.claimNumber;
},
cancelButtonAction() {
this.$router.navigateWithoutSaving(
this.navigationScenarios.CLICKED_CANCEL_VERIFICATION,
@ -599,6 +629,7 @@ export default {
dateOfBirth: this.dateOfBirth,
subrogation: this.subrogationSelectedValue,
streetAddress: this.streetAddress,
claimNumber: this.claimNumber,
},
false
);

View file

@ -59,7 +59,6 @@ import {
import { externalParameterStatus } from "@/constants/external-parameters";
import { experimentSettings } from "@/constants/experiments";
import { addPricesToLineItems, getAmountDue } from "@/helpers/pricing-helper";
// Export State
const getDefaultState = () => {
return {
@ -190,6 +189,13 @@ const getDefaultState = () => {
lossLocation: null,
lastName: null,
subrogation: null,
morePolicyQuestions: {
additionalDamage: false,
rental: false,
otherResponsibleParty: false,
thirdPartyVehicle: false,
injuries: false,
},
},
schedule: {
date: null,
@ -1009,6 +1015,8 @@ export const mutations = {
state.order.policy.insuranceCompanyName =
sessionInformation.order.policy?.insuranceCompanyName;
state.order.policy.morePolicyQuestions = sessionInformation.order.policy?.morePolicyQuestions;
state.order.policy.isNoComp =
coverageTypeValue(sessionInformation?.order.insuranceCoverage.coverageType) ===
coverageType.NOCOMP
@ -2895,6 +2903,7 @@ export const actions = {
currentDeductible: order.policy?.currentDeductible,
originalDeductible: order.policy?.originalDeductible,
policyNumber: order.policy?.policyNumber,
morePolicyQuestions: order.policy?.morePolicyQuestions,
},
serviceLocation: {
streetAddress: order.serviceLocation.address,

View file

@ -795,6 +795,112 @@ describe("Actions", () => {
expect(response.data).toEqual({ referralNumber: 123 });
});
it("saveSession action, includes the selected morePolicyQuestions answers in the policy payload", async () => {
// Arrange
const context = state;
context.getters = {
vehicle: {
registration: {},
},
order: {
damage: {
numberOfChips: "2",
partQuestionAnswers: {},
moldingQuestionAnswers: {},
capabilityQuestionAnswers: {},
},
},
damage: {},
applicationUser: {
lastPageVisited: "test-page",
crmCustomerId: "xxx-xxx-xxx",
savedSessionId: "xxx-xxx-xxx",
},
};
context.state = {
order: {
damage: {
numberOfChips: "2",
partQuestionAnswers: {},
moldingQuestionAnswers: {},
capabilityQuestionAnswers: {},
},
payment: {
insuranceCoverage: {
isVerified: false,
},
isInsurance: false,
ccToken: {
subscriptionId: null,
expMonth: null,
expYear: null,
cardType: null,
billToPostalCode: null,
billToFirstName: null,
billToLastName: null,
referenceNumber: null,
authCode: null,
transactionId: null,
transReferenceNumber: null,
lastFour: null,
},
},
policy: {
morePolicyQuestions: {
additionalDamage: true,
rental: true,
otherResponsibleParty: false,
thirdPartyVehicle: false,
injuries: false,
},
},
serviceLocation: {},
customer: {
emailAddress: "test@safelite.com",
firstName: "John",
lastName: "Doe",
isSmsOptIn: true,
phoneNumber: "1234567890",
address: {
streetAddress: "123 Main St",
streetAddress2: "Apt 1",
city: "Anytown",
state: "OH",
zipCode: "12345",
},
},
lineItems: {},
},
};
globalMethods.callHttpClient.mockImplementation(() => {
return Promise.resolve({ data: { referralNumber: 123 } });
});
// Act
await actions.saveSession(context, { pageNameToLog: "test" });
// Assert
expect(globalMethods.callHttpClient).toHaveBeenCalledWith(
expect.objectContaining({
payload: expect.objectContaining({
order: expect.objectContaining({
policy: expect.objectContaining({
morePolicyQuestions: {
additionalDamage: true,
rental: true,
otherResponsibleParty: false,
thirdPartyVehicle: false,
injuries: false,
},
}),
}),
}),
})
);
});
it("loadSession action, returns order information, calls mutation", async () => {
// Arrange
const context = state;