import { expect, type Locator, type Page } from '@playwright/test'; import { BasePage } from './BasePage'; import { IPaymentDetails } from 'safelite-playwright-core'; import { AppointmentTimeslot, ServiceLocation, PaymentType, ServicePackage, VehicleDamage } from 'safelite-playwright-core'; import { PaymentMethod, ProgressBarPercentages } from 'framework/localTypes/Enums'; import { PaymentPage } from './PaymentPage'; import { AfterpayPage } from './AfterpayPage'; import { PaypalPage } from './PaypalPage'; import { ITestData } from 'framework/TestData'; import { step } from 'framework/localTypes/Step'; export class PaymentMethodPage extends BasePage { readonly page: Page; readonly payAtServiceButton: Locator; // readonly paypalButton: Locator; // readonly creditCardButton: Locator; readonly payNowButton: Locator; readonly payWithInsuranceButton: Locator; readonly payInFourButton: Locator; readonly amountDueTextField: Locator; readonly amountDueDropDown: Locator; readonly appointmentDetailsSection: Locator; readonly appointmentDetailsDropdown: Locator; readonly subtotalAmountTextField: Locator; readonly submitButton: Locator; readonly recalibrationCheckbox: Locator; readonly paymentPage: PaymentPage; readonly paypalPage: PaypalPage; // Payment detail page validation locators readonly reviewTable: Locator; readonly vehicleSection: Locator; readonly damageSection: Locator; readonly serviceDetailsSection: Locator; readonly serviceLocationSection: Locator; readonly appointmentDateSection: Locator; readonly contactDetailsSection: Locator; readonly cartPanelDetails: Locator; readonly subtotalText: Locator; readonly finalAmountDueText: Locator; readonly glassServiceText: Locator; readonly wiperBladesText: Locator; readonly rainDefenseText: Locator; readonly deductibleText: Locator; url = process.env['BASE_URL']! + '/fmg/?fmgPage=payment-method'; constructor(page: Page) { super(page); this.page = page; this.payAtServiceButton = this.page.locator('[buttonlabel="Pay at my appointment"]'); //this.page.getByText('Pay at time of service'); this.amountDueTextField = this.page.getByLabel('expand cart').locator('.amount-due'); this.amountDueDropDown = this.page.getByLabel('expand cart'); this.appointmentDetailsSection = this.page.locator('div .appt-details-snapshot'); this.appointmentDetailsDropdown = this.page.getByLabel('expand appointment details'); this.payWithInsuranceButton = this.page.locator('div').filter({ hasText: /^Pay with insurance$/ }).nth(1); this.subtotalAmountTextField = this.page.locator('.sub-total span').nth(1); this.payNowButton = this.page.locator('[buttonlabel="Pay now"]'); this.payInFourButton = this.page.locator('[buttonlabel="Pay in 4 installments"]'); this.submitButton = this.page.locator('[data-test-id="nav-bar-main-button"]'); this.recalibrationCheckbox = this.page.getByLabel('I understand after windshield'); // this.creditCardButton = page.locator('div').filter({ hasText: /^Credit or Debit$/ }).nth(1); this.paymentPage = new PaymentPage(page); this.paypalPage = new PaypalPage(page); // Payment details validation locators this.reviewTable = this.page.locator('div.review-table'); // Section locators - find by heading text this.vehicleSection = this.page.locator('.review-table').locator('div', { hasText: 'Vehicle' }).first(); this.damageSection = this.page.locator('.review-table').locator('div', { hasText: 'Damage' }).first(); this.serviceDetailsSection = this.page.locator('.review-table').locator('div', { hasText: 'Glass service only' }).first(); this.serviceLocationSection = this.page.locator('.review-table').locator('div', { hasText: "We're coming to you" }).first(); this.appointmentDateSection = this.page.locator('.review-table').locator('div', { hasText: 'Appointment date + time' }).first(); this.contactDetailsSection = this.page.locator('.review-table').locator('div', { hasText: 'Contact details' }).first(); // Cart panel elements this.cartPanelDetails = this.page.locator('.cart-panel'); this.subtotalText = this.page.locator('.sub-total'); this.finalAmountDueText = this.cartPanelDetails.locator('div.amount-due'); this.glassServiceText = this.page.locator('div', { hasText: /^Glass service only$/ }); this.wiperBladesText = this.page.locator('div', { hasText: /^New wiper blades$/ }); this.rainDefenseText = this.page.locator('div', { hasText: /^Rain Defense™$/ }); this.deductibleText = this.page.locator('#deductible-value'); } async validatePaymentDetailsPage(testData: Partial) { // Destructure data we use const { vehicleDetails, customerDetails, servicePackage, flow, claimDetails, paymentDetails, appointmentDetails, isUseVehicleOnPolicy, paymentMethod, vehicleDamage } = testData; // Wait for review table to be visible to ensure page is loaded await this.appointmentDetailsDropdown.waitFor({state: "visible"}); await this.appointmentDetailsDropdown.click().then( async () => { await waitUntil(async () => { return (await this.page.locator('.review-toggle.expanded').evaluate(el => window.getComputedStyle(el, ':after').transform)) === 'matrix(-1, 0, 0, -1, 0, 0)'; }); } ); let expectedAppointmentDetails = new Map(); expectedAppointmentDetails = await this.getExpectedVehicleDetails(testData, expectedAppointmentDetails); expectedAppointmentDetails = await this.getExpectedVehicleDamage(testData, expectedAppointmentDetails); expectedAppointmentDetails = await this.expectedServicePackageDetails(testData, expectedAppointmentDetails); expectedAppointmentDetails = await this.getExpectedServiceLocation(testData, expectedAppointmentDetails); expectedAppointmentDetails = await this.getExpectedAppointmentDate(testData, expectedAppointmentDetails); expectedAppointmentDetails = await this.getExpectedCustomerDetails(testData, expectedAppointmentDetails); let actualAppointmentDetails = await this.getActualAppointmentDetails(); for (const key in expectedAppointmentDetails) { Soft.expect(actualAppointmentDetails[key]?.map(item => item.toLowerCase())) .toEqual(expectedAppointmentDetails[key].map(item => item.toLowerCase())); } // Expand cart to see all details await this.amountDueDropDown.click(); await this.reviewTable.waitFor({ state: "visible" }); // Cart Validation // Get pricing information from cart panel if (await this.subtotalText.isVisible()) { const subtotalValue = await this.subtotalText.innerText(); const finalAmountDueValue = await this.finalAmountDueText.innerText(); // Pricing validations differ by payment method if (paymentMethod === PaymentMethod.SelfPay && paymentDetails?.paymentType !== PaymentType.PayWithInsurance) { // Extract amounts for self-pay customers const subtotalAmount = this.extractAmount(subtotalValue); const finalAmountDueAmount = this.extractAmount(finalAmountDueValue); // Subtotal should be greater than 0 Soft.expect(subtotalAmount).toBeGreaterThan(0); // Final amount differs based on payment type if (paymentDetails?.paymentType === PaymentType.PayAtService) { Soft.expect(finalAmountDueAmount).toBeGreaterThan(0); } else if (paymentDetails?.paymentType === PaymentType.Credit || paymentDetails?.paymentType === PaymentType.Paypal || paymentDetails?.paymentType === PaymentType.AfterPay) { // For payment types that charge immediately, amount due could be 0 // This logic might need adjusting based on actual business rules } } else { // For insurance payments, check for proper indicators // TODO: Apply logic for deductible and insurance payment logic } // Service package validations const servicePackageValue = await this.cartPanelDetails.textContent(); await Soft.expect(servicePackageValue).toContain(`${servicePackage}`) if (servicePackage === ServicePackage.Premium || servicePackage === ServicePackage.Standard) { Soft.expect(servicePackageValue).toContain('New wiper blades'); } if (servicePackage === ServicePackage.Premium) { Soft.expect(servicePackageValue).toContain('Rain Repel Treatment'); } // Promo Code Validation if (paymentDetails!.promoCode) { expect.soft(servicePackageValue).toContain(`Promo code ${paymentDetails!.promoCode} applied`); }; // Early Bird line item validation if (appointmentDetails?.appointmentTimeSlot == AppointmentTimeslot.EarlyBird) { Soft.expect(servicePackageValue).toContain('Early bird'); } } } /** * Helper method to extract numeric amount from price strings like "$123.45" */ private extractAmount(valueString: string): number { const matches = valueString.match(/\$([0-9,]+(\.[0-9]{2})?)/); if (matches && matches[1]) { return parseFloat(matches[1].replace(/,/g, '')); } return 0; } async getAmountDue(): Promise { await expect(this.amountDueTextField).toBeVisible(); const text = await this.amountDueTextField.textContent(); if (!text) throw new Error("Amount due text is empty"); return text; } async getSubtotal(): Promise { await this.amountDueDropDown.click(); await expect(this.subtotalAmountTextField).toBeVisible(); const text = await this.subtotalAmountTextField.textContent(); if (!text) throw new Error("Subtotal text is empty"); return text; } async executePayment(paymentDetails: IPaymentDetails, isRecalVehicle: boolean) { const browserContext = this.page.context(); switch(paymentDetails.paymentType) { case PaymentType.Credit: await this.selectCreditCard(); await this.nextPage(); await this.paymentPage.populateCreditCardDetails(paymentDetails); await this.nextPage(); break; case PaymentType.AfterPay: await this.payInFourButton.click(); await this.nextPage(); // Capture popup const afterpayPopup = await browserContext.waitForEvent('page'); const afterpayPage = new AfterpayPage(afterpayPopup); // Execute payment await afterpayPage.executeAfterpayPayment(paymentDetails); break; case PaymentType.Paypal: await this.selectPaypal(); // TODO: Click paypal button await this.nextPage(); await this.paymentPage.navigateToPaypalCheckout(); await this.paypalPage.completePaypalPurchase(paymentDetails); break; case PaymentType.PayAtService: await this.selectPayAtService(isRecalVehicle); await this.nextPage(); break; case PaymentType.PayWithInsurance: await this.payWithInsuranceButton.click(); await this.nextPage(); break; default: console.error('PaymentMethodPage >> Logic for this payment method unimplemented'); break; } } async selectPaypal(){ await this.payNowButton.click(); } l async selectCreditCard(){ await this.payNowButton.click(); } async selectPayAtService(isRecalVehicle: boolean){ if (await this.payAtServiceButton.isVisible()) { await this.payAtServiceButton.click(); } else if (isRecalVehicle) { await this.recalibrationCheckbox.click(); } } async verifyVAPS(): Promise { // Get Vuex state from localStorage const vuexState = JSON.parse(await this.page.evaluate('localStorage.getItem(\'vuex\')')); // Validate the count of FRONT WIPER parts if (vuexState.order?.lineItems?.vaps?.length > 0) { const frontWiperPartsCount = vuexState.order.lineItems.vaps.filter(vap => vap.partType === "FRONT WIPER" ).length; await expect(frontWiperPartsCount).toBe(2); } else { throw new Error("No VAPS found in the order"); } } async getActualAppointmentDetails(): Promise { let actualAppointmentDetails = new Map(); let appointmentDetailsSubSections = await this.appointmentDetailsSection.locator('.review-table .py-3').all(); for (let element of appointmentDetailsSubSections) { let label = await element.locator('div .text-block').innerText(); //added to trim the text to remove any leading or trailing spaces (example: "expert installation " to "expert installation") let value = (await element.locator('.review-block-content').allInnerTexts() as string[]).map(item => item.trim()); actualAppointmentDetails[label] = value; } return actualAppointmentDetails; } async getExpectedVehicleDetails(testdata: Partial, expectedServicePackageDetails: Map): Promise { const { vehicleDetails } = testdata; expectedServicePackageDetails["Vehicle"] = [vehicleDetails?.year + " " + vehicleDetails?.make + " " + vehicleDetails?.model]; return expectedServicePackageDetails; } async getExpectedVehicleDamage(testdata: Partial, expectedServicePackageDetails: Map): Promise { const {vehicleDamage} = testdata; let vehicleDamageText: string[] = []; if (vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.WindshieldOneChip)) { vehicleDamageText.push("Windshield repair - 1 chip"); } else if (vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.WindshieldTwoChips)) { vehicleDamageText.push("Windshield repair - 2 chips"); } else if (vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.WindshieldThreeChips)) { vehicleDamageText.push("Windshield repair - 3 chips"); } if (vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.WindshieldCrack)) { vehicleDamageText.push("Windshield crack"); } if (vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.DriverVentGlass)) { vehicleDamageText.includes("Side door - Driver side") ? vehicleDamageText[vehicleDamageText.length - 1 ] += "\nVent glass" : vehicleDamageText.push("Side door - Driver side", "Vent glass"); } if (vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.DriverFrontDoor)) { vehicleDamageText.includes("Side door - Driver side") ? vehicleDamageText[vehicleDamageText.length - 1 ] += "\nFront door" : vehicleDamageText.push("Side door - Driver side", "Front door"); } if (vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.DriverRearDoor)) { vehicleDamageText.includes("Side door - Driver side") ? vehicleDamageText[vehicleDamageText.length - 1 ] += "\nBack door" : vehicleDamageText.push("Side door - Driver side", "Back door"); } if (vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.DriverQuarterPanel)) { vehicleDamageText.includes("Side door - Driver side") ? vehicleDamageText[vehicleDamageText.length - 1 ] += "\nQuarter panel" : vehicleDamageText.push("Side door - Driver side", "Quarter panel"); } if (vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.PassengerVentGlass)) { vehicleDamageText.includes("Side door - Passenger side") ? vehicleDamageText[vehicleDamageText.length - 1 ] += "\nVent glass" : vehicleDamageText.push("Side door - Passenger side", "Vent glass"); } if (vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.PassengerFrontDoor)) { vehicleDamageText.includes("Side door - Passenger side") ? vehicleDamageText[vehicleDamageText.length - 1 ] += "\nFront door" : vehicleDamageText.push("Side door - Passenger side", "Front door"); } if (vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.PassengerRearDoor)) { vehicleDamageText.includes("Side door - Passenger side") ? vehicleDamageText[vehicleDamageText.length - 1 ] += "\nBack door" : vehicleDamageText.push("Side door - Passenger side", "Back door"); } if (vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.PassengerQuarterPanel)) { vehicleDamageText.includes("Side door - Passenger side") ? vehicleDamageText[vehicleDamageText.length - 1 ] += "\nQuarter panel" : vehicleDamageText.push("Side door - Passenger side", "Quarter panel"); } if (vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.RearWindow || item == VehicleDamage.RearSliding)) { vehicleDamageText.push("Rear window"); } expectedServicePackageDetails["Damage"] = vehicleDamageText; return expectedServicePackageDetails; } async expectedServicePackageDetails(testData: Partial, expectedServicePackageDetails: Map): Promise { const {servicePackage} = testData; let localStorage= JSON.parse(await this.page.evaluate('localStorage.getItem(\'vuex\')')); let isRepair = localStorage.order.damage.isRepair as boolean; let isInsurance = localStorage.order.payment.isInsurance as boolean; let hasNonWindshieldGlass: boolean = false; if (!isRepair) { hasNonWindshieldGlass = localStorage.order.lineItems.glassParts.find((item: any) => item.partType !== "WINDSHIELD") ? true : false; } let isCaliforniaState = localStorage.order.serviceLocation.state as string == 'CA' ? true : false; let hasRecalPart: boolean = false; let canSafeliteRecalibrate: boolean = false; if (!isRepair) { hasRecalPart = localStorage.order.lineItems.glassParts.find((item: any) => item.requiresRecalibration === true) ? true : false; canSafeliteRecalibrate = localStorage.order.lineItems.glassParts.find((item: any) => item.canSafeliteRecalibrate === true) ? true : false; } let recalRequired = !isRepair && (isInsurance || isCaliforniaState) && hasRecalPart && canSafeliteRecalibrate let stringForRepair: string[] = ["Expert windshield repair", "Exclusive resin sealant", "Nationwide lifetime guarantee"]; let stringIfRecal = isRepair ? "" : recalRequired ? " and recalibration" : ""; let stringForReplace: string[] = [hasNonWindshieldGlass ? "New replacement glass" : "New replacement windshield", "Expert installation" + `${stringIfRecal}`, "Nationwide lifetime warranty"]; switch (servicePackage) { case ServicePackage.GlassOnly: expectedServicePackageDetails["Glass service only"] = isRepair ? stringForRepair : stringForReplace; break; case ServicePackage.Standard: stringForRepair.push("New wiper blades"); stringForReplace.push("New wiper blades"); expectedServicePackageDetails["Standard service"] = isRepair ? stringForRepair : stringForReplace; break; case ServicePackage.Premium: stringForRepair.push("New wiper blades", "Rain repel treatment"); stringForReplace.push("New wiper blades", "Rain repel treatment"); expectedServicePackageDetails["Premium service"] = isRepair ? stringForRepair : stringForReplace; break; } return expectedServicePackageDetails; } async getExpectedServiceLocation(testData: Partial, expectedServicePackageDetails: Map): Promise { const { appointmentDetails } = testData; let serviceLocationTitle = appointmentDetails?.serviceLocation == ServiceLocation.Mobile ? "We're coming to you" : "You're going to a Safelite shop"; let serviceLocation: string[] = []; serviceLocation.push( appointmentDetails?.serviceLocation == ServiceLocation.Mobile ? appointmentDetails?.serviceAddress ? appointmentDetails.serviceAddress.street + ", " + appointmentDetails.serviceAddress.city + ", " + appointmentDetails.serviceAddress.state + " " + appointmentDetails.serviceAddress.postalCode : "" : appointmentDetails?.shopAddress ? appointmentDetails.shopAddress : "" ); expectedServicePackageDetails[serviceLocationTitle] = serviceLocation; return expectedServicePackageDetails; } async getExpectedAppointmentDate(testData: Partial, expectedServicePackageDetails: Map): Promise { const { customerDetails, appointmentDetails } = testData; let appointmentDateText: string[] = []; const isMobileAppointment = appointmentDetails?.serviceLocation == ServiceLocation.Mobile if (isMobileAppointment) { let localStorage= JSON.parse(await this.page.evaluate('localStorage.getItem(\'vuex\')')); let jobMinMinutes = localStorage.order.schedule.jobMinMinutes as number; let jobMaxMinutes = localStorage.order.schedule.jobMaxMinutes as number; if (jobMinMinutes < 60) { customerDetails!.apptDuration = `${jobMinMinutes} - ${jobMaxMinutes} minutes`; } else { const minHours = Math.floor(jobMinMinutes / 60); const maxHours = Math.floor(jobMaxMinutes / 60); customerDetails!.apptDuration = `${minHours} - ${maxHours} hours`; } } appointmentDateText.push( customerDetails?.apptDate ? await this.getFormattedAppointmentDate(customerDetails.apptDate) + " " + customerDetails?.apptTime?.replace("-", "—") : "", customerDetails?.apptDuration ? "Estimated appointment length: " + customerDetails.apptDuration : "" ); expectedServicePackageDetails["Appointment date + time"] = appointmentDateText; return expectedServicePackageDetails; } async getExpectedCustomerDetails(testData: Partial, expectedServicePackageDetails: Map): Promise { const { customerDetails } = testData; let customerDetailsText: string[] = []; customerDetailsText.push( customerDetails?.firstName.toUpperCase() + " " + customerDetails?.lastName.toUpperCase(), customerDetails?.email ? customerDetails?.email.toUpperCase() : "", customerDetails?.phoneNumber ? customerDetails?.phoneNumber : "", "Opted out of text message updates" ); expectedServicePackageDetails["Contact details"] = customerDetailsText; return expectedServicePackageDetails; } async getFormattedAppointmentDate(appointmentDate: string) { // Parse the original date let parsedAppointmentDate = new Date(`${appointmentDate}` + 'T00:00:00'); // Format the new date as a string let updatedAppointmentDate = parsedAppointmentDate.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); return updatedAppointmentDate; } @step("PaymentMethodPage >> Select Payment Method: ") async handlePaymentMethodPage(testData: Partial) { const { servicePackage, isRecalVehicle, paymentDetails } = testData; await this.validateProgressBar(ProgressBarPercentages.PaymentMethodPage); await this.validatePaymentDetailsPage(testData); // Verify VAPS wipers on backend for standard and premium packages if (servicePackage === ServicePackage.Standard || servicePackage === ServicePackage.Premium) { await this.verifyVAPS(); } if (paymentDetails?.paymentType) { await this.executePayment(paymentDetails!, isRecalVehicle!); } else { await this.nextPage(); } } }