Skip to content

Commit

Permalink
Refactor: introduce enrollment module (#2338)
Browse files Browse the repository at this point in the history
  • Loading branch information
angela-tran authored Sep 10, 2024
2 parents 977dfd4 + e4b4d7a commit c70789e
Show file tree
Hide file tree
Showing 4 changed files with 704 additions and 549 deletions.
146 changes: 146 additions & 0 deletions benefits/enrollment/enrollment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
from enum import Enum
from datetime import timedelta

from django.utils import timezone
from littlepay.api.client import Client
from requests.exceptions import HTTPError

from benefits.core import session


class Status(Enum):
# SUCCESS means the enrollment went through successfully
SUCCESS = 1

# SYSTEM_ERROR means the enrollment system encountered an internal error (returned a 500 HTTP status)
SYSTEM_ERROR = 2

# EXCEPTION means the enrollment system is working, but something unexpected happened
# because of a misconfiguration or invalid request from our side
EXCEPTION = 3

# REENROLLMENT_ERROR means that the user tried to re-enroll but is not within the reenrollment window
REENROLLMENT_ERROR = 4


def enroll(request, card_token):
"""
Attempts to enroll this card into the transit processor group for the flow in the request's session.
Returns a tuple containing a Status indicating the result of the attempt and any exception that occurred.
"""
agency = session.agency(request)
flow = session.flow(request)

client = Client(
base_url=agency.transit_processor.api_base_url,
client_id=agency.transit_processor_client_id,
client_secret=agency.transit_processor_client_secret,
audience=agency.transit_processor_audience,
)
client.oauth.ensure_active_token(client.token)

funding_source = client.get_funding_source_by_token(card_token)
group_id = flow.group_id

exception = None
try:
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 flow.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(flow.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)
)
status = Status.SUCCESS
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),
)
status = Status.SUCCESS
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),
)
status = Status.SUCCESS
else:
# re-enrollment error, return enrollment error with expiration and reenrollment_date
status = Status.REENROLLMENT_ERROR
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)
status = Status.SUCCESS
else: # already_enrolled
if group_funding_source.expiry_date is None:
# no action, return success
status = Status.SUCCESS
else:
# remove expiration date, return success
raise NotImplementedError("Removing expiration date is currently not supported")

except HTTPError as e:
if e.response.status_code >= 500:
status = Status.SYSTEM_ERROR
exception = e
else:
status = Status.EXCEPTION
exception = Exception(f"{e}: {e.response.json()}")
except Exception as e:
status = Status.EXCEPTION
exception = e

return status, exception


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
133 changes: 18 additions & 115 deletions benefits/enrollment/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,11 @@
"""

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 All @@ -19,6 +18,7 @@
from benefits.core.middleware import EligibleSessionRequired, FlowSessionRequired, pageview_decorator

from . import analytics, forms
from .enrollment import Status, enroll

TEMPLATE_RETRY = "enrollment/retry.html"
TEMPLATE_SYSTEM_ERROR = "enrollment/system_error.html"
Expand Down Expand Up @@ -76,102 +76,36 @@ def index(request):
"""View handler for the enrollment landing page."""
session.update(request, origin=reverse(routes.ENROLLMENT_INDEX))

agency = session.agency(request)
flow = session.flow(request)

# POST back after transit processor form, process card token
if request.method == "POST":
form = forms.CardTokenizeSuccessForm(request.POST)
if not form.is_valid():
raise Exception("Invalid card token form")

card_token = form.cleaned_data.get("card_token")
status, exception = enroll(request, card_token)

client = Client(
base_url=agency.transit_processor.api_base_url,
client_id=agency.transit_processor_client_id,
client_secret=agency.transit_processor_client_secret,
audience=agency.transit_processor_audience,
)
client.oauth.ensure_active_token(client.token)

funding_source = client.get_funding_source_by_token(card_token)
group_id = flow.group_id
match (status):
case Status.SUCCESS:
return success(request)

try:
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
case Status.SYSTEM_ERROR:
analytics.returned_error(request, str(exception))
sentry_sdk.capture_exception(exception)
return system_error(request)

if flow.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(flow.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:
if e.response.status_code >= 500:
analytics.returned_error(request, str(e))
sentry_sdk.capture_exception(e)
case Status.EXCEPTION:
analytics.returned_error(request, str(exception))
raise exception

return system_error(request)
else:
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
case Status.REENROLLMENT_ERROR:
return reenrollment_error(request)

# GET enrollment index
else:
agency = session.agency(request)
flow = session.flow(request)

tokenize_retry_form = forms.CardTokenizeFailForm(routes.ENROLLMENT_RETRY, "form-card-tokenize-fail-retry")
tokenize_server_error_form = forms.CardTokenizeFailForm(routes.SERVER_ERROR, "form-card-tokenize-fail-server-error")
tokenize_system_error_form = forms.CardTokenizeFailForm(
Expand Down Expand Up @@ -203,37 +137,6 @@ def index(request):
return TemplateResponse(request, flow.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
Loading

0 comments on commit c70789e

Please sign in to comment.