1
1
"""
2
2
Subsidy Service signals handler.
3
+
4
+ The following two scenarios detail what happens when either ECS or a learner initiates unenrollment, and explains how
5
+ infinite loops are terminated.
6
+
7
+ 1. When ECS invokes transaction reversal:
8
+ =========================================
9
+ * Reversal gets created.
10
+ ↳ Emit TRANSACTION_REVERSED signal.
11
+ * TRANSACTION_REVERSED triggers the `listen_for_transaction_reversal()` handler.
12
+ ↳ Revoke internal & external fulfillments.
13
+ ↳ Emit LEARNER_CREDIT_COURSE_ENROLLMENT_REVOKED openedx event.
14
+ ↳ Emit LEDGER_TRANSACTION_REVERSED openedx event.
15
+ * LEARNER_CREDIT_COURSE_ENROLLMENT_REVOKED triggers the `handle_lc_enrollment_revoked()` handler.
16
+ ↳ Fail first base case (reversal already exists) and quit. <-------THIS TERMINATES THE INFINITE LOOP!
17
+ * LEDGER_TRANSACTION_REVERSED triggers the `update_assignment_status_for_reversed_transaction()` handler.
18
+ ↳ Updates any assignments as needed.
19
+
20
+ 2. When a learner invokes unenrollment:
21
+ =======================================
22
+ * Enterprise app will perform internal fulfillment revocation.
23
+ ↳ Emit LEARNER_CREDIT_COURSE_ENROLLMENT_REVOKED openedx event.
24
+ * LEARNER_CREDIT_COURSE_ENROLLMENT_REVOKED triggers the `handle_lc_enrollment_revoked()` handler.
25
+ ↳ Revoke external fulfillments.
26
+ ↳ Create reversal.
27
+ ↳ Emit TRANSACTION_REVERSED signal.
28
+ * TRANSACTION_REVERSED triggers the `listen_for_transaction_reversal()` handler.
29
+ ↳ Attempt to idempotently revoke external enrollment (no-op).
30
+ ↳ Attempt to idempotently revoke internal enrollment (no-op). <----THIS TERMINATES THE INFINITE LOOP!
31
+ ↳ Emit LEDGER_TRANSACTION_REVERSED openedx event.
32
+ * LEDGER_TRANSACTION_REVERSED triggers the `update_assignment_status_for_reversed_transaction()` handler.
33
+ ↳ Updates any assignments as needed.
3
34
"""
4
35
import logging
36
+ from datetime import datetime , timedelta
5
37
38
+ from django .conf import settings
6
39
from django .dispatch import receiver
40
+ from openedx_events .enterprise .signals import LEARNER_CREDIT_COURSE_ENROLLMENT_REVOKED
41
+ from openedx_ledger .models import Transaction , TransactionStateChoices
7
42
from openedx_ledger .signals .signals import TRANSACTION_REVERSED
8
43
44
+ from enterprise_subsidy .apps .content_metadata .api import ContentMetadataApi
9
45
from enterprise_subsidy .apps .core .event_bus import send_transaction_reversed_event
10
-
11
- from ..api import cancel_transaction_external_fulfillment , cancel_transaction_fulfillment
12
- from ..exceptions import TransactionFulfillmentCancelationException
46
+ from enterprise_subsidy .apps .transaction .api import (
47
+ cancel_transaction_external_fulfillment ,
48
+ cancel_transaction_fulfillment ,
49
+ reverse_transaction
50
+ )
51
+ from enterprise_subsidy .apps .transaction .exceptions import TransactionFulfillmentCancelationException
13
52
14
53
logger = logging .getLogger (__name__ )
15
54
16
55
17
56
@receiver (TRANSACTION_REVERSED )
18
57
def listen_for_transaction_reversal (sender , ** kwargs ):
19
58
"""
20
- Listen for the TRANSACTION_REVERSED signals and issue an unenrollment request to platform.
59
+ Listen for the TRANSACTION_REVERSED signals and issue an unenrollment request to internal and external fulfillments.
60
+
61
+ This subsequently emits a LEDGER_TRANSACTION_REVERSED openedx event to signal to enterprise-access that any
62
+ assignents need to be reversed too.
21
63
"""
22
64
logger .info (
23
65
f"Received TRANSACTION_REVERSED signal from { sender } , attempting to unenroll platform enrollment object"
@@ -36,3 +78,151 @@ def listen_for_transaction_reversal(sender, **kwargs):
36
78
error_msg = f"Error canceling platform fulfillment { transaction .fulfillment_identifier } : { exc } "
37
79
logger .exception (error_msg )
38
80
raise exc
81
+
82
+
83
+ def unenrollment_can_be_refunded (
84
+ content_metadata ,
85
+ enterprise_course_enrollment ,
86
+ ):
87
+ """
88
+ helper method to determine if an unenrollment is refundable
89
+ """
90
+ # Retrieve the course start date from the content metadata
91
+ enrollment_course_run_key = enterprise_course_enrollment .get ("course_id" )
92
+ course_start_date = None
93
+ if content_metadata .get ('content_type' ) == 'courserun' :
94
+ course_start_date = content_metadata .get ('start' )
95
+ else :
96
+ for run in content_metadata .get ('course_runs' , []):
97
+ if run .get ('key' ) == enrollment_course_run_key :
98
+ course_start_date = run .get ('start' )
99
+ break
100
+
101
+ if not course_start_date :
102
+ logger .warning (
103
+ f"No course start date found for course run: { enrollment_course_run_key } . "
104
+ "Unable to determine refundability."
105
+ )
106
+ return False
107
+
108
+ # https://2u-internal.atlassian.net/browse/ENT-6825
109
+ # OCM course refundability is defined as True IFF:
110
+ # ie MAX(enterprise enrollment created at, course start date) + 14 days > unenrolled_at date
111
+ enrollment_created_datetime = enterprise_course_enrollment .get ("created" )
112
+ enrollment_unenrolled_at_datetime = enterprise_course_enrollment .get ("unenrolled_at" )
113
+ course_start_datetime = datetime .fromisoformat (course_start_date )
114
+ refund_cutoff_date = max (course_start_datetime , enrollment_created_datetime ) + timedelta (days = 14 )
115
+ if refund_cutoff_date > enrollment_unenrolled_at_datetime :
116
+ logger .info (
117
+ f"Course run: { enrollment_course_run_key } is refundable for enterprise customer user: "
118
+ f"{ enterprise_course_enrollment .get ('enterprise_customer_user' )} . Writing Reversal record."
119
+ )
120
+ return True
121
+ else :
122
+ logger .info (
123
+ f"Unenrollment from course: { enrollment_course_run_key } by user: "
124
+ f"{ enterprise_course_enrollment .get ('enterprise_customer_user' )} is not refundable."
125
+ )
126
+ return False
127
+
128
+
129
+ @receiver (LEARNER_CREDIT_COURSE_ENROLLMENT_REVOKED )
130
+ def handle_lc_enrollment_revoked (** kwargs ):
131
+ """
132
+ openedx event handler to respond to LearnerCreditEnterpriseCourseEnrollment revocations.
133
+
134
+ The critical bits of this handler's business logic can be summarized as follows:
135
+
136
+ * Receive LC fulfillment revocation event and run this handler.
137
+ * BASE CASE: If this fulfillment's transaction has already been reversed, quit.
138
+ * BASE CASE: If the refund deadline has passed, quit.
139
+ * Cancel/unenroll any external fulfillments related to the transaction.
140
+ * Reverse the transaction.
141
+
142
+ Args:
143
+ learner_credit_course_enrollment (dict-like):
144
+ An openedx-events serialized representation of LearnerCreditEnterpriseCourseEnrollment.
145
+ """
146
+ revoked_enrollment_data = kwargs .get ('learner_credit_course_enrollment' )
147
+ fulfillment_uuid = revoked_enrollment_data .get ("uuid" )
148
+ enterprise_course_enrollment = revoked_enrollment_data .get ("enterprise_course_enrollment" )
149
+ enrollment_course_run_key = enterprise_course_enrollment .get ("course_id" )
150
+ enrollment_unenrolled_at = enterprise_course_enrollment .get ("unenrolled_at" )
151
+
152
+ # Look for a transaction related to the unenrollment
153
+ related_transaction = Transaction .objects .filter (
154
+ uuid = revoked_enrollment_data .get ('transaction_id' )
155
+ ).first ()
156
+ if not related_transaction :
157
+ logger .info (
158
+ f"No Subsidy Transaction found for enterprise fulfillment: { fulfillment_uuid } "
159
+ )
160
+ return
161
+ # Fail early if the transaction is not committed, even though reverse_full_transaction()
162
+ # would throw an exception later anyway.
163
+ if related_transaction .state != TransactionStateChoices .COMMITTED :
164
+ logger .info (
165
+ f"Transaction: { related_transaction } is not in a committed state. "
166
+ f"Skipping Reversal creation."
167
+ )
168
+ return
169
+
170
+ # Look for a Reversal related to the unenrollment
171
+ existing_reversal = related_transaction .get_reversal ()
172
+ if existing_reversal :
173
+ logger .info (
174
+ f"Found existing Reversal: { existing_reversal } for enterprise fulfillment: "
175
+ f"{ fulfillment_uuid } . Skipping Reversal creation for Transaction: { related_transaction } ."
176
+ )
177
+ return
178
+
179
+ # Continue on if no reversal found
180
+ logger .info (
181
+ f"No existing Reversal found for enterprise fulfillment: { fulfillment_uuid } . "
182
+ f"Writing Reversal for Transaction: { related_transaction } ."
183
+ )
184
+
185
+ # On initial release we are only supporting learner initiated unenrollments for OCM courses.
186
+ # OCM courses are identified by the lack of an external_reference on the Transaction object.
187
+ # Externally referenced transactions can be unenrolled through the Django admin actions related to the
188
+ # Transaction model.
189
+ automatic_external_cancellation = getattr (settings , "ENTERPRISE_SUBSIDY_AUTOMATIC_EXTERNAL_CANCELLATION" , False )
190
+ if related_transaction .external_reference .exists () and not automatic_external_cancellation :
191
+ logger .info (
192
+ f"Found unenrolled enterprise fulfillment: { fulfillment_uuid } related to "
193
+ f"an externally referenced transaction: { related_transaction .external_reference .first ()} . "
194
+ f"Skipping ENTERPRISE_SUBSIDY_AUTOMATIC_EXTERNAL_CANCELLATION={ automatic_external_cancellation } ."
195
+ )
196
+ return
197
+
198
+ # NOTE: get_content_metadata() is backed by TieredCache, so this would be performant if a bunch learners unenroll
199
+ # from the same course at the same time. However, normally no two learners in the same course would unenroll within
200
+ # a single cache timeout period, so we'd expect this to normally always re-fetch from remote API. That's OK because
201
+ # unenrollment volumes are manageable.
202
+ content_metadata = ContentMetadataApi .get_content_metadata (
203
+ enrollment_course_run_key ,
204
+ )
205
+
206
+ # Check if the OCM unenrollment is refundable
207
+ if not unenrollment_can_be_refunded (content_metadata , enterprise_course_enrollment ):
208
+ logger .info (
209
+ f"Unenrollment from course: { enrollment_course_run_key } by user: "
210
+ f"{ enterprise_course_enrollment .get ('enterprise_customer_user' )} is not refundable."
211
+ )
212
+ return
213
+
214
+ logger .info (
215
+ f"Course run: { enrollment_course_run_key } is refundable for enterprise "
216
+ f"customer user: { enterprise_course_enrollment .get ('enterprise_customer_user' )} . Writing "
217
+ "Reversal record."
218
+ )
219
+
220
+ successfully_canceled = cancel_transaction_external_fulfillment (related_transaction )
221
+ if not successfully_canceled :
222
+ logger .warning (
223
+ 'Could not cancel external fulfillment for transaction %s, no reversal written' ,
224
+ related_transaction .uuid ,
225
+ )
226
+ return
227
+
228
+ reverse_transaction (related_transaction , unenroll_time = enrollment_unenrolled_at )
0 commit comments