diff --git a/benefits/core/migrations/0009_eligibilitytype_reenrollment_error_template.py b/benefits/core/migrations/0009_eligibilitytype_reenrollment_error_template.py
new file mode 100644
index 000000000..d0abd30cf
--- /dev/null
+++ b/benefits/core/migrations/0009_eligibilitytype_reenrollment_error_template.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.0.3 on 2024-05-01 19:30
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("core", "0008_eligibilityverifier_unverified_template"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="eligibilitytype",
+ name="reenrollment_error_template",
+ field=models.TextField(blank=True, null=True),
+ ),
+ ]
diff --git a/benefits/core/models.py b/benefits/core/models.py
index a2f763c29..959c94451 100644
--- a/benefits/core/models.py
+++ b/benefits/core/models.py
@@ -114,6 +114,7 @@ class EligibilityType(models.Model):
expiration_days = models.PositiveSmallIntegerField(null=True, blank=True)
expiration_reenrollment_days = models.PositiveSmallIntegerField(null=True, blank=True)
enrollment_index_template = models.TextField(default="enrollment/index.html")
+ reenrollment_error_template = models.TextField(null=True, blank=True)
def __str__(self):
return self.label
@@ -141,6 +142,7 @@ def clean(self):
supports_expiration = self.supports_expiration
expiration_days = self.expiration_days
expiration_reenrollment_days = self.expiration_reenrollment_days
+ reenrollment_error_template = self.reenrollment_error_template
if supports_expiration:
errors = {}
@@ -149,6 +151,8 @@ def clean(self):
errors.update(expiration_days=ValidationError(message))
if expiration_reenrollment_days is None or expiration_reenrollment_days <= 0:
errors.update(expiration_reenrollment_days=ValidationError(message))
+ if reenrollment_error_template is None:
+ errors.update(reenrollment_error_template=ValidationError("Required when supports expiration is True."))
if errors:
raise ValidationError(errors)
diff --git a/benefits/enrollment/templates/enrollment/reenrollment-error--calfresh.html b/benefits/enrollment/templates/enrollment/reenrollment-error--calfresh.html
new file mode 100644
index 000000000..f9a3391c6
--- /dev/null
+++ b/benefits/enrollment/templates/enrollment/reenrollment-error--calfresh.html
@@ -0,0 +1,10 @@
+{% extends "enrollment/reenrollment-error-base.html" %}
+{% load i18n %}
+
+{% block error-message %}
+
+ {% translate "Your CalFresh Cardholder transit benefit does not expire until" %} {{ enrollment.expires|date }}.
+ {% translate "You can re-enroll for this benefit beginning on" %} {{ enrollment.reenrollment|date }}.
+ {% translate "Please try again then." %}
+
+{% endblock error-message %}
diff --git a/benefits/enrollment/templates/enrollment/reenrollment-error-base.html b/benefits/enrollment/templates/enrollment/reenrollment-error-base.html
new file mode 100644
index 000000000..720bc8131
--- /dev/null
+++ b/benefits/enrollment/templates/enrollment/reenrollment-error-base.html
@@ -0,0 +1,31 @@
+{% extends "core/base.html" %}
+{% load i18n %}
+
+{% block page-title %}
+ {% translate "Enrollment error" %}
+{% endblock page-title %}
+
+{% block main-content %}
+ {% if authentication and authentication.sign_out_link_template %}
+ {% include authentication.sign_out_link_template %}
+ {% endif %}
+
+
+
+ {% include "core/includes/icon.html" with name="calendarcheck" %}
+ {% translate "You are still enrolled in this benefit" %}
+
+
+
+
+ {% block error-message %}
+ {% endblock error-message %}
+
+
+
+
+
{% include "core/includes/button--index.html" %}
+
+
+
+{% endblock main-content %}
diff --git a/benefits/enrollment/urls.py b/benefits/enrollment/urls.py
index 22398bc98..f74eb2452 100644
--- a/benefits/enrollment/urls.py
+++ b/benefits/enrollment/urls.py
@@ -12,6 +12,7 @@
# /enrollment
path("", views.index, name="index"),
path("token", views.token, name="token"),
+ path("reenrollment-error", views.reenrollment_error, name="reenrollment-error"),
path("retry", views.retry, name="retry"),
path("success", views.success, name="success"),
]
diff --git a/benefits/enrollment/views.py b/benefits/enrollment/views.py
index 6ea2edf29..b6191e811 100644
--- a/benefits/enrollment/views.py
+++ b/benefits/enrollment/views.py
@@ -12,16 +12,13 @@
from requests.exceptions import HTTPError
from benefits.core import session
-from benefits.core.middleware import (
- EligibleSessionRequired,
- VerifierSessionRequired,
- pageview_decorator,
-)
+from benefits.core.middleware import EligibleSessionRequired, VerifierSessionRequired, pageview_decorator
from benefits.core.views import ROUTE_LOGGED_OUT
-from . import analytics, forms
+from . import analytics, forms
ROUTE_INDEX = "enrollment:index"
+ROUTE_REENROLLMENT_ERROR = "enrollment:reenrollment-error"
ROUTE_RETRY = "enrollment:retry"
ROUTE_SUCCESS = "enrollment:success"
ROUTE_TOKEN = "enrollment:token"
@@ -122,6 +119,25 @@ def index(request):
return TemplateResponse(request, eligibility.enrollment_index_template, context)
+@decorator_from_middleware(EligibleSessionRequired)
+def reenrollment_error(request):
+ """View handler for a re-enrollment attempt that is not yet within the re-enrollment window."""
+ eligibility = session.eligibility(request)
+ verifier = session.verifier(request)
+
+ if eligibility.reenrollment_error_template is None:
+ raise Exception(f"Re-enrollment error with null template on: {eligibility.label}")
+
+ if session.logged_in(request) and verifier.auth_provider.supports_sign_out:
+ # overwrite origin for a logged in user
+ # if they click the logout button, they are taken to the new route
+ session.update(request, origin=reverse(ROUTE_LOGGED_OUT))
+
+ analytics.returned_error(request, "Re-enrollment error.")
+
+ return TemplateResponse(request, eligibility.reenrollment_error_template)
+
+
@decorator_from_middleware(EligibleSessionRequired)
def retry(request):
"""View handler for a recoverable failure condition."""
diff --git a/benefits/locale/en/LC_MESSAGES/django.po b/benefits/locale/en/LC_MESSAGES/django.po
index 71d0c92be..5c2984fe0 100644
--- a/benefits/locale/en/LC_MESSAGES/django.po
+++ b/benefits/locale/en/LC_MESSAGES/django.po
@@ -615,6 +615,21 @@ msgstr ""
msgid "Enroll"
msgstr ""
+msgid "Your CalFresh Cardholder transit benefit does not expire until"
+msgstr ""
+
+msgid "You can re-enroll for this benefit beginning on"
+msgstr ""
+
+msgid "Please try again then."
+msgstr ""
+
+msgid "Enrollment error"
+msgstr ""
+
+msgid "You are still enrolled in this benefit"
+msgstr ""
+
msgid "Unable to register card"
msgstr ""
diff --git a/benefits/locale/es/LC_MESSAGES/django.po b/benefits/locale/es/LC_MESSAGES/django.po
index 964c902cf..c8da9dfd8 100644
--- a/benefits/locale/es/LC_MESSAGES/django.po
+++ b/benefits/locale/es/LC_MESSAGES/django.po
@@ -731,6 +731,21 @@ msgstr "Espere por favor..."
msgid "Enroll"
msgstr "Inscribir"
+msgid "Your CalFresh Cardholder transit benefit does not expire until"
+msgstr ""
+
+msgid "You can re-enroll for this benefit beginning on"
+msgstr ""
+
+msgid "Please try again then."
+msgstr ""
+
+msgid "Enrollment error"
+msgstr ""
+
+msgid "You are still enrolled in this benefit"
+msgstr ""
+
msgid "Unable to register card"
msgstr "No se pudo registrar la tarjeta"
diff --git a/tests/pytest/conftest.py b/tests/pytest/conftest.py
index c10093d6d..2dfb4aa48 100644
--- a/tests/pytest/conftest.py
+++ b/tests/pytest/conftest.py
@@ -139,6 +139,7 @@ def model_EligibilityType_supports_expiration(model_EligibilityType):
model_EligibilityType.supports_expiration = True
model_EligibilityType.expiration_days = 365
model_EligibilityType.expiration_reenrollment_days = 14
+ model_EligibilityType.reenrollment_error_template = "enrollment/reenrollment-error--calfresh.html"
model_EligibilityType.save()
return model_EligibilityType
diff --git a/tests/pytest/core/test_models.py b/tests/pytest/core/test_models.py
index 798dfeb8b..1cb359fc4 100644
--- a/tests/pytest/core/test_models.py
+++ b/tests/pytest/core/test_models.py
@@ -210,6 +210,19 @@ def test_EligibilityType_zero_expiration_reenrollment_days(model_EligibilityType
)
+@pytest.mark.django_db
+def test_EligibilityType_missing_reenrollment_template(model_EligibilityType_supports_expiration):
+ model_EligibilityType_supports_expiration.reenrollment_error_template = None
+ model_EligibilityType_supports_expiration.save()
+
+ with pytest.raises(ValidationError) as exception_info:
+ model_EligibilityType_supports_expiration.full_clean()
+
+ error_dict = exception_info.value.error_dict
+ assert len(error_dict["reenrollment_error_template"]) == 1
+ assert error_dict["reenrollment_error_template"][0].message == "Required when supports expiration is True."
+
+
@pytest.mark.django_db
def test_EligibilityType_supports_expiration(model_EligibilityType_supports_expiration):
# test will fail if any error is raised
diff --git a/tests/pytest/enrollment/test_views.py b/tests/pytest/enrollment/test_views.py
index fba0b7c53..dfec565db 100644
--- a/tests/pytest/enrollment/test_views.py
+++ b/tests/pytest/enrollment/test_views.py
@@ -1,24 +1,23 @@
import time
+import pytest
from django.urls import reverse
-
from littlepay.api.funding_sources import FundingSourceResponse
from requests import HTTPError
-import pytest
+import benefits.enrollment.views
from benefits.core.middleware import TEMPLATE_USER_ERROR
from benefits.core.views import ROUTE_LOGGED_OUT
from benefits.enrollment.views import (
ROUTE_INDEX,
- ROUTE_TOKEN,
- ROUTE_SUCCESS,
+ ROUTE_REENROLLMENT_ERROR,
ROUTE_RETRY,
+ ROUTE_SUCCESS,
+ ROUTE_TOKEN,
TEMPLATE_SUCCESS,
TEMPLATE_RETRY,
)
-import benefits.enrollment.views
-
@pytest.fixture
def card_tokenize_form_data():
@@ -211,6 +210,38 @@ def test_index_ineligible(client):
assert response.template_name == TEMPLATE_USER_ERROR
+@pytest.mark.django_db
+def test_reenrollment_error_ineligible(client):
+ path = reverse(ROUTE_REENROLLMENT_ERROR)
+
+ response = client.get(path)
+
+ assert response.status_code == 200
+ assert response.template_name == TEMPLATE_USER_ERROR
+
+
+@pytest.mark.django_db
+@pytest.mark.usefixtures("mocked_session_agency", "mocked_session_verifier", "mocked_session_eligibility")
+def test_reenrollment_error_eligibility_no_error_template(client):
+ path = reverse(ROUTE_REENROLLMENT_ERROR)
+
+ with pytest.raises(Exception, match="Re-enrollment error with null template"):
+ client.get(path)
+
+
+@pytest.mark.django_db
+@pytest.mark.usefixtures("mocked_session_agency", "mocked_session_verifier")
+def test_reenrollment_error(client, model_EligibilityType_supports_expiration, mocked_session_eligibility):
+ mocked_session_eligibility.return_value = model_EligibilityType_supports_expiration
+
+ path = reverse(ROUTE_REENROLLMENT_ERROR)
+
+ response = client.get(path)
+
+ assert response.status_code == 200
+ assert response.template_name == model_EligibilityType_supports_expiration.reenrollment_error_template
+
+
@pytest.mark.django_db
def test_retry_ineligible(client):
path = reverse(ROUTE_RETRY)