Repository: cal-itp/benefits

Issue:
Include expiry_date with all CalFresh / Low income enrollments
Depends on https://github.com/cal-itp/littlepay/issues/17 and #1848 

## Acceptance Criteria

<!-- Remember to consider edge cases -->

- [ ] Expiry date is 365 days from the day of enrollment
- [ ] Expiration should occur on midnight of the 366th day
- [ ] Expiry date is [ISO 8601 formatted datetime](https://en.wikipedia.org/wiki/ISO_8601) in UTC i.e. `YYYY-MM-DDTHH:mm:ssZ`

## Additional context

Example:

* User enrolls on March 12, 2024 at 14:30 Pacific
* Enrollment time in UTC corresponds to March 12, 2024 at 21:30
* Expiration is Wednesday, March 12, 2025 (+365 days)
* `expiry_date` should be set to Thursday, March 13, 2025 at 00:00 Pacific (midnight on the 366th day)
* `expiry_date` should be set to Thursday, March 13, 2025 at 07:00 UTC



Discussion:

Resolving patch:
diff --git a/benefits/enrollment/views.py b/benefits/enrollment/views.py
--- a/benefits/enrollment/views.py
+++ b/benefits/enrollment/views.py
@@ -3,10 +3,12 @@
 """
 
 import logging
+from datetime import timedelta
 
 from django.http import JsonResponse
 from django.template.response import TemplateResponse
 from django.urls import reverse
+from django.utils import timezone
 from django.utils.decorators import decorator_from_middleware
 from littlepay.api.client import Client
 from requests.exceptions import HTTPError
@@ -26,6 +28,7 @@
 ROUTE_SUCCESS = "enrollment:success"
 ROUTE_TOKEN = "enrollment:token"
 
+TEMPLATE_REENROLLMENT_ERROR = "enrollment/reenrollment-error.html"
 TEMPLATE_RETRY = "enrollment/retry.html"
 TEMPLATE_SUCCESS = "enrollment/success.html"
 
@@ -69,7 +72,6 @@ def index(request):
         if not form.is_valid():
             raise Exception("Invalid card token form")
 
-        logger.debug("Read tokenized card")
         card_token = form.cleaned_data.get("card_token")
 
         client = Client(
@@ -81,25 +83,73 @@ def index(request):
         client.oauth.ensure_active_token(client.token)
 
         funding_source = client.get_funding_source_by_token(card_token)
+        group_id = eligibility.group_id
 
         try:
-            client.link_concession_group_funding_source(funding_source_id=funding_source.id, group_id=eligibility.group_id)
+            group_funding_source = _get_group_funding_source(
+                client=client, group_id=group_id, funding_source_id=funding_source.id
+            )
+
+            already_enrolled = group_funding_source is not None
+
+            if eligibility.supports_expiration:
+                # set expiry on session
+                if already_enrolled and group_funding_source.concession_expiry is not None:
+                    session.update(request, enrollment_expiry=group_funding_source.concession_expiry)
+                else:
+                    session.update(request, enrollment_expiry=_calculate_expiry(eligibility.expiration_days))
+
+                if not already_enrolled:
+                    # enroll user with an expiration date, return success
+                    client.link_concession_group_funding_source(
+                        group_id=group_id, funding_source_id=funding_source.id, expiry_date=session.enrollment_expiry(request)
+                    )
+                    return success(request)
+                else:  # already_enrolled
+                    if group_funding_source.concession_expiry is None:
+                        # update expiration of existing enrollment, return success
+                        client.update_concession_group_funding_source_expiry(
+                            group_id=group_id,
+                            funding_source_id=funding_source.id,
+                            expiry_date=session.enrollment_expiry(request),
+                        )
+                        return success(request)
+                    else:
+                        is_expired = _is_expired(group_funding_source.concession_expiry)
+                        is_within_reenrollment_window = _is_within_reenrollment_window(
+                            group_funding_source.concession_expiry, session.enrollment_reenrollment(request)
+                        )
+
+                        if is_expired or is_within_reenrollment_window:
+                            # update expiration of existing enrollment, return success
+                            client.update_concession_group_funding_source_expiry(
+                                group_id=group_id,
+                                funding_source_id=funding_source.id,
+                                expiry_date=session.enrollment_expiry(request),
+                            )
+                            return success(request)
+                        else:
+                            # re-enrollment error, return enrollment error with expiration and reenrollment_date
+                            return reenrollment_error(request)
+            else:  # eligibility does not support expiration
+                if not already_enrolled:
+                    # enroll user with no expiration date, return success
+                    client.link_concession_group_funding_source(group_id=group_id, funding_source_id=funding_source.id)
+                    return success(request)
+                else:  # already_enrolled
+                    if group_funding_source.concession_expiry is None:
+                        # no action, return success
+                        return success(request)
+                    else:
+                        # remove expiration date, return success
+                        raise NotImplementedError("Removing expiration date is currently not supported")
+
         except HTTPError as e:
-            # 409 means that customer already belongs to a concession group.
-            # the response JSON will look like:
-            # {"errors":[{"detail":"Conflict (409) - Customer already belongs to a concession group."}]}
-            if e.response.status_code == 409:
-                analytics.returned_success(request, eligibility.group_id)
-                return success(request)
-            else:
-                analytics.returned_error(request, str(e))
-                raise Exception(f"{e}: {e.response.json()}")
+            analytics.returned_error(request, str(e))
+            raise Exception(f"{e}: {e.response.json()}")
         except Exception as e:
             analytics.returned_error(request, str(e))
             raise e
-        else:
-            analytics.returned_success(request, eligibility.group_id)
-            return success(request)
 
     # GET enrollment index
     else:
@@ -122,6 +172,43 @@ def index(request):
         return TemplateResponse(request, eligibility.enrollment_index_template, context)
 
 
+def _get_group_funding_source(client: Client, group_id, funding_source_id):
+    group_funding_sources = client.get_concession_group_linked_funding_sources(group_id)
+    matching_group_funding_source = None
+    for group_funding_source in group_funding_sources:
+        if group_funding_source.id == funding_source_id:
+            matching_group_funding_source = group_funding_source
+            break
+
+    return matching_group_funding_source
+
+
+def _is_expired(concession_expiry):
+    """Returns whether the passed in datetime is expired or not."""
+    return concession_expiry <= timezone.now()
+
+
+def _is_within_reenrollment_window(concession_expiry, enrollment_reenrollment_date):
+    """Returns if we are currently within the reenrollment window."""
+    return enrollment_reenrollment_date <= timezone.now() < concession_expiry
+
+
+def _calculate_expiry(expiration_days):
+    """Returns the expiry datetime, which should be midnight in our configured timezone of the (N + 1)th day from now,
+    where N is expiration_days."""
+    default_time_zone = timezone.get_default_timezone()
+    expiry_date = timezone.localtime(timezone=default_time_zone) + timedelta(days=expiration_days + 1)
+    expiry_datetime = expiry_date.replace(hour=0, minute=0, second=0, microsecond=0)
+
+    return expiry_datetime
+
+
+def reenrollment_error(request):
+    """View handler for a re-enrollment attempt that is not yet within the re-enrollment window."""
+    analytics.returned_error(request, "Re-enrollment error")
+    return TemplateResponse(request, TEMPLATE_REENROLLMENT_ERROR)
+
+
 @decorator_from_middleware(EligibleSessionRequired)
 def retry(request):
     """View handler for a recoverable failure condition."""
@@ -139,6 +226,7 @@ def retry(request):
 
 
 @pageview_decorator
+@decorator_from_middleware(EligibleSessionRequired)
 @decorator_from_middleware(VerifierSessionRequired)
 def success(request):
     """View handler for the final success page."""
@@ -147,10 +235,12 @@ def success(request):
 
     agency = session.agency(request)
     verifier = session.verifier(request)
+    eligibility = session.eligibility(request)
 
     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_success(request, eligibility.group_id)
     return TemplateResponse(request, agency.enrollment_success_template)
diff --git a/benefits/settings.py b/benefits/settings.py
--- a/benefits/settings.py
+++ b/benefits/settings.py
@@ -204,7 +204,11 @@ def RUNTIME_ENVIRONMENT():
 
 USE_I18N = True
 
-TIME_ZONE = "UTC"
+# See https://docs.djangoproject.com/en/5.0/ref/settings/#std-setting-TIME_ZONE
+# > Note that this isn’t necessarily the time zone of the server.
+# > When USE_TZ is True, this is the default time zone that Django will use to display datetimes in templates
+# > and to interpret datetimes entered in forms.
+TIME_ZONE = "America/Los_Angeles"
 USE_TZ = True
 
 # Static files (CSS, JavaScript, Images)


Test patch:
diff --git a/tests/pytest/conftest.py b/tests/pytest/conftest.py
--- a/tests/pytest/conftest.py
+++ b/tests/pytest/conftest.py
@@ -1,6 +1,7 @@
 from unittest.mock import create_autospec
 from django.contrib.sessions.middleware import SessionMiddleware
 from django.middleware.locale import LocaleMiddleware
+from django.utils import timezone
 
 import pytest
 from pytest_socket import disable_socket
@@ -256,6 +257,15 @@ def mocked_session_oauth_token(mocker):
     return mocker.patch("benefits.core.session.oauth_token", autospec=True, return_value="token")
 
 
+@pytest.fixture
+def mocked_session_enrollment_expiry(mocker):
+    return mocker.patch(
+        "benefits.core.session.enrollment_expiry",
+        autospec=True,
+        return_value=timezone.make_aware(timezone.datetime(2024, 1, 1), timezone=timezone.get_default_timezone()),
+    )
+
+
 @pytest.fixture
 def mocked_session_verifier(mocker, model_EligibilityVerifier):
     return mocker.patch("benefits.core.session.verifier", autospec=True, return_value=model_EligibilityVerifier)
diff --git a/tests/pytest/enrollment/test_views.py b/tests/pytest/enrollment/test_views.py
--- a/tests/pytest/enrollment/test_views.py
+++ b/tests/pytest/enrollment/test_views.py
@@ -1,8 +1,11 @@
+from datetime import timedelta
 import time
 
 from django.urls import reverse
+from django.utils import timezone
 
 from littlepay.api.funding_sources import FundingSourceResponse
+from littlepay.api.groups import GroupFundingSourceResponse
 from requests import HTTPError
 import pytest
 
@@ -13,8 +16,13 @@
     ROUTE_TOKEN,
     ROUTE_SUCCESS,
     ROUTE_RETRY,
+    TEMPLATE_REENROLLMENT_ERROR,
     TEMPLATE_SUCCESS,
     TEMPLATE_RETRY,
+    _get_group_funding_source,
+    _calculate_expiry,
+    _is_expired,
+    _is_within_reenrollment_window,
 )
 
 import benefits.enrollment.views
@@ -51,6 +59,28 @@ def mocked_funding_source():
     )
 
 
+@pytest.fixture
+def mocked_group_funding_source_no_expiry(mocked_funding_source):
+    return GroupFundingSourceResponse(
+        id=mocked_funding_source.id,
+        participant_id=mocked_funding_source.participant_id,
+        concession_expiry=None,
+        concession_created_at=None,
+        concession_updated_at=None,
+    )
+
+
+@pytest.fixture
+def mocked_group_funding_source_with_expiry(mocked_funding_source):
+    return GroupFundingSourceResponse(
+        id=mocked_funding_source.id,
+        participant_id=mocked_funding_source.participant_id,
+        concession_expiry="2023-01-01T00:00:00Z",
+        concession_created_at="2021-01-01T00:00:00Z",
+        concession_updated_at="2021-01-01T00:00:00Z",
+    )
+
+
 @pytest.mark.django_db
 def test_token_ineligible(client):
     path = reverse(ROUTE_TOKEN)
@@ -131,7 +161,6 @@ def test_index_eligible_post_valid_form_http_error(mocker, client, card_tokenize
     mock_client_cls = mocker.patch("benefits.enrollment.views.Client")
     mock_client = mock_client_cls.return_value
 
-    # any status_code that isn't 409 is considered an error
     mock_error = {"message": "Mock error message"}
     mock_error_response = mocker.Mock(status_code=400, **mock_error)
     mock_error_response.json.return_value = mock_error
@@ -157,33 +186,122 @@ def test_index_eligible_post_valid_form_failure(mocker, client, card_tokenize_fo
         client.post(path, card_tokenize_form_data)
 
 
+@pytest.mark.django_db
+@pytest.mark.usefixtures("model_EligibilityType")
+def test_get_group_funding_sources_funding_source_not_enrolled_yet(mocker, mocked_funding_source):
+    mock_client = mocker.Mock()
+    mock_client.get_concession_group_linked_funding_sources.return_value = []
+
+    matching_group_funding_source = _get_group_funding_source(mock_client, "group123", mocked_funding_source.id)
+
+    assert matching_group_funding_source is None
+
+
+@pytest.mark.django_db
+@pytest.mark.usefixtures("model_EligibilityType")
+def test_get_group_funding_sources_funding_source_already_enrolled(
+    mocker, mocked_funding_source, mocked_group_funding_source_no_expiry
+):
+    mock_client = mocker.Mock()
+    mock_client.get_concession_group_linked_funding_sources.return_value = [mocked_group_funding_source_no_expiry]
+
+    matching_group_funding_source = _get_group_funding_source(mock_client, "group123", mocked_funding_source.id)
+
+    assert matching_group_funding_source == mocked_group_funding_source_no_expiry
+
+
+@pytest.mark.django_db
+@pytest.mark.usefixtures("mocked_session_agency", "mocked_session_verifier", "mocked_session_eligibility")
+def test_index_eligible_post_valid_form_success_does_not_support_expiration_customer_already_enrolled_no_expiry(
+    mocker,
+    client,
+    card_tokenize_form_data,
+    mocked_analytics_module,
+    model_EligibilityType_does_not_support_expiration,
+    mocked_funding_source,
+    mocked_group_funding_source_no_expiry,
+):
+    mock_client_cls = mocker.patch("benefits.enrollment.views.Client")
+    mock_client = mock_client_cls.return_value
+    mock_client.get_funding_source_by_token.return_value = mocked_funding_source
+
+    mocker.patch("benefits.enrollment.views._get_group_funding_source", return_value=mocked_group_funding_source_no_expiry)
+
+    path = reverse(ROUTE_INDEX)
+    response = client.post(path, card_tokenize_form_data)
+
+    assert response.status_code == 200
+    assert response.template_name == TEMPLATE_SUCCESS
+    mocked_analytics_module.returned_success.assert_called_once()
+    assert (
+        model_EligibilityType_does_not_support_expiration.group_id in mocked_analytics_module.returned_success.call_args.args
+    )
+
+
 @pytest.mark.django_db
 @pytest.mark.usefixtures("mocked_session_agency", "mocked_session_verifier", "mocked_session_eligibility")
-def test_index_eligible_post_valid_form_customer_already_enrolled(
-    mocker, client, card_tokenize_form_data, mocked_analytics_module, model_EligibilityType, mocked_funding_source
+def test_index_eligible_post_valid_form_success_does_not_support_expiration_no_expiry(
+    mocker,
+    client,
+    card_tokenize_form_data,
+    mocked_analytics_module,
+    model_EligibilityType_does_not_support_expiration,
+    mocked_funding_source,
 ):
     mock_client_cls = mocker.patch("benefits.enrollment.views.Client")
     mock_client = mock_client_cls.return_value
     mock_client.get_funding_source_by_token.return_value = mocked_funding_source
-    mock_error_response = mocker.Mock(status_code=409)
-    mock_client.link_concession_group_funding_source.side_effect = HTTPError(response=mock_error_response)
 
     path = reverse(ROUTE_INDEX)
     response = client.post(path, card_tokenize_form_data)
 
     mock_client.link_concession_group_funding_source.assert_called_once_with(
-        funding_source_id=mocked_funding_source.id, group_id=model_EligibilityType.group_id
+        funding_source_id=mocked_funding_source.id, group_id=model_EligibilityType_does_not_support_expiration.group_id
     )
     assert response.status_code == 200
     assert response.template_name == TEMPLATE_SUCCESS
     mocked_analytics_module.returned_success.assert_called_once()
-    assert model_EligibilityType.group_id in mocked_analytics_module.returned_success.call_args.args
+    assert (
+        model_EligibilityType_does_not_support_expiration.group_id in mocked_analytics_module.returned_success.call_args.args
+    )
+
+
+def test_calculate_expiry():
+    expiration_days = 365
+
+    expiry_date = _calculate_expiry(expiration_days)
+
+    assert expiry_date == (
+        timezone.localtime(timezone=timezone.get_default_timezone()) + timedelta(days=expiration_days + 1)
+    ).replace(hour=0, minute=0, second=0, microsecond=0)
+
+
+def test_calculate_expiry_specific_date(mocker):
+    expiration_days = 14
+    mocker.patch(
+        "benefits.enrollment.views.timezone.now",
+        return_value=timezone.make_aware(
+            value=timezone.datetime(2024, 3, 1, 13, 37, 11, 5), timezone=timezone.get_fixed_timezone(offset=0)
+        ),
+    )
+
+    expiry_date = _calculate_expiry(expiration_days)
+
+    assert expiry_date == timezone.make_aware(
+        value=timezone.datetime(2024, 3, 16, 0, 0, 0, 0), timezone=timezone.get_default_timezone()
+    )
 
 
 @pytest.mark.django_db
 @pytest.mark.usefixtures("mocked_session_agency", "mocked_session_verifier", "mocked_session_eligibility")
-def test_index_eligible_post_valid_form_success(
-    mocker, client, card_tokenize_form_data, mocked_analytics_module, model_EligibilityType, mocked_funding_source
+def test_index_eligible_post_valid_form_success_supports_expiration(
+    mocker,
+    client,
+    card_tokenize_form_data,
+    mocked_analytics_module,
+    model_EligibilityType_supports_expiration,
+    mocked_funding_source,
+    mocked_session_enrollment_expiry,
 ):
     mock_client_cls = mocker.patch("benefits.enrollment.views.Client")
     mock_client = mock_client_cls.return_value
@@ -193,12 +311,297 @@ def test_index_eligible_post_valid_form_success(
     response = client.post(path, card_tokenize_form_data)
 
     mock_client.link_concession_group_funding_source.assert_called_once_with(
-        funding_source_id=mocked_funding_source.id, group_id=model_EligibilityType.group_id
+        funding_source_id=mocked_funding_source.id,
+        group_id=model_EligibilityType_supports_expiration.group_id,
+        expiry_date=mocked_session_enrollment_expiry.return_value,
+    )
+    assert response.status_code == 200
+    assert response.template_name == TEMPLATE_SUCCESS
+    mocked_analytics_module.returned_success.assert_called_once()
+    assert model_EligibilityType_supports_expiration.group_id in mocked_analytics_module.returned_success.call_args.args
+
+
+@pytest.mark.django_db
+@pytest.mark.usefixtures("mocked_session_agency", "mocked_session_verifier", "mocked_session_eligibility")
+def test_index_eligible_post_valid_form_success_supports_expiration_no_expiry(
+    mocker,
+    client,
+    card_tokenize_form_data,
+    mocked_analytics_module,
+    model_EligibilityType_supports_expiration,
+    mocked_funding_source,
+    mocked_group_funding_source_no_expiry,
+    mocked_session_enrollment_expiry,
+):
+    mock_client_cls = mocker.patch("benefits.enrollment.views.Client")
+    mock_client = mock_client_cls.return_value
+    mock_client.get_funding_source_by_token.return_value = mocked_funding_source
+
+    mocker.patch("benefits.enrollment.views._get_group_funding_source", return_value=mocked_group_funding_source_no_expiry)
+
+    path = reverse(ROUTE_INDEX)
+    response = client.post(path, card_tokenize_form_data)
+
+    mock_client.update_concession_group_funding_source_expiry.assert_called_once_with(
+        funding_source_id=mocked_funding_source.id,
+        group_id=model_EligibilityType_supports_expiration.group_id,
+        expiry_date=mocked_session_enrollment_expiry.return_value,
+    )
+    assert response.status_code == 200
+    assert response.template_name == TEMPLATE_SUCCESS
+    mocked_analytics_module.returned_success.assert_called_once()
+    assert model_EligibilityType_supports_expiration.group_id in mocked_analytics_module.returned_success.call_args.args
+
+
+def test_is_expired_concession_expiry_is_in_the_past(mocker):
+    concession_expiry = timezone.make_aware(timezone.datetime(2023, 12, 31), timezone.get_default_timezone())
+
+    # mock datetime of "now" to be specific date for testing
+    mocker.patch(
+        "benefits.enrollment.views.timezone.now",
+        return_value=timezone.make_aware(timezone.datetime(2024, 1, 1, 10, 30), timezone.get_default_timezone()),
+    )
+
+    assert _is_expired(concession_expiry)
+
+
+def test_is_expired_concession_expiry_is_in_the_future(mocker):
+    concession_expiry = timezone.make_aware(timezone.datetime(2024, 1, 1, 17, 34), timezone.get_default_timezone())
+
+    # mock datetime of "now" to be specific date for testing
+    mocker.patch(
+        "benefits.enrollment.views.timezone.now",
+        return_value=timezone.make_aware(timezone.datetime(2024, 1, 1, 11, 5), timezone.get_default_timezone()),
+    )
+
+    assert not _is_expired(concession_expiry)
+
+
+def test_is_expired_concession_expiry_equals_now(mocker):
+    concession_expiry = timezone.make_aware(timezone.datetime(2024, 1, 1, 13, 37), timezone.get_default_timezone())
+
+    # mock datetime of "now" to be specific date for testing
+    mocker.patch(
+        "benefits.enrollment.views.timezone.now",
+        return_value=timezone.make_aware(timezone.datetime(2024, 1, 1, 13, 37), timezone.get_default_timezone()),
+    )
+
+    assert _is_expired(concession_expiry)
+
+
+@pytest.mark.django_db
+@pytest.mark.usefixtures("mocked_session_agency", "mocked_session_verifier", "mocked_session_eligibility")
+def test_index_eligible_post_valid_form_success_supports_expiration_is_expired(
+    mocker,
+    client,
+    card_tokenize_form_data,
+    mocked_analytics_module,
+    model_EligibilityType_supports_expiration,
+    mocked_funding_source,
+    mocked_group_funding_source_with_expiry,
+    mocked_session_enrollment_expiry,
+):
+    mock_client_cls = mocker.patch("benefits.enrollment.views.Client")
+    mock_client = mock_client_cls.return_value
+    mock_client.get_funding_source_by_token.return_value = mocked_funding_source
+
+    # mock that a funding source already exists, doesn't matter what concession_expiry is
+    mocker.patch("benefits.enrollment.views._get_group_funding_source", return_value=mocked_group_funding_source_with_expiry)
+
+    mocker.patch("benefits.enrollment.views._is_expired", return_value=True)
+
+    path = reverse(ROUTE_INDEX)
+    response = client.post(path, card_tokenize_form_data)
+
+    mock_client.update_concession_group_funding_source_expiry.assert_called_once_with(
+        funding_source_id=mocked_funding_source.id,
+        group_id=model_EligibilityType_supports_expiration.group_id,
+        expiry_date=mocked_session_enrollment_expiry.return_value,
+    )
+    assert response.status_code == 200
+    assert response.template_name == TEMPLATE_SUCCESS
+    mocked_analytics_module.returned_success.assert_called_once()
+    assert model_EligibilityType_supports_expiration.group_id in mocked_analytics_module.returned_success.call_args.args
+
+
+def test_is_within_enrollment_window_True(mocker):
+    enrollment_reenrollment_date = timezone.make_aware(timezone.datetime(2023, 2, 1), timezone=timezone.get_default_timezone())
+    concession_expiry = timezone.make_aware(timezone.datetime(2023, 3, 1), timezone=timezone.get_default_timezone())
+
+    # mock datetime of "now" to be specific date for testing
+    mocker.patch(
+        "benefits.enrollment.views.timezone.now",
+        return_value=timezone.make_aware(timezone.datetime(2023, 2, 15, 15, 30), timezone=timezone.get_default_timezone()),
+    )
+
+    is_within_reenrollment_window = _is_within_reenrollment_window(concession_expiry, enrollment_reenrollment_date)
+
+    assert is_within_reenrollment_window
+
+
+def test_is_within_enrollment_window_before_window(mocker):
+    enrollment_reenrollment_date = timezone.make_aware(timezone.datetime(2023, 2, 1), timezone=timezone.get_default_timezone())
+    concession_expiry = timezone.make_aware(timezone.datetime(2023, 3, 1), timezone=timezone.get_default_timezone())
+
+    # mock datetime of "now" to be specific date for testing
+    mocker.patch(
+        "benefits.enrollment.views.timezone.now",
+        return_value=timezone.make_aware(timezone.datetime(2023, 1, 15, 15, 30), timezone=timezone.get_default_timezone()),
+    )
+
+    is_within_reenrollment_window = _is_within_reenrollment_window(concession_expiry, enrollment_reenrollment_date)
+
+    assert not is_within_reenrollment_window
+
+
+def test_is_within_enrollment_window_after_window(mocker):
+    enrollment_reenrollment_date = timezone.make_aware(timezone.datetime(2023, 2, 1), timezone=timezone.get_default_timezone())
+    concession_expiry = timezone.make_aware(timezone.datetime(2023, 3, 1), timezone=timezone.get_default_timezone())
+
+    # mock datetime of "now" to be specific date for testing
+    mocker.patch(
+        "benefits.enrollment.views.timezone.now",
+        return_value=timezone.make_aware(timezone.datetime(2023, 3, 15, 15, 30), timezone=timezone.get_default_timezone()),
+    )
+
+    is_within_reenrollment_window = _is_within_reenrollment_window(concession_expiry, enrollment_reenrollment_date)
+
+    assert not is_within_reenrollment_window
+
+
+def test_is_within_enrollment_window_equal_reenrollment_date(mocker):
+    enrollment_reenrollment_date = timezone.make_aware(timezone.datetime(2023, 2, 1), timezone=timezone.get_default_timezone())
+    concession_expiry = timezone.make_aware(timezone.datetime(2023, 3, 1), timezone=timezone.get_default_timezone())
+
+    # mock datetime of "now" to be specific date for testing
+    mocker.patch(
+        "benefits.enrollment.views.timezone.now",
+        return_value=enrollment_reenrollment_date,
+    )
+
+    is_within_reenrollment_window = _is_within_reenrollment_window(concession_expiry, enrollment_reenrollment_date)
+
+    assert is_within_reenrollment_window
+
+
+def test_is_within_enrollment_window_equal_concession_expiry(mocker):
+    enrollment_reenrollment_date = timezone.make_aware(timezone.datetime(2023, 2, 1), timezone=timezone.get_default_timezone())
+    concession_expiry = timezone.make_aware(timezone.datetime(2023, 3, 1), timezone=timezone.get_default_timezone())
+
+    # mock datetime of "now" to be specific date for testing
+    mocker.patch(
+        "benefits.enrollment.views.timezone.now",
+        return_value=concession_expiry,
+    )
+
+    is_within_reenrollment_window = _is_within_reenrollment_window(concession_expiry, enrollment_reenrollment_date)
+
+    assert not is_within_reenrollment_window
+
+
+@pytest.mark.django_db
+@pytest.mark.usefixtures("mocked_session_agency", "mocked_session_verifier", "mocked_session_eligibility")
+def test_index_eligible_post_valid_form_success_supports_expiration_is_within_reenrollment_window(
+    mocker,
+    client,
+    card_tokenize_form_data,
+    mocked_analytics_module,
+    model_EligibilityType_supports_expiration,
+    mocked_funding_source,
+    mocked_group_funding_source_with_expiry,
+    mocked_session_enrollment_expiry,
+):
+    mock_client_cls = mocker.patch("benefits.enrollment.views.Client")
+    mock_client = mock_client_cls.return_value
+    mock_client.get_funding_source_by_token.return_value = mocked_funding_source
+
+    # mock that a funding source already exists, doesn't matter what concession_expiry is
+    mocker.patch("benefits.enrollment.views._get_group_funding_source", return_value=mocked_group_funding_source_with_expiry)
+
+    mocker.patch("benefits.enrollment.views._is_within_reenrollment_window", return_value=True)
+
+    path = reverse(ROUTE_INDEX)
+    response = client.post(path, card_tokenize_form_data)
+
+    mock_client.update_concession_group_funding_source_expiry.assert_called_once_with(
+        funding_source_id=mocked_funding_source.id,
+        group_id=model_EligibilityType_supports_expiration.group_id,
+        expiry_date=mocked_session_enrollment_expiry.return_value,
     )
     assert response.status_code == 200
     assert response.template_name == TEMPLATE_SUCCESS
     mocked_analytics_module.returned_success.assert_called_once()
-    assert model_EligibilityType.group_id in mocked_analytics_module.returned_success.call_args.args
+    assert model_EligibilityType_supports_expiration.group_id in mocked_analytics_module.returned_success.call_args.args
+
+
+@pytest.mark.django_db
+@pytest.mark.usefixtures(
+    "mocked_session_agency",
+    "mocked_session_verifier",
+    "mocked_session_eligibility",
+    "model_EligibilityType_supports_expiration",
+)
+def test_index_eligible_post_valid_form_success_supports_expiration_is_not_expired_yet(
+    mocker,
+    client,
+    card_tokenize_form_data,
+    mocked_analytics_module,
+    mocked_funding_source,
+    mocked_group_funding_source_with_expiry,
+):
+    mock_client_cls = mocker.patch("benefits.enrollment.views.Client")
+    mock_client = mock_client_cls.return_value
+    mock_client.get_funding_source_by_token.return_value = mocked_funding_source
+
+    # mock that a funding source already exists, doesn't matter what concession_expiry is
+    mocker.patch("benefits.enrollment.views._get_group_funding_source", return_value=mocked_group_funding_source_with_expiry)
+
+    mocker.patch("benefits.enrollment.views._is_expired", return_value=False)
+    mocker.patch("benefits.enrollment.views._is_within_reenrollment_window", return_value=False)
+
+    path = reverse(ROUTE_INDEX)
+    response = client.post(path, card_tokenize_form_data)
+
+    assert response.status_code == 200
+    assert response.template_name == TEMPLATE_REENROLLMENT_ERROR
+    mocked_analytics_module.returned_error.assert_called_once()
+
+
+@pytest.mark.django_db
+@pytest.mark.usefixtures("mocked_session_agency", "mocked_session_verifier", "mocked_session_eligibility")
+def test_index_eligible_post_valid_form_success_does_not_support_expiration_has_expiration_date(
+    mocker,
+    client,
+    card_tokenize_form_data,
+    mocked_analytics_module,
+    model_EligibilityType_does_not_support_expiration,
+    mocked_funding_source,
+    mocked_group_funding_source_with_expiry,
+):
+    mock_client_cls = mocker.patch("benefits.enrollment.views.Client")
+    mock_client = mock_client_cls.return_value
+    mock_client.get_funding_source_by_token.return_value = mocked_funding_source
+
+    # mock that a funding source already exists, doesn't matter what concession_expiry is
+    mocker.patch("benefits.enrollment.views._get_group_funding_source", return_value=mocked_group_funding_source_with_expiry)
+
+    path = reverse(ROUTE_INDEX)
+    with pytest.raises(NotImplementedError):
+        client.post(path, card_tokenize_form_data)
+
+    # this is what we would assert if removing expiration were supported
+    #
+    # mock_client.link_concession_group_funding_source.assert_called_once_with(
+    #     funding_source_id=mocked_funding_source.id,
+    #     group_id=model_EligibilityType_does_not_support_expiration.group_id,
+    #     expiry_date=None,
+    # )
+    # assert response.status_code == 200
+    # assert response.template_name == TEMPLATE_SUCCESS
+    # mocked_analytics_module.returned_success.assert_called_once()
+    # assert (
+    #     model_EligibilityType_does_not_support_expiration.group_id in mocked_analytics_module.returned_success.call_args.args
+    # )
 
 
 @pytest.mark.django_db
@@ -263,8 +666,8 @@ def test_success_no_verifier(client):
 
 
 @pytest.mark.django_db
-@pytest.mark.usefixtures("mocked_session_verifier_auth_required")
-def test_success_authentication_logged_in(mocker, client, model_TransitAgency):
+@pytest.mark.usefixtures("mocked_session_verifier_auth_required", "mocked_session_eligibility")
+def test_success_authentication_logged_in(mocker, client, model_TransitAgency, mocked_analytics_module):
     mock_session = mocker.patch("benefits.enrollment.views.session")
     mock_session.logged_in.return_value = True
     mock_session.agency.return_value = model_TransitAgency
@@ -275,11 +678,12 @@ def test_success_authentication_logged_in(mocker, client, model_TransitAgency):
     assert response.status_code == 200
     assert response.template_name == TEMPLATE_SUCCESS
     assert {"origin": reverse(ROUTE_LOGGED_OUT)} in mock_session.update.call_args
+    mocked_analytics_module.returned_success.assert_called_once()
 
 
 @pytest.mark.django_db
-@pytest.mark.usefixtures("mocked_session_verifier_auth_required")
-def test_success_authentication_not_logged_in(mocker, client, model_TransitAgency):
+@pytest.mark.usefixtures("mocked_session_verifier_auth_required", "mocked_session_eligibility")
+def test_success_authentication_not_logged_in(mocker, client, model_TransitAgency, mocked_analytics_module):
     mock_session = mocker.patch("benefits.enrollment.views.session")
     mock_session.logged_in.return_value = False
     mock_session.agency.return_value = model_TransitAgency
@@ -289,13 +693,15 @@ def test_success_authentication_not_logged_in(mocker, client, model_TransitAgenc
 
     assert response.status_code == 200
     assert response.template_name == TEMPLATE_SUCCESS
+    mocked_analytics_module.returned_success.assert_called_once()
 
 
 @pytest.mark.django_db
-@pytest.mark.usefixtures("mocked_session_agency", "mocked_session_verifier_auth_not_required")
-def test_success_no_authentication(client):
+@pytest.mark.usefixtures("mocked_session_agency", "mocked_session_verifier_auth_not_required", "mocked_session_eligibility")
+def test_success_no_authentication(client, mocked_analytics_module):
     path = reverse(ROUTE_SUCCESS)
     response = client.get(path)
 
     assert response.status_code == 200
     assert response.template_name == TEMPLATE_SUCCESS
+    mocked_analytics_module.returned_success.assert_called_once()