Skip to content

Commit f1f8f88

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 f1f8f88

File tree

10 files changed

+352
-11
lines changed

10 files changed

+352
-11
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

+149
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
"""
22
Tests for the subsidy service transaction app signal handlers
33
"""
4+
import re
5+
from datetime import datetime
46
from unittest import mock
7+
from uuid import uuid4
58

9+
import ddt
610
import pytest
711
from django.test import TestCase
12+
from django.test.utils import override_settings
13+
from openedx_ledger.models import TransactionStateChoices
814
from openedx_ledger.signals.signals import TRANSACTION_REVERSED
915
from openedx_ledger.test_utils.factories import (
1016
ExternalFulfillmentProviderFactory,
@@ -13,12 +19,18 @@
1319
ReversalFactory,
1420
TransactionFactory
1521
)
22+
from pytz import UTC
1623

1724
from enterprise_subsidy.apps.api_client.enterprise import EnterpriseApiClient
1825
from enterprise_subsidy.apps.fulfillment.api import GEAGFulfillmentHandler
26+
from enterprise_subsidy.apps.transaction.signals.handlers import (
27+
handle_lc_enrollment_revoked,
28+
unenrollment_can_be_refunded
29+
)
1930
from test_utils.utils import MockResponse
2031

2132

33+
@ddt.ddt
2234
class TransactionSignalHandlerTestCase(TestCase):
2335
"""
2436
Tests for the transaction signal handlers
@@ -92,3 +104,140 @@ def test_transaction_reversed_signal_without_fulfillment_identifier(
92104

93105
assert mock_oauth_client.return_value.post.call_count == 0
94106
self.assertFalse(mock_send_event_bus_reversed.called)
107+
108+
109+
@ddt.data(
110+
# Happy path.
111+
{},
112+
# Sad paths:
113+
{
114+
"transaction_state": None,
115+
"expected_log_regex": "No Subsidy Transaction found",
116+
"expected_reverse_transaction_called": False,
117+
},
118+
{
119+
"transaction_state": TransactionStateChoices.PENDING,
120+
"expected_log_regex": "not in a committed state",
121+
"expected_reverse_transaction_called": False,
122+
},
123+
{
124+
"reversal_exists": True,
125+
"expected_log_regex": "Found existing Reversal",
126+
"expected_reverse_transaction_called": False,
127+
},
128+
{
129+
"refundable": False,
130+
"expected_log_regex": "not refundable",
131+
"expected_reverse_transaction_called": False,
132+
},
133+
{
134+
"external_fulfillment_will_succeed": False,
135+
"expected_log_regex": "no reversal written",
136+
"expected_reverse_transaction_called": False,
137+
},
138+
)
139+
@ddt.unpack
140+
@mock.patch('enterprise_subsidy.apps.transaction.signals.handlers.cancel_transaction_external_fulfillment')
141+
@mock.patch('enterprise_subsidy.apps.transaction.signals.handlers.reverse_transaction')
142+
@mock.patch('enterprise_subsidy.apps.transaction.signals.handlers.unenrollment_can_be_refunded')
143+
@mock.patch('enterprise_subsidy.apps.transaction.signals.handlers.ContentMetadataApi.get_content_metadata')
144+
@override_settings(ENTERPRISE_SUBSIDY_AUTOMATIC_EXTERNAL_CANCELLATION=True)
145+
def test_handle_lc_enrollment_revoked(
146+
self,
147+
mock_get_content_metadata,
148+
mock_unenrollment_can_be_refunded,
149+
mock_reverse_transaction,
150+
mock_cancel_transaction_external_fulfillment,
151+
transaction_state=TransactionStateChoices.COMMITTED,
152+
reversal_exists=False,
153+
refundable=True,
154+
external_fulfillment_will_succeed=True,
155+
expected_log_regex=None,
156+
expected_reverse_transaction_called=True,
157+
):
158+
mock_get_content_metadata.return_value = {"unused": "unused"}
159+
mock_unenrollment_can_be_refunded.return_value = refundable
160+
mock_cancel_transaction_external_fulfillment.return_value = external_fulfillment_will_succeed
161+
ledger = LedgerFactory()
162+
transaction = None
163+
if transaction_state:
164+
transaction = TransactionFactory(ledger=ledger, state=transaction_state)
165+
if reversal_exists:
166+
ReversalFactory(
167+
transaction=transaction,
168+
quantity=-transaction.quantity,
169+
)
170+
enrollment_unenrolled_at = datetime(2020, 1, 1)
171+
test_lc_course_enrollment = {
172+
"uuid": uuid4(),
173+
"transaction_id": transaction.uuid if transaction else uuid4(),
174+
"enterprise_course_enrollment": {
175+
"course_id": "course-v1:bin+bar+baz",
176+
"unenrolled_at": enrollment_unenrolled_at,
177+
"enterprise_customer_user": {
178+
"unused": "unused",
179+
},
180+
}
181+
}
182+
with self.assertLogs(level='INFO') as logs:
183+
handle_lc_enrollment_revoked(learner_credit_course_enrollment=test_lc_course_enrollment)
184+
if expected_log_regex:
185+
assert any(re.search(expected_log_regex, log) for log in logs.output)
186+
if expected_reverse_transaction_called:
187+
mock_reverse_transaction.assert_called_once_with(transaction, unenroll_time=enrollment_unenrolled_at)
188+
189+
@ddt.data(
190+
# ALMOST non-refundable due to enterprise_enrollment_created_at.
191+
{
192+
"enterprise_enrollment_created_at": datetime(2020, 1, 10, tzinfo=UTC),
193+
"course_start_date": datetime(2020, 1, 1, tzinfo=UTC),
194+
"unenrolled_at": datetime(2020, 1, 23, tzinfo=UTC),
195+
"expected_refundable": True,
196+
},
197+
# Non-refundable due to enterprise_enrollment_created_at.
198+
{
199+
"enterprise_enrollment_created_at": datetime(2020, 1, 10, tzinfo=UTC),
200+
"course_start_date": datetime(2020, 1, 1, tzinfo=UTC),
201+
"unenrolled_at": datetime(2020, 1, 24, tzinfo=UTC),
202+
"expected_refundable": False,
203+
},
204+
# ALMOST non-refundable due to course_start_date.
205+
{
206+
"enterprise_enrollment_created_at": datetime(2020, 1, 1, tzinfo=UTC),
207+
"course_start_date": datetime(2020, 1, 10, tzinfo=UTC),
208+
"unenrolled_at": datetime(2020, 1, 23, tzinfo=UTC),
209+
"expected_refundable": True,
210+
},
211+
# Non-refundable due to course_start_date.
212+
{
213+
"enterprise_enrollment_created_at": datetime(2020, 1, 1, tzinfo=UTC),
214+
"course_start_date": datetime(2020, 1, 10, tzinfo=UTC),
215+
"unenrolled_at": datetime(2020, 1, 24, tzinfo=UTC),
216+
"expected_refundable": False,
217+
},
218+
)
219+
@ddt.unpack
220+
def test_unenrollment_can_be_refunded(
221+
self,
222+
enterprise_enrollment_created_at,
223+
course_start_date,
224+
unenrolled_at,
225+
expected_refundable,
226+
):
227+
"""
228+
Make sure the following forumla is respected:
229+
230+
MAX(enterprise_enrollment_created_at, course_start_date) + 14 days > unenrolled_at
231+
"""
232+
test_content_metadata = {
233+
"content_type": "courserun",
234+
"start": course_start_date.strftime('%Y-%m-%dT%H:%M:%SZ'),
235+
}
236+
test_enterprise_course_enrollment = {
237+
"created": enterprise_enrollment_created_at,
238+
"unenrolled_at": unenrolled_at,
239+
}
240+
assert unenrollment_can_be_refunded(
241+
test_content_metadata,
242+
test_enterprise_course_enrollment,
243+
) == expected_refundable

0 commit comments

Comments
 (0)