Skip to content

Commit 05d820f

Browse files
committed
feat: write reversal on LC enrollment revoked event
Handle the following event bus event: org.openedx.enterprise.learner_credit_course_enrollment.revoked.v1 under the following openedx-signal: LEARNER_CREDIT_COURSE_ENROLLMENT_REVOKED This will perform the same duties as the `write_reversals_from_enterprise_unenrollments` management command, except it operates on only one unenrollment at a time, and no longer calls the "recent unenrollments" API located at: {LMS_BASE_URL}/enterprise/api/v1/operator/enterprise-subsidy-fulfillment/unenrolled/ ENT-9213
1 parent ed38ec1 commit 05d820f

File tree

10 files changed

+292
-12
lines changed

10 files changed

+292
-12
lines changed

enterprise_subsidy/apps/transaction/signals/handlers.py

+194-4
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,65 @@
11
"""
22
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.
334
"""
435
import logging
36+
from datetime import datetime, timedelta
537

38+
from django.conf import settings
639
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
742
from openedx_ledger.signals.signals import TRANSACTION_REVERSED
843

44+
from enterprise_subsidy.apps.content_metadata.api import ContentMetadataApi
945
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
1352

1453
logger = logging.getLogger(__name__)
1554

1655

1756
@receiver(TRANSACTION_REVERSED)
1857
def listen_for_transaction_reversal(sender, **kwargs):
1958
"""
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.
2163
"""
2264
logger.info(
2365
f"Received TRANSACTION_REVERSED signal from {sender}, attempting to unenroll platform enrollment object"
@@ -36,3 +78,151 @@ def listen_for_transaction_reversal(sender, **kwargs):
3678
error_msg = f"Error canceling platform fulfillment {transaction.fulfillment_identifier}: {exc}"
3779
logger.exception(error_msg)
3880
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)

enterprise_subsidy/apps/transaction/tests/test_signal_handlers.py

+89-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
"""
22
Tests for the subsidy service transaction app signal handlers
33
"""
4+
import re
5+
from uuid import uuid4
46
from unittest import mock
7+
from datetime import datetime
8+
import ddt
59

10+
from django.test.utils import override_settings
11+
from openedx_ledger.models import TransactionStateChoices
612
import pytest
713
from django.test import TestCase
814
from openedx_ledger.signals.signals import TRANSACTION_REVERSED
@@ -13,12 +19,13 @@
1319
ReversalFactory,
1420
TransactionFactory
1521
)
16-
1722
from enterprise_subsidy.apps.api_client.enterprise import EnterpriseApiClient
1823
from enterprise_subsidy.apps.fulfillment.api import GEAGFulfillmentHandler
24+
from enterprise_subsidy.apps.transaction.signals.handlers import handle_lc_enrollment_revoked
1925
from test_utils.utils import MockResponse
2026

2127

28+
@ddt.ddt
2229
class TransactionSignalHandlerTestCase(TestCase):
2330
"""
2431
Tests for the transaction signal handlers
@@ -92,3 +99,84 @@ def test_transaction_reversed_signal_without_fulfillment_identifier(
9299

93100
assert mock_oauth_client.return_value.post.call_count == 0
94101
self.assertFalse(mock_send_event_bus_reversed.called)
102+
103+
104+
@ddt.data(
105+
# Happy path.
106+
{},
107+
# Sad paths:
108+
{
109+
"transaction_state": None,
110+
"expected_log_regex": "No Subsidy Transaction found",
111+
"expected_reverse_transaction_called": False,
112+
},
113+
{
114+
"transaction_state": TransactionStateChoices.PENDING,
115+
"expected_log_regex": "not in a committed state",
116+
"expected_reverse_transaction_called": False,
117+
},
118+
{
119+
"reversal_exists": True,
120+
"expected_log_regex": "Found existing Reversal",
121+
"expected_reverse_transaction_called": False,
122+
},
123+
{
124+
"refundable": False,
125+
"expected_log_regex": "not refundable",
126+
"expected_reverse_transaction_called": False,
127+
},
128+
{
129+
"external_fulfillment_will_succeed": False,
130+
"expected_log_regex": "no reversal written",
131+
"expected_reverse_transaction_called": False,
132+
},
133+
)
134+
@ddt.unpack
135+
@mock.patch('enterprise_subsidy.apps.transaction.signals.handlers.cancel_transaction_external_fulfillment')
136+
@mock.patch('enterprise_subsidy.apps.transaction.signals.handlers.reverse_transaction')
137+
@mock.patch('enterprise_subsidy.apps.transaction.signals.handlers.unenrollment_can_be_refunded')
138+
@mock.patch('enterprise_subsidy.apps.transaction.signals.handlers.ContentMetadataApi.get_content_metadata')
139+
@override_settings(ENTERPRISE_SUBSIDY_AUTOMATIC_EXTERNAL_CANCELLATION=True)
140+
def test_handle_lc_enrollment_revoked(
141+
self,
142+
mock_get_content_metadata,
143+
mock_unenrollment_can_be_refunded,
144+
mock_reverse_transaction,
145+
mock_cancel_transaction_external_fulfillment,
146+
transaction_state=TransactionStateChoices.COMMITTED,
147+
reversal_exists=False,
148+
refundable=True,
149+
external_fulfillment_will_succeed=True,
150+
expected_log_regex=None,
151+
expected_reverse_transaction_called=True,
152+
):
153+
mock_get_content_metadata.return_value = {"unused": "unused"}
154+
mock_unenrollment_can_be_refunded.return_value = refundable
155+
mock_cancel_transaction_external_fulfillment.return_value = external_fulfillment_will_succeed
156+
ledger = LedgerFactory()
157+
transaction = None
158+
if transaction_state:
159+
transaction = TransactionFactory(ledger=ledger, state=transaction_state)
160+
if reversal_exists:
161+
ReversalFactory(
162+
transaction=transaction,
163+
quantity=-transaction.quantity,
164+
)
165+
enrollment_unenrolled_at = datetime(2020, 1, 1)
166+
test_lc_course_enrollment = {
167+
"uuid": uuid4(),
168+
"transaction_id": transaction.uuid if transaction else uuid4(),
169+
"enterprise_course_enrollment": {
170+
"course_id": "course-v1:bin+bar+baz",
171+
"unenrolled_at": enrollment_unenrolled_at,
172+
"enterprise_customer_user": {
173+
"unused": "unused",
174+
},
175+
}
176+
}
177+
with self.assertLogs(level='INFO') as logs:
178+
handle_lc_enrollment_revoked(learner_credit_course_enrollment=test_lc_course_enrollment)
179+
if expected_log_regex:
180+
assert any(re.search(expected_log_regex, log) for log in logs.output)
181+
if expected_reverse_transaction_called:
182+
mock_reverse_transaction.assert_called_once_with(transaction, unenroll_time=enrollment_unenrolled_at)

requirements/base.txt

+1-1
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ oauthlib==3.2.2
194194
# getsmarter-api-clients
195195
# requests-oauthlib
196196
# social-auth-core
197-
openedx-events==9.11.0
197+
openedx-events @ git+https://github.com/pwnage101/openedx-events.git@f00926ae93e84a114779a0e9c98c152e8927706d
198198
# via
199199
# -r requirements/base.in
200200
# edx-event-bus-kafka

requirements/constraints.txt

+2
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,5 @@ django-simple-history>=3.4.0,<3.5.0
1818
# around default model fields vs. the serializer field defs,
1919
# and the discontinued use of OrderedDict in serializers.
2020
djangorestframework<3.15
21+
22+
git+https://github.com/pwnage101/openedx-events.git@f00926ae93e84a114779a0e9c98c152e8927706d#egg=openedx_events

requirements/dev.txt

+1-1
Original file line numberDiff line numberDiff line change
@@ -370,7 +370,7 @@ oauthlib==3.2.2
370370
# getsmarter-api-clients
371371
# requests-oauthlib
372372
# social-auth-core
373-
openedx-events==9.11.0
373+
openedx-events @ git+https://github.com/pwnage101/openedx-events.git@f00926ae93e84a114779a0e9c98c152e8927706d
374374
# via
375375
# -r requirements/validation.txt
376376
# edx-event-bus-kafka

requirements/doc.txt

+1-1
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,7 @@ oauthlib==3.2.2
355355
# getsmarter-api-clients
356356
# requests-oauthlib
357357
# social-auth-core
358-
openedx-events==9.11.0
358+
openedx-events @ git+https://github.com/pwnage101/openedx-events.git@f00926ae93e84a114779a0e9c98c152e8927706d
359359
# via
360360
# -r requirements/test.txt
361361
# edx-event-bus-kafka

requirements/production.txt

+1-1
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ oauthlib==3.2.2
239239
# getsmarter-api-clients
240240
# requests-oauthlib
241241
# social-auth-core
242-
openedx-events==9.11.0
242+
openedx-events @ git+https://github.com/pwnage101/openedx-events.git@f00926ae93e84a114779a0e9c98c152e8927706d
243243
# via
244244
# -r requirements/base.txt
245245
# edx-event-bus-kafka

requirements/quality.txt

+1-1
Original file line numberDiff line numberDiff line change
@@ -336,7 +336,7 @@ oauthlib==3.2.2
336336
# getsmarter-api-clients
337337
# requests-oauthlib
338338
# social-auth-core
339-
openedx-events==9.11.0
339+
openedx-events @ git+https://github.com/pwnage101/openedx-events.git@f00926ae93e84a114779a0e9c98c152e8927706d
340340
# via
341341
# -r requirements/test.txt
342342
# edx-event-bus-kafka

requirements/test.txt

+1-1
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,7 @@ oauthlib==3.2.2
282282
# getsmarter-api-clients
283283
# requests-oauthlib
284284
# social-auth-core
285-
openedx-events==9.11.0
285+
openedx-events @ git+https://github.com/pwnage101/openedx-events.git@f00926ae93e84a114779a0e9c98c152e8927706d
286286
# via
287287
# -r requirements/base.txt
288288
# edx-event-bus-kafka

0 commit comments

Comments
 (0)