Coverage for benefits/enrollment/enrollment.py: 99%
96 statements
« prev ^ index » next coverage.py v7.6.7, created at 2024-11-22 18:00 +0000
« prev ^ index » next coverage.py v7.6.7, created at 2024-11-22 18:00 +0000
1from dataclasses import dataclass
2from datetime import timedelta
3from enum import Enum
5from django.utils import timezone
6from littlepay.api.client import Client
7from requests.exceptions import HTTPError
9from benefits.core import session
12class Status(Enum):
13 # SUCCESS means the enrollment went through successfully
14 SUCCESS = 1
16 # SYSTEM_ERROR means the enrollment system encountered an internal error (returned a 500 HTTP status)
17 SYSTEM_ERROR = 2
19 # EXCEPTION means the enrollment system is working, but something unexpected happened
20 # because of a misconfiguration or invalid request from our side
21 EXCEPTION = 3
23 # REENROLLMENT_ERROR means that the user tried to re-enroll but is not within the reenrollment window
24 REENROLLMENT_ERROR = 4
27@dataclass
28class CardTokenizationAccessResponse:
29 status: Status
30 access_token: str
31 expires_at: str
32 exception: Exception = None
33 status_code: int = None
36def request_card_tokenization_access(request) -> CardTokenizationAccessResponse:
37 """
38 Requests an access token to be used for card tokenization.
39 """
40 agency = session.agency(request)
42 try:
43 client = Client(
44 base_url=agency.transit_processor.api_base_url,
45 client_id=agency.transit_processor_client_id,
46 client_secret=agency.transit_processor_client_secret,
47 audience=agency.transit_processor_audience,
48 )
49 client.oauth.ensure_active_token(client.token)
50 response = client.request_card_tokenization_access()
52 return CardTokenizationAccessResponse(
53 status=Status.SUCCESS, access_token=response.get("access_token"), expires_at=response.get("expires_at")
54 )
55 except Exception as e:
56 exception = e
58 if isinstance(e, HTTPError):
59 status_code = e.response.status_code
61 if status_code >= 500:
62 status = Status.SYSTEM_ERROR
63 else:
64 status = Status.EXCEPTION
65 else:
66 status_code = None
67 status = Status.EXCEPTION
69 return CardTokenizationAccessResponse(
70 status=status, access_token=None, expires_at=None, exception=exception, status_code=status_code
71 )
74def enroll(request, card_token) -> tuple[Status, Exception]:
75 """
76 Attempts to enroll this card into the transit processor group for the flow in the request's session.
78 Returns a tuple containing a Status indicating the result of the attempt and any exception that occurred.
79 """
80 agency = session.agency(request)
81 flow = session.flow(request)
83 client = Client(
84 base_url=agency.transit_processor.api_base_url,
85 client_id=agency.transit_processor_client_id,
86 client_secret=agency.transit_processor_client_secret,
87 audience=agency.transit_processor_audience,
88 )
89 client.oauth.ensure_active_token(client.token)
91 funding_source = client.get_funding_source_by_token(card_token)
92 group_id = flow.group_id
94 exception = None
95 try:
96 group_funding_source = _get_group_funding_source(client=client, group_id=group_id, funding_source_id=funding_source.id)
98 already_enrolled = group_funding_source is not None
100 if flow.supports_expiration:
101 # set expiry on session
102 if already_enrolled and group_funding_source.expiry_date is not None:
103 session.update(request, enrollment_expiry=group_funding_source.expiry_date)
104 else:
105 session.update(request, enrollment_expiry=_calculate_expiry(flow.expiration_days))
107 if not already_enrolled:
108 # enroll user with an expiration date, return success
109 client.link_concession_group_funding_source(
110 group_id=group_id, funding_source_id=funding_source.id, expiry=session.enrollment_expiry(request)
111 )
112 status = Status.SUCCESS
113 else: # already_enrolled
114 if group_funding_source.expiry_date is None:
115 # update expiration of existing enrollment, return success
116 client.update_concession_group_funding_source_expiry(
117 group_id=group_id,
118 funding_source_id=funding_source.id,
119 expiry=session.enrollment_expiry(request),
120 )
121 status = Status.SUCCESS
122 else:
123 is_expired = _is_expired(group_funding_source.expiry_date)
124 is_within_reenrollment_window = _is_within_reenrollment_window(
125 group_funding_source.expiry_date, session.enrollment_reenrollment(request)
126 )
128 if is_expired or is_within_reenrollment_window:
129 # update expiration of existing enrollment, return success
130 client.update_concession_group_funding_source_expiry(
131 group_id=group_id,
132 funding_source_id=funding_source.id,
133 expiry=session.enrollment_expiry(request),
134 )
135 status = Status.SUCCESS
136 else:
137 # re-enrollment error, return enrollment error with expiration and reenrollment_date
138 status = Status.REENROLLMENT_ERROR
139 else: # eligibility does not support expiration
140 if not already_enrolled:
141 # enroll user with no expiration date, return success
142 client.link_concession_group_funding_source(group_id=group_id, funding_source_id=funding_source.id)
143 status = Status.SUCCESS
144 else: # already_enrolled
145 if group_funding_source.expiry_date is None:
146 # no action, return success
147 status = Status.SUCCESS
148 else:
149 # remove expiration date, return success
150 raise NotImplementedError("Removing expiration date is currently not supported")
152 except HTTPError as e:
153 if e.response.status_code >= 500:
154 status = Status.SYSTEM_ERROR
155 exception = e
156 else:
157 status = Status.EXCEPTION
158 exception = Exception(f"{e}: {e.response.json()}")
159 except Exception as e:
160 status = Status.EXCEPTION
161 exception = e
163 return status, exception
166def _get_group_funding_source(client: Client, group_id, funding_source_id):
167 group_funding_sources = client.get_concession_group_linked_funding_sources(group_id)
168 matching_group_funding_source = None
169 for group_funding_source in group_funding_sources:
170 if group_funding_source.id == funding_source_id: 170 ↛ 169line 170 didn't jump to line 169 because the condition on line 170 was always true
171 matching_group_funding_source = group_funding_source
172 break
174 return matching_group_funding_source
177def _is_expired(expiry_date):
178 """Returns whether the passed in datetime is expired or not."""
179 return expiry_date <= timezone.now()
182def _is_within_reenrollment_window(expiry_date, enrollment_reenrollment_date):
183 """Returns if we are currently within the reenrollment window."""
184 return enrollment_reenrollment_date <= timezone.now() < expiry_date
187def _calculate_expiry(expiration_days):
188 """Returns the expiry datetime, which should be midnight in our configured timezone of the (N + 1)th day from now,
189 where N is expiration_days."""
190 default_time_zone = timezone.get_default_timezone()
191 expiry_date = timezone.localtime(timezone=default_time_zone) + timedelta(days=expiration_days + 1)
192 expiry_datetime = expiry_date.replace(hour=0, minute=0, second=0, microsecond=0)
194 return expiry_datetime