Repository: bcgov/onroutebc
Pull request #1951: ORV2-3409: Create Non-Resident Quarterly ICBC Basic Insurance (QRFR) Permits
Author: login: zgong-gov
Created: 2025-04-17T01:21:33Z | Merged: 2025-04-17T17:10:59Z

<!-- Provide a general summary of your changes in the Title above -->

# Description

Please provide a summary of the change and the issue fixed. Please include relevant context. List dependency changes.

- Introduce Non-Resident Quarterly ICBC Basic Insurance (QRFR) permit type to be used by staff and CV clients
- Modifications to logic related to permit duration, to accommodate QRFR requirements (quarterly durations/periods)

## Type of change

Please delete options that are not relevant.

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update

# How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration

- [x] Observe that all existing permit types still work as expected.
- [x] Observe that both CV clients and staff can now create and update a QRFR permit application.


## Checklist

<!-- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!-- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->

- [x] I have read the [CONTRIBUTING](CONTRIBUTING.md) doc
- [x] I have performed a self-review of my own code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have already been accepted and merged


## Further comments

<!-- If this is a relatively large or complex change, kick off the discussion by explaining why you chose the solution you did and what alternatives you considered, etc... -->


---

Thanks for the PR!

Deployments, as required, will be available below:
- [Frontend](https://onroutebc-1951-frontend.apps.silver.devops.gov.bc.ca)
- [Vehicles](https://onroutebc-1951-vehicles.apps.silver.devops.gov.bc.ca/api)
- [Dops](https://onroutebc-1951-dops.apps.silver.devops.gov.bc.ca/api)
- [Policy](https://onroutebc-1951-policy.apps.silver.devops.gov.bc.ca/api)
- [Scheduler](https://onroutebc-1951-scheduler.apps.silver.devops.gov.bc.ca/api)

Please create PRs in draft mode.  Mark as ready to enable:
- [Analysis Workflow](https://github.com/bcgov/onroutebc/actions/workflows/analysis.yml)

After merge, new images are promoted to:
- [Merge Workflow](https://github.com/bcgov/onroutebc/actions/workflows/merge.yml)


Diff:
diff --git a/frontend/src/common/helpers/formatDate.ts b/frontend/src/common/helpers/formatDate.ts
index ca98886694..2aea54671c 100644
--- a/frontend/src/common/helpers/formatDate.ts
+++ b/frontend/src/common/helpers/formatDate.ts
@@ -3,12 +3,14 @@ import utc from "dayjs/plugin/utc";
 import timezone from "dayjs/plugin/timezone";
 import localizedFormat from "dayjs/plugin/localizedFormat";
 import advancedFormat from "dayjs/plugin/advancedFormat";
+import quarterOfYear from "dayjs/plugin/quarterOfYear";
 
 // Need to add these plugins here
 dayjs.extend(utc); // for using utc
 dayjs.extend(timezone); // for using timezones
 dayjs.extend(localizedFormat); // for using localized datetime formats (eg. LLL)
 dayjs.extend(advancedFormat); // for using advanced datetime formats
+dayjs.extend(quarterOfYear); // for calculating dates based on quarters of a year
 
 export const DATE_FORMATS = {
   DATEONLY: "YYYY-MM-DD",
@@ -126,7 +128,7 @@ export const getDateDiffInDays = (laterDt: Dayjs, earlierDt: Dayjs) => {
 
 /**
  * Get the start of any datetime (ie. the date + time of 00:00:00 am).
- * @param date Any Dayjs object
+ * @param date Any Dayjs object or date string representation
  * @returns Dayjs object representing the start of the datetime (with time 00:00:00 am)
  */
 export const getStartOfDate = (date: Dayjs | string) => {
@@ -135,9 +137,27 @@ export const getStartOfDate = (date: Dayjs | string) => {
 
 /**
  * Get the end of any datetime (ie. the date + time of 23:59:59 pm).
- * @param date Any Dayjs object
+ * @param date Any Dayjs object or date string representation
  * @returns Dayjs object representing the end of the datetime (with time 23:59:59 pm)
  */
 export const getEndOfDate = (date: Dayjs | string) => {
   return dayjs(date).hour(23).minute(59).second(59).millisecond(999);
 };
+
+/**
+ * Get the end of the quarter for any given datetime (ie. the date + time of 23:59:59 pm).
+ * @param date Any Dayjs object or date string representation
+ * @returns Dayjs object representing the end of the quarter for the datetime (with time 23:59:59 pm)
+ */
+export const getEndOfQuarter = (date: Dayjs | string) => {
+  return dayjs(date).endOf("quarter").hour(23).minute(59).second(59).millisecond(999);
+};
+
+/**
+ * Get the quarter number for a given date.
+ * @param date Any Dayjs object or date string representation
+ * @returns Number representing the quarter that the date is in
+ */
+export const getQuarterForDate = (date: Dayjs | string) => {
+  return dayjs(date).quarter();
+};
diff --git a/frontend/src/features/permits/components/dashboard/ApplicationStepPage.tsx b/frontend/src/features/permits/components/dashboard/ApplicationStepPage.tsx
index 4af5f84004..0152d9e6d2 100644
--- a/frontend/src/features/permits/components/dashboard/ApplicationStepPage.tsx
+++ b/frontend/src/features/permits/components/dashboard/ApplicationStepPage.tsx
@@ -64,6 +64,7 @@ export const ApplicationStepPage = ({
   const enableSTOS = featureFlags?.["STOS"] === "ENABLED";
   const enableMFP = featureFlags?.["MFP"] === "ENABLED";
   const enableSTFR = featureFlags?.["STFR"] === "ENABLED";
+  const enableQRFR = featureFlags?.["QRFR"] === "ENABLED";
   
   // Query for the application data whenever this page is rendered
   const {
@@ -101,12 +102,13 @@ export const ApplicationStepPage = ({
   );
 
   // Currently onRouteBC only handles TROS and TROW permits
-  // STOS is only allowed if feature flag is enabled, and same for MFP, STFR, etc.
+  // other permit types are only allowed if its feature flag is enabled
   const isPermitTypeAllowed = () => {
     const allowedPermitTypes: string[] = ([PERMIT_TYPES.TROS, PERMIT_TYPES.TROW] as string[])
       .concat(enableSTOS ? [PERMIT_TYPES.STOS] : [])
       .concat(enableMFP ? [PERMIT_TYPES.MFP] : [])
-      .concat(enableSTFR ? [PERMIT_TYPES.STFR] : []);
+      .concat(enableSTFR ? [PERMIT_TYPES.STFR] : [])
+      .concat(enableQRFR ? [PERMIT_TYPES.QRFR] : []);
 
     return allowedPermitTypes.includes(applicationPermitType);
   };
diff --git a/frontend/src/features/permits/components/dashboard/tests/integration/fixtures/getActiveApplication.ts b/frontend/src/features/permits/components/dashboard/tests/integration/fixtures/getActiveApplication.ts
index 22a01e65c3..29fdcf5807 100644
--- a/frontend/src/features/permits/components/dashboard/tests/integration/fixtures/getActiveApplication.ts
+++ b/frontend/src/features/permits/components/dashboard/tests/integration/fixtures/getActiveApplication.ts
@@ -4,7 +4,7 @@ import { getDefaultUserDetails } from "./getUserDetails";
 import { getDefaultPowerUnits } from "./getVehicleInfo";
 import { getDefaultCompanyInfo } from "./getCompanyInfo";
 import { TROS_CONDITIONS } from "../../../../../constants/tros";
-import { DEFAULT_PERMIT_TYPE, PERMIT_TYPES } from "../../../../../types/PermitType";
+import { DEFAULT_PERMIT_TYPE, isQuarterlyPermit, PERMIT_TYPES } from "../../../../../types/PermitType";
 import { getExpiryDate } from "../../../../../helpers/permitState";
 import { VEHICLE_TYPES } from "../../../../../../manageVehicles/types/Vehicle";
 import { PermitStatus } from "../../../../../types/PermitStatus";
@@ -140,7 +140,12 @@ export const getDefaultApplication = () => {
   const startDate = dayjsToLocalStr(currentDt, DATE_FORMATS.DATEONLY);
   const permitType = DEFAULT_PERMIT_TYPE;
   const minDuration = minDurationForPermitType(permitType);
-  const expiryDt = getExpiryDate(currentDt, minDuration);
+  const expiryDt = getExpiryDate(
+    currentDt,
+    isQuarterlyPermit(permitType),
+    minDuration,
+  );
+  
   const expiryDate = dayjsToLocalStr(expiryDt, DATE_FORMATS.DATEONLY);
   const { companyId, userDetails } = getDefaultUserDetails();
   
diff --git a/frontend/src/features/permits/constants/constants.ts b/frontend/src/features/permits/constants/constants.ts
index a20c91ca0b..5b896a06db 100644
--- a/frontend/src/features/permits/constants/constants.ts
+++ b/frontend/src/features/permits/constants/constants.ts
@@ -52,6 +52,10 @@ export const ALL_PERMIT_TYPE_CHOOSE_FROM_OPTIONS: PermitTypeChooseFromItem[] = [
     value: PERMIT_CATEGORIES.NON_RESIDENT,
     label: getPermitCategoryName(PERMIT_CATEGORIES.NON_RESIDENT),
     items: [
+      {
+        value: PERMIT_TYPES.QRFR,
+        label: getPermitTypeShortName(PERMIT_TYPES.QRFR),
+      },
       {
         value: PERMIT_TYPES.STFR,
         label: getPermitTypeShortName(PERMIT_TYPES.STFR),
diff --git a/frontend/src/features/permits/constants/qrfr.ts b/frontend/src/features/permits/constants/qrfr.ts
new file mode 100644
index 0000000000..855f9afb2c
--- /dev/null
+++ b/frontend/src/features/permits/constants/qrfr.ts
@@ -0,0 +1,13 @@
+import { PermitCondition } from "../types/PermitCondition";
+
+export const QRFR_CONDITIONS: PermitCondition[] = [
+  {
+    description: "Insurance Certificate Conditions",
+    condition: "APV96",
+    conditionLink: "https://www.th.gov.bc.ca/forms/getForm.aspx?formId=1547",
+    checked: true,
+    disabled: true
+  },
+];
+
+export const MANDATORY_QRFR_CONDITIONS: PermitCondition[] = [...QRFR_CONDITIONS];
diff --git a/frontend/src/features/permits/helpers/conditions.ts b/frontend/src/features/permits/helpers/conditions.ts
index c732ca9ffc..3b56464f95 100644
--- a/frontend/src/features/permits/helpers/conditions.ts
+++ b/frontend/src/features/permits/helpers/conditions.ts
@@ -1,6 +1,7 @@
 import { isVehicleSubtypeLCV } from "../../manageVehicles/helpers/vehicleSubtypes";
 import { LCV_CONDITION } from "../constants/constants";
 import { MANDATORY_STFR_CONDITIONS, STFR_CONDITIONS } from "../constants/stfr";
+import { MANDATORY_QRFR_CONDITIONS, QRFR_CONDITIONS } from "../constants/qrfr";
 import { MANDATORY_MFP_CONDITIONS, MFP_CONDITIONS } from "../constants/mfp";
 import { MANDATORY_STOS_CONDITIONS, STOS_CONDITIONS } from "../constants/stos";
 import { MANDATORY_TROS_CONDITIONS, TROS_CONDITIONS } from "../constants/tros";
@@ -20,6 +21,8 @@ export const getMandatoryConditions = (
 ) => {
   const additionalConditions = includeLcvCondition ? [LCV_CONDITION] : [];
   switch (permitType) {
+    case PERMIT_TYPES.QRFR:
+      return MANDATORY_QRFR_CONDITIONS.concat(additionalConditions);
     case PERMIT_TYPES.STFR:
       return MANDATORY_STFR_CONDITIONS.concat(additionalConditions);
     case PERMIT_TYPES.MFP:
@@ -41,6 +44,8 @@ const getConditionsByPermitType = (
 ) => {
   const additionalConditions = includeLcvCondition ? [LCV_CONDITION] : [];
   switch (permitType) {
+    case PERMIT_TYPES.QRFR:
+      return QRFR_CONDITIONS.concat(additionalConditions);
     case PERMIT_TYPES.STFR:
       return STFR_CONDITIONS.concat(additionalConditions);
     case PERMIT_TYPES.MFP:
diff --git a/frontend/src/features/permits/helpers/dateSelection.ts b/frontend/src/features/permits/helpers/dateSelection.ts
index 88d09d6dc7..9769599e47 100644
--- a/frontend/src/features/permits/helpers/dateSelection.ts
+++ b/frontend/src/features/permits/helpers/dateSelection.ts
@@ -4,7 +4,7 @@ import {
   BASE_DAYS_IN_YEAR,
   TERM_DURATION_INTERVAL_DAYS,
 } from "../constants/constants";
-import { PERMIT_TYPES, PermitType } from "../types/PermitType";
+import { isQuarterlyPermit, PERMIT_TYPES, PermitType } from "../types/PermitType";
 import { getExpiryDate } from "./permitState";
 import { getMostRecentExpiryFromLOAs } from "./permitLOA";
 import { PermitLOA } from "../types/PermitLOA";
@@ -60,6 +60,7 @@ export const durationOptionsForPermitType = (permitType: PermitType) => {
       return TROW_DURATION_OPTIONS;
     case PERMIT_TYPES.TROS:
       return TROS_DURATION_OPTIONS;
+    case PERMIT_TYPES.QRFR:
     default:
       return [];
   }
@@ -82,6 +83,7 @@ export const minDurationForPermitType = (permitType: PermitType) => {
       return MIN_TROW_DURATION;
     case PERMIT_TYPES.TROS:
       return MIN_TROS_DURATION;
+    case PERMIT_TYPES.QRFR:
     default:
       return 0;
   }
@@ -142,7 +144,11 @@ export const getMinPermitExpiryDate = (
   startDate: Dayjs,
 ) => {
   const minDuration = minDurationForPermitType(permitType);
-  return getExpiryDate(startDate, minDuration);
+  return getExpiryDate(
+    startDate,
+    isQuarterlyPermit(permitType),
+    minDuration,
+  );
 };
 
 /**
@@ -165,7 +171,13 @@ export const getAvailableDurationOptions = (
 
   return fullDurationOptions.filter(
     ({ value: durationDays }) =>
-      !mostRecentLOAExpiry.isBefore(getExpiryDate(startDate, durationDays)),
+      !mostRecentLOAExpiry.isBefore(
+        getExpiryDate(
+          startDate,
+          false, // only non-quarterly permits have duration options
+          durationDays,
+        ),
+      ),
   );
 };
 
diff --git a/frontend/src/features/permits/helpers/getDefaultApplicationFormData.ts b/frontend/src/features/permits/helpers/getDefaultApplicationFormData.ts
index e6493048da..52d3b67e08 100644
--- a/frontend/src/features/permits/helpers/getDefaultApplicationFormData.ts
+++ b/frontend/src/features/permits/helpers/getDefaultApplicationFormData.ts
@@ -4,7 +4,7 @@ import { BCeIDUserDetailContext } from "../../../common/authentication/OnRouteBC
 import { getMandatoryConditions } from "./conditions";
 import { Nullable } from "../../../common/types/common";
 import { PERMIT_STATUSES } from "../types/PermitStatus";
-import { PERMIT_TYPES, PermitType } from "../types/PermitType";
+import { isQuarterlyPermit, PERMIT_TYPES, PermitType } from "../types/PermitType";
 import { getExpiryDate } from "./permitState";
 import { PermitMailingAddress } from "../types/PermitMailingAddress";
 import { PermitContactDetails } from "../types/PermitContactDetails";
@@ -125,13 +125,14 @@ export const getStartDateOrDefault = (
 
 export const getExpiryDateOrDefault = (
   startDateOrDefault: Dayjs,
+  isQuarterly: boolean,
   durationOrDefault: number,
   expiryDate?: Nullable<Dayjs | string>,
 ): Dayjs => {
   return applyWhenNotNullable(
     (date) => getEndOfDate(dayjs(date)),
     expiryDate,
-    getExpiryDate(startDateOrDefault, durationOrDefault),
+    getExpiryDate(startDateOrDefault, isQuarterly, durationOrDefault),
   );
 };
 
@@ -161,6 +162,7 @@ export const getDefaultValues = (
 
   const expiryDateOrDefault = getExpiryDateOrDefault(
     startDateOrDefault,
+    isQuarterlyPermit(permitType),
     durationOrDefault,
     applicationData?.permitData?.expiryDate,
   );
diff --git a/frontend/src/features/permits/helpers/permitState.ts b/frontend/src/features/permits/helpers/permitState.ts
index e5c9dcef26..9de0667070 100644
--- a/frontend/src/features/permits/helpers/permitState.ts
+++ b/frontend/src/features/permits/helpers/permitState.ts
@@ -5,9 +5,11 @@ import { Nullable } from "../../../common/types/common";
 import { removeEmptyIdsFromPermitsActionResponse } from "./mappers";
 import { minDurationForPermitType } from "./dateSelection";
 import { BASE_DAYS_IN_YEAR } from "../constants/constants";
+import { isQuarterlyPermit } from "../types/PermitType";
 import {
   getDateDiffInDays,
   getEndOfDate,
+  getEndOfQuarter,
   getStartOfDate,
   now,
   toLocalDayjs,
@@ -34,8 +36,10 @@ export const daysLeftBeforeExpiry = (permit: Permit) => {
   const permitStartDate = getStartOfDate(
     toLocalDayjs(permit.permitData.startDate),
   );
+
   const permitExpiryDate = getExpiryDate(
     permitStartDate,
+    isQuarterlyPermit(permit.permitType),
     permit.permitData.permitDuration,
   );
 
@@ -58,8 +62,10 @@ export const getPermitState = (permit: Permit): PermitState => {
   const permitStartDate = getStartOfDate(
     toLocalDayjs(permit.permitData.startDate),
   );
+
   const permitExpiryDate = getExpiryDate(
     permitStartDate,
+    isQuarterlyPermit(permit.permitType),
     permit.permitData.permitDuration,
   );
 
@@ -82,9 +88,13 @@ export const getPermitState = (permit: Permit): PermitState => {
 /**
  * Get the expiry date of a permit given its start date and duration.
  * @param startDate Dayjs object representing start date
+ * @param isQuarterly Whether or not the permit is a quarterly permit
  * @param duration Number representing duration period
+ * @returns Dayjs object representing the exact expiry date
  */
-export const getExpiryDate = (startDate: Dayjs, duration: number) => {
+export const getExpiryDate = (startDate: Dayjs, isQuarterly: boolean, duration: number) => {
+  if (isQuarterly) return getEndOfQuarter(startDate);
+
   if (duration === BASE_DAYS_IN_YEAR) {
     // This is when user selects "1 year", and the library will take handle the leap year situation
     return getEndOfDate(dayjs(startDate).add(1, "year").subtract(1, "day"));
diff --git a/frontend/src/features/permits/helpers/serialize/deserializeApplication.ts b/frontend/src/features/permits/helpers/serialize/deserializeApplication.ts
index f68797767a..dddc538f23 100644
--- a/frontend/src/features/permits/helpers/serialize/deserializeApplication.ts
+++ b/frontend/src/features/permits/helpers/serialize/deserializeApplication.ts
@@ -5,6 +5,7 @@ import { Application, ApplicationResponseData } from "../../types/application";
 import { getDurationOrDefault } from "../getDefaultApplicationFormData";
 import { getExpiryDate } from "../permitState";
 import { minDurationForPermitType } from "../dateSelection";
+import { isQuarterlyPermit } from "../../types/PermitType";
 import {
   getEndOfDate,
   getStartOfDate,
@@ -36,7 +37,11 @@ export const deserializeApplicationResponse = (
   const expiryDateOrDefault = applyWhenNotNullable(
     (datetimeStr: string): Dayjs => getEndOfDate(toLocalDayjs(datetimeStr)),
     response.permitData.expiryDate,
-    getExpiryDate(startDateOrDefault, durationOrDefault),
+    getExpiryDate(
+      startDateOrDefault,
+      isQuarterlyPermit(response.permitType),
+      durationOrDefault,
+    ),
   );
 
   return {
diff --git a/frontend/src/features/permits/helpers/tests/permitState.test.ts b/frontend/src/features/permits/helpers/tests/permitState.test.ts
index 250bcd6367..d77ff212b0 100644
--- a/frontend/src/features/permits/helpers/tests/permitState.test.ts
+++ b/frontend/src/features/permits/helpers/tests/permitState.test.ts
@@ -11,7 +11,7 @@ describe("Permit States", () => {
     const duration = 30;
 
     // Act
-    const expiryDate = getExpiryDate(startDate, duration);
+    const expiryDate = getExpiryDate(startDate, false, duration);
     const expiryDateStr = dayjsToLocalStr(expiryDate, DATE_FORMATS.DATEONLY);
 
     // Assert
@@ -24,7 +24,7 @@ describe("Permit States", () => {
     const duration = BASE_DAYS_IN_YEAR;
 
     // Act
-    const expiryDate = getExpiryDate(startDate, duration);
+    const expiryDate = getExpiryDate(startDate, false, duration);
     const expiryDateStr = dayjsToLocalStr(expiryDate, DATE_FORMATS.DATEONLY);
 
     // Assert
@@ -37,10 +37,34 @@ describe("Permit States", () => {
     const duration = BASE_DAYS_IN_YEAR;
 
     // Act
-    const expiryDate = getExpiryDate(startDate, duration);
+    const expiryDate = getExpiryDate(startDate, false, duration);
     const expiryDateStr = dayjsToLocalStr(expiryDate, DATE_FORMATS.DATEONLY);
 
     // Assert
     expect(expiryDateStr).toBe("2024-02-29"); // +366 (-1) days
   });
+
+  it("yields correct expiry date for quarterly permit with given start date", () => {
+    // Arrange
+    const startDateQ1 = dayjs("2023-03-01");
+    const startDateQ2 = dayjs("2023-04-01");
+    const startDateQ3 = dayjs("2023-07-01");
+    const startDateQ4 = dayjs("2023-10-01");
+
+    // Act
+    const expiryDateQ1 = getExpiryDate(startDateQ1, true, 0);
+    const expiryDateStrQ1 = dayjsToLocalStr(expiryDateQ1, DATE_FORMATS.DATEONLY);
+    const expiryDateQ2 = getExpiryDate(startDateQ2, true, 0);
+    const expiryDateStrQ2 = dayjsToLocalStr(expiryDateQ2, DATE_FORMATS.DATEONLY);
+    const expiryDateQ3 = getExpiryDate(startDateQ3, true, 0);
+    const expiryDateStrQ3 = dayjsToLocalStr(expiryDateQ3, DATE_FORMATS.DATEONLY);
+    const expiryDateQ4 = getExpiryDate(startDateQ4, true, 0);
+    const expiryDateStrQ4 = dayjsToLocalStr(expiryDateQ4, DATE_FORMATS.DATEONLY);
+
+    // Assert
+    expect(expiryDateStrQ1).toBe("2023-03-31");
+    expect(expiryDateStrQ2).toBe("2023-06-30");
+    expect(expiryDateStrQ3).toBe("2023-09-30");
+    expect(expiryDateStrQ4).toBe("2023-12-31");
+  });
 });
diff --git a/frontend/src/features/permits/hooks/usePermitDateSelection.ts b/frontend/src/features/permits/hooks/usePermitDateSelection.ts
index c45462ddb2..a42c8c1e20 100644
--- a/frontend/src/features/permits/hooks/usePermitDateSelection.ts
+++ b/frontend/src/features/permits/hooks/usePermitDateSelection.ts
@@ -2,7 +2,7 @@ import { useEffect } from "react";
 import { Dayjs } from "dayjs";
 
 import { getExpiryDate } from "../helpers/permitState";
-import { PermitType } from "../types/PermitType";
+import { isQuarterlyPermit, PermitType } from "../types/PermitType";
 import { getAvailableDurationOptions, handleUpdateDurationIfNeeded } from "../helpers/dateSelection";
 import { PermitLOA } from "../types/PermitLOA";
 import { useMemoizedArray } from "../../../common/hooks/useMemoizedArray";
@@ -52,11 +52,18 @@ export const usePermitDateSelection = (
     updatedDuration,
   ]);
   
-  const expiryDate = getExpiryDate(startDate, selectedDuration);
   useEffect(() => {
+    const expiryDate = getExpiryDate(
+      startDate,
+      isQuarterlyPermit(permitType),
+      selectedDuration,
+    );
+
     onSetExpiryDate(expiryDate);
   }, [
-    expiryDate,
+    startDate,
+    selectedDuration,
+    permitType,
   ]);
 
   return { availableDurationOptions };
diff --git a/frontend/src/features/permits/pages/Application/components/dashboard/StartApplicationAction.tsx b/frontend/src/features/permits/pages/Application/components/dashboard/StartApplicationAction.tsx
index f5a86c31a4..c780ac051d 100644
--- a/frontend/src/features/permits/pages/Application/components/dashboard/StartApplicationAction.tsx
+++ b/frontend/src/features/permits/pages/Application/components/dashboard/StartApplicationAction.tsx
@@ -33,6 +33,7 @@ export const StartApplicationAction = () => {
   const enableSTOS = featureFlags?.["STOS"] === "ENABLED";
   const enableMFP = featureFlags?.["MFP"] === "ENABLED";
   const enableSTFR = featureFlags?.["STFR"] === "ENABLED";
+  const enableQRFR = featureFlags?.["QRFR"] === "ENABLED";
   
   const handleChooseFrom = (
     _event: React.MouseEvent<HTMLElement>,
@@ -54,7 +55,7 @@ export const StartApplicationAction = () => {
   // Update the structure of menuItems to ensure the callback is applied correctly
   const menuItems = ALL_PERMIT_TYPE_CHOOSE_FROM_OPTIONS
     .filter(option => enableSTOS ? true : option.value !== PERMIT_CATEGORIES.SINGLE_TRIP)
-    .filter(option => enableSTFR ? true : option.value !== PERMIT_CATEGORIES.NON_RESIDENT)
+    .filter(option => (enableSTFR || enableQRFR) ? true : option.value !== PERMIT_CATEGORIES.NON_RESIDENT)
     .filter(option => enableMFP ? true : option.value !== PERMIT_TYPES.MFP)
     .map(
       (item: PermitTypeChooseFromItem) => ({
diff --git a/frontend/src/features/permits/pages/Application/components/form/PermitDetails.tsx b/frontend/src/features/permits/pages/Application/components/form/PermitDetails.tsx
index 8695a7852c..dc2f0f1af0 100644
--- a/frontend/src/features/permits/pages/Application/components/form/PermitDetails.tsx
+++ b/frontend/src/features/permits/pages/Application/components/form/PermitDetails.tsx
@@ -12,7 +12,7 @@ import { CustomExternalLink } from "../../../../../../common/components/links/Cu
 import { BANNER_MESSAGES } from "../../../../../../common/constants/bannerMessages";
 import { PermitCondition } from "../../../../types/PermitCondition";
 import { DATE_FORMATS } from "../../../../../../common/helpers/formatDate";
-import { PERMIT_TYPES, PermitType } from "../../../../types/PermitType";
+import { isQuarterlyPermit, PERMIT_TYPES, PermitType } from "../../../../types/PermitType";
 import {
   CustomDatePicker,
   PastStartDateStatus,
@@ -49,6 +49,7 @@ export const PermitDetails = ({
   const formattedExpiryDate = dayjs(expiryDate).format(DATE_FORMATS.SHORT);
 
   const showSTFRInfoBanner = permitType === PERMIT_TYPES.STFR;
+  const showQRFRInfoBanner = permitType === PERMIT_TYPES.QRFR;
 
   return (
     <Box className="permit-details">
@@ -64,6 +65,13 @@ export const PermitDetails = ({
           />
         ) : null}
 
+        {showQRFRInfoBanner ? (
+          <InfoBcGovBanner
+            className="permit-details__info-banner--top"
+            msg={BANNER_MESSAGES.PERMIT_START_DATE_VALID_QUARTER}
+          />
+        ) : null}
+
         <Box className="permit-details__date-selection">
           <CustomDatePicker
             className="permit-details__input permit-details__input--start-date"
@@ -79,23 +87,25 @@ export const PermitDetails = ({
             maxDaysInFuture={14}
           />
 
-          <CustomFormComponent
-            className="permit-details__input permit-details__input--duration"
-            type="select"
-            feature={feature}
-            options={{
-              name: "permitData.permitDuration",
-              rules: {
-                required: { value: true, message: requiredMessage() },
-              },
-              label: "Permit Duration",
-            }}
-            menuOptions={durationOptions.map((data) => (
-              <MenuItem key={data.value} value={data.value}>
-                {data.label}
-              </MenuItem>
-            ))}
-          />
+          {!isQuarterlyPermit(permitType) ? (
+            <CustomFormComponent
+              className="permit-details__input permit-details__input--duration"
+              type="select"
+              feature={feature}
+              options={{
+                name: "permitData.permitDuration",
+                rules: {
+                  required: { value: true, message: requiredMessage() },
+                },
+                label: "Permit Duration",
+              }}
+              menuOptions={durationOptions.map((data) => (
+                <MenuItem key={data.value} value={data.value}>
+                  {data.label}
+                </MenuItem>
+              ))}
+            />
+          ) : null}
         </Box>
 
         <PermitExpiryDateBanner expiryDate={formattedExpiryDate} />
diff --git a/frontend/src/features/permits/pages/Application/components/form/tests/PermitDetails.test.tsx b/frontend/src/features/permits/pages/Application/components/form/tests/PermitDetails.test.tsx
index ac854e6c38..bd2706890a 100644
--- a/frontend/src/features/permits/pages/Application/components/form/tests/PermitDetails.test.tsx
+++ b/frontend/src/features/permits/pages/Application/components/form/tests/PermitDetails.test.tsx
@@ -72,9 +72,11 @@ describe("Permit Details duration", () => {
     await selectDurationOption(user, durationText);
 
     // Assert
-    const expectedExpiry = getExpiryDate(dayjs(currentDt), durationDays).format(
-      DATE_FORMATS.SHORT,
-    );
+    const expectedExpiry = getExpiryDate(
+      dayjs(currentDt),
+      false, // only non-quarterly permits have selectable duration list
+      durationDays,
+    ).format(DATE_FORMATS.SHORT);
 
     expect(await expiryDateElement()).toHaveTextContent(expectedExpiry);
   });
@@ -208,6 +210,7 @@ describe("Permit Details start date", () => {
     // Assert
     const expectedExpiry = getExpiryDate(
       dayjs(tomorrow),
+      false, // non-quarterly permit being used as test data
       defaultDuration,
     ).format(DATE_FORMATS.SHORT);
 
diff --git a/frontend/src/features/permits/pages/Application/components/form/tests/helpers/prepare.tsx b/frontend/src/features/permits/pages/Application/components/form/tests/helpers/prepare.tsx
index 5638c99a2c..05b8a291f5 100644
--- a/frontend/src/features/permits/pages/Application/components/form/tests/helpers/prepare.tsx
+++ b/frontend/src/features/permits/pages/Application/components/form/tests/helpers/prepare.tsx
@@ -4,11 +4,8 @@ import dayjs, { Dayjs } from "dayjs";
 import userEvent from "@testing-library/user-event";
 import { Options } from "@testing-library/user-event/dist/types/options";
 
-import { DEFAULT_PERMIT_TYPE } from "../../../../../../types/PermitType";
-import {
-  getDefaultConditions,
-  getMandatoryConditions,
-} from "../../../../../../helpers/conditions";
+import { DEFAULT_PERMIT_TYPE, isQuarterlyPermit } from "../../../../../../types/PermitType";
+import { getDefaultConditions, getMandatoryConditions } from "../../../../../../helpers/conditions";
 import { PermitDetails } from "../../PermitDetails";
 import { getExpiryDate } from "../../../../../../helpers/permitState";
 import { PermitCondition } from "../../../../../../types/PermitCondition";
@@ -64,7 +61,11 @@ const TestFormWrapper = (props: React.PropsWithChildren) => {
       permitData: {
         startDate: currentDt,
         permitDuration: defaultDuration,
-        expiryDate: getExpiryDate(currentDt, defaultDuration),
+        expiryDate: getExpiryDate(
+          currentDt,
+          isQuarterlyPermit(permitType),
+          defaultDuration,
+        ),
         commodities: [],
         loas: [],
       },
@@ -83,12 +84,16 @@ export const renderTestComponent = (
 ) => {
   const user = userEvent.setup(userEventOptions);
   let selectedConditions = [...conditions];
-  const expiryDate = getExpiryDate(startDate, duration);
-  const allConditions = getDefaultConditions(permitType, false).map(
-    (condition) => {
-      const existingCondition = selectedConditions.find(
-        (c) => c.condition === condition.condition,
-      );
+  const expiryDate = getExpiryDate(
+    startDate,
+    isQuarterlyPermit(permitType),
+    duration,
+  );
+
+  const allConditions = getDefaultConditions(permitType, false)
+    .map(condition => {
+      const existingCondition = selectedConditions
+        .find(c => c.condition === condition.condition);
 
       return {
         ...condition,
diff --git a/frontend/src/features/permits/pages/Application/components/review/PermitReview.tsx b/frontend/src/features/permits/pages/Application/components/review/PermitReview.tsx
index 634e47c844..b2f4b63ec6 100644
--- a/frontend/src/features/permits/pages/Application/components/review/PermitReview.tsx
+++ b/frontend/src/features/permits/pages/Application/components/review/PermitReview.tsx
@@ -141,6 +141,7 @@ export const PermitReview = (props: PermitReviewProps) => {
         <ReviewPermitLOAs loas={props.loas} />
 
         <ReviewPermitDetails
+          permitType={props.permitType}
           startDate={props.permitStartDate}
           permitDuration={props.permitDuration}
           expiryDate={props.permitExpiryDate}
diff --git a/frontend/src/features/permits/pages/Application/components/review/ReviewPermitDetails.tsx b/frontend/src/features/permits/pages/Application/components/review/ReviewPermitDetails.tsx
index bb2144360d..7131c51658 100644
--- a/frontend/src/features/permits/pages/Application/components/review/ReviewPermitDetails.tsx
+++ b/frontend/src/features/permits/pages/Application/components/review/ReviewPermitDetails.tsx
@@ -12,12 +12,15 @@ import { pastStartOrExpiryDate } from "../../../../../../common/helpers/validati
 import { ErrorBcGovBanner } from "../../../../../../common/components/banners/ErrorBcGovBanner";
 import { PermitExpiryDateBanner } from "../../../../../../common/components/banners/PermitExpiryDateBanner";
 import { areValuesDifferent } from "../../../../../../common/helpers/equality";
+import { isQuarterlyPermit, PermitType } from "../../../../types/PermitType";
 import {
   DATE_FORMATS,
   dayjsToLocalStr,
+  getQuarterForDate,
 } from "../../../../../../common/helpers/formatDate";
 
 export const ReviewPermitDetails = ({
+  permitType,
   startDate,
   permitDuration,
   expiryDate,
@@ -27,6 +30,7 @@ export const ReviewPermitDetails = ({
   oldDuration,
   showDateErrorBanner,
 }: {
+  permitType?: Nullable<PermitType>;
   startDate?: Nullable<Dayjs>;
   permitDuration?: Nullable<number>;
   expiryDate?: Nullable<Dayjs>;
@@ -65,6 +69,21 @@ export const ReviewPermitDetails = ({
       : `${duration} ${measurementUnit}`;
   };
 
+  const displayDurationQuarter = (start: Dayjs) => {
+    const quarter = getQuarterForDate(start);
+    switch (quarter) {
+      case 2:
+        return "Apr. - Jun.";
+      case 3:
+        return "Jul. - Sep.";
+      case 4:
+        return "Oct. - Dec.";
+      case 1:
+      default:
+        return "Jan. - Mar.";
+    }
+  };
+
   return (
     <Box className="review-permit-details">
       <Box className="review-permit-details__header">
@@ -104,10 +123,14 @@ export const ReviewPermitDetails = ({
               className="permit-dates__data"
               data-testid="permit-duration"
             >
-              {applyWhenNotNullable(
+              {!isQuarterlyPermit(permitType) ? applyWhenNotNullable(
                 displayDuration,
                 permitDuration,
                 "",
+              ) : applyWhenNotNullable(
+                displayDurationQuarter,
+                startDate,
+                "",
               )}
             </Typography>
           </div>
diff --git a/frontend/src/features/permits/types/PermitType.ts b/frontend/src/features/permits/types/PermitType.ts
index a85676631d..3f1fd908b1 100644
--- a/frontend/src/features/permits/types/PermitType.ts
+++ b/frontend/src/features/permits/types/PermitType.ts
@@ -202,3 +202,13 @@ export const isPermitTypeValid = (permitType?: Nullable<string>) => {
 export const isTermPermitType = (permitType: PermitType) => {
   return permitType === PERMIT_TYPES.TROS || permitType === PERMIT_TYPES.TROW;
 };
+
+/**
+ * Determine whether or not a permit type is considered a quarterly permit.
+ * @param permitType Type of permit
+ * @returns Whether or not the permit of that type is considered a quarterly permit
+ */
+export const isQuarterlyPermit = (permitType?: Nullable<PermitType>) => {
+  if (!permitType) return false;
+  return ([PERMIT_TYPES.QRFR] as PermitType[]).includes(permitType);
+};


Reviews:
user: login: krishnan-aot
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T13:51:02Z
user: login: zgong-gov
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-17T17:08:56Z

Review comments:
diff_hunk: @@ -64,6 +64,7 @@ export const ApplicationStepPage = ({
   const enableSTOS = featureFlags?.["STOS"] === "ENABLED";
   const enableMFP = featureFlags?.["MFP"] === "ENABLED";
   const enableSTFR = featureFlags?.["STFR"] === "ENABLED";
+  const enableQRFR = featureFlags?.["QRFR"] === "ENABLED";
path: frontend/src/features/permits/components/dashboard/ApplicationStepPage.tsx
user: login: krishnan-aot
body: Does it need a DB entry? or is there one already?
created_at: 2025-04-17T13:49:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -64,6 +64,7 @@ export const ApplicationStepPage = ({
   const enableSTOS = featureFlags?.["STOS"] === "ENABLED";
   const enableMFP = featureFlags?.["MFP"] === "ENABLED";
   const enableSTFR = featureFlags?.["STFR"] === "ENABLED";
+  const enableQRFR = featureFlags?.["QRFR"] === "ENABLED";
path: frontend/src/features/permits/components/dashboard/ApplicationStepPage.tsx
user: login: zgong-gov
body: Yes, it has a DB entry already, along with STFR
<img width="842" alt="onroutebc-qrfr-feature-flag" src="https://github.com/user-attachments/assets/6f66f9ac-647e-4e48-a637-c672eb84d381" />

created_at: 2025-04-17T17:08:56Z
author_association: COLLABORATOR

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-04-17T01:34:33Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=onroutebc_policy&pullRequest=1951) **Quality Gate passed for 'onroutebc_policy'**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=onroutebc_policy&pullRequest=1951&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=onroutebc_policy&pullRequest=1951&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=onroutebc_policy&pullRequest=1951&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=onroutebc_policy&pullRequest=1951&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=onroutebc_policy&pullRequest=1951&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=onroutebc_policy&pullRequest=1951)


author_association: NONE
user: login: sonarqubecloud[bot]
created_at: 2025-04-17T01:34:41Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=onroutebc_dops&pullRequest=1951) **Quality Gate passed for 'onroutebc dops'**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=onroutebc_dops&pullRequest=1951&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=onroutebc_dops&pullRequest=1951&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=onroutebc_dops&pullRequest=1951&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=onroutebc_dops&pullRequest=1951&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=onroutebc_dops&pullRequest=1951&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=onroutebc_dops&pullRequest=1951)


author_association: NONE
user: login: sonarqubecloud[bot]
created_at: 2025-04-17T01:34:42Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=onroutebc_scheduler&pullRequest=1951) **Quality Gate passed for 'onroutebc_scheduler'**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=onroutebc_scheduler&pullRequest=1951&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=onroutebc_scheduler&pullRequest=1951&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=onroutebc_scheduler&pullRequest=1951&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=onroutebc_scheduler&pullRequest=1951&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=onroutebc_scheduler&pullRequest=1951&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=onroutebc_scheduler&pullRequest=1951)


author_association: NONE
user: login: sonarqubecloud[bot]
created_at: 2025-04-17T01:34:48Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=onroutebc_vehicles&pullRequest=1951) **Quality Gate passed for 'onroutebc vehicles'**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=onroutebc_vehicles&pullRequest=1951&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=onroutebc_vehicles&pullRequest=1951&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=onroutebc_vehicles&pullRequest=1951&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=onroutebc_vehicles&pullRequest=1951&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=onroutebc_vehicles&pullRequest=1951&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=onroutebc_vehicles&pullRequest=1951)


author_association: NONE
user: login: sonarqubecloud[bot]
created_at: 2025-04-17T01:35:12Z
body: ## [![Quality Gate Failed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-failed-20px.png 'Quality Gate Failed')](https://sonarcloud.io/dashboard?id=onroutebc_frontend&pullRequest=1951) **Quality Gate failed for 'onroutebc frontend'**  
Failed conditions  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/failed-16px.png '') [55.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=onroutebc_frontend&pullRequest=1951&metric=new_coverage&view=list) (required ≥ 80%)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=onroutebc_frontend&pullRequest=1951)


author_association: NONE