Repository: cal-itp/benefits

Issue:
Exception: 409 Client Error: Conflict for url: https://backoffice.littlepay.com/api/v1/concession_groups/e91...
Sentry Issue: [BENEFITS-H1](https://sentry.calitp.org/organizations/sentry/issues/89925/?referrer=github_integration)

```
Exception: 409 Client Error: Conflict for url: https://backoffice.littlepay.com/api/v1/concession_groups/e919852a-5e47-478d-b4bc-7362ca3706e6/fundingsources: {'errors': [{'detail': 'Funding source 162f8e82-ba09-4acf-9765-b13c94619ca4 for participant mst already in group e919852a-5e47-478d-b4bc-7362ca3706e6'}]}
(9 additional frame(s) were not displayed)
...
  File "benefits/core/mixins.py", line 24, in dispatch
    return super().dispatch(request, *args, **kwargs)
  File "benefits/core/mixins.py", line 56, in dispatch
    return super().dispatch(request, *args, **kwargs)
  File "benefits/core/mixins.py", line 38, in dispatch
    return super().dispatch(request, *args, **kwargs)
  File "benefits/enrollment_littlepay/views.py", line 130, in form_valid
    return handle_enrollment_results(
  File "benefits/enrollment/enrollment.py", line 111, in handle_enrollment_results
    raise exception
```

Discussion:

Fix (patch):
diff --git a/benefits/enrollment_littlepay/enrollment.py b/benefits/enrollment_littlepay/enrollment.py
index 10c50c8a81..23011d83eb 100644
--- a/benefits/enrollment_littlepay/enrollment.py
+++ b/benefits/enrollment_littlepay/enrollment.py
@@ -1,3 +1,5 @@
+import re
+
 from dataclasses import dataclass
 
 from littlepay.api.client import Client
@@ -136,6 +138,10 @@ def enroll(request, card_token) -> tuple[Status, Exception]:
         if e.response.status_code >= 500:
             status = Status.SYSTEM_ERROR
             exception = e
+        elif e.response.status_code == 409 and re.search(r"Funding source .+ already in group", e.response.text):
+            # Handle situations where we errantly tried to link an already-enrolled funding source.
+            # See: https://github.com/cal-itp/benefits/issues/3292
+            status = Status.SUCCESS
         else:
             status = Status.EXCEPTION
             exception = Exception(f"{e}: {e.response.json()}")


Test changes:
diff --git a/tests/pytest/enrollment_littlepay/test_enrollment.py b/tests/pytest/enrollment_littlepay/test_enrollment.py
index da56d25af3..917ceda529 100644
--- a/tests/pytest/enrollment_littlepay/test_enrollment.py
+++ b/tests/pytest/enrollment_littlepay/test_enrollment.py
@@ -146,6 +146,40 @@ def test_enroll_exception_http_error_400(
     assert exception.args[0] == f"{http_error}: {http_error.response.json()}"
 
 
+@pytest.mark.django_db
+@pytest.mark.usefixtures(
+    "mocked_api_base_url",
+    "mocked_session_agency",
+    "mocked_session_flow",
+    "model_EnrollmentFlow_does_not_support_expiration",
+    "model_LittlepayGroup",
+    "model_LittlepayConfig",
+)
+def test_enroll_exception_http_error_409(
+    mocker,
+    app_request,
+    card_token,
+):
+    """Test for when the Littlepay API seemingly returns incorrect response when checking for an existing enrollment.
+
+    See: https://github.com/cal-itp/benefits/issues/3292
+    """
+    mock_client_cls = mocker.patch("benefits.enrollment_littlepay.enrollment.Client")
+    mock_client = mock_client_cls.return_value
+
+    mock_error = {"detail": "Funding source <uuid> for participant cst already in group <uuid>"}
+    mock_error_response = mocker.Mock(status_code=409, **mock_error)
+    mock_error_response.text = str(mock_error)
+    http_error = HTTPError(
+        response=mock_error_response,
+    )
+    mock_client.link_concession_group_funding_source.side_effect = http_error
+
+    status, exception, funding_source = enroll(app_request, card_token)
+
+    assert status is Status.SUCCESS
+
+
 @pytest.mark.django_db
 @pytest.mark.usefixtures(
     "mocked_api_base_url",