Coverage for benefits/enrollment_littlepay/enrollment.py: 99%
91 statements
« prev ^ index » next coverage.py v7.8.2, created at 2025-06-06 20:07 +0000
« prev ^ index » next coverage.py v7.8.2, created at 2025-06-06 20:07 +0000
1from dataclasses import dataclass
2from datetime import timedelta
4from django.utils import timezone
5from littlepay.api.client import Client
6from requests.exceptions import HTTPError
8from benefits.core import session
9from benefits.enrollment.enrollment import Status
12@dataclass
13class CardTokenizationAccessResponse:
14 status: Status
15 access_token: str
16 expires_at: int
17 exception: Exception = None
18 status_code: int = None
21def request_card_tokenization_access(request) -> CardTokenizationAccessResponse:
22 """
23 Requests an access token to be used for card tokenization.
24 """
25 agency = session.agency(request)
27 try:
28 client = Client(
29 base_url=agency.littlepay_config.api_base_url,
30 client_id=agency.littlepay_config.client_id,
31 client_secret=agency.littlepay_config.client_secret,
32 audience=agency.littlepay_config.audience,
33 )
34 client.oauth.ensure_active_token(client.token)
35 response = client.request_card_tokenization_access()
37 return CardTokenizationAccessResponse(
38 status=Status.SUCCESS, access_token=response.get("access_token"), expires_at=response.get("expires_at")
39 )
40 except Exception as e:
41 exception = e
43 if isinstance(e, HTTPError):
44 status_code = e.response.status_code
46 if status_code >= 500:
47 status = Status.SYSTEM_ERROR
48 else:
49 status = Status.EXCEPTION
50 else:
51 status_code = None
52 status = Status.EXCEPTION
54 return CardTokenizationAccessResponse(
55 status=status, access_token=None, expires_at=None, exception=exception, status_code=status_code
56 )
59def enroll(request, card_token) -> tuple[Status, Exception]:
60 """
61 Attempts to enroll this card into the transit processor group for the flow in the request's session.
63 Returns a tuple containing a Status indicating the result of the attempt and any exception that occurred.
64 """
65 agency = session.agency(request)
66 flow = session.flow(request)
68 client = Client(
69 base_url=agency.littlepay_config.api_base_url,
70 client_id=agency.littlepay_config.client_id,
71 client_secret=agency.littlepay_config.client_secret,
72 audience=agency.littlepay_config.audience,
73 )
74 client.oauth.ensure_active_token(client.token)
76 funding_source = client.get_funding_source_by_token(card_token)
77 group_id = flow.group_id
79 exception = None
80 try:
81 group_funding_source = _get_group_funding_source(client=client, group_id=group_id, funding_source_id=funding_source.id)
83 already_enrolled = group_funding_source is not None
85 if flow.supports_expiration:
86 # set expiry on session
87 if already_enrolled and group_funding_source.expiry_date is not None:
88 session.update(request, enrollment_expiry=group_funding_source.expiry_date)
89 else:
90 session.update(request, enrollment_expiry=_calculate_expiry(flow.expiration_days))
92 if not already_enrolled:
93 # enroll user with an expiration date, return success
94 client.link_concession_group_funding_source(
95 group_id=group_id, funding_source_id=funding_source.id, expiry=session.enrollment_expiry(request)
96 )
97 status = Status.SUCCESS
98 else: # already_enrolled
99 if group_funding_source.expiry_date is None:
100 # update expiration of existing enrollment, return success
101 client.update_concession_group_funding_source_expiry(
102 group_id=group_id,
103 funding_source_id=funding_source.id,
104 expiry=session.enrollment_expiry(request),
105 )
106 status = Status.SUCCESS
107 else:
108 is_expired = _is_expired(group_funding_source.expiry_date)
109 is_within_reenrollment_window = _is_within_reenrollment_window(
110 group_funding_source.expiry_date, session.enrollment_reenrollment(request)
111 )
113 if is_expired or is_within_reenrollment_window:
114 # update expiration of existing enrollment, return success
115 client.update_concession_group_funding_source_expiry(
116 group_id=group_id,
117 funding_source_id=funding_source.id,
118 expiry=session.enrollment_expiry(request),
119 )
120 status = Status.SUCCESS
121 else:
122 # re-enrollment error, return enrollment error with expiration and reenrollment_date
123 status = Status.REENROLLMENT_ERROR
124 else: # eligibility does not support expiration
125 if not already_enrolled:
126 # enroll user with no expiration date, return success
127 client.link_concession_group_funding_source(group_id=group_id, funding_source_id=funding_source.id)
128 status = Status.SUCCESS
129 else: # already_enrolled
130 if group_funding_source.expiry_date is None:
131 # no action, return success
132 status = Status.SUCCESS
133 else:
134 # remove expiration date, return success
135 raise NotImplementedError("Removing expiration date is currently not supported")
137 except HTTPError as e:
138 if e.response.status_code >= 500:
139 status = Status.SYSTEM_ERROR
140 exception = e
141 else:
142 status = Status.EXCEPTION
143 exception = Exception(f"{e}: {e.response.json()}")
144 except Exception as e:
145 status = Status.EXCEPTION
146 exception = e
148 return status, exception
151def _get_group_funding_source(client: Client, group_id, funding_source_id):
152 group_funding_sources = client.get_concession_group_linked_funding_sources(group_id)
153 matching_group_funding_source = None
154 for group_funding_source in group_funding_sources:
155 if group_funding_source.id == funding_source_id: 155 ↛ 154line 155 didn't jump to line 154 because the condition on line 155 was always true
156 matching_group_funding_source = group_funding_source
157 break
159 return matching_group_funding_source
162def _is_expired(expiry_date):
163 """Returns whether the passed in datetime is expired or not."""
164 return expiry_date <= timezone.now()
167def _is_within_reenrollment_window(expiry_date, enrollment_reenrollment_date):
168 """Returns if we are currently within the reenrollment window."""
169 return enrollment_reenrollment_date <= timezone.now() < expiry_date
172def _calculate_expiry(expiration_days):
173 """Returns the expiry datetime, which should be midnight in our configured timezone of the (N + 1)th day from now,
174 where N is expiration_days."""
175 default_time_zone = timezone.get_default_timezone()
176 expiry_date = timezone.localtime(timezone=default_time_zone) + timedelta(days=expiration_days + 1)
177 expiry_datetime = expiry_date.replace(hour=0, minute=0, second=0, microsecond=0)
179 return expiry_datetime