diff --git a/RELEASE.rst b/RELEASE.rst index 45823950ad..434b5627ab 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,12 @@ Release Notes ============= +Version 1.164.1 +--------------- + +- add command to trasfer grades to another account (#3627) +- Expose refund state on the order receipt payload (#3873) + Version 1.164.0 (Released August 31, 2026) --------------- diff --git a/courses/management/commands/transfer_user_course_records.py b/courses/management/commands/transfer_user_course_records.py new file mode 100644 index 0000000000..03f3b65fb4 --- /dev/null +++ b/courses/management/commands/transfer_user_course_records.py @@ -0,0 +1,273 @@ +"""Transfer course-related user records from one user to another.""" + +from argparse import RawTextHelpFormatter + +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction + +from courses.models import ( + CourseRunCertificate, + CourseRunEnrollment, + CourseRunGrade, + ProgramCertificate, + ProgramEnrollment, +) +from users.api import fetch_user + +User = get_user_model() + + +class Command(BaseCommand): + """ + Transfer course-related records between two users. + + Moves course run/program enrollments, grades, and certificates from one + user to another, matching users by email address. The transfer is + aborted if the destination user already has any overlapping records. + + Example: transfer_user_course_records --from_email=old@example.com --to_email=new@example.com + """ + + help = __doc__ + + def add_arguments(self, parser): + """Add command line arguments.""" + parser.formatter_class = RawTextHelpFormatter + parser.add_argument( + "--from_email", + "--from-email", + dest="from_email", + type=str, + required=True, + help="Email address for the user records should be moved from", + ) + parser.add_argument( + "--to_email", + "--to-email", + dest="to_email", + type=str, + required=True, + help="Email address for the user records should be moved to", + ) + super().add_arguments(parser) + + def handle(self, *args, **options): # noqa: ARG002 + """Handle command execution.""" + source_user = self._fetch_user(options["from_email"], "from_email") + destination_user = self._fetch_user(options["to_email"], "to_email") + + if source_user.pk == destination_user.pk: + raise CommandError("Source and destination users must be different.") # noqa: EM101 + + source_records = self._load_source_records(source_user) + self._raise_on_conflicts(source_records, destination_user) + + with transaction.atomic(): + transfer_counts = self._transfer_records(source_records, destination_user) + + self.stdout.write( + self.style.SUCCESS( + "Transferred records from {source_email} to {destination_email}: " + "{counts}".format( + source_email=source_user.email, + destination_email=destination_user.email, + counts=", ".join( + f"{label}={count}" for label, count in transfer_counts.items() + ), + ) + ) + ) + + def _fetch_user(self, email, option_name): + """Look up a user by email and normalize fetch errors to CommandError.""" + try: + return fetch_user(email) + except User.DoesNotExist as exc: + msg = f"Could not find user for --{option_name}={email}." + raise CommandError(msg) from exc + + def _load_source_records(self, source_user): + """Load all transfer candidates for the source user.""" + return { + "course_run_enrollments": list( + CourseRunEnrollment.all_objects.filter(user=source_user).select_related( + "run" + ) + ), + "program_enrollments": list( + ProgramEnrollment.all_objects.filter(user=source_user).select_related( + "program" + ) + ), + "course_run_grades": list( + CourseRunGrade.objects.filter(user=source_user).select_related( + "course_run" + ) + ), + "course_run_certificates": list( + CourseRunCertificate.all_objects.filter( + user=source_user + ).select_related("course_run") + ), + "program_certificates": list( + ProgramCertificate.all_objects.filter(user=source_user).select_related( + "program" + ) + ), + } + + def _raise_on_conflicts(self, source_records, destination_user): + """Abort if the destination already has any overlapping records.""" + conflicts = [] + + course_run_ids = [ + enrollment.run_id for enrollment in source_records["course_run_enrollments"] + ] + program_ids = [ + enrollment.program_id + for enrollment in source_records["program_enrollments"] + ] + graded_course_run_ids = [ + grade.course_run_id for grade in source_records["course_run_grades"] + ] + certificate_course_run_ids = [ + certificate.course_run_id + for certificate in source_records["course_run_certificates"] + ] + certificate_program_ids = [ + certificate.program_id + for certificate in source_records["program_certificates"] + ] + + conflicting_course_run_enrollments = list( + CourseRunEnrollment.all_objects.filter( + user=destination_user, run_id__in=course_run_ids + ).select_related("run") + ) + if conflicting_course_run_enrollments: + conflicts.append( + "course run enrollments for {}".format( + ", ".join( + sorted( + { + enrollment.run.courseware_id + for enrollment in conflicting_course_run_enrollments + } + ) + ) + ) + ) + + conflicting_program_enrollments = list( + ProgramEnrollment.all_objects.filter( + user=destination_user, program_id__in=program_ids + ).select_related("program") + ) + if conflicting_program_enrollments: + conflicts.append( + "program enrollments for {}".format( + ", ".join( + sorted( + { + enrollment.program.readable_id + for enrollment in conflicting_program_enrollments + } + ) + ) + ) + ) + + conflicting_grades = list( + CourseRunGrade.objects.filter( + user=destination_user, course_run_id__in=graded_course_run_ids + ).select_related("course_run") + ) + if conflicting_grades: + conflicts.append( + "course run grades for {}".format( + ", ".join( + sorted( + { + grade.course_run.courseware_id + for grade in conflicting_grades + } + ) + ) + ) + ) + + conflicting_course_run_certificates = list( + CourseRunCertificate.all_objects.filter( + user=destination_user, course_run_id__in=certificate_course_run_ids + ).select_related("course_run") + ) + if conflicting_course_run_certificates: + conflicts.append( + "course run certificates for {}".format( + ", ".join( + sorted( + { + certificate.course_run.courseware_id + for certificate in conflicting_course_run_certificates + } + ) + ) + ) + ) + + conflicting_program_certificates = list( + ProgramCertificate.all_objects.filter( + user=destination_user, program_id__in=certificate_program_ids + ).select_related("program") + ) + if conflicting_program_certificates: + conflicts.append( + "program certificates for {}".format( + ", ".join( + sorted( + { + certificate.program.readable_id + for certificate in conflicting_program_certificates + } + ) + ) + ) + ) + + if conflicts: + raise CommandError( + "Transfer aborted because the destination user already has {}.".format( # noqa: EM103 + "; ".join(conflicts) + ) + ) + + def _transfer_records(self, source_records, destination_user): + """Transfer each record set and return counts by label.""" + for enrollment in source_records["course_run_enrollments"]: + enrollment.user = destination_user + enrollment.save_and_log(None) + + for enrollment in source_records["program_enrollments"]: + enrollment.user = destination_user + enrollment.save_and_log(None) + + for grade in source_records["course_run_grades"]: + grade.user = destination_user + grade.save_and_log(None) + + for certificate in source_records["course_run_certificates"]: + certificate.user = destination_user + certificate.save(update_fields=["user"]) + + for certificate in source_records["program_certificates"]: + certificate.user = destination_user + certificate.save(update_fields=["user"]) + + return { + "course_run_enrollments": len(source_records["course_run_enrollments"]), + "program_enrollments": len(source_records["program_enrollments"]), + "course_run_grades": len(source_records["course_run_grades"]), + "course_run_certificates": len(source_records["course_run_certificates"]), + "program_certificates": len(source_records["program_certificates"]), + } diff --git a/courses/models.py b/courses/models.py index f01f8a64d4..53edb7f200 100644 --- a/courses/models.py +++ b/courses/models.py @@ -47,6 +47,7 @@ from main.utils import serialize_model_object from openedx.constants import ( EDX_DEFAULT_ENROLLMENT_MODE, + EDX_ENROLLMENT_AUDIT_MODE, EDX_ENROLLMENT_VERIFIED_MODE, EDX_ENROLLMENTS_PAID_MODES, ) @@ -250,6 +251,13 @@ def text_id(self): """Gets the readable_id""" return self.readable_id + @property + def has_free_audit(self): + """Whether this can still be taken for free once a paid enrollment ends.""" + return self.enrollment_modes.filter( + mode_slug=EDX_ENROLLMENT_AUDIT_MODE + ).exists() + @property def related_programs_qs(self): """ @@ -1616,6 +1624,13 @@ def text_id(self): """Gets the courseware_id""" return self.courseware_id + @property + def has_free_audit(self): + """Whether this can still be taken for free once a paid enrollment ends.""" + return self.enrollment_modes.filter( + mode_slug=EDX_ENROLLMENT_AUDIT_MODE + ).exists() + @property def course_number(self): """ diff --git a/drf_lint_baseline.json b/drf_lint_baseline.json index 02befa2e09..374dbaeb56 100644 --- a/drf_lint_baseline.json +++ b/drf_lint_baseline.json @@ -8,7 +8,6 @@ "cms/serializers.py:97:12:ORM001", "courses/serializers/base.py:53:16:ORM001", "courses/serializers/v1/base.py:75:20:ORM002", - "courses/serializers/v1/courses.py:173:18:ORM001", "courses/serializers/v1/courses.py:59:16:ORM001", "courses/serializers/v1/programs.py:181:12:ORM001", "courses/serializers/v1/programs.py:196:12:ORM001", @@ -18,14 +17,11 @@ "courses/serializers/v1/programs.py:318:16:ORM001", "courses/serializers/v1/programs.py:335:17:ORM001", "courses/serializers/v2/courses.py:276:17:ORM002", - "courses/serializers/v2/courses.py:339:18:ORM001", "courses/serializers/v2/departments.py:35:40:ORM002", "courses/serializers/v2/departments.py:49:42:ORM002", "courses/serializers/v2/programs.py:387:50:ORM002", "courses/serializers/v2/programs.py:500:12:ORM002", - "courses/serializers/v3/courses.py:113:14:ORM001", "courses/serializers/v3/courses.py:57:12:ORM002", - "courses/serializers/v3/programs.py:55:22:ORM001", "ecommerce/serializers/__init__.py:205:17:ORM001", "ecommerce/serializers/__init__.py:207:18:ORM001", "ecommerce/serializers/__init__.py:208:18:ORM001", @@ -42,34 +38,27 @@ "ecommerce/serializers/__init__.py:587:20:ORM002", "ecommerce/serializers/__init__.py:719:22:ORM002", "ecommerce/serializers/__init__.py:901:28:ORM002", - "ecommerce/serializers/v0/__init__.py:284:17:ORM001", - "ecommerce/serializers/v0/__init__.py:286:18:ORM001", - "ecommerce/serializers/v0/__init__.py:287:18:ORM001", - "ecommerce/serializers/v0/__init__.py:309:26:ORM002", - "ecommerce/serializers/v0/__init__.py:397:26:ORM002", - "ecommerce/serializers/v0/__init__.py:406:35:ORM002", - "ecommerce/serializers/v0/__init__.py:413:20:ORM002", - "ecommerce/serializers/v0/__init__.py:419:35:ORM002", - "ecommerce/serializers/v0/__init__.py:435:31:ORM002", - "ecommerce/serializers/v0/__init__.py:511:24:ORM002", - "ecommerce/serializers/v0/__init__.py:530:12:ORM001", - "ecommerce/serializers/v0/__init__.py:554:22:ORM002", - "ecommerce/serializers/v0/__init__.py:601:22:ORM002", - "ecommerce/serializers/v0/__init__.py:672:20:ORM002", - "ecommerce/serializers/v0/__init__.py:821:22:ORM002", - "ecommerce/serializers/v0/__init__.py:965:28:ORM002", - "flexiblepricing/serializers.py:129:38:ORM001", - "flexiblepricing/serializers.py:132:34:ORM001", + "ecommerce/serializers/v0/__init__.py:292:17:ORM001", + "ecommerce/serializers/v0/__init__.py:294:18:ORM001", + "ecommerce/serializers/v0/__init__.py:295:18:ORM001", + "ecommerce/serializers/v0/__init__.py:317:26:ORM002", + "ecommerce/serializers/v0/__init__.py:405:26:ORM002", + "ecommerce/serializers/v0/__init__.py:414:35:ORM002", + "ecommerce/serializers/v0/__init__.py:421:20:ORM002", + "ecommerce/serializers/v0/__init__.py:427:35:ORM002", + "ecommerce/serializers/v0/__init__.py:443:31:ORM002", + "ecommerce/serializers/v0/__init__.py:540:24:ORM002", + "ecommerce/serializers/v0/__init__.py:559:12:ORM001", + "ecommerce/serializers/v0/__init__.py:583:22:ORM002", + "ecommerce/serializers/v0/__init__.py:630:22:ORM002", + "ecommerce/serializers/v0/__init__.py:705:20:ORM002", + "ecommerce/serializers/v0/__init__.py:854:22:ORM002", + "ecommerce/serializers/v0/__init__.py:998:28:ORM002", "flexiblepricing/serializers.py:147:34:ORM001", "flexiblepricing/serializers.py:170:34:ORM001", "flexiblepricing/serializers.py:173:20:ORM001", "flexiblepricing/serializers.py:204:30:ORM001", "flexiblepricing/serializers.py:207:31:ORM001", "flexiblepricing/serializers.py:212:16:ORM001", - "flexiblepricing/serializers.py:216:16:ORM001", - "users/serializers.py:227:16:ORM001", - "users/serializers.py:280:20:ORM001", - "users/serializers.py:327:19:ORM001", - "users/serializers.py:459:13:ORM001", - "users/serializers.py:495:11:ORM001" + "flexiblepricing/serializers.py:216:16:ORM001" ] diff --git a/ecommerce/constants.py b/ecommerce/constants.py index 0e6d4da319..e48473157a 100644 --- a/ecommerce/constants.py +++ b/ecommerce/constants.py @@ -8,6 +8,11 @@ REFERENCE_NUMBER_PREFIX = "mitxonline-" +# Standard self-service refund window, per the terms of service: learners may +# request a refund within this many days of purchase, or of the course start +# when they purchased before the course began. +REFUND_WINDOW_DAYS = 7 + DISCOUNT_TYPE_PERCENT_OFF = "percent-off" DISCOUNT_TYPE_DOLLARS_OFF = "dollars-off" DISCOUNT_TYPE_FIXED_PRICE = "fixed-price" diff --git a/ecommerce/migrations/0050_refund_reason_text_max_length.py b/ecommerce/migrations/0050_refund_reason_text_max_length.py new file mode 100644 index 0000000000..5e99b27947 --- /dev/null +++ b/ecommerce/migrations/0050_refund_reason_text_max_length.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.15 on 2026-08-20 21:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("ecommerce", "0049_line_discounted_unit_price_not_null"), + ] + + operations = [ + migrations.AlterField( + model_name="refundrequest", + name="refund_reason_text", + field=models.TextField(blank=True, max_length=1000), + ), + ] diff --git a/ecommerce/migrations/0051_refund_reason_choices_from_design.py b/ecommerce/migrations/0051_refund_reason_choices_from_design.py new file mode 100644 index 0000000000..50298ae422 --- /dev/null +++ b/ecommerce/migrations/0051_refund_reason_choices_from_design.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.15 on 2026-08-20 22:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("ecommerce", "0050_refund_reason_text_max_length"), + ] + + operations = [ + migrations.AlterField( + model_name="refundrequest", + name="refund_reason", + field=models.CharField( + blank=True, + choices=[ + ("not_enough_time", "I do not have enough time"), + ("course_not_as_expected", "Course is not what I expected"), + ("technical_difficulties", "I had a technical issue"), + ("course_too_difficult", "Course is too difficult"), + ("purchased_by_mistake", "I purchased by mistake"), + ("prefer_not_to_say", "Prefer not to say"), + ("other", "Other"), + ("enrolled_in_another_course", "I enrolled in another course"), + ("financial_reasons", "Financial reasons"), + ], + max_length=100, + ), + ), + ] diff --git a/ecommerce/models.py b/ecommerce/models.py index f7948b372e..b5f4cc23c5 100644 --- a/ecommerce/models.py +++ b/ecommerce/models.py @@ -1,7 +1,7 @@ from __future__ import annotations import uuid -from datetime import datetime +from datetime import datetime, timedelta from decimal import Decimal from typing import List # noqa: UP035 from zoneinfo import ZoneInfo @@ -38,6 +38,7 @@ REDEMPTION_TYPE_ONE_TIME_PER_USER, REDEMPTION_TYPES, REFERENCE_NUMBER_PREFIX, + REFUND_WINDOW_DAYS, TRANSACTION_TYPE_PAYMENT, TRANSACTION_TYPE_REFUND, TRANSACTION_TYPES, @@ -601,6 +602,23 @@ class OrderStatus(TextChoices): PARTIALLY_REFUNDED = "partially_refunded" +class OrderRefundStatus(TextChoices): + """ + Where an order sits in the self-service refund flow. + + Derived state, not stored: it combines the order's own status, any refund + request the learner has already made, and the refund window. Consumers get + one value to branch on instead of reassembling those rules themselves. + """ + + COMPLETED = "completed" + REQUESTED = "requested" + DENIED = "denied" + ELIGIBLE = "eligible" + WINDOW_CLOSED = "window_closed" + INELIGIBLE = "ineligible" + + class OrderFlow: state = State(OrderStatus, default=OrderStatus.PENDING) @@ -833,26 +851,104 @@ def is_review(self): def is_fulfilled(self): return self.state == OrderStatus.FULFILLED + @cached_property + def refund_deadline(self): + """ + Return the moment the standard refund window closes: + - REFUND_WINDOW_DAYS after purchase, OR + - REFUND_WINDOW_DAYS after the course start date, if the user purchased + before the course started. + + Whichever is later, since either one on its own makes the order eligible. + """ + window = timedelta(days=REFUND_WINDOW_DAYS) + return max( + [ + self.created_on + window, + *[ + run.start_date + window + for run in self.purchased_runs + if run.start_date and run.start_date > self.created_on + ], + ] + ) + + @property + def is_within_refund_window(self): + """Return True if the standard refund window has not closed yet.""" + return now_in_utc() <= self.refund_deadline + @property def is_refund_eligible(self): + """Return True if the learner could request a refund for this order now.""" + return self.refund_status == OrderRefundStatus.ELIGIBLE + + @cached_property + def is_b2b_order(self): """ - Returns True if the order is within the standard refund window: - - Within 7 days of purchase, OR - - Within 7 days of the course start date, if the user purchased before the course started. + Return True if any purchased run belongs to a B2B contract. + + Reads the line's denormalized `purchased_object` rather than resolving + `product.purchasable_object` through reversion. The two identify the same + object: `Line` rows are created with `purchased_object_id` and + `purchased_content_type_id` copied straight off the product, and that + triple is the line's uniqueness constraint. """ - from datetime import timedelta # noqa: PLC0415 + return any(run.b2b_contract_id for run in self.purchased_runs) - now = now_in_utc() - if now <= self.created_on + timedelta(days=7): - return True - for run in self.purchased_runs: - if ( - run.start_date - and run.start_date > self.created_on - and now <= run.start_date + timedelta(days=7) - ): - return True - return False + @cached_property + def latest_refund_request(self): + """Return the learner's most recent refund request for this order, if any.""" + return self.refund_requests.order_by("-created_on").first() + + @property + def refund_reviewed_on(self): + """ + Return when the latest refund request was decided, or None if it is + still pending. + + `RefundRequest` carries no dedicated review timestamp. A request only + leaves `pending` when someone acts on it, so `updated_on` is that moment. + """ + request = self.latest_refund_request + if request and request.status != RefundRequestStatus.PENDING: + return request.updated_on + return None + + @property + def refund_status(self): + """ + Return where this order sits in the self-service refund flow. + + Precedence matters: a refund that already happened settles the question, + then any request the learner has made, and only then whether they could + make one right now. + + The final branch deliberately mirrors what `RefundRequestSerializer` + accepts, so anything but `eligible` or `window_closed` means a request + would be rejected. + """ + if self.state in (OrderStatus.REFUNDED, OrderStatus.PARTIALLY_REFUNDED): + return OrderRefundStatus.COMPLETED + + latest_request = self.latest_refund_request + if latest_request: + # An approved request still reads as "requested" until the money moves, + # since nothing marks the refund itself as done. + return ( + OrderRefundStatus.DENIED + if latest_request.status == RefundRequestStatus.DENIED + else OrderRefundStatus.REQUESTED + ) + + if self.state != OrderStatus.FULFILLED or self.is_b2b_order: + return OrderRefundStatus.INELIGIBLE + + return ( + OrderRefundStatus.ELIGIBLE + if self.is_within_refund_window + else OrderRefundStatus.WINDOW_CLOSED + ) def _generate_reference_number(self): return f"{REFERENCE_NUMBER_PREFIX}{settings.ENVIRONMENT}-{self.id}" @@ -1392,14 +1488,27 @@ def save(self, **kwargs): class RefundReasonChoices(models.TextChoices): + """ + Reasons a learner can give for requesting a refund. + + The first seven are the choices the refund modal offers, in the order it + shows them. The last two predate that design and are kept so existing + requests still resolve to a label; nothing submits them any more. + """ + + NOT_ENOUGH_TIME = "not_enough_time", "I do not have enough time" + COURSE_NOT_AS_EXPECTED = "course_not_as_expected", "Course is not what I expected" + TECHNICAL_DIFFICULTIES = "technical_difficulties", "I had a technical issue" + COURSE_TOO_DIFFICULT = "course_too_difficult", "Course is too difficult" + PURCHASED_BY_MISTAKE = "purchased_by_mistake", "I purchased by mistake" + PREFER_NOT_TO_SAY = "prefer_not_to_say", "Prefer not to say" + OTHER = "other", "Other" + ENROLLED_IN_ANOTHER_COURSE = ( "enrolled_in_another_course", "I enrolled in another course", ) - COURSE_NOT_AS_EXPECTED = "course_not_as_expected", "Course is not what I expected" - TECHNICAL_DIFFICULTIES = "technical_difficulties", "Technical difficulties" FINANCIAL_REASONS = "financial_reasons", "Financial reasons" - OTHER = "other", "Other" class RefundRequestStatus(models.TextChoices): @@ -1426,7 +1535,7 @@ class RefundRequest(TimestampedModel): choices=RefundReasonChoices, blank=True, ) - refund_reason_text = models.TextField(blank=True) + refund_reason_text = models.TextField(blank=True, max_length=1000) consent_given = models.BooleanField(default=False) status = models.CharField( max_length=20, diff --git a/ecommerce/models_test.py b/ecommerce/models_test.py index 540f670180..f3bf7ca026 100644 --- a/ecommerce/models_test.py +++ b/ecommerce/models_test.py @@ -12,16 +12,20 @@ from mitol.common.utils import now_in_utc from reversion.models import Version +from b2b.factories import ContractPageFactory +from courses.factories import CourseRunFactory from ecommerce.constants import ( DISCOUNT_TYPE_DOLLARS_OFF, DISCOUNT_TYPE_FIXED_PRICE, DISCOUNT_TYPE_PERCENT_OFF, + REFUND_WINDOW_DAYS, ZERO_PAYMENT_DATA, ) from ecommerce.factories import ( BasketFactory, BasketItemFactory, DiscountFactory, + LineFactory, OneTimeDiscountFactory, OneTimePerUserDiscountFactory, OrderFactory, @@ -39,9 +43,12 @@ FulfilledOrder, Line, Order, + OrderRefundStatus, OrderStatus, PendingOrder, Product, + RefundRequest, + RefundRequestStatus, StripeEventLog, Transaction, UserDiscount, @@ -1104,3 +1111,219 @@ def test_repricing_a_pending_order_applies_a_newly_added_discount(basket): assert reprised.pk == order.pk assert reprised.lines.get().discounted_unit_price == Decimal("75.00") assert reprised.total_price_paid == Decimal("75.00") + + +def _add_purchased_run(order, start_date): + """Attach a purchased course run to an order, the way a real purchase does.""" + run = CourseRunFactory.create(start_date=start_date) + with reversion.create_revision(): + product = ProductFactory.create(purchasable_object=run) + LineFactory.create( + order=order, + purchased_object=run, + product_version=Version.objects.get_for_object(product).last(), + ) + return run + + +def test_refund_deadline_defaults_to_purchase_date(): + """With no run starting after the purchase, the window runs from the purchase.""" + order = OrderFactory.create() + _add_purchased_run(order, order.created_on - timedelta(days=30)) + + assert order.refund_deadline == order.created_on + timedelta( + days=REFUND_WINDOW_DAYS + ) + + +def test_refund_deadline_extends_to_later_course_start(): + """A run starting after the purchase pushes the deadline out to its start date.""" + order = OrderFactory.create() + start_date = order.created_on + timedelta(days=60) + _add_purchased_run(order, start_date) + + assert order.refund_deadline == start_date + timedelta(days=REFUND_WINDOW_DAYS) + + +def test_refund_deadline_uses_the_latest_run(): + """With several qualifying runs, the latest start date wins.""" + order = OrderFactory.create() + for offset in (30, 90, 60): + _add_purchased_run(order, order.created_on + timedelta(days=offset)) + + assert order.refund_deadline == order.created_on + timedelta( + days=90 + REFUND_WINDOW_DAYS + ) + + +@pytest.mark.parametrize("days_since_purchase", [0, REFUND_WINDOW_DAYS - 1]) +def test_within_refund_window_until_it_closes(days_since_purchase): + """The window stays open right up to the deadline.""" + order = OrderFactory.create() + + with freeze_time(order.created_on + timedelta(days=days_since_purchase)): + assert order.is_within_refund_window + + +def test_not_within_refund_window_after_it_closes(): + """The window is shut once the deadline passes.""" + order = OrderFactory.create() + + with freeze_time(order.created_on + timedelta(days=REFUND_WINDOW_DAYS, seconds=1)): + assert not order.is_within_refund_window + + +def test_refund_window_extends_for_a_course_starting_after_purchase(): + """ + A purchase made well before the course starts stays in window into the + course, even though the purchase-date window has long closed. + """ + order = OrderFactory.create() + start_date = order.created_on + timedelta(days=60) + _add_purchased_run(order, start_date) + + with freeze_time(start_date + timedelta(days=REFUND_WINDOW_DAYS - 1)): + assert order.is_within_refund_window + + with freeze_time(start_date + timedelta(days=REFUND_WINDOW_DAYS, seconds=1)): + assert not order.is_within_refund_window + + +def test_is_refund_eligible_only_when_a_request_would_be_accepted(): + """ + `is_refund_eligible` answers "could the learner request a refund now", not + "is the window open" — being in window is necessary but not sufficient. + """ + fulfilled = OrderFactory.create(state=OrderStatus.FULFILLED) + assert fulfilled.is_refund_eligible + + pending = OrderFactory.create(state=OrderStatus.PENDING) + assert pending.is_within_refund_window + assert not pending.is_refund_eligible + + +def test_is_refund_eligible_false_once_a_request_exists(user): + """A learner with a request outstanding cannot make another.""" + order = OrderFactory.create(purchaser=user, state=OrderStatus.FULFILLED) + assert order.is_refund_eligible + + RefundRequest.objects.create(order=order, user=user, consent_given=True) + del order.latest_refund_request # clear the cached_property + + assert not order.is_refund_eligible + + +def test_refund_status_eligible(): + """A fulfilled order inside the window can be refunded on request.""" + order = OrderFactory.create(state=OrderStatus.FULFILLED) + + assert order.refund_status == OrderRefundStatus.ELIGIBLE + + +def test_refund_status_window_closed(): + """Past the window the learner may still ask, but through a review.""" + order = OrderFactory.create(state=OrderStatus.FULFILLED) + + with freeze_time(order.created_on + timedelta(days=REFUND_WINDOW_DAYS, seconds=1)): + assert order.refund_status == OrderRefundStatus.WINDOW_CLOSED + + +@pytest.mark.parametrize( + "state", + [OrderStatus.PENDING, OrderStatus.CANCELED, OrderStatus.DECLINED], +) +def test_refund_status_ineligible_for_unfulfilled_orders(state): + """Only a fulfilled order can be refunded.""" + order = OrderFactory.create(state=state) + + assert order.refund_status == OrderRefundStatus.INELIGIBLE + + +@pytest.mark.skip_nplusone_check +def test_refund_status_ineligible_for_b2b_orders(): + """B2B contract orders are handled off the self-service path.""" + order = OrderFactory.create(state=OrderStatus.FULFILLED) + run = CourseRunFactory.create(b2b_contract=ContractPageFactory.create()) + with reversion.create_revision(): + product = ProductFactory.create(purchasable_object=run) + LineFactory.create( + order=order, + purchased_object=run, + product_version=Version.objects.get_for_object(product).last(), + ) + + assert order.refund_status == OrderRefundStatus.INELIGIBLE + + +@pytest.mark.parametrize( + ("request_status", "expected"), + [ + (RefundRequestStatus.PENDING, OrderRefundStatus.REQUESTED), + # Nothing marks the refund itself as done, so an approved request still + # reads as outstanding until the order state changes. + (RefundRequestStatus.APPROVED, OrderRefundStatus.REQUESTED), + (RefundRequestStatus.DENIED, OrderRefundStatus.DENIED), + ], +) +def test_refund_status_reflects_an_existing_request(user, request_status, expected): + """An existing request outranks the learner's eligibility to make one.""" + order = OrderFactory.create(purchaser=user, state=OrderStatus.FULFILLED) + RefundRequest.objects.create( + order=order, user=user, consent_given=True, status=request_status + ) + + assert order.refund_status == expected + + +def test_refund_status_uses_the_latest_request(user): + """A fresh request after a denial supersedes it.""" + order = OrderFactory.create(purchaser=user, state=OrderStatus.FULFILLED) + RefundRequest.objects.create( + order=order, user=user, consent_given=True, status=RefundRequestStatus.DENIED + ) + RefundRequest.objects.create( + order=order, user=user, consent_given=True, status=RefundRequestStatus.PENDING + ) + + assert order.refund_status == OrderRefundStatus.REQUESTED + + +@pytest.mark.parametrize( + "state", [OrderStatus.REFUNDED, OrderStatus.PARTIALLY_REFUNDED] +) +def test_refund_status_completed_outranks_everything(user, state): + """Once the money is back, no other state matters.""" + order = OrderFactory.create(purchaser=user, state=state) + RefundRequest.objects.create( + order=order, user=user, consent_given=True, status=RefundRequestStatus.PENDING + ) + + assert order.refund_status == OrderRefundStatus.COMPLETED + + +def test_refund_reviewed_on_is_none_while_pending(user): + """A request nobody has acted on has no review date.""" + order = OrderFactory.create(purchaser=user, state=OrderStatus.FULFILLED) + RefundRequest.objects.create(order=order, user=user, consent_given=True) + + assert order.refund_reviewed_on is None + + +@pytest.mark.parametrize( + "status", [RefundRequestStatus.APPROVED, RefundRequestStatus.DENIED] +) +def test_refund_reviewed_on_is_set_once_decided(user, status): + """Deciding a request stamps it, and that is the review date.""" + order = OrderFactory.create(purchaser=user, state=OrderStatus.FULFILLED) + request = RefundRequest.objects.create( + order=order, user=user, consent_given=True, status=status + ) + + assert order.refund_reviewed_on == request.updated_on + + +def test_refund_reviewed_on_is_none_without_a_request(): + """An order nobody has asked to refund has no review date.""" + order = OrderFactory.create(state=OrderStatus.FULFILLED) + + assert order.refund_reviewed_on is None diff --git a/ecommerce/serializers/v0/__init__.py b/ecommerce/serializers/v0/__init__.py index 68a1d5bd78..d7ee954299 100644 --- a/ecommerce/serializers/v0/__init__.py +++ b/ecommerce/serializers/v0/__init__.py @@ -25,9 +25,12 @@ Basket, BasketItem, Order, + OrderRefundStatus, OrderStatus, Product, + RefundReasonChoices, RefundRequest, + RefundRequestStatus, ) from flexiblepricing.api import determine_courseware_flexible_price_discount from main.constants import ( @@ -84,6 +87,7 @@ class TransactionLineSerializer(serializers.Serializer): total_paid = serializers.CharField() discount = serializers.CharField() price = serializers.CharField() + has_free_audit = serializers.BooleanField() def to_representation(self, instance): """Returns the representation of the object.""" @@ -121,6 +125,10 @@ def to_representation(self, instance): # object was deleted; the receipt still has to serialize. start_date=content_object.start_date if content_object else None, end_date=content_object.end_date if content_object else None, + # Refunding drops the learner to the audit track, unless there is + # no audit track, in which case they lose access altogether. The + # refund modal warns about whichever applies. + has_free_audit=(content_object.has_free_audit if content_object else False), ) return line # noqa: RET504 @@ -495,11 +503,32 @@ class OrderSerializer(serializers.ModelSerializer): transactions = serializers.SerializerMethodField() street_address = serializers.SerializerMethodField() refund_eligible = serializers.SerializerMethodField() + refund_deadline = serializers.SerializerMethodField() + refund_status = serializers.SerializerMethodField() + refund_requested_on = serializers.SerializerMethodField() + refund_reviewed_on = serializers.SerializerMethodField() @extend_schema_field(serializers.BooleanField()) def get_refund_eligible(self, instance): return instance.is_refund_eligible + @extend_schema_field(serializers.DateTimeField()) + def get_refund_deadline(self, instance): + return instance.refund_deadline + + @extend_schema_field(serializers.ChoiceField(choices=OrderRefundStatus.choices)) + def get_refund_status(self, instance): + return instance.refund_status + + @extend_schema_field(serializers.DateTimeField(allow_null=True)) + def get_refund_requested_on(self, instance): + request = instance.latest_refund_request + return request.created_on if request else None + + @extend_schema_field(serializers.DateTimeField(allow_null=True)) + def get_refund_reviewed_on(self, instance): + return instance.refund_reviewed_on + @extend_schema_field(TransactionLineSerializer(many=True)) def get_lines(self, instance): """Get product information along with applied discounts""" @@ -651,6 +680,10 @@ class Meta: "transactions", "street_address", "refund_eligible", + "refund_deadline", + "refund_status", + "refund_requested_on", + "refund_reviewed_on", ] model = models.Order @@ -1054,8 +1087,6 @@ class Meta: fields = ["order", "refund_reason", "refund_reason_text", "consent_given"] def validate_order(self, order): - from courses.utils import is_contract_order # noqa: PLC0415 - user = self.context["request"].user if order.purchaser != user: msg = "You can only request a refund for your own orders." @@ -1063,11 +1094,16 @@ def validate_order(self, order): if order.state != OrderStatus.FULFILLED: msg = "Refund requests can only be submitted for fulfilled orders." raise serializers.ValidationError(msg) - if is_contract_order(order): + # Same property `refund_status` reports on, so what the receipt advertises + # and what this endpoint accepts cannot drift apart. + if order.is_b2b_order: msg = ( "B2B contract orders are not eligible for self-service refund requests." ) raise serializers.ValidationError(msg) + if order.refund_requests.filter(status=RefundRequestStatus.PENDING).exists(): + msg = "A refund request for this order is already awaiting review." + raise serializers.ValidationError(msg) return order def validate_consent_given(self, value): @@ -1076,6 +1112,25 @@ def validate_consent_given(self, value): raise serializers.ValidationError(msg) return value + def validate(self, attrs): + """ + Require free text when no preset reason identifies the request. + + Requests made after the refund window offer no preset reasons at all, and + "Other" says nothing on its own, so both cases need the learner's own + words for customer service to act on. + """ + reason = attrs.get("refund_reason", "") + if ( + reason in ("", RefundReasonChoices.OTHER) + and not attrs.get("refund_reason_text", "").strip() + ): + msg = { + "refund_reason_text": "Please tell us why you are requesting a refund." + } + raise serializers.ValidationError(msg) + return attrs + def create(self, validated_data): validated_data["user"] = self.context["request"].user return super().create(validated_data) diff --git a/ecommerce/serializers/v0/serializers_test.py b/ecommerce/serializers/v0/serializers_test.py index 03bf32ae44..56ddf96f06 100644 --- a/ecommerce/serializers/v0/serializers_test.py +++ b/ecommerce/serializers/v0/serializers_test.py @@ -8,12 +8,13 @@ from django.urls import reverse from reversion.models import Version -from courses.models import CourseRun, Program +from courses.models import CourseRun, EnrollmentMode, Program from ecommerce.api import generate_checkout_payload from ecommerce.factories import OrderFactory, ProductFactory, ProgramProductFactory from ecommerce.models import Line, Order, OrderStatus from ecommerce.serializers.v0 import TransactionLineSerializer from ecommerce.views.legacy.views_test import create_basket +from openedx.constants import EDX_ENROLLMENT_AUDIT_MODE pytestmark = [pytest.mark.django_db] @@ -76,6 +77,7 @@ def build_expected_line(instance): price=str(instance.product.price), start_date=content_object.start_date, end_date=content_object.end_date, + has_free_audit=content_object.has_free_audit, ) @@ -181,3 +183,36 @@ def test_receipt_line_shows_no_discount_when_the_price_was_not_discounted(): assert data["discount"] == "0.00" assert data["total_paid"] == "300.00" + + +@pytest.mark.skip_nplusone_check +def test_order_line_reports_a_free_audit_track(settings, mocker, user): + """A run with an audit mode tells the receipt the learner can fall back to it.""" + settings.OPENEDX_SERVICE_WORKER_API_TOKEN = "mock_api_token" # noqa: S105 + + with reversion.create_revision(): + products = ProductFactory.create_batch(1) + order = create_order(mocker, user, products) + run = CourseRun.objects.get(id=products[0].object_id) + run.enrollment_modes.add( + EnrollmentMode.objects.get_or_create(mode_slug=EDX_ENROLLMENT_AUDIT_MODE)[0] + ) + + serialized = TransactionLineSerializer(instance=order.lines, many=True).data + + assert serialized[0]["has_free_audit"] is True + + +@pytest.mark.skip_nplusone_check +def test_order_line_reports_no_free_audit_track(settings, mocker, user): + """Without an audit mode, refunding costs the learner access entirely.""" + settings.OPENEDX_SERVICE_WORKER_API_TOKEN = "mock_api_token" # noqa: S105 + + with reversion.create_revision(): + products = ProductFactory.create_batch(1) + order = create_order(mocker, user, products) + CourseRun.objects.get(id=products[0].object_id).enrollment_modes.clear() + + serialized = TransactionLineSerializer(instance=order.lines, many=True).data + + assert serialized[0]["has_free_audit"] is False diff --git a/ecommerce/views/v0/__init__.py b/ecommerce/views/v0/__init__.py index c455a1e298..64b8c4b391 100644 --- a/ecommerce/views/v0/__init__.py +++ b/ecommerce/views/v0/__init__.py @@ -954,6 +954,8 @@ def get_queryset(self): return ( Order.objects.filter(purchaser=self.request.user) .filter(state__in=[OrderStatus.FULFILLED, OrderStatus.REFUNDED]) + # Every serialized order reads both, once per row. + .prefetch_related("refund_requests", "lines__purchased_object") .order_by("-created_on") .all() ) diff --git a/ecommerce/views/v0/views_test.py b/ecommerce/views/v0/views_test.py index cbf1669b81..fc9d6ebb35 100644 --- a/ecommerce/views/v0/views_test.py +++ b/ecommerce/views/v0/views_test.py @@ -13,6 +13,7 @@ from django.forms.models import model_to_dict from django.test import Client from django.urls import reverse +from django.utils.dateparse import parse_datetime from mitol.common.utils.datetime import now_in_utc from reversion.models import Version @@ -56,6 +57,7 @@ Order, OrderStatus, RefundRequest, + RefundRequestStatus, UserDiscount, ) from ecommerce.serializers import ( @@ -1637,3 +1639,228 @@ def test_order_history_includes_refund_eligible(user, user_drf_client): returned_orders = resp.json()["results"] assert len(returned_orders) == 1 assert "refund_eligible" in returned_orders[0] + + +@pytest.mark.skip_nplusone_check +def test_order_receipt_includes_refund_deadline(user, user_drf_client): + """The receipt carries the refund window's end so the UI can display it.""" + with reversion.create_revision(): + product = ProductFactory.create() + product_version = Version.objects.get_for_object(product).last() + order = OrderFactory.create(purchaser=user, state=OrderStatus.FULFILLED) + LineFactory.create(order=order, product_version=product_version) + + resp = user_drf_client.get(reverse("v0:order_receipt_api", kwargs={"pk": order.id})) + + assert resp.status_code == 200 + receipt = resp.json() + assert receipt["refund_eligible"] is True + assert parse_datetime(receipt["refund_deadline"]) == order.refund_deadline + + +def test_refund_request_duplicate_rejected(user, user_drf_client): + """A second request is rejected while the first is still awaiting review.""" + order = OrderFactory.create(purchaser=user, state=OrderStatus.FULFILLED) + payload = { + "order": order.id, + "refund_reason": "financial_reasons", + "consent_given": True, + } + + assert ( + user_drf_client.post( + reverse("v0:refund_requests_api"), data=payload + ).status_code + == 201 + ) + + resp = user_drf_client.post(reverse("v0:refund_requests_api"), data=payload) + + assert resp.status_code == 400 + assert "order" in resp.json()["errors"] + assert RefundRequest.objects.filter(order=order).count() == 1 + + +def test_refund_request_allowed_after_denial(user, user_drf_client): + """A denied request does not block the learner from asking again.""" + order = OrderFactory.create(purchaser=user, state=OrderStatus.FULFILLED) + RefundRequest.objects.create( + order=order, user=user, consent_given=True, status=RefundRequestStatus.DENIED + ) + + resp = user_drf_client.post( + reverse("v0:refund_requests_api"), + data={ + "order": order.id, + "refund_reason": "financial_reasons", + "consent_given": True, + }, + ) + + assert resp.status_code == 201 + + +@pytest.mark.parametrize("refund_reason", ["", "other"]) +def test_refund_request_requires_text_without_a_preset_reason( + user, user_drf_client, refund_reason +): + """A blank reason and "Other" both need the learner's own words.""" + order = OrderFactory.create(purchaser=user, state=OrderStatus.FULFILLED) + + resp = user_drf_client.post( + reverse("v0:refund_requests_api"), + data={ + "order": order.id, + "refund_reason": refund_reason, + "refund_reason_text": " ", + "consent_given": True, + }, + ) + + assert resp.status_code == 400 + assert "refund_reason_text" in resp.json()["errors"] + + +def test_refund_request_preset_reason_needs_no_text(user, user_drf_client): + """A preset reason stands on its own.""" + order = OrderFactory.create(purchaser=user, state=OrderStatus.FULFILLED) + + resp = user_drf_client.post( + reverse("v0:refund_requests_api"), + data={ + "order": order.id, + "refund_reason": "technical_difficulties", + "consent_given": True, + }, + ) + + assert resp.status_code == 201 + + +def test_refund_request_text_length_capped(user, user_drf_client): + """The free-text reason is capped to the length the UI advertises.""" + order = OrderFactory.create(purchaser=user, state=OrderStatus.FULFILLED) + + resp = user_drf_client.post( + reverse("v0:refund_requests_api"), + data={ + "order": order.id, + "refund_reason": "other", + "refund_reason_text": "x" * 1001, + "consent_given": True, + }, + ) + + assert resp.status_code == 400 + assert "refund_reason_text" in resp.json()["errors"] + + +@pytest.mark.skip_nplusone_check +def test_order_receipt_includes_refund_status(user, user_drf_client): + """The receipt reports one refund state for the UI to branch on.""" + with reversion.create_revision(): + product = ProductFactory.create() + product_version = Version.objects.get_for_object(product).last() + order = OrderFactory.create(purchaser=user, state=OrderStatus.FULFILLED) + LineFactory.create(order=order, product_version=product_version) + + resp = user_drf_client.get(reverse("v0:order_receipt_api", kwargs={"pk": order.id})) + + assert resp.status_code == 200 + assert resp.json()["refund_status"] == "eligible" + assert resp.json()["refund_requested_on"] is None + + +@pytest.mark.skip_nplusone_check +def test_order_receipt_refund_status_after_requesting(user, user_drf_client): + """Submitting a request moves the receipt's refund state without a page change.""" + with reversion.create_revision(): + product = ProductFactory.create() + product_version = Version.objects.get_for_object(product).last() + order = OrderFactory.create(purchaser=user, state=OrderStatus.FULFILLED) + LineFactory.create(order=order, product_version=product_version) + + user_drf_client.post( + reverse("v0:refund_requests_api"), + data={ + "order": order.id, + "refund_reason": "financial_reasons", + "consent_given": True, + }, + ) + + receipt = user_drf_client.get( + reverse("v0:order_receipt_api", kwargs={"pk": order.id}) + ).json() + + assert receipt["refund_status"] == "requested" + assert parse_datetime(receipt["refund_requested_on"]) == ( + order.refund_requests.get().created_on + ) + + +@pytest.mark.parametrize( + "refund_reason", + [ + "not_enough_time", + "course_not_as_expected", + "technical_difficulties", + "course_too_difficult", + "purchased_by_mistake", + "prefer_not_to_say", + ], +) +def test_refund_request_accepts_every_preset_reason( + user, user_drf_client, refund_reason +): + """Every reason the refund modal offers is accepted without free text.""" + order = OrderFactory.create(purchaser=user, state=OrderStatus.FULFILLED) + + resp = user_drf_client.post( + reverse("v0:refund_requests_api"), + data={ + "order": order.id, + "refund_reason": refund_reason, + "consent_given": True, + }, + ) + + assert resp.status_code == 201 + assert RefundRequest.objects.get(order=order).refund_reason == refund_reason + + +def test_refund_request_rejects_an_unknown_reason(user, user_drf_client): + """A reason outside the enum is rejected rather than stored.""" + order = OrderFactory.create(purchaser=user, state=OrderStatus.FULFILLED) + + resp = user_drf_client.post( + reverse("v0:refund_requests_api"), + data={ + "order": order.id, + "refund_reason": "i_changed_my_mind", + "consent_given": True, + }, + ) + + assert resp.status_code == 400 + assert "refund_reason" in resp.json()["errors"] + + +@pytest.mark.skip_nplusone_check +def test_order_receipt_reports_when_a_request_was_decided(user, user_drf_client): + """A declined request carries the date it was decided, not just submitted.""" + with reversion.create_revision(): + product = ProductFactory.create() + product_version = Version.objects.get_for_object(product).last() + order = OrderFactory.create(purchaser=user, state=OrderStatus.FULFILLED) + LineFactory.create(order=order, product_version=product_version) + request = RefundRequest.objects.create( + order=order, user=user, consent_given=True, status=RefundRequestStatus.DENIED + ) + + receipt = user_drf_client.get( + reverse("v0:order_receipt_api", kwargs={"pk": order.id}) + ).json() + + assert receipt["refund_status"] == "denied" + assert parse_datetime(receipt["refund_reviewed_on"]) == request.updated_on diff --git a/main/settings.py b/main/settings.py index 89b4cd28d9..1a1a72d780 100644 --- a/main/settings.py +++ b/main/settings.py @@ -39,7 +39,7 @@ from main.sentry import init_sentry from openapi.settings_spectacular import open_spectacular_settings -VERSION = "1.164.0" +VERSION = "1.164.1" log = logging.getLogger() diff --git a/openapi/specs/v0.yaml b/openapi/specs/v0.yaml index bae107c37c..534a18d535 100644 --- a/openapi/specs/v0.yaml +++ b/openapi/specs/v0.yaml @@ -7558,13 +7558,35 @@ components: refund_eligible: type: boolean readOnly: true + refund_deadline: + type: string + format: date-time + readOnly: true + refund_status: + allOf: + - $ref: '#/components/schemas/RefundStatusEnum' + readOnly: true + refund_requested_on: + type: string + format: date-time + nullable: true + readOnly: true + refund_reviewed_on: + type: string + format: date-time + nullable: true + readOnly: true required: - created_on - discounts - id - lines - purchaser + - refund_deadline - refund_eligible + - refund_requested_on + - refund_reviewed_on + - refund_status - refunds - state - street_address @@ -8967,24 +8989,36 @@ components: - unlimited RefundReasonEnum: enum: - - enrolled_in_another_course + - not_enough_time - course_not_as_expected - technical_difficulties - - financial_reasons + - course_too_difficult + - purchased_by_mistake + - prefer_not_to_say - other + - enrolled_in_another_course + - financial_reasons type: string description: |- - * `enrolled_in_another_course` - I enrolled in another course + * `not_enough_time` - I do not have enough time * `course_not_as_expected` - Course is not what I expected - * `technical_difficulties` - Technical difficulties - * `financial_reasons` - Financial reasons + * `technical_difficulties` - I had a technical issue + * `course_too_difficult` - Course is too difficult + * `purchased_by_mistake` - I purchased by mistake + * `prefer_not_to_say` - Prefer not to say * `other` - Other + * `enrolled_in_another_course` - I enrolled in another course + * `financial_reasons` - Financial reasons x-enum-descriptions: - - I enrolled in another course + - I do not have enough time - Course is not what I expected - - Technical difficulties - - Financial reasons + - I had a technical issue + - Course is too difficult + - I purchased by mistake + - Prefer not to say - Other + - I enrolled in another course + - Financial reasons RefundRequest: type: object description: Serializer for creating learner-submitted refund requests. @@ -8997,6 +9031,7 @@ components: - $ref: '#/components/schemas/BlankEnum' refund_reason_text: type: string + maxLength: 1000 consent_given: type: boolean required: @@ -9013,10 +9048,34 @@ components: - $ref: '#/components/schemas/BlankEnum' refund_reason_text: type: string + maxLength: 1000 consent_given: type: boolean required: - order + RefundStatusEnum: + enum: + - completed + - requested + - denied + - eligible + - window_closed + - ineligible + type: string + description: |- + * `completed` - Completed + * `requested` - Requested + * `denied` - Denied + * `eligible` - Eligible + * `window_closed` - Window Closed + * `ineligible` - Ineligible + x-enum-descriptions: + - Completed + - Requested + - Denied + - Eligible + - Window Closed + - Ineligible ResultEnum: enum: - b2b-disallowed @@ -9292,12 +9351,15 @@ components: type: string price: type: string + has_free_audit: + type: boolean required: - CEUs - content_title - content_type - discount - end_date + - has_free_audit - price - quantity - readable_id diff --git a/openapi/specs/v1.yaml b/openapi/specs/v1.yaml index 337dd7a049..0f93c1343e 100644 --- a/openapi/specs/v1.yaml +++ b/openapi/specs/v1.yaml @@ -7558,13 +7558,35 @@ components: refund_eligible: type: boolean readOnly: true + refund_deadline: + type: string + format: date-time + readOnly: true + refund_status: + allOf: + - $ref: '#/components/schemas/RefundStatusEnum' + readOnly: true + refund_requested_on: + type: string + format: date-time + nullable: true + readOnly: true + refund_reviewed_on: + type: string + format: date-time + nullable: true + readOnly: true required: - created_on - discounts - id - lines - purchaser + - refund_deadline - refund_eligible + - refund_requested_on + - refund_reviewed_on + - refund_status - refunds - state - street_address @@ -8967,24 +8989,36 @@ components: - unlimited RefundReasonEnum: enum: - - enrolled_in_another_course + - not_enough_time - course_not_as_expected - technical_difficulties - - financial_reasons + - course_too_difficult + - purchased_by_mistake + - prefer_not_to_say - other + - enrolled_in_another_course + - financial_reasons type: string description: |- - * `enrolled_in_another_course` - I enrolled in another course + * `not_enough_time` - I do not have enough time * `course_not_as_expected` - Course is not what I expected - * `technical_difficulties` - Technical difficulties - * `financial_reasons` - Financial reasons + * `technical_difficulties` - I had a technical issue + * `course_too_difficult` - Course is too difficult + * `purchased_by_mistake` - I purchased by mistake + * `prefer_not_to_say` - Prefer not to say * `other` - Other + * `enrolled_in_another_course` - I enrolled in another course + * `financial_reasons` - Financial reasons x-enum-descriptions: - - I enrolled in another course + - I do not have enough time - Course is not what I expected - - Technical difficulties - - Financial reasons + - I had a technical issue + - Course is too difficult + - I purchased by mistake + - Prefer not to say - Other + - I enrolled in another course + - Financial reasons RefundRequest: type: object description: Serializer for creating learner-submitted refund requests. @@ -8997,6 +9031,7 @@ components: - $ref: '#/components/schemas/BlankEnum' refund_reason_text: type: string + maxLength: 1000 consent_given: type: boolean required: @@ -9013,10 +9048,34 @@ components: - $ref: '#/components/schemas/BlankEnum' refund_reason_text: type: string + maxLength: 1000 consent_given: type: boolean required: - order + RefundStatusEnum: + enum: + - completed + - requested + - denied + - eligible + - window_closed + - ineligible + type: string + description: |- + * `completed` - Completed + * `requested` - Requested + * `denied` - Denied + * `eligible` - Eligible + * `window_closed` - Window Closed + * `ineligible` - Ineligible + x-enum-descriptions: + - Completed + - Requested + - Denied + - Eligible + - Window Closed + - Ineligible ResultEnum: enum: - b2b-disallowed @@ -9292,12 +9351,15 @@ components: type: string price: type: string + has_free_audit: + type: boolean required: - CEUs - content_title - content_type - discount - end_date + - has_free_audit - price - quantity - readable_id diff --git a/openapi/specs/v2.yaml b/openapi/specs/v2.yaml index 754122fca6..025984c2f9 100644 --- a/openapi/specs/v2.yaml +++ b/openapi/specs/v2.yaml @@ -7558,13 +7558,35 @@ components: refund_eligible: type: boolean readOnly: true + refund_deadline: + type: string + format: date-time + readOnly: true + refund_status: + allOf: + - $ref: '#/components/schemas/RefundStatusEnum' + readOnly: true + refund_requested_on: + type: string + format: date-time + nullable: true + readOnly: true + refund_reviewed_on: + type: string + format: date-time + nullable: true + readOnly: true required: - created_on - discounts - id - lines - purchaser + - refund_deadline - refund_eligible + - refund_requested_on + - refund_reviewed_on + - refund_status - refunds - state - street_address @@ -8967,24 +8989,36 @@ components: - unlimited RefundReasonEnum: enum: - - enrolled_in_another_course + - not_enough_time - course_not_as_expected - technical_difficulties - - financial_reasons + - course_too_difficult + - purchased_by_mistake + - prefer_not_to_say - other + - enrolled_in_another_course + - financial_reasons type: string description: |- - * `enrolled_in_another_course` - I enrolled in another course + * `not_enough_time` - I do not have enough time * `course_not_as_expected` - Course is not what I expected - * `technical_difficulties` - Technical difficulties - * `financial_reasons` - Financial reasons + * `technical_difficulties` - I had a technical issue + * `course_too_difficult` - Course is too difficult + * `purchased_by_mistake` - I purchased by mistake + * `prefer_not_to_say` - Prefer not to say * `other` - Other + * `enrolled_in_another_course` - I enrolled in another course + * `financial_reasons` - Financial reasons x-enum-descriptions: - - I enrolled in another course + - I do not have enough time - Course is not what I expected - - Technical difficulties - - Financial reasons + - I had a technical issue + - Course is too difficult + - I purchased by mistake + - Prefer not to say - Other + - I enrolled in another course + - Financial reasons RefundRequest: type: object description: Serializer for creating learner-submitted refund requests. @@ -8997,6 +9031,7 @@ components: - $ref: '#/components/schemas/BlankEnum' refund_reason_text: type: string + maxLength: 1000 consent_given: type: boolean required: @@ -9013,10 +9048,34 @@ components: - $ref: '#/components/schemas/BlankEnum' refund_reason_text: type: string + maxLength: 1000 consent_given: type: boolean required: - order + RefundStatusEnum: + enum: + - completed + - requested + - denied + - eligible + - window_closed + - ineligible + type: string + description: |- + * `completed` - Completed + * `requested` - Requested + * `denied` - Denied + * `eligible` - Eligible + * `window_closed` - Window Closed + * `ineligible` - Ineligible + x-enum-descriptions: + - Completed + - Requested + - Denied + - Eligible + - Window Closed + - Ineligible ResultEnum: enum: - b2b-disallowed @@ -9292,12 +9351,15 @@ components: type: string price: type: string + has_free_audit: + type: boolean required: - CEUs - content_title - content_type - discount - end_date + - has_free_audit - price - quantity - readable_id diff --git a/uv.lock b/uv.lock index 479323d4ac..10bc50abbe 100644 --- a/uv.lock +++ b/uv.lock @@ -3,55 +3,55 @@ revision = 3 requires-python = "==3.11.*" [options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer = "2026-08-24T14:08:08.404727548Z" exclude-newer-span = "P7D" [options.exclude-newer-package] -mitol-django-google-sheets = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -ol-openedx-uai-content-customization = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -ol-openedx-canvas-integration = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -ol-concourse = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -mitol-django-olposthog = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -mitol-django-oauth-toolkit-extensions = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -ol-social-auth = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -mitol-django-payment-gateway = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -ol-openedx-ai-static-translations = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -mitol-django-google-sheets-refunds = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -ol-openedx-chat-xblock = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -ol-openedx-events-handler = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -ol-openedx-feedback = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -ol-openedx-logging = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -ol-openedx-rapid-response-reports = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -mitol-django-observability = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -mitol-django-authentication = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -ol-openedx-lti-utilities = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -ol-openedx-checkout-external = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -mitol-django-geoip = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -ol-openedx-git-auto-export = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -ol-openedx-chat = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -mitol-django-hubspot-api = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -openedx-companion-auth = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -ol-openedx-course-sync = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -mitol-django-digitalcredentials = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -mitol-django-common = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -ol-openedx-sentry = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -rapid-response-xblock = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -mitol-django-mail = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -mitol-django-google-sheets-deferrals = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -ol-openedx-course-translations = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -ol-openedx-course-outline-api = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -mitol-django-transcoding = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -edx-username-changer = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -ol-openedx-auto-select-language = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -mitol-django-apigateway = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -ol-openedx-course-structure-api = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -mitol-drf-lint = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -django-aqueduct = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -ol-openedx-otel-monitoring = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -edx-sysadmin = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -ol-openedx-course-export = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -mitol-django-openedx = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -mitol-django-scim = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } +mitol-django-google-sheets = { timestamp = "2026-08-31T14:08:08.404953529Z", span = "PT0S" } +ol-openedx-uai-content-customization = { timestamp = "2026-08-31T14:08:08.404949069Z", span = "PT0S" } +ol-openedx-canvas-integration = { timestamp = "2026-08-31T14:08:08.404943217Z", span = "PT0S" } +ol-concourse = { timestamp = "2026-08-31T14:08:08.40493635Z", span = "PT0S" } +mitol-django-olposthog = { timestamp = "2026-08-31T14:08:08.404955264Z", span = "PT0S" } +mitol-django-oauth-toolkit-extensions = { timestamp = "2026-08-31T14:08:08.404954944Z", span = "PT0S" } +ol-social-auth = { timestamp = "2026-08-31T14:08:08.404949386Z", span = "PT0S" } +mitol-django-payment-gateway = { timestamp = "2026-08-31T14:08:08.404955911Z", span = "PT0S" } +ol-openedx-ai-static-translations = { timestamp = "2026-08-31T14:08:08.404942426Z", span = "PT0S" } +mitol-django-google-sheets-refunds = { timestamp = "2026-08-31T14:08:08.404954234Z", span = "PT0S" } +ol-openedx-chat-xblock = { timestamp = "2026-08-31T14:08:08.404944036Z", span = "PT0S" } +ol-openedx-events-handler = { timestamp = "2026-08-31T14:08:08.4049465Z", span = "PT0S" } +ol-openedx-feedback = { timestamp = "2026-08-31T14:08:08.404946812Z", span = "PT0S" } +ol-openedx-logging = { timestamp = "2026-08-31T14:08:08.404947444Z", span = "PT0S" } +ol-openedx-rapid-response-reports = { timestamp = "2026-08-31T14:08:08.404948423Z", span = "PT0S" } +mitol-django-observability = { timestamp = "2026-08-31T14:08:08.404956881Z", span = "PT0S" } +mitol-django-authentication = { timestamp = "2026-08-31T14:08:08.404952582Z", span = "PT0S" } +ol-openedx-lti-utilities = { timestamp = "2026-08-31T14:08:08.404947767Z", span = "PT0S" } +ol-openedx-checkout-external = { timestamp = "2026-08-31T14:08:08.404944379Z", span = "PT0S" } +mitol-django-geoip = { timestamp = "2026-08-31T14:08:08.40495322Z", span = "PT0S" } +ol-openedx-git-auto-export = { timestamp = "2026-08-31T14:08:08.404947142Z", span = "PT0S" } +ol-openedx-chat = { timestamp = "2026-08-31T14:08:08.404943555Z", span = "PT0S" } +mitol-django-hubspot-api = { timestamp = "2026-08-31T14:08:08.404954563Z", span = "PT0S" } +openedx-companion-auth = { timestamp = "2026-08-31T14:08:08.404949722Z", span = "PT0S" } +ol-openedx-course-sync = { timestamp = "2026-08-31T14:08:08.404945658Z", span = "PT0S" } +mitol-django-digitalcredentials = { timestamp = "2026-08-31T14:08:08.404952911Z", span = "PT0S" } +mitol-django-common = { timestamp = "2026-08-31T14:08:08.404950369Z", span = "PT0S" } +ol-openedx-sentry = { timestamp = "2026-08-31T14:08:08.404948726Z", span = "PT0S" } +rapid-response-xblock = { timestamp = "2026-08-31T14:08:08.404950047Z", span = "PT0S" } +mitol-django-mail = { timestamp = "2026-08-31T14:08:08.404950681Z", span = "PT0S" } +mitol-django-google-sheets-deferrals = { timestamp = "2026-08-31T14:08:08.404953866Z", span = "PT0S" } +ol-openedx-course-translations = { timestamp = "2026-08-31T14:08:08.404945964Z", span = "PT0S" } +ol-openedx-course-outline-api = { timestamp = "2026-08-31T14:08:08.404945034Z", span = "PT0S" } +mitol-django-transcoding = { timestamp = "2026-08-31T14:08:08.404956257Z", span = "PT0S" } +edx-username-changer = { timestamp = "2026-08-31T14:08:08.40494188Z", span = "PT0S" } +ol-openedx-auto-select-language = { timestamp = "2026-08-31T14:08:08.404942837Z", span = "PT0S" } +mitol-django-apigateway = { timestamp = "2026-08-31T14:08:08.404956572Z", span = "PT0S" } +ol-openedx-course-structure-api = { timestamp = "2026-08-31T14:08:08.404945354Z", span = "PT0S" } +mitol-drf-lint = { timestamp = "2026-08-31T14:08:08.404957504Z", span = "PT0S" } +django-aqueduct = { timestamp = "2026-08-31T14:08:08.404941143Z", span = "PT0S" } +ol-openedx-otel-monitoring = { timestamp = "2026-08-31T14:08:08.404948085Z", span = "PT0S" } +edx-sysadmin = { timestamp = "2026-08-31T14:08:08.404941532Z", span = "PT0S" } +ol-openedx-course-export = { timestamp = "2026-08-31T14:08:08.404944695Z", span = "PT0S" } +mitol-django-openedx = { timestamp = "2026-08-31T14:08:08.404955579Z", span = "PT0S" } +mitol-django-scim = { timestamp = "2026-08-31T14:08:08.40495718Z", span = "PT0S" } [[package]] name = "amqp" @@ -1758,7 +1758,7 @@ name = "jinxed" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ansicon" }, + { name = "ansicon", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/20/d0/59b2b80e7a52d255f9e0ad040d2e826342d05580c4b1d7d7747cfb8db731/jinxed-1.3.0.tar.gz", hash = "sha256:1593124b18a41b7a3da3b078471442e51dbad3d77b4d4f2b0c26ab6f7d660dbf", size = 80981, upload-time = "2024-07-31T22:39:18.854Z" } wheels = [ @@ -2418,7 +2418,7 @@ dev = [ { name = "django-debug-toolbar", specifier = ">=6.0.0,<7" }, { name = "django-zeal", specifier = ">=2.0.4,<3" }, { name = "factory-boy", specifier = ">=3.2.0,<4" }, - { name = "faker", specifier = "<=40.15.0,>=40.15.0" }, + { name = "faker", specifier = ">=40.15.0,<=40.15.0" }, { name = "flaky", specifier = ">=3.7.0,<4" }, { name = "freezegun", specifier = ">=1.2,<2" }, { name = "granian", extras = ["reload"], specifier = ">=2.5.4,<3" },