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

Admin: Enrollment View #2327

Closed
wants to merge 13 commits into from
Closed
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: 4 additions & 3 deletions benefits/enrollment/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,17 @@

from django import forms

from benefits.routes import routes


class CardTokenizeSuccessForm(forms.Form):
"""Form to bring client card token back to server."""

action_url = routes.ENROLLMENT_INDEX
id = "form-card-tokenize-success"
method = "POST"

def __init__(self, data, action_url=None, *args, **kwargs):
super().__init__(data, *args, **kwargs)
self.action_url = action_url

# hidden input with no label
card_token = forms.CharField(widget=forms.HiddenInput(), label="")

Expand Down
219 changes: 120 additions & 99 deletions benefits/enrollment/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,124 +81,145 @@ def index(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")
return enrollment_post(request, agency, flow, success, reenrollment_error, system_error)

card_token = form.cleaned_data.get("card_token")
# GET enrollment index
else:
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(
routes.ENROLLMENT_SYSTEM_ERROR, "form-card-tokenize-fail-system-error"
)
tokenize_success_form = forms.CardTokenizeSuccessForm(
data=None, action_url=routes.ENROLLMENT_INDEX, auto_id=True, label_suffix=""
)

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,
context = enrollment_get_context(
request,
agency,
tokenize_retry_form=tokenize_retry_form,
tokenize_server_error_form=tokenize_server_error_form,
tokenize_system_error_form=tokenize_system_error_form,
tokenize_success_form=tokenize_success_form,
)
client.oauth.ensure_active_token(client.token)
logger.debug(f'card_tokenize_url: {context["card_tokenize_url"]}')

funding_source = client.get_funding_source_by_token(card_token)
group_id = flow.group_id
return TemplateResponse(request, flow.enrollment_index_template, context)

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
def enrollment_post(request, agency, flow, success_function, reenrollment_error_function, system_error_function):
"""Processes the card token returned from transit processor form."""
form = forms.CardTokenizeSuccessForm(request.POST)
if not form.is_valid():
raise Exception("Invalid card token form")

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))
card_token = form.cleaned_data.get("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

try:
group_funding_source = _get_group_funding_source(client=client, group_id=group_id, funding_source_id=funding_source.id)

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)
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)
)
return success_function(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: # already_enrolled
if group_funding_source.expiry_date is None:
return success_function(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)
return success_function(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)
# re-enrollment error, return enrollment error with expiration and reenrollment_date
return reenrollment_error_function(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_function(request)
else: # already_enrolled
if group_funding_source.expiry_date is None:
# no action, return success
return success_function(request)
else:
# remove expiration date, return success
raise NotImplementedError("Removing expiration date is currently not supported")

return system_error(request)
else:
analytics.returned_error(request, str(e))
raise Exception(f"{e}: {e.response.json()}")
except Exception as e:
except HTTPError as e:
if e.response.status_code >= 500:
analytics.returned_error(request, str(e))
raise e

# GET enrollment index
else:
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(
routes.ENROLLMENT_SYSTEM_ERROR, "form-card-tokenize-fail-system-error"
)
tokenize_success_form = forms.CardTokenizeSuccessForm(auto_id=True, label_suffix="")

# mapping from Django's I18N LANGUAGE_CODE to Littlepay's overlay language code
overlay_language = {"en": "en", "es": "es-419"}.get(request.LANGUAGE_CODE, "en")

context = {
"forms": [tokenize_retry_form, tokenize_server_error_form, tokenize_system_error_form, tokenize_success_form],
"cta_button": "tokenize_card",
"card_tokenize_env": agency.transit_processor.card_tokenize_env,
"card_tokenize_func": agency.transit_processor.card_tokenize_func,
"card_tokenize_url": agency.transit_processor.card_tokenize_url,
"token_field": "card_token",
"form_retry": tokenize_retry_form.id,
"form_server_error": tokenize_server_error_form.id,
"form_success": tokenize_success_form.id,
"form_system_error": tokenize_system_error_form.id,
"overlay_language": overlay_language,
}

logger.debug(f'card_tokenize_url: {context["card_tokenize_url"]}')
sentry_sdk.capture_exception(e)

return TemplateResponse(request, flow.enrollment_index_template, context)
return system_error_function(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


def enrollment_get_context(
request, agency, tokenize_retry_form, tokenize_server_error_form, tokenize_system_error_form, tokenize_success_form
):
"""Returns a context object for the template used for a GET request for the enrollment page."""

# mapping from Django's I18N LANGUAGE_CODE to Littlepay's overlay language code
overlay_language = {"en": "en", "es": "es-419"}.get(request.LANGUAGE_CODE, "en")

context = {
"forms": [tokenize_retry_form, tokenize_server_error_form, tokenize_system_error_form, tokenize_success_form],
"cta_button": "tokenize_card",
"card_tokenize_env": agency.transit_processor.card_tokenize_env,
"card_tokenize_func": agency.transit_processor.card_tokenize_func,
"card_tokenize_url": agency.transit_processor.card_tokenize_url,
"token_field": "card_token",
"form_retry": tokenize_retry_form.id,
"form_server_error": tokenize_server_error_form.id,
"form_success": tokenize_success_form.id,
"form_system_error": tokenize_system_error_form.id,
"overlay_language": overlay_language,
}

return context


def _get_group_funding_source(client: Client, group_id, funding_source_id):
Expand Down
110 changes: 110 additions & 0 deletions benefits/in_person/templates/in_person/enrollment.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
{% extends "admin/agency-base.html" %}

{% block content %}

<div class="row justify-content-center">
<div class="col-lg-6">
<div class="border border-3 p-3">
<h2 class="p-0 m-0 text-left">In-person enrollment</h2>
</div>
<div class="border border-3 border-top-0 p-3">
<div class="loading">
<p>
<span class="spinner-border align-middle text-primary" role="status"></span>
<span class="fw-bold p-2">Please wait...</span>
</p>
</div>
<div class="invisible">
<p>Provide the rider's contactless debit or credit card details.</p>
<iframe class="invisible card-collection" name="card-collection-iframe"></iframe>
</div>
</div>
</div>
</div>

<!--
This hidden button is needed by the card tokenisation function's `element` option.
The iframe's source isn't set until the button is clicked.
-->
<div class="d-none">
<button id="{{ cta_button }}" href="#{{ cta_button }}" class="btn btn-lg btn-primary" role="button">Enroll</button>
</div>

<script nonce="{{ request.csp_nonce }}">

$.ajax({ dataType: "script", attrs: { nonce: "{{ request.csp_nonce }}"}, url: "{{ card_tokenize_url }}" })
.done(function() {
$.get("/../..{% url routes.ENROLLMENT_TOKEN %}", function(data) {
if (data.redirect) {
// https://stackoverflow.com/a/42469170
// use 'assign' because 'replace' was giving strange Back button behavior
window.location.assign(data.redirect);
return; // exit early so the rest of this function doesn't execute
}

$(".loading").remove();
$(".invisible").removeClass("invisible");

new Promise((resolve) => {
{{ card_tokenize_func }}({
authorization: data.token,
element: '#{{ cta_button }}',
envUrl: '{{ card_tokenize_env }}',
options: {
color: '#046b99',
language: '{{ overlay_language }}',
targetiframename: 'card-collection-iframe'
},
onTokenize: function (response) {
/* This function executes when the
/* card verification returns
/* successfully with a token from enrollment server */

var form = $("form#{{ form_success }}");
$("#{{ token_field }}", form).val(response.token);
form.submit();
},
onVerificationFailure: function (response) {
/* This function executes when the
/* card verification fails and server
/* return verification failure message */

var form = $("form#{{ form_retry }}");
form.submit();
},
onError: function (response) {
/* This function executes when the
/* server returns error or token is invalid.
/* 400 or 500 will return. */

if (response.status >= 500) {
var form = $("form#{{ form_system_error }}");
} else {
var form = $("form#{{ form_server_error }}");
}
form.submit();
},
onCancel: function () {
/* This function executes when the
/* user cancels and closes the window
/* and returns to home page. */

return location.reload();
}
});
resolve();
}).then(() => $("#{{ cta_button }}").click())
});
})
.fail(function(jqxhr, settings, exception) {
$(".loading").remove();
console.log(exception);
});

</script>

{% for f in forms %}
{% include "core/includes/form.html" with form=f %}
{% endfor %}

{% endblock content %}
Empty file.
Empty file.
Empty file.
11 changes: 11 additions & 0 deletions benefits/in_person/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,15 @@
urlpatterns = [
path("eligibility/", admin.site.admin_view(views.eligibility), name=routes.name(routes.IN_PERSON_ELIGIBILITY)),
path("enrollment/", admin.site.admin_view(views.enrollment), name=routes.name(routes.IN_PERSON_ENROLLMENT)),
path(
"enrollment/error/reenrollment",
admin.site.admin_view(views.reenrollment_error),
name=routes.name(routes.IN_PERSON_ENROLLMENT_REENROLLMENT_ERROR),
),
path("enrollment/retry", admin.site.admin_view(views.retry), name=routes.name(routes.IN_PERSON_ENROLLMENT_RETRY)),
path(
"enrollment/error",
admin.site.admin_view(views.system_error),
name=routes.name(routes.IN_PERSON_ENROLLMENT_SYSTEM_ERROR),
),
]
Loading
Loading