Repository: cal-itp/benefits

Issue:
Create and send a real event for enrollment
Right now we send a page view event for the [`success`](https://github.com/cal-itp/benefits/blob/dev/benefits/enrollment/views.py#L138) view in `enrollment`. In Amplitude, we have a custom event for page view with path = `/enrollment/success` to indicate a successful enrollment.

We should send a dedicated event instead, for greater reliability and less chance of e.g. duplicate events or a missing page view.

## Acceptance Criteria

<!-- Remember to consider edge cases -->

- [ ] We have a dedicated `completed enrollment` event, sent only when the user has successfully registered with the payment processor



Discussion:

Resolving patch:
diff --git a/benefits/enrollment/analytics.py b/benefits/enrollment/analytics.py
new file mode 100644
--- /dev/null
+++ b/benefits/enrollment/analytics.py
@@ -0,0 +1,28 @@
+"""
+The enrollment application: analytics implementation.
+"""
+from benefits.core import analytics as core
+
+
+class ReturnedEnrollmentEvent(core.Event):
+    """Analytics event representing the end of payment processor enrollment request."""
+
+    def __init__(self, request, status, error=None):
+        super().__init__(request, "returned enrollment")
+        if str(status).lower() in ("error", "retry", "success"):
+            self.update_event_properties(status=status, error=error)
+
+
+def returned_error(request, error):
+    """Send the "returned enrollment" analytics event with an error status and message."""
+    core.send_event(ReturnedEnrollmentEvent(request, status="error", error=error))
+
+
+def returned_retry(request):
+    """Send the "returned enrollment" analytics event with a retry status."""
+    core.send_event(ReturnedEnrollmentEvent(request, status="retry"))
+
+
+def returned_success(request):
+    """Send the "returned enrollment" analytics event with a success status."""
+    core.send_event(ReturnedEnrollmentEvent(request, status="success"))
diff --git a/benefits/enrollment/views.py b/benefits/enrollment/views.py
--- a/benefits/enrollment/views.py
+++ b/benefits/enrollment/views.py
@@ -12,7 +12,7 @@
 from benefits.core import models, session, viewmodels
 from benefits.core.middleware import EligibleSessionRequired, VerifierSessionRequired, pageview_decorator
 from benefits.core.views import ROUTE_HELP
-from . import api, forms
+from . import analytics, api, forms
 
 
 ROUTE_INDEX = "enrollment:index"
@@ -64,6 +64,7 @@ def index(request):
         if response.success:
             return success(request)
         else:
+            analytics.returned_error(request, response.message)
             raise Exception(response.message)
 
     # GET enrollment index, with button to initiate payment processor connection
@@ -116,6 +117,7 @@ def index(request):
 def retry(request):
     """View handler for a recoverable failure condition."""
     if request.method == "POST":
+        analytics.returned_retry(request)
         form = forms.CardTokenizeFailForm(request.POST)
         if form.is_valid():
             agency = session.agency(request)
@@ -130,8 +132,10 @@ def retry(request):
             page.buttons.append(viewmodels.Button.primary(text=_("core.buttons.retry"), url=session.origin(request)))
             return TemplateResponse(request, TEMPLATE_RETRY, page.context_dict())
         else:
+            analytics.returned_error(request, "Invalid retry submission.")
             raise Exception("Invalid retry submission.")
     else:
+        analytics.returned_error(request, "This view method only supports POST.")
         raise Exception("This view method only supports POST.")
 
 
@@ -139,6 +143,8 @@ def retry(request):
 @decorator_from_middleware(VerifierSessionRequired)
 def success(request):
     """View handler for the final success page."""
+    analytics.returned_success(request)
+
     request.path = "/enrollment/success"
     session.update(request, origin=reverse(ROUTE_SUCCESS))
     verifier = session.verifier(request)


Test patch:
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
@@ -14,6 +14,8 @@
     TEMPLATE_RETRY,
 )
 
+import benefits.enrollment.views
+
 
 @pytest.fixture
 def card_tokenize_form_data():
@@ -25,6 +27,11 @@ def invalid_form_data():
     return {"invalid": "data"}
 
 
+@pytest.fixture
+def mocked_analytics_module(mocked_analytics_module):
+    return mocked_analytics_module(benefits.enrollment.views)
+
+
 @pytest.mark.django_db
 def test_token_ineligible(client):
     path = reverse(ROUTE_TOKEN)
@@ -106,7 +113,7 @@ def test_index_eligible_post_valid_form_failure(mocker, client, card_tokenize_fo
 
 @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):
+def test_index_eligible_post_valid_form_success(mocker, client, card_tokenize_form_data, mocked_analytics_module):
     mock_response = mocker.Mock()
     mock_response.success = True
     mocker.patch("benefits.enrollment.views.api.Client.enroll", return_value=mock_response)
@@ -116,6 +123,7 @@ def test_index_eligible_post_valid_form_success(mocker, client, card_tokenize_fo
 
     assert response.status_code == 200
     assert response.template_name == TEMPLATE_SUCCESS
+    mocked_analytics_module.returned_success.assert_called_once()
 
 
 @pytest.mark.django_db
@@ -152,7 +160,7 @@ def test_retry_invalid_form(mocker, client):
 
 @pytest.mark.django_db
 @pytest.mark.usefixtures("mocked_session_agency", "mocked_session_eligibility")
-def test_retry_valid_form(mocker, client):
+def test_retry_valid_form(mocker, client, mocked_analytics_module):
     mocker.patch("benefits.enrollment.views.forms.CardTokenizeFailForm.is_valid", return_value=True)
 
     path = reverse(ROUTE_RETRY)
@@ -160,6 +168,7 @@ def test_retry_valid_form(mocker, client):
 
     assert response.status_code == 200
     assert response.template_name == TEMPLATE_RETRY
+    mocked_analytics_module.returned_retry.assert_called_once()
 
 
 @pytest.mark.django_db
@@ -171,13 +180,14 @@ 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):
+def test_success_authentication_logged_in(mocker, client, mocked_analytics_module):
     mock_session = mocker.patch("benefits.enrollment.views.session")
     mock_session.logged_in.return_value = True
 
     path = reverse(ROUTE_SUCCESS)
     response = client.get(path)
 
+    mocked_analytics_module.returned_success.assert_called_once()
     assert response.status_code == 200
     assert response.template_name == TEMPLATE_SUCCESS
     assert "page" in response.context_data
@@ -186,13 +196,14 @@ def test_success_authentication_logged_in(mocker, client):
 
 @pytest.mark.django_db
 @pytest.mark.usefixtures("mocked_session_verifier_auth_required")
-def test_success_authentication_not_logged_in(mocker, client):
+def test_success_authentication_not_logged_in(mocker, client, mocked_analytics_module):
     mock_session = mocker.patch("benefits.enrollment.views.session")
     mock_session.logged_in.return_value = False
 
     path = reverse(ROUTE_SUCCESS)
     response = client.get(path)
 
+    mocked_analytics_module.returned_success.assert_called_once()
     assert response.status_code == 200
     assert response.template_name == TEMPLATE_SUCCESS
     assert "page" in response.context_data
@@ -201,10 +212,11 @@ def test_success_authentication_not_logged_in(mocker, client):
 
 @pytest.mark.django_db
 @pytest.mark.usefixtures("mocked_session_verifier_auth_not_required")
-def test_success_no_authentication(client):
+def test_success_no_authentication(mocker, client, mocked_analytics_module):
     path = reverse(ROUTE_SUCCESS)
     response = client.get(path)
 
+    mocked_analytics_module.returned_success.assert_called_once()
     assert response.status_code == 200
     assert response.template_name == TEMPLATE_SUCCESS
     assert "page" in response.context_data