diff --git a/enterprise_subsidy/apps/api_client/tests/test_enterprise.py b/enterprise_subsidy/apps/api_client/tests/test_enterprise.py index db01b723..bd3135a5 100644 --- a/enterprise_subsidy/apps/api_client/tests/test_enterprise.py +++ b/enterprise_subsidy/apps/api_client/tests/test_enterprise.py @@ -222,7 +222,7 @@ def test_successful_fetching_of_recent_unenrollments(self, mock_oauth_client): 'enterprise_customer_user': 10, 'course_id': self.courserun_key, 'created': '2023-05-25T19:27:29Z', - 'unenrolled_at': '2023-06-1T19:27:29Z', + 'unenrolled_at': '2023-06-01T19:27:29Z', }, 'transaction_id': transaction_id, 'uuid': fulfillment_uuid, @@ -236,7 +236,7 @@ def test_successful_fetching_of_recent_unenrollments(self, mock_oauth_client): 'enterprise_customer_user': 10, 'course_id': self.courserun_key, 'created': '2023-05-25T19:27:29Z', - 'unenrolled_at': '2023-06-1T19:27:29Z', + 'unenrolled_at': '2023-06-01T19:27:29Z', }, 'transaction_id': transaction_id, 'uuid': fulfillment_uuid, diff --git a/enterprise_subsidy/apps/transaction/management/commands/write_reversals_from_enterprise_unenrollments.py b/enterprise_subsidy/apps/transaction/management/commands/write_reversals_from_enterprise_unenrollments.py index e21dc900..03f04667 100644 --- a/enterprise_subsidy/apps/transaction/management/commands/write_reversals_from_enterprise_unenrollments.py +++ b/enterprise_subsidy/apps/transaction/management/commands/write_reversals_from_enterprise_unenrollments.py @@ -3,7 +3,6 @@ Transaction Reversals where appropriate. """ import logging -from datetime import datetime, timedelta from django.conf import settings from django.contrib import auth @@ -14,6 +13,7 @@ from enterprise_subsidy.apps.api_client.enterprise import EnterpriseApiClient from enterprise_subsidy.apps.content_metadata.api import ContentMetadataApi from enterprise_subsidy.apps.transaction.api import cancel_transaction_external_fulfillment, reverse_transaction +from enterprise_subsidy.apps.transaction.utils import unenrollment_can_be_refunded logger = logging.getLogger(__name__) User = auth.get_user_model() @@ -52,67 +52,6 @@ def add_arguments(self, parser): ), ) - def convert_unenrollment_datetime_string(self, datetime_str): - """ - Helper method to strip microseconds from a datetime object - """ - try: - formatted_datetime = datetime.strptime(datetime_str, "%Y-%m-%dT%H:%M:%SZ") - except ValueError: - formatted_datetime = datetime.strptime(datetime_str, "%Y-%m-%dT%H:%M:%S.%fZ") - return formatted_datetime - - def unenrollment_can_be_refunded( - self, - 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") - enrollment_created_at = enterprise_course_enrollment.get("created") - 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_at = enterprise_course_enrollment.get("created") - enrollment_unenrolled_at = enterprise_course_enrollment.get("unenrolled_at") - - enrollment_created_datetime = self.convert_unenrollment_datetime_string(enrollment_created_at) - course_start_datetime = self.convert_unenrollment_datetime_string(course_start_date) - enrollment_unenrolled_at_datetime = self.convert_unenrollment_datetime_string(enrollment_unenrolled_at) - 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 - def handle_reversing_enterprise_course_unenrollment(self, unenrollment): """ Helper method to determine refund eligibility of unenrollments and generating reversals for enterprise course @@ -168,7 +107,7 @@ def handle_reversing_enterprise_course_unenrollment(self, unenrollment): content_metadata = self.fetched_content_metadata.get(enrollment_course_run_key) # Check if the OCM unenrollment is refundable - if not self.unenrollment_can_be_refunded(content_metadata, enterprise_course_enrollment): + if not unenrollment_can_be_refunded(content_metadata, enterprise_course_enrollment): logger.info( f"{self.dry_run_prefix}Unenrollment from course: {enrollment_course_run_key} by user: " f"{enterprise_course_enrollment.get('enterprise_customer_user')} is not refundable." diff --git a/enterprise_subsidy/apps/transaction/signals/handlers.py b/enterprise_subsidy/apps/transaction/signals/handlers.py index 0ceb9e8c..a51a1751 100644 --- a/enterprise_subsidy/apps/transaction/signals/handlers.py +++ b/enterprise_subsidy/apps/transaction/signals/handlers.py @@ -1,15 +1,54 @@ """ 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 (API no-op). + ↳ Attempt to idempotently revoke internal enrollment (API 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 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 +from enterprise_subsidy.apps.transaction.utils import unenrollment_can_be_refunded logger = logging.getLogger(__name__) @@ -17,7 +56,10 @@ @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" @@ -36,3 +78,98 @@ def listen_for_transaction_reversal(sender, **kwargs): error_msg = f"Error canceling platform fulfillment {transaction.fulfillment_identifier}: {exc}" logger.exception(error_msg) raise exc + + +@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. + """ + if not settings.ENABLE_HANDLE_LC_ENROLLMENT_REVOKED: + logger.info( + "Handling of LEARNER_CREDIT_COURSE_ENROLLMENT_REVOKED event has been disabled. " + "Skipping handle_lc_enrollment_revoked() handler." + ) + return + 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}." + ) + + # 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) diff --git a/enterprise_subsidy/apps/transaction/tests/test_management.py b/enterprise_subsidy/apps/transaction/tests/test_management.py index 137ae328..7c4ed122 100644 --- a/enterprise_subsidy/apps/transaction/tests/test_management.py +++ b/enterprise_subsidy/apps/transaction/tests/test_management.py @@ -288,7 +288,7 @@ def test_write_reversals_from_enterprise_unenrollment_does_not_rerequest_metadat 'enterprise_customer_user': 10, 'course_id': self.transaction.content_key, 'created': '2023-05-25T19:27:29Z', - 'unenrolled_at': '2023-06-1T19:27:29Z', + 'unenrolled_at': '2023-06-01T19:27:29Z', }, 'transaction_id': self.transaction.uuid, 'uuid': str(self.transaction.fulfillment_identifier), @@ -298,7 +298,7 @@ def test_write_reversals_from_enterprise_unenrollment_does_not_rerequest_metadat 'enterprise_customer_user': 11, 'course_id': self.transaction.content_key, 'created': '2023-05-25T19:27:29Z', - 'unenrolled_at': '2023-06-1T19:27:29Z', + 'unenrolled_at': '2023-06-01T19:27:29Z', }, 'transaction_id': transaction_uuid_2, 'uuid': str(uuid.uuid4()), @@ -385,7 +385,7 @@ def test_write_reversals_from_enterprise_unenrollment_transaction_does_not_exist 'enterprise_customer_user': 10, 'course_id': self.courserun_key, 'created': '2023-05-25T19:27:29Z', - 'unenrolled_at': '2023-06-1T19:27:29Z', + 'unenrolled_at': '2023-06-01T19:27:29Z', }, 'transaction_id': uuid.uuid4(), 'uuid': self.fulfillment_identifier, @@ -413,7 +413,7 @@ def test_write_reversals_from_enterprise_unenrollment_with_uncommitted_transacti 'enterprise_customer_user': 10, 'course_id': self.courserun_key, 'created': '2023-05-25T19:27:29Z', - 'unenrolled_at': '2023-06-1T19:27:29Z', + 'unenrolled_at': '2023-06-01T19:27:29Z', }, 'transaction_id': self.transaction.uuid, 'uuid': self.fulfillment_identifier, @@ -441,8 +441,8 @@ def test_write_reversals_from_enterprise_unenrollment_with_uncommitted_transacti 'enterprise_subsidy.apps.transaction.api.EnterpriseApiClient' ) @ddt.data( - ('2023-05-25T19:27:29Z', '2023-06-1T19:27:29Z'), - ('2023-06-1T19:27:29Z', '2023-05-25T19:27:29Z'), + ('2023-05-25T19:27:29Z', '2023-06-01T19:27:29Z'), + ('2023-06-01T19:27:29Z', '2023-05-25T19:27:29Z'), ) @ddt.unpack def test_write_reversals_from_enterprise_unenrollment_refund_period_ended( @@ -574,7 +574,7 @@ def test_write_reversals_from_enterprise_unenrollments( 'enterprise_customer_user': 10, 'course_id': self.transaction.content_key, 'created': '2023-05-25T19:27:29Z', - 'unenrolled_at': '2023-06-1T19:27:29Z', + 'unenrolled_at': '2023-06-01T19:27:29Z', }, 'transaction_id': self.transaction.uuid, 'uuid': str(self.transaction.fulfillment_identifier), @@ -646,7 +646,7 @@ def test_write_reversals_from_enterprise_unenrollments( reversal = Reversal.objects.first() assert reversal.transaction == self.transaction assert reversal.idempotency_key == ( - f'unenrollment-reversal-{self.transaction.fulfillment_identifier}-2023-06-1T19:27:29Z' + f'unenrollment-reversal-{self.transaction.fulfillment_identifier}-2023-06-01T19:27:29Z' ) mock_send_event_bus_reversed.assert_called_once_with(self.transaction) else: @@ -692,7 +692,7 @@ def test_write_reversals_from_geag_enterprise_unenrollments_enabled_setting( 'enterprise_customer_user': 10, 'course_id': self.geag_transaction.content_key, 'created': '2023-05-25T19:27:29Z', - 'unenrolled_at': '2023-06-1T19:27:29Z', + 'unenrolled_at': '2023-06-01T19:27:29Z', }, 'transaction_id': self.geag_transaction.uuid, 'uuid': str(self.geag_transaction.fulfillment_identifier), @@ -797,7 +797,7 @@ def test_write_reversals_from_geag_enterprise_unenrollments_unknown_provider( 'enterprise_customer_user': 10, 'course_id': self.unknown_transaction.content_key, 'created': '2023-05-25T19:27:29Z', - 'unenrolled_at': '2023-06-1T19:27:29Z', + 'unenrolled_at': '2023-06-01T19:27:29Z', }, 'transaction_id': self.unknown_transaction.uuid, 'uuid': str(self.unknown_transaction.fulfillment_identifier), diff --git a/enterprise_subsidy/apps/transaction/tests/test_signal_handlers.py b/enterprise_subsidy/apps/transaction/tests/test_signal_handlers.py index 98b4f745..b2fe6a68 100644 --- a/enterprise_subsidy/apps/transaction/tests/test_signal_handlers.py +++ b/enterprise_subsidy/apps/transaction/tests/test_signal_handlers.py @@ -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, @@ -16,9 +22,11 @@ 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 from test_utils.utils import MockResponse +@ddt.ddt class TransactionSignalHandlerTestCase(TestCase): """ Tests for the transaction signal handlers @@ -92,3 +100,83 @@ 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(ENABLE_HANDLE_LC_ENROLLMENT_REVOKED=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) diff --git a/enterprise_subsidy/apps/transaction/tests/test_utils.py b/enterprise_subsidy/apps/transaction/tests/test_utils.py new file mode 100644 index 00000000..e121da7e --- /dev/null +++ b/enterprise_subsidy/apps/transaction/tests/test_utils.py @@ -0,0 +1,73 @@ +""" +Tests for Transaction utils. +""" +from datetime import datetime + +import ddt +from django.test import TestCase +from pytz import UTC + +from enterprise_subsidy.apps.transaction.signals.handlers import unenrollment_can_be_refunded + + +@ddt.ddt +class TransactionUtilsTestCase(TestCase): + """ + Tests for Transaction utils. + """ + + @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 diff --git a/enterprise_subsidy/apps/transaction/utils.py b/enterprise_subsidy/apps/transaction/utils.py index 8b6fdec5..8faeeac7 100644 --- a/enterprise_subsidy/apps/transaction/utils.py +++ b/enterprise_subsidy/apps/transaction/utils.py @@ -1,9 +1,13 @@ """ Utility functions used in the implementation of subsidy Transactions. """ +import logging +from datetime import datetime, timedelta from django.db.models import Q +logger = logging.getLogger(__name__) + def generate_transaction_reversal_idempotency_key(fulfillment_uuid, enrollment_unenrolled_at): """ @@ -34,3 +38,68 @@ def batch_by_pk(ModelClass, extra_filter=Q(), batch_size=10000): for item in qs: start_pk = item.pk qs = ModelClass.objects.filter(pk__gt=start_pk).filter(extra_filter).order_by('pk')[:batch_size] + + +def normalize_to_datetime(datetime_or_str): + """ + Given a datetime or ISO timestamp string, always return a datetime object. + """ + try: + parsed_dt = datetime.fromisoformat(datetime_or_str) + except TypeError: + parsed_dt = datetime_or_str + return parsed_dt + + +def unenrollment_can_be_refunded( + content_metadata, + enterprise_course_enrollment, +): + """ + Helper method to determine if an unenrollment is refundable. + + Args: + content_metadata (dict): Metadata for course from which the learner has been unenrolled. + enterprise_course_enrollment: (dict-like): + Serialized ECE object. This supports two possible serialization formats: + 1. Serialized via the DRF when calling the unenrolled API. + 2. Serialized via openedx-events when edx-enterprise emits a learner credit lifecycle event. + + """ + # 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 = normalize_to_datetime(enterprise_course_enrollment.get("created")) + enrollment_unenrolled_at_datetime = normalize_to_datetime(enterprise_course_enrollment.get("unenrolled_at")) + course_start_datetime = normalize_to_datetime(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 diff --git a/enterprise_subsidy/settings/base.py b/enterprise_subsidy/settings/base.py index 81541e15..77100da7 100644 --- a/enterprise_subsidy/settings/base.py +++ b/enterprise_subsidy/settings/base.py @@ -422,3 +422,11 @@ def root(*path_fragments): }, }, } + +# FEATURE FLAGS CONFIGURATION + +# Enable handling of the LEARNER_CREDIT_COURSE_ENROLLMENT_REVOKED event, which triggers +# writing of a reversal on learner-initiated unenrollment. +ENABLE_HANDLE_LC_ENROLLMENT_REVOKED = False + +# END FEATURE FLAGS CONFIGURATION