Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: enrollments can expire #2165

Merged
merged 2 commits into from
Jun 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion benefits/core/migrations/local_fixtures.json
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,12 @@
"fields": {
"name": "calfresh",
"label": "CalFresh",
"group_id": "group123"
"group_id": "group123",
"supports_expiration": "True",
"expiration_days": 5,
"expiration_reenrollment_days": 3,
"reenrollment_error_template": "enrollment/reenrollment-error--calfresh.html",
"enrollment_success_template": "enrollment/success--mst.html"
}
},
{
Expand Down
107 changes: 95 additions & 12 deletions benefits/enrollment/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -92,7 +94,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(
Expand All @@ -104,17 +105,69 @@ 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.expiry_date is not None:
session.update(request, enrollment_expiry=group_funding_source.expiry_date)
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=session.enrollment_expiry(request)
)
return success(request)
else: # already_enrolled
if group_funding_source.expiry_date 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=session.enrollment_expiry(request),
)
return success(request)
else:
is_expired = _is_expired(group_funding_source.expiry_date)
is_within_reenrollment_window = _is_within_reenrollment_window(
group_funding_source.expiry_date, 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=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.expiry_date 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)
elif e.response.status_code >= 500:
if e.response.status_code >= 500:
analytics.returned_error(request, str(e))
sentry_sdk.capture_exception(e)

Expand All @@ -125,9 +178,6 @@ def index(request):
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:
Expand All @@ -154,6 +204,37 @@ 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(expiry_date):
"""Returns whether the passed in datetime is expired or not."""
return expiry_date <= timezone.now()


def _is_within_reenrollment_window(expiry_date, enrollment_reenrollment_date):
"""Returns if we are currently within the reenrollment window."""
return enrollment_reenrollment_date <= timezone.now() < expiry_date


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


@decorator_from_middleware(EligibleSessionRequired)
def reenrollment_error(request):
"""View handler for a re-enrollment attempt that is not yet within the re-enrollment window."""
Expand Down Expand Up @@ -192,6 +273,7 @@ def system_error(request):


@pageview_decorator
@decorator_from_middleware(EligibleSessionRequired)
@decorator_from_middleware(VerifierSessionRequired)
def success(request):
"""View handler for the final success page."""
Expand All @@ -206,4 +288,5 @@ def success(request):
# 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, eligibility.enrollment_success_template)
6 changes: 5 additions & 1 deletion benefits/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

# https://docs.djangoproject.com/en/5.0/topics/i18n/formatting/#creating-custom-format-files
Expand Down
10 changes: 10 additions & 0 deletions tests/pytest/conftest.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -261,6 +262,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)
Expand Down
Loading
Loading