Skip to content

Commit

Permalink
feat: write reversal on LC enrollment revoked event
Browse files Browse the repository at this point in the history
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
  • Loading branch information
pwnage101 committed Aug 8, 2024
1 parent ed38ec1 commit f1f8f88
Show file tree
Hide file tree
Showing 10 changed files with 352 additions and 11 deletions.
198 changes: 194 additions & 4 deletions enterprise_subsidy/apps/transaction/signals/handlers.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,65 @@
"""
Subsidy Service signals handler.
The following two scenarios detail what happens when either ECS or a learner initiates unenrollment, and explains how
infinite loops are terminated.
1. When ECS invokes transaction reversal:
=========================================
* Reversal gets created.
↳ Emit TRANSACTION_REVERSED signal.
* TRANSACTION_REVERSED triggers the `listen_for_transaction_reversal()` handler.
↳ Revoke internal & external fulfillments.
↳ Emit LEARNER_CREDIT_COURSE_ENROLLMENT_REVOKED openedx event.
↳ Emit LEDGER_TRANSACTION_REVERSED openedx event.
* LEARNER_CREDIT_COURSE_ENROLLMENT_REVOKED triggers the `handle_lc_enrollment_revoked()` handler.
↳ Fail first base case (reversal already exists) and quit. <-------THIS TERMINATES THE INFINITE LOOP!
* LEDGER_TRANSACTION_REVERSED triggers the `update_assignment_status_for_reversed_transaction()` handler.
↳ Updates any assignments as needed.
2. When a learner invokes unenrollment:
=======================================
* Enterprise app will perform internal fulfillment revocation.
↳ Emit LEARNER_CREDIT_COURSE_ENROLLMENT_REVOKED openedx event.
* LEARNER_CREDIT_COURSE_ENROLLMENT_REVOKED triggers the `handle_lc_enrollment_revoked()` handler.
↳ Revoke external fulfillments.
↳ Create reversal.
↳ Emit TRANSACTION_REVERSED signal.
* TRANSACTION_REVERSED triggers the `listen_for_transaction_reversal()` handler.
↳ Attempt to idempotently revoke external enrollment (no-op).
↳ Attempt to idempotently revoke internal enrollment (no-op). <----THIS TERMINATES THE INFINITE LOOP!
↳ Emit LEDGER_TRANSACTION_REVERSED openedx event.
* LEDGER_TRANSACTION_REVERSED triggers the `update_assignment_status_for_reversed_transaction()` handler.
↳ Updates any assignments as needed.
"""
import logging
from datetime import datetime, timedelta

from django.conf import settings
from django.dispatch import receiver
from openedx_events.enterprise.signals import LEARNER_CREDIT_COURSE_ENROLLMENT_REVOKED
from openedx_ledger.models import Transaction, TransactionStateChoices
from openedx_ledger.signals.signals import TRANSACTION_REVERSED

from enterprise_subsidy.apps.content_metadata.api import ContentMetadataApi
from enterprise_subsidy.apps.core.event_bus import send_transaction_reversed_event

from ..api import cancel_transaction_external_fulfillment, cancel_transaction_fulfillment
from ..exceptions import TransactionFulfillmentCancelationException
from enterprise_subsidy.apps.transaction.api import (
cancel_transaction_external_fulfillment,
cancel_transaction_fulfillment,
reverse_transaction
)
from enterprise_subsidy.apps.transaction.exceptions import TransactionFulfillmentCancelationException

logger = logging.getLogger(__name__)


@receiver(TRANSACTION_REVERSED)
def listen_for_transaction_reversal(sender, **kwargs):
"""
Listen for the TRANSACTION_REVERSED signals and issue an unenrollment request to platform.
Listen for the TRANSACTION_REVERSED signals and issue an unenrollment request to internal and external fulfillments.
This subsequently emits a LEDGER_TRANSACTION_REVERSED openedx event to signal to enterprise-access that any
assignents need to be reversed too.
"""
logger.info(
f"Received TRANSACTION_REVERSED signal from {sender}, attempting to unenroll platform enrollment object"
Expand All @@ -36,3 +78,151 @@ def listen_for_transaction_reversal(sender, **kwargs):
error_msg = f"Error canceling platform fulfillment {transaction.fulfillment_identifier}: {exc}"
logger.exception(error_msg)
raise exc


def unenrollment_can_be_refunded(
content_metadata,
enterprise_course_enrollment,
):
"""
helper method to determine if an unenrollment is refundable
"""
# Retrieve the course start date from the content metadata
enrollment_course_run_key = enterprise_course_enrollment.get("course_id")
course_start_date = None
if content_metadata.get('content_type') == 'courserun':
course_start_date = content_metadata.get('start')
else:
for run in content_metadata.get('course_runs', []):
if run.get('key') == enrollment_course_run_key:
course_start_date = run.get('start')
break

if not course_start_date:
logger.warning(
f"No course start date found for course run: {enrollment_course_run_key}. "
"Unable to determine refundability."
)
return False

# https://2u-internal.atlassian.net/browse/ENT-6825
# OCM course refundability is defined as True IFF:
# ie MAX(enterprise enrollment created at, course start date) + 14 days > unenrolled_at date
enrollment_created_datetime = enterprise_course_enrollment.get("created")
enrollment_unenrolled_at_datetime = enterprise_course_enrollment.get("unenrolled_at")
course_start_datetime = datetime.fromisoformat(course_start_date)
refund_cutoff_date = max(course_start_datetime, enrollment_created_datetime) + timedelta(days=14)
if refund_cutoff_date > enrollment_unenrolled_at_datetime:
logger.info(
f"Course run: {enrollment_course_run_key} is refundable for enterprise customer user: "
f"{enterprise_course_enrollment.get('enterprise_customer_user')}. Writing Reversal record."
)
return True
else:
logger.info(
f"Unenrollment from course: {enrollment_course_run_key} by user: "
f"{enterprise_course_enrollment.get('enterprise_customer_user')} is not refundable."
)
return False


@receiver(LEARNER_CREDIT_COURSE_ENROLLMENT_REVOKED)
def handle_lc_enrollment_revoked(**kwargs):
"""
openedx event handler to respond to LearnerCreditEnterpriseCourseEnrollment revocations.
The critical bits of this handler's business logic can be summarized as follows:
* Receive LC fulfillment revocation event and run this handler.
* BASE CASE: If this fulfillment's transaction has already been reversed, quit.
* BASE CASE: If the refund deadline has passed, quit.
* Cancel/unenroll any external fulfillments related to the transaction.
* Reverse the transaction.
Args:
learner_credit_course_enrollment (dict-like):
An openedx-events serialized representation of LearnerCreditEnterpriseCourseEnrollment.
"""
revoked_enrollment_data = kwargs.get('learner_credit_course_enrollment')
fulfillment_uuid = revoked_enrollment_data.get("uuid")
enterprise_course_enrollment = revoked_enrollment_data.get("enterprise_course_enrollment")
enrollment_course_run_key = enterprise_course_enrollment.get("course_id")
enrollment_unenrolled_at = enterprise_course_enrollment.get("unenrolled_at")

# Look for a transaction related to the unenrollment
related_transaction = Transaction.objects.filter(
uuid=revoked_enrollment_data.get('transaction_id')
).first()
if not related_transaction:
logger.info(
f"No Subsidy Transaction found for enterprise fulfillment: {fulfillment_uuid}"
)
return
# Fail early if the transaction is not committed, even though reverse_full_transaction()
# would throw an exception later anyway.
if related_transaction.state != TransactionStateChoices.COMMITTED:
logger.info(
f"Transaction: {related_transaction} is not in a committed state. "
f"Skipping Reversal creation."
)
return

# Look for a Reversal related to the unenrollment
existing_reversal = related_transaction.get_reversal()
if existing_reversal:
logger.info(
f"Found existing Reversal: {existing_reversal} for enterprise fulfillment: "
f"{fulfillment_uuid}. Skipping Reversal creation for Transaction: {related_transaction}."
)
return

# Continue on if no reversal found
logger.info(
f"No existing Reversal found for enterprise fulfillment: {fulfillment_uuid}. "
f"Writing Reversal for Transaction: {related_transaction}."
)

# On initial release we are only supporting learner initiated unenrollments for OCM courses.
# OCM courses are identified by the lack of an external_reference on the Transaction object.
# Externally referenced transactions can be unenrolled through the Django admin actions related to the
# Transaction model.
automatic_external_cancellation = getattr(settings, "ENTERPRISE_SUBSIDY_AUTOMATIC_EXTERNAL_CANCELLATION", False)
if related_transaction.external_reference.exists() and not automatic_external_cancellation:
logger.info(
f"Found unenrolled enterprise fulfillment: {fulfillment_uuid} related to "
f"an externally referenced transaction: {related_transaction.external_reference.first()}. "
f"Skipping ENTERPRISE_SUBSIDY_AUTOMATIC_EXTERNAL_CANCELLATION={automatic_external_cancellation}."
)
return

# NOTE: get_content_metadata() is backed by TieredCache, so this would be performant if a bunch learners unenroll
# from the same course at the same time. However, normally no two learners in the same course would unenroll within
# a single cache timeout period, so we'd expect this to normally always re-fetch from remote API. That's OK because
# unenrollment volumes are manageable.
content_metadata = ContentMetadataApi.get_content_metadata(
enrollment_course_run_key,
)

# Check if the OCM unenrollment is refundable
if not unenrollment_can_be_refunded(content_metadata, enterprise_course_enrollment):
logger.info(
f"Unenrollment from course: {enrollment_course_run_key} by user: "
f"{enterprise_course_enrollment.get('enterprise_customer_user')} is not refundable."
)
return

logger.info(
f"Course run: {enrollment_course_run_key} is refundable for enterprise "
f"customer user: {enterprise_course_enrollment.get('enterprise_customer_user')}. Writing "
"Reversal record."
)

successfully_canceled = cancel_transaction_external_fulfillment(related_transaction)
if not successfully_canceled:
logger.warning(
'Could not cancel external fulfillment for transaction %s, no reversal written',
related_transaction.uuid,
)
return

reverse_transaction(related_transaction, unenroll_time=enrollment_unenrolled_at)
149 changes: 149 additions & 0 deletions enterprise_subsidy/apps/transaction/tests/test_signal_handlers.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
"""
Tests for the subsidy service transaction app signal handlers
"""
import re
from datetime import datetime
from unittest import mock
from uuid import uuid4

import ddt
import pytest
from django.test import TestCase
from django.test.utils import override_settings
from openedx_ledger.models import TransactionStateChoices
from openedx_ledger.signals.signals import TRANSACTION_REVERSED
from openedx_ledger.test_utils.factories import (
ExternalFulfillmentProviderFactory,
Expand All @@ -13,12 +19,18 @@
ReversalFactory,
TransactionFactory
)
from pytz import UTC

from enterprise_subsidy.apps.api_client.enterprise import EnterpriseApiClient
from enterprise_subsidy.apps.fulfillment.api import GEAGFulfillmentHandler
from enterprise_subsidy.apps.transaction.signals.handlers import (
handle_lc_enrollment_revoked,
unenrollment_can_be_refunded
)
from test_utils.utils import MockResponse


@ddt.ddt
class TransactionSignalHandlerTestCase(TestCase):
"""
Tests for the transaction signal handlers
Expand Down Expand Up @@ -92,3 +104,140 @@ def test_transaction_reversed_signal_without_fulfillment_identifier(

assert mock_oauth_client.return_value.post.call_count == 0
self.assertFalse(mock_send_event_bus_reversed.called)


@ddt.data(
# Happy path.
{},
# Sad paths:
{
"transaction_state": None,
"expected_log_regex": "No Subsidy Transaction found",
"expected_reverse_transaction_called": False,
},
{
"transaction_state": TransactionStateChoices.PENDING,
"expected_log_regex": "not in a committed state",
"expected_reverse_transaction_called": False,
},
{
"reversal_exists": True,
"expected_log_regex": "Found existing Reversal",
"expected_reverse_transaction_called": False,
},
{
"refundable": False,
"expected_log_regex": "not refundable",
"expected_reverse_transaction_called": False,
},
{
"external_fulfillment_will_succeed": False,
"expected_log_regex": "no reversal written",
"expected_reverse_transaction_called": False,
},
)
@ddt.unpack
@mock.patch('enterprise_subsidy.apps.transaction.signals.handlers.cancel_transaction_external_fulfillment')
@mock.patch('enterprise_subsidy.apps.transaction.signals.handlers.reverse_transaction')
@mock.patch('enterprise_subsidy.apps.transaction.signals.handlers.unenrollment_can_be_refunded')
@mock.patch('enterprise_subsidy.apps.transaction.signals.handlers.ContentMetadataApi.get_content_metadata')
@override_settings(ENTERPRISE_SUBSIDY_AUTOMATIC_EXTERNAL_CANCELLATION=True)
def test_handle_lc_enrollment_revoked(
self,
mock_get_content_metadata,
mock_unenrollment_can_be_refunded,
mock_reverse_transaction,
mock_cancel_transaction_external_fulfillment,
transaction_state=TransactionStateChoices.COMMITTED,
reversal_exists=False,
refundable=True,
external_fulfillment_will_succeed=True,
expected_log_regex=None,
expected_reverse_transaction_called=True,
):
mock_get_content_metadata.return_value = {"unused": "unused"}
mock_unenrollment_can_be_refunded.return_value = refundable
mock_cancel_transaction_external_fulfillment.return_value = external_fulfillment_will_succeed
ledger = LedgerFactory()
transaction = None
if transaction_state:
transaction = TransactionFactory(ledger=ledger, state=transaction_state)
if reversal_exists:
ReversalFactory(
transaction=transaction,
quantity=-transaction.quantity,
)
enrollment_unenrolled_at = datetime(2020, 1, 1)
test_lc_course_enrollment = {
"uuid": uuid4(),
"transaction_id": transaction.uuid if transaction else uuid4(),
"enterprise_course_enrollment": {
"course_id": "course-v1:bin+bar+baz",
"unenrolled_at": enrollment_unenrolled_at,
"enterprise_customer_user": {
"unused": "unused",
},
}
}
with self.assertLogs(level='INFO') as logs:
handle_lc_enrollment_revoked(learner_credit_course_enrollment=test_lc_course_enrollment)
if expected_log_regex:
assert any(re.search(expected_log_regex, log) for log in logs.output)
if expected_reverse_transaction_called:
mock_reverse_transaction.assert_called_once_with(transaction, unenroll_time=enrollment_unenrolled_at)

@ddt.data(
# ALMOST non-refundable due to enterprise_enrollment_created_at.
{
"enterprise_enrollment_created_at": datetime(2020, 1, 10, tzinfo=UTC),
"course_start_date": datetime(2020, 1, 1, tzinfo=UTC),
"unenrolled_at": datetime(2020, 1, 23, tzinfo=UTC),
"expected_refundable": True,
},
# Non-refundable due to enterprise_enrollment_created_at.
{
"enterprise_enrollment_created_at": datetime(2020, 1, 10, tzinfo=UTC),
"course_start_date": datetime(2020, 1, 1, tzinfo=UTC),
"unenrolled_at": datetime(2020, 1, 24, tzinfo=UTC),
"expected_refundable": False,
},
# ALMOST non-refundable due to course_start_date.
{
"enterprise_enrollment_created_at": datetime(2020, 1, 1, tzinfo=UTC),
"course_start_date": datetime(2020, 1, 10, tzinfo=UTC),
"unenrolled_at": datetime(2020, 1, 23, tzinfo=UTC),
"expected_refundable": True,
},
# Non-refundable due to course_start_date.
{
"enterprise_enrollment_created_at": datetime(2020, 1, 1, tzinfo=UTC),
"course_start_date": datetime(2020, 1, 10, tzinfo=UTC),
"unenrolled_at": datetime(2020, 1, 24, tzinfo=UTC),
"expected_refundable": False,
},
)
@ddt.unpack
def test_unenrollment_can_be_refunded(
self,
enterprise_enrollment_created_at,
course_start_date,
unenrolled_at,
expected_refundable,
):
"""
Make sure the following forumla is respected:
MAX(enterprise_enrollment_created_at, course_start_date) + 14 days > unenrolled_at
"""
test_content_metadata = {
"content_type": "courserun",
"start": course_start_date.strftime('%Y-%m-%dT%H:%M:%SZ'),
}
test_enterprise_course_enrollment = {
"created": enterprise_enrollment_created_at,
"unenrolled_at": unenrolled_at,
}
assert unenrollment_can_be_refunded(
test_content_metadata,
test_enterprise_course_enrollment,
) == expected_refundable
Loading

0 comments on commit f1f8f88

Please sign in to comment.