diff --git a/RELEASE.rst b/RELEASE.rst index 82ba2242c2..18eb72038a 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,12 @@ Release Notes ============= +Version 1.166.7 +--------------- + +- Scope the CSRF cookie domain by Origin and re-issue it when missing (#3978) +- Make verified-program discounts an "internal" redemption type (#3972) + Version 1.166.5 --------------- diff --git a/drf_lint_baseline.json b/drf_lint_baseline.json index 2a222b4328..6c4d27a1ae 100644 --- a/drf_lint_baseline.json +++ b/drf_lint_baseline.json @@ -68,33 +68,33 @@ "courses/serializers/v3/courses.py:62:18:ORM004", "courses/serializers/v3/courses.py:69:18:ORM004", "courses/serializers/v3/courses.py:74:18:ORM004", - "ecommerce/serializers/__init__.py:243:12:ORM005", - "ecommerce/serializers/__init__.py:254:17:ORM001", - "ecommerce/serializers/__init__.py:256:18:ORM001", - "ecommerce/serializers/__init__.py:257:18:ORM001", - "ecommerce/serializers/__init__.py:279:26:ORM002", - "ecommerce/serializers/__init__.py:317:42:ORM006", - "ecommerce/serializers/__init__.py:367:26:ORM002", - "ecommerce/serializers/__init__.py:375:31:ORM002", - "ecommerce/serializers/__init__.py:381:20:ORM002", - "ecommerce/serializers/__init__.py:383:19:ORM004", - "ecommerce/serializers/__init__.py:386:31:ORM002", - "ecommerce/serializers/__init__.py:402:35:ORM002", - "ecommerce/serializers/__init__.py:422:4:ORM005", - "ecommerce/serializers/__init__.py:423:4:ORM005", - "ecommerce/serializers/__init__.py:424:4:ORM005", - "ecommerce/serializers/__init__.py:431:12:ORM005", - "ecommerce/serializers/__init__.py:451:15:ORM003", - "ecommerce/serializers/__init__.py:461:24:ORM002", - "ecommerce/serializers/__init__.py:480:12:ORM001", - "ecommerce/serializers/__init__.py:504:22:ORM002", - "ecommerce/serializers/__init__.py:551:22:ORM002", - "ecommerce/serializers/__init__.py:588:46:ORM006", - "ecommerce/serializers/__init__.py:615:15:ORM003", - "ecommerce/serializers/__init__.py:621:20:ORM002", - "ecommerce/serializers/__init__.py:658:26:ORM004", - "ecommerce/serializers/__init__.py:796:22:ORM002", - "ecommerce/serializers/__init__.py:978:28:ORM002", + "ecommerce/serializers/__init__.py:258:12:ORM005", + "ecommerce/serializers/__init__.py:269:17:ORM001", + "ecommerce/serializers/__init__.py:271:18:ORM001", + "ecommerce/serializers/__init__.py:272:18:ORM001", + "ecommerce/serializers/__init__.py:294:26:ORM002", + "ecommerce/serializers/__init__.py:332:42:ORM006", + "ecommerce/serializers/__init__.py:382:26:ORM002", + "ecommerce/serializers/__init__.py:390:31:ORM002", + "ecommerce/serializers/__init__.py:396:20:ORM002", + "ecommerce/serializers/__init__.py:398:19:ORM004", + "ecommerce/serializers/__init__.py:401:31:ORM002", + "ecommerce/serializers/__init__.py:417:35:ORM002", + "ecommerce/serializers/__init__.py:437:4:ORM005", + "ecommerce/serializers/__init__.py:438:4:ORM005", + "ecommerce/serializers/__init__.py:439:4:ORM005", + "ecommerce/serializers/__init__.py:446:12:ORM005", + "ecommerce/serializers/__init__.py:466:15:ORM003", + "ecommerce/serializers/__init__.py:476:24:ORM002", + "ecommerce/serializers/__init__.py:495:12:ORM001", + "ecommerce/serializers/__init__.py:519:22:ORM002", + "ecommerce/serializers/__init__.py:566:22:ORM002", + "ecommerce/serializers/__init__.py:603:46:ORM006", + "ecommerce/serializers/__init__.py:630:15:ORM003", + "ecommerce/serializers/__init__.py:636:20:ORM002", + "ecommerce/serializers/__init__.py:673:26:ORM004", + "ecommerce/serializers/__init__.py:811:22:ORM002", + "ecommerce/serializers/__init__.py:993:28:ORM002", "ecommerce/serializers/v0/__init__.py:1105:28:ORM002", "ecommerce/serializers/v0/__init__.py:294:17:ORM001", "ecommerce/serializers/v0/__init__.py:296:18:ORM001", diff --git a/ecommerce/admin.py b/ecommerce/admin.py index 70ff5e3cc0..59a8d1e8ab 100644 --- a/ecommerce/admin.py +++ b/ecommerce/admin.py @@ -16,6 +16,7 @@ from viewflow import fsm from ecommerce.api import refund_order +from ecommerce.constants import REDEMPTION_TYPE_INTERNAL from ecommerce.discount_sources import fulfilled_redemptions_funded_by from ecommerce.forms import AdminRefundOrderForm from ecommerce.models import ( @@ -154,6 +155,7 @@ class BasketItemAdmin(VersionAdmin): @admin.register(Discount) class DiscountAdmin(admin.ModelAdmin): model = Discount + exclude = ["is_program_discount"] search_fields = ["discount_type", "redemption_type", "discount_code"] list_display = [ "id", @@ -165,6 +167,14 @@ class DiscountAdmin(admin.ModelAdmin): ] list_filter = ["discount_type", "redemption_type", "payment_type"] + def get_readonly_fields(self, request, obj=None): # noqa: ARG002 + # An internal discount's code is visible on receipts, so any other + # redemption type would make that code live. DiscountShapeMixin refuses + # the same change over the API. + if obj is not None and obj.redemption_type == REDEMPTION_TYPE_INTERNAL: + return ("redemption_type",) + return () + @admin.register(DiscountProduct) class DiscountProductAdmin(admin.ModelAdmin): diff --git a/ecommerce/admin_test.py b/ecommerce/admin_test.py index e7681449c9..fb67de91a6 100644 --- a/ecommerce/admin_test.py +++ b/ecommerce/admin_test.py @@ -1,17 +1,22 @@ """Tests for ecommerce admin views""" import pytest +from django.contrib import admin from django.contrib.contenttypes.models import ContentType from django.contrib.messages import get_messages +from django.test import RequestFactory from django.urls import NoReverseMatch, reverse from reversion.models import Version from courses.factories import CourseRunFactory +from ecommerce.admin import DiscountAdmin from ecommerce.factories import ( DiscountRedemptionFactory, + InternalDiscountFactory, OrderFactory, + UnlimitedUseDiscountFactory, ) -from ecommerce.models import OrderStatus, Product +from ecommerce.models import Discount, OrderStatus, Product pytestmark = [pytest.mark.django_db] @@ -336,3 +341,22 @@ def test_admin_refund_view_keeps_the_credit_warning_on_a_rejected_form( assert response.context["form_valid"] is False assert list(response.context["used_source_redemptions"]) == [redemption] assert redemption.redeemed_order.reference_number in response.content.decode() + + +@pytest.mark.parametrize( + ("factory", "locked"), + [(InternalDiscountFactory, True), (UnlimitedUseDiscountFactory, False)], +) +def test_discount_admin_locks_redemption_type_for_internal_discounts( + admin_user, factory, locked +): + """Re-typing an internal discount would turn it into a live 100%-off code.""" + discount = factory.create() + request = RequestFactory().get("/") + request.user = admin_user + + readonly = DiscountAdmin(Discount, admin.site).get_readonly_fields( + request, discount + ) + + assert ("redemption_type" in readonly) is locked diff --git a/ecommerce/api.py b/ecommerce/api.py index 88baceaa30..8d5abcd5cf 100644 --- a/ecommerce/api.py +++ b/ecommerce/api.py @@ -43,6 +43,7 @@ DISCOUNT_TYPE_PERCENT_OFF, PAYMENT_TYPE_FINANCIAL_ASSISTANCE, PAYMENT_TYPE_SALES, + REDEMPTION_TYPE_INTERNAL, REDEMPTION_TYPE_ONE_TIME, REDEMPTION_TYPE_ONE_TIME_PER_USER, REDEMPTION_TYPE_UNLIMITED, @@ -70,6 +71,7 @@ source_line_for, ) from ecommerce.exceptions import ( + VerifiedProgramCourseNotInProgramError, VerifiedProgramInvalidBasketError, VerifiedProgramInvalidOrderError, VerifiedProgramNoEnrollmentError, @@ -1425,8 +1427,10 @@ def create_verified_program_discount(program): codes - this creates one for the program that is set up to make the order zero-value, so the learner doesn't have to pay for upgraded enrollments. - This will create a single discount, with the "verified program" flag set, - with unlimited redemptions, set to 100% off. + This creates a single 100%-off discount with the "internal" redemption type + and no redemption cap: learners cannot redeem it, and it prices whatever the + verified-enrollment flow attaches it to. Callers are responsible for + checking the run belongs to the program before attaching it. If a discount already exists for this purpose, this will return it. @@ -1445,7 +1449,9 @@ def create_verified_program_discount(program): Q(activation_date__isnull=True) | Q(activation_date__lte=now_in_utc()), Q(expiration_date__isnull=True) | Q(expiration_date__gte=now_in_utc()), products__product=product, - is_program_discount=True, + redemption_type=REDEMPTION_TYPE_INTERNAL, + discount_type=DISCOUNT_TYPE_PERCENT_OFF, + amount=100, ) if existing_discount_qs.exists(): @@ -1455,11 +1461,10 @@ def create_verified_program_discount(program): amount=Decimal(100), automatic=False, discount_type=DISCOUNT_TYPE_PERCENT_OFF, - redemption_type=REDEMPTION_TYPE_UNLIMITED, + redemption_type=REDEMPTION_TYPE_INTERNAL, payment_type=PAYMENT_TYPE_SALES, discount_code=f"{program.readable_id}-{uuid.uuid4()}", is_bulk=True, - is_program_discount=True, ) DiscountProduct.objects.create(discount=discount, product=product) @@ -1496,6 +1501,8 @@ def create_verified_program_course_run_enrollment(request, courserun, program): Raises: - VerifiedProgramNoEnrollmentError if the learner doesn't have a program enrollment + - VerifiedProgramCourseNotInProgramError if the run's course is not in the + program's requirements - VerifiedProgramInvalidBasketError if the basket isn't zero value - VerifiedProgramInvalidOrderError if the order doesn't get processed through """ @@ -1506,6 +1513,12 @@ def create_verified_program_course_run_enrollment(request, courserun, program): msg = f"No verified enrollment for {request.user} for program {program}" raise VerifiedProgramNoEnrollmentError(msg) + # The program's internal discount prices whatever it is attached to, so + # membership is decided here, against the current requirements tree. + if not program.courses_qset.filter(courseruns=courserun).exists(): + msg = f"Course run {courserun} is not in program {program}" + raise VerifiedProgramCourseNotInProgramError(msg) + discount = create_verified_program_discount(program) cr_ctype = ContentType.objects.get_for_model(courserun) diff --git a/ecommerce/api_test.py b/ecommerce/api_test.py index 8663e8d9c1..4da12c2b42 100644 --- a/ecommerce/api_test.py +++ b/ecommerce/api_test.py @@ -65,6 +65,7 @@ DISCOUNT_TYPE_FIXED_PRICE, DISCOUNT_TYPE_PERCENT_OFF, PAYMENT_TYPE_FINANCIAL_ASSISTANCE, + REDEMPTION_TYPE_INTERNAL, STRIPE_CHECKOUT_SESSION_STATUS_COMPLETE, STRIPE_CHECKOUT_SESSION_STATUS_EXPIRED, STRIPE_CHECKOUT_SESSION_STATUS_OPEN, @@ -87,10 +88,12 @@ ZERO_PAYMENT_DATA, ) from ecommerce.exceptions import ( + VerifiedProgramCourseNotInProgramError, VerifiedProgramNoEnrollmentError, ) from ecommerce.factories import ( DiscountRedemptionFactory, + InternalDiscountFactory, LineFactory, OneTimeDiscountFactory, OneTimePerUserDiscountFactory, @@ -978,10 +981,11 @@ def test_create_verified_program_discount(): discount = create_verified_program_discount(program) assert discount - assert discount.is_program_discount + assert discount.redemption_type == REDEMPTION_TYPE_INTERNAL assert discount.products.filter( product__content_type=content_type, product__object_id=program.id ).exists() + assert create_verified_program_discount(program) == discount def test_create_verified_program_course_run_enrollment( @@ -1047,6 +1051,27 @@ def test_create_vpcre_no_program(bootstrapped_verified_program, user): assert "No verified enrollment" in str(exc.value) +def test_create_vpcre_run_not_in_program(bootstrapped_verified_program, user): + """ + The program's discount prices anything it is attached to, so a run whose + course is outside the program's requirements is refused before a basket + exists. + """ + (program, _, _, _, _) = bootstrapped_verified_program + ProgramEnrollmentFactory.create( + program=program, user=user, enrollment_mode=EDX_ENROLLMENT_VERIFIED_MODE + ) + other_run = CourseRunFactory.create() + + request = RequestFactory().get("/") + request.user = user + + with pytest.raises(VerifiedProgramCourseNotInProgramError): + create_verified_program_course_run_enrollment(request, other_run, program) + + assert not BasketDiscount.objects.filter(redeemed_by=user).exists() + + def test_create_vpcre_bad_basket( mocker, mock_hubspot_order, @@ -1582,6 +1607,22 @@ def test_quote_user_price_skips_an_automatic_tied_to_another_learner(user): assert quote.price == product.price +def test_quote_user_price_skips_an_internal_discount(user): + """ + Checkout refuses an internal discount, so a UserDiscount row tying one to + this learner must not quote a price the cart will not honor. + """ + product = ProductFactory.create() + internal = InternalDiscountFactory.create() + DiscountProduct.objects.create(discount=internal, product=product) + UserDiscount.objects.create(discount=internal, user=user) + + quote = quote_user_price(product, user) + + assert quote.discount is None + assert quote.price == product.price + + def test_quote_user_price_skips_a_discount_linked_to_another_product(user): """ A discount carrying DiscountProduct links is in scope only for the products diff --git a/ecommerce/constants.py b/ecommerce/constants.py index 2e7d8e57f5..e25bd75da9 100644 --- a/ecommerce/constants.py +++ b/ecommerce/constants.py @@ -54,23 +54,32 @@ REDEMPTION_TYPE_ONE_TIME_PER_USER = "one-time-per-user" REDEMPTION_TYPE_UNLIMITED = "unlimited" REDEMPTION_TYPE_PROGRAM_CHILD_PURCHASE = "program-child-purchase" +# An internal discount reaches a basket only through application code that has +# already decided the learner is entitled to it — the one such caller is +# ecommerce.api.create_verified_program_course_run_enrollment. Every +# learner-facing route refuses it (Discount._within_redemption_limits), and +# pricing does not re-check eligibility: its product links say what it is for, +# not where it applies. +REDEMPTION_TYPE_INTERNAL = "internal" ALL_REDEMPTION_TYPES = [ REDEMPTION_TYPE_ONE_TIME, REDEMPTION_TYPE_ONE_TIME_PER_USER, REDEMPTION_TYPE_UNLIMITED, REDEMPTION_TYPE_PROGRAM_CHILD_PURCHASE, + REDEMPTION_TYPE_INTERNAL, ] REDEMPTION_TYPES = list(zip(ALL_REDEMPTION_TYPES, ALL_REDEMPTION_TYPES)) # program-child-purchase forces automatic=True and program-only product -# links even when paired with a standard calculation, so the random draw -# skips it too. +# links even when paired with a standard calculation, and internal is never +# learner-redeemable, so the random draw and bulk generation skip both. STANDARD_REDEMPTION_TYPES = [ redemption_type for redemption_type in ALL_REDEMPTION_TYPES - if redemption_type != REDEMPTION_TYPE_PROGRAM_CHILD_PURCHASE + if redemption_type + not in (REDEMPTION_TYPE_PROGRAM_CHILD_PURCHASE, REDEMPTION_TYPE_INTERNAL) ] BULK_GENERATION_REDEMPTION_TYPES = list( zip(STANDARD_REDEMPTION_TYPES, STANDARD_REDEMPTION_TYPES) diff --git a/ecommerce/discounts.py b/ecommerce/discounts.py index b0b05e634b..96f32d663c 100644 --- a/ecommerce/discounts.py +++ b/ecommerce/discounts.py @@ -7,6 +7,7 @@ DISCOUNT_TYPE_FIXED_PRICE, DISCOUNT_TYPE_PAID_AMOUNT_OFF, DISCOUNT_TYPE_PERCENT_OFF, + REDEMPTION_TYPE_INTERNAL, ) from ecommerce.models import Discount, Product @@ -59,10 +60,11 @@ def get_discounted_price( return price def get_product_price(self, product: Product): - # Program discounts are linked to a program product for identification only; - # they must still apply to the course-run products placed in the basket. + # An internal discount's product links say what it is for, not what it + # prices; the code that attached it decided eligibility (see + # REDEMPTION_TYPE_INTERNAL). if ( - not self.discount.is_program_discount + self.discount.redemption_type != REDEMPTION_TYPE_INTERNAL and not self.discount.applies_to_products([product]) ): return product.price diff --git a/ecommerce/discounts_test.py b/ecommerce/discounts_test.py index 682138924d..f9d17d0479 100644 --- a/ecommerce/discounts_test.py +++ b/ecommerce/discounts_test.py @@ -11,6 +11,7 @@ ) from ecommerce.factories import ( DiscountFactory, + InternalDiscountFactory, PaidAmountOffDiscountFactory, ProductFactory, UnlimitedUseDiscountFactory, @@ -144,23 +145,19 @@ def test_product_specific_discount_does_not_apply_to_other_products(): assert discount_cls.get_product_price(other_product) == other_product.price -def test_program_discount_applies_regardless_of_product_restriction(): - """Program discounts link to the program product for identification only; they must - still apply to course-run products placed in the basket. +def test_internal_discount_prices_products_outside_its_links(): """ - program_product = ProductFactory.create(price=Decimal("500.00")) - course_run_product = ProductFactory.create(price=Decimal("100.00")) + An internal discount's link names the program it belongs to; the code that + attached it decided eligibility, so the link does not scope pricing. + """ + program_product = ProductFactory.create() + course_run_product = ProductFactory.create() - discount = UnlimitedUseDiscountFactory.create( - discount_type="percent-off", - amount=100, - is_program_discount=True, - ) + discount = InternalDiscountFactory.create() DiscountProduct.objects.create(discount=discount, product=program_product) discount_cls = DiscountType.for_discount(discount) - # Must apply to a course-run product even though it's not in DiscountProduct assert discount_cls.get_product_price(course_run_product) == Decimal("0.00") diff --git a/ecommerce/exceptions.py b/ecommerce/exceptions.py index 1fde5b3fc5..5efe5dcdf2 100644 --- a/ecommerce/exceptions.py +++ b/ecommerce/exceptions.py @@ -39,3 +39,11 @@ class VerifiedProgramInvalidOrderError(Exception): Raised if we've tried to process a verified enrollment for a program's course run, but the processed order either had an error or it required payment. """ + + +class VerifiedProgramCourseNotInProgramError(Exception): + """ + Raised if a verified enrollment in a program's course run is requested for a + run whose course is not in that program's requirements. The program's + internal discount would otherwise price the run at zero. + """ diff --git a/ecommerce/factories.py b/ecommerce/factories.py index 464145ec00..1d8eaa60dc 100644 --- a/ecommerce/factories.py +++ b/ecommerce/factories.py @@ -12,6 +12,8 @@ from ecommerce import models from ecommerce.constants import ( DISCOUNT_TYPE_PAID_AMOUNT_OFF, + DISCOUNT_TYPE_PERCENT_OFF, + REDEMPTION_TYPE_INTERNAL, REDEMPTION_TYPE_ONE_TIME, REDEMPTION_TYPE_ONE_TIME_PER_USER, REDEMPTION_TYPE_PROGRAM_CHILD_PURCHASE, @@ -84,6 +86,12 @@ class PaidAmountOffDiscountFactory(DiscountFactory): automatic = True +class InternalDiscountFactory(DiscountFactory): + amount = 100 + discount_type = DISCOUNT_TYPE_PERCENT_OFF + redemption_type = REDEMPTION_TYPE_INTERNAL + + class BasketFactory(DjangoModelFactory): """Factory for Basket""" diff --git a/ecommerce/management/commands/check_discount_code.py b/ecommerce/management/commands/check_discount_code.py index 73d2b0cdd8..7711e4d302 100644 --- a/ecommerce/management/commands/check_discount_code.py +++ b/ecommerce/management/commands/check_discount_code.py @@ -79,7 +79,6 @@ def handle(self, *args, **kwargs): # noqa: C901, PLR0915, ARG002 ] bulk = "Yes" if code.is_bulk else "No" - program = "Yes" if code.is_program_discount else "No" can_redeem = "No" @@ -105,7 +104,6 @@ def handle(self, *args, **kwargs): # noqa: C901, PLR0915, ARG002 self.stdout.write(f"Activation date: {code.activation_date}") self.stdout.write(f"Expiration date: {code.expiration_date}") self.stdout.write(f"Bulk discount? {bulk}") - self.stdout.write(f"For program enrollments? {program}") self.stdout.write(f"Can be redeemed? {can_redeem}") self.stdout.write("\n") diff --git a/ecommerce/migrations/0055_internal_redemption_type.py b/ecommerce/migrations/0055_internal_redemption_type.py new file mode 100644 index 0000000000..e22e348943 --- /dev/null +++ b/ecommerce/migrations/0055_internal_redemption_type.py @@ -0,0 +1,70 @@ +from django.db import migrations, models + +INTERNAL = "internal" +UNLIMITED = "unlimited" + + +def flagged_rows_become_internal(apps, schema_editor): + Discount = apps.get_model("ecommerce", "Discount") + Discount.objects.filter(is_program_discount=True).update(redemption_type=INTERNAL) + + +def internal_rows_become_unlimited(apps, schema_editor): + # Rows created after the forward step have the flag at its default (False), + # and the previous release finds and prices these discounts by the flag. + Discount = apps.get_model("ecommerce", "Discount") + Discount.objects.filter(redemption_type=INTERNAL).update( + redemption_type=UNLIMITED, is_program_discount=True + ) + + +class Migration(migrations.Migration): + # The choices/help_text changes emit no SQL. ADD CONSTRAINT scans + # ecommerce_discount (~600k rows) under the table lock, about a second, + # the same trade-off 0054 made. A flagged row that is also automatic=True + # aborts the whole (single-transaction) migration at the constraint, in + # either operation order; that is intended, since it must not become an + # auto-applied internal discount. + + dependencies = [ + ("ecommerce", "0054_paid_amount_off_discounts"), + ] + + operations = [ + migrations.AlterField( + model_name="discount", + name="redemption_type", + field=models.CharField( + choices=[ + ("one-time", "one-time"), + ("one-time-per-user", "one-time-per-user"), + ("unlimited", "unlimited"), + ("program-child-purchase", "program-child-purchase"), + ("internal", "internal"), + ], + help_text="'internal' discounts are attached by application code that has verified the learner's eligibility (e.g. verified program enrollment). Learners cannot redeem them and pricing does not re-check the product.", + max_length=30, + ), + ), + migrations.AlterField( + model_name="discount", + name="is_program_discount", + field=models.BooleanField( + blank=True, + default=False, + help_text="Deprecated and unused; superseded by redemption_type 'internal'.", + null=True, + ), + ), + migrations.AddConstraint( + model_name="discount", + constraint=models.CheckConstraint( + condition=models.Q(("redemption_type", "internal"), _negated=True) + | models.Q(("automatic", False)), + name="internal_discount_never_automatic", + ), + ), + migrations.RunPython( + flagged_rows_become_internal, internal_rows_become_unlimited + ), + ] diff --git a/ecommerce/models.py b/ecommerce/models.py index eb849fc853..1a45c40799 100644 --- a/ecommerce/models.py +++ b/ecommerce/models.py @@ -36,6 +36,7 @@ DISCOUNT_TYPES, PAYMENT_TYPE_FINANCIAL_ASSISTANCE, PAYMENT_TYPES, + REDEMPTION_TYPE_INTERNAL, REDEMPTION_TYPE_ONE_TIME, REDEMPTION_TYPE_ONE_TIME_PER_USER, REDEMPTION_TYPE_PROGRAM_CHILD_PURCHASE, @@ -282,11 +283,11 @@ def base_price(self): ) -def validate_program_child_purchase_shape( +def validate_discount_shape( *, discount_type, redemption_type, amount, automatic, discount=None ): """ - Enforce the paid-amount-off / program-child-purchase shape on unsaved values. + Enforce the row-local shape rules for a Discount on unsaved values. Raises django.core.exceptions.ValidationError. DRF's Serializer.run_validation turns that into a 400 when it comes from validate(), so serializers call this @@ -322,6 +323,11 @@ def validate_program_child_purchase_shape( ): raise ValidationError(PROGRAM_PRODUCTS_ONLY_ERROR) + if redemption_type == REDEMPTION_TYPE_INTERNAL and automatic: + raise ValidationError( + "An internal discount cannot be automatic; only application code that has checked eligibility may attach one." # noqa: EM101 + ) + def validate_program_child_purchase_product(*, redemption_type, product): """Enforce the program-products clause for a single product link.""" @@ -342,7 +348,16 @@ class Discount(TimestampedModel): ) automatic = models.BooleanField(default=False) discount_type = models.CharField(choices=DISCOUNT_TYPES, max_length=30) - redemption_type = models.CharField(choices=REDEMPTION_TYPES, max_length=30) + redemption_type = models.CharField( + choices=REDEMPTION_TYPES, + max_length=30, + help_text=( + "'internal' discounts are attached by application code that has " + "verified the learner's eligibility (e.g. verified program " + "enrollment). Learners cannot redeem them and pricing does not " + "re-check the product." + ), + ) payment_type = models.CharField(null=True, choices=PAYMENT_TYPES, max_length=30) # noqa: DJ001 max_redemptions = models.PositiveIntegerField(null=True, default=0) discount_code = models.CharField(max_length=100) @@ -361,7 +376,7 @@ class Discount(TimestampedModel): null=True, blank=True, default=False, - help_text="Discount is only for creating verified course run enrollments for a program.", + help_text="Deprecated and unused; superseded by redemption_type 'internal'.", ) # Only for B2B enrollment codes where the contract has a Google Sheet configured. # This is just to save time/energy when we want to update the sheet later. @@ -375,9 +390,9 @@ class Discount(TimestampedModel): class Meta: # A storage-layer backstop for the row-local clauses of - # validate_program_child_purchase_shape, because bulk_create and queryset - # update() skip save(). The cross-table program-products clause can't - # be expressed here. + # validate_discount_shape, because bulk_create and queryset update() + # skip save(). The cross-table program-products clause can't be + # expressed here. # # The type constraint is one-way on purpose: a program-child-purchase # redemption may pair with a standard calculation (e.g. a @@ -400,6 +415,11 @@ class Meta: | models.Q(automatic=True), name="program_child_purchase_requires_automatic", ), + models.CheckConstraint( + condition=~models.Q(redemption_type=REDEMPTION_TYPE_INTERNAL) + | models.Q(automatic=False), + name="internal_discount_never_automatic", + ), ] def __str__(self): @@ -424,8 +444,8 @@ def check_date_validity(self): return True - def check_program_child_purchase_validity(self, *, include_product_links=False): - validate_program_child_purchase_shape( + def check_shape_validity(self, *, include_product_links=False): + validate_discount_shape( discount_type=self.discount_type, redemption_type=self.redemption_type, amount=self.amount, @@ -443,12 +463,12 @@ def save(self, *args, **kwargs): # row fail with an error about products. clean() and the serializers # enforce that clause where the edit is actually being made, and # DiscountProduct.save() guards the attach direction. - self.check_program_child_purchase_validity() + self.check_shape_validity() super().save(*args, **kwargs) def clean(self, *args, **kwargs): self.check_date_validity() - self.check_program_child_purchase_validity(include_product_links=True) + self.check_shape_validity(include_product_links=True) super().clean(*args, **kwargs) @cached_property @@ -458,10 +478,11 @@ def is_redeemed(self) -> bool: def is_redeemable_by(self, user: User, products: Iterable[Product] | None = None): """ - Enforces the redemption rules for a given discount: how often it may be - redeemed, whether it is inside its date window, and — for a - program-child-purchase redemption — whether this user still holds an - unconsumed qualifying purchase for one of the products in hand. + Enforces the redemption rules for a given discount: whether its type is + learner-redeemable at all, how often it may be redeemed, whether it is + inside its date window, and — for a program-child-purchase redemption — + whether this user still holds an unconsumed qualifying purchase for one + of the products in hand. Independent of check_validity_with_products (product scope and liveness); is_valid_for_basket composes the two. @@ -489,7 +510,18 @@ def is_redeemable_by(self, user: User, products: Iterable[Product] | None = None return self._within_redemption_limits(user) def _within_redemption_limits(self, user: User) -> bool: - """The redemption-count and date-window rules, without the source check.""" + """ + The redemption-type, redemption-count and date-window rules, without the + source check. + """ + # An internal discount is attached only by application code that has + # already decided eligibility (see REDEMPTION_TYPE_INTERNAL). The rule + # belongs at this depth rather than in is_redeemable_by because + # discount_product, and so quote_user_price, reaches the redemption + # rules through here. + if self.redemption_type == REDEMPTION_TYPE_INTERNAL: + return False + if ( self.redemption_type == REDEMPTION_TYPE_ONE_TIME and DiscountRedemption.objects.filter( diff --git a/ecommerce/models_test.py b/ecommerce/models_test.py index 9b1d012832..ba02747340 100644 --- a/ecommerce/models_test.py +++ b/ecommerce/models_test.py @@ -21,6 +21,7 @@ DISCOUNT_TYPE_FIXED_PRICE, DISCOUNT_TYPE_PAID_AMOUNT_OFF, DISCOUNT_TYPE_PERCENT_OFF, + REDEMPTION_TYPE_INTERNAL, REDEMPTION_TYPE_PROGRAM_CHILD_PURCHASE, REDEMPTION_TYPE_UNLIMITED, REFUND_WINDOW_DAYS, @@ -32,6 +33,7 @@ BasketItemFactory, DiscountFactory, DiscountRedemptionFactory, + InternalDiscountFactory, LineFactory, OneTimeDiscountFactory, OneTimePerUserDiscountFactory, @@ -1595,6 +1597,25 @@ def test_db_constraint_allows_program_child_purchase_redemption_with_standard_ty assert Discount.objects.filter(discount_code="reverse-pairing").exists() +def test_internal_discount_cannot_be_automatic(): + """ + Auto-apply selects discounts by flag rather than by a caller's decision, so + the DB backstop holds even for writes that skip model validation. + """ + with pytest.raises(IntegrityError), transaction.atomic(): + Discount.objects.bulk_create( + [ + Discount( + amount=100, + discount_code="internal-automatic", + discount_type=DISCOUNT_TYPE_PERCENT_OFF, + redemption_type=REDEMPTION_TYPE_INTERNAL, + automatic=True, + ) + ] + ) + + @pytest.mark.parametrize( "override", [ @@ -1611,6 +1632,15 @@ def test_paid_amount_off_discount_shape_is_enforced_on_save(override): PaidAmountOffDiscountFactory.create(**override) +def test_internal_discount_shape_is_enforced_on_save(): + """ + Saving an automatic internal discount raises instead of hitting the DB + constraint, which is what lets the admin and the staff API report it. + """ + with pytest.raises(ValidationError): + InternalDiscountFactory.create(automatic=True) + + def test_program_child_purchase_discount_only_links_program_products(): """ Enforced on the link row rather than only on Discount.save(), so every write @@ -1803,6 +1833,17 @@ def test_is_valid_for_basket_inherits_the_program_child_purchase_guard( assert paid_amount_off_source.discount.is_valid_for_basket(stranger_basket) is False +def test_internal_discount_is_not_redeemable_by_anyone(user): + """ + Only application code that has checked eligibility attaches one, so every + code-redemption route has to be refused even though the type has no + redemption limit of its own. + """ + discount = InternalDiscountFactory.create() + + assert discount.is_redeemable_by(user) is False + + def test_friendly_format_for_paid_amount_off(): """The label carries no amount — the true value is per-user.""" discount = PaidAmountOffDiscountFactory.create() diff --git a/ecommerce/serializers/__init__.py b/ecommerce/serializers/__init__.py index e547bbf74c..23ceaa564b 100644 --- a/ecommerce/serializers/__init__.py +++ b/ecommerce/serializers/__init__.py @@ -18,6 +18,7 @@ DISCOUNT_TYPE_DOLLARS_OFF, DISCOUNT_TYPE_PERCENT_OFF, PAYMENT_TYPES, + REDEMPTION_TYPE_INTERNAL, TRANSACTION_TYPE_REFUND, ) from ecommerce.models import ( @@ -25,7 +26,7 @@ BasketItem, Order, Product, - validate_program_child_purchase_shape, + validate_discount_shape, ) from flexiblepricing.api import determine_courseware_flexible_price_discount from main.settings import TIME_ZONE @@ -200,33 +201,47 @@ def discount_is_price_neutral(discount) -> bool: ) -class ProgramChildPurchaseShapeMixin: +class DiscountShapeMixin: """ - Runs the paid-amount-off / program-child-purchase shape rules over the - merged field values, so a PATCH that would make the stored row invalid is - a 400 rather than an unconverted ValidationError out of Model.save(). + Runs the Discount shape rules over the merged field values, so a PATCH that + would make the stored row invalid is a 400 rather than an unconverted + ValidationError out of Model.save(). Also enforces the one rule those + row-local checks cannot see: an internal discount may not be re-typed. Mix into any serializer that writes a Discount. """ def validate(self, attrs): - def _value(name, default=None): + def _merged(name, default=None): if name in attrs: return attrs[name] return getattr(self.instance, name, default) - validate_program_child_purchase_shape( - discount_type=_value("discount_type"), - redemption_type=_value("redemption_type"), - amount=_value("amount"), - automatic=_value("automatic", default=False), + # An internal discount's code is visible on receipts, so any other + # redemption type would make that code live. The model cannot carry + # this rule: by the time save() runs, the instance holds the new value + # and the stored one is gone. + if ( + self.instance is not None + and self.instance.redemption_type == REDEMPTION_TYPE_INTERNAL + and _merged("redemption_type") != REDEMPTION_TYPE_INTERNAL + ): + raise serializers.ValidationError( + {"redemption_type": "An internal discount cannot change type."} + ) + + validate_discount_shape( + discount_type=_merged("discount_type"), + redemption_type=_merged("redemption_type"), + amount=_merged("amount"), + automatic=_merged("automatic", default=False), discount=self.instance, ) return super().validate(attrs) -class DiscountSerializer(ProgramChildPurchaseShapeMixin, serializers.ModelSerializer): +class DiscountSerializer(DiscountShapeMixin, serializers.ModelSerializer): """Serializes a discount.""" class Meta: diff --git a/ecommerce/serializers/serializers_test.py b/ecommerce/serializers/serializers_test.py index 8476cb2e0d..521e58890b 100644 --- a/ecommerce/serializers/serializers_test.py +++ b/ecommerce/serializers/serializers_test.py @@ -584,7 +584,7 @@ def test_legacy_receipt_line_reports_the_recorded_price(): def test_discount_serializer_runs_the_program_child_purchase_shape_rules(): """ The rules themselves are covered against V0DiscountSerializer; this pins - that ProgramChildPurchaseShapeMixin is wired into this surface too. + that DiscountShapeMixin is wired into this surface too. """ serializer = DiscountSerializer( data={ diff --git a/ecommerce/serializers/v0/__init__.py b/ecommerce/serializers/v0/__init__.py index beb6ab707f..6f8851f8e5 100644 --- a/ecommerce/serializers/v0/__init__.py +++ b/ecommerce/serializers/v0/__init__.py @@ -32,7 +32,7 @@ RefundRequestStatus, ) from ecommerce.serializers import ( - ProgramChildPurchaseShapeMixin, + DiscountShapeMixin, discount_is_price_neutral, ) from flexiblepricing.api import determine_courseware_flexible_price_discount @@ -54,7 +54,7 @@ User = get_user_model() -class V0DiscountSerializer(ProgramChildPurchaseShapeMixin, serializers.ModelSerializer): +class V0DiscountSerializer(DiscountShapeMixin, serializers.ModelSerializer): """Serializes a discount.""" class Meta: diff --git a/ecommerce/serializers/v0/serializers_test.py b/ecommerce/serializers/v0/serializers_test.py index e68dc8f714..a74ea3cbec 100644 --- a/ecommerce/serializers/v0/serializers_test.py +++ b/ecommerce/serializers/v0/serializers_test.py @@ -244,7 +244,7 @@ def test_order_line_reports_no_free_audit_track(settings, mocker, user): def test_v0_discount_serializer_rejects_a_malformed_paid_amount_off_discount(): """ - The API mirror of Discount.check_program_child_purchase_validity returns a 400, + The API mirror of Discount.check_shape_validity returns a 400, not a 500. The individual shape clauses are pinned in models_test. """ data = { diff --git a/ecommerce/views/v0/views_test.py b/ecommerce/views/v0/views_test.py index 148db7967f..151f0a9a9a 100644 --- a/ecommerce/views/v0/views_test.py +++ b/ecommerce/views/v0/views_test.py @@ -36,6 +36,7 @@ DISCOUNT_TYPE_PERCENT_OFF, PAYMENT_TYPE_CUSTOMER_SUPPORT, PAYMENT_TYPE_FINANCIAL_ASSISTANCE, + REDEMPTION_TYPE_INTERNAL, REDEMPTION_TYPE_ONE_TIME, REDEMPTION_TYPE_PROGRAM_CHILD_PURCHASE, REDEMPTION_TYPE_UNLIMITED, @@ -47,6 +48,7 @@ BasketItemFactory, DiscountFactory, DiscountRedemptionFactory, + InternalDiscountFactory, LineFactory, OrderFactory, PaidAmountOffDiscountFactory, @@ -845,6 +847,22 @@ def test_redeem_discount( # noqa: PLR0913 assert resp_json["message"] == "Discount applied" +def test_redeem_internal_discount_is_not_found(user, user_drf_client, products): + """An internal discount's code is inert at the cart, even when it links to the product in the basket.""" + basket = create_basket(user, products) + discount = InternalDiscountFactory.create() + DiscountProduct.objects.create( + discount=discount, product=basket.basket_items.first().product + ) + + resp = user_drf_client.post( + reverse("checkout_api-redeem_discount"), {"discount": discount.discount_code} + ) + + assert resp.status_code == 404 + assert basket.discounts.count() == 0 + + # Discount tests @@ -925,6 +943,37 @@ def test_discount_rest_api(admin_drf_client, user_drf_client): assert Discount.objects.filter(pk=discount_payload["id"]).count() == 0 +def test_discount_rest_api_refuses_to_retype_an_internal_discount(admin_drf_client): + """Staff can edit discounts over the API, but re-typing an internal one would make its code live.""" + discount = InternalDiscountFactory.create() + + resp = admin_drf_client.patch( + reverse("v0:discounts_api-detail", kwargs={"pk": discount.id}), + {"redemption_type": REDEMPTION_TYPE_UNLIMITED}, + ) + + assert resp.status_code == 400 + discount.refresh_from_db() + assert discount.redemption_type == REDEMPTION_TYPE_INTERNAL + + +def test_discount_rest_api_refuses_an_automatic_internal_discount(admin_drf_client): + """The API mirror of the internal shape rule returns a 400, not a 500.""" + resp = admin_drf_client.post( + reverse("v0:discounts_api-list"), + { + "amount": 100, + "automatic": True, + "discount_type": DISCOUNT_TYPE_PERCENT_OFF, + "redemption_type": REDEMPTION_TYPE_INTERNAL, + "discount_code": "automatic-internal", + }, + ) + + assert resp.status_code == 400 + assert not Discount.objects.filter(discount_code="automatic-internal").exists() + + def test_attaching_a_non_program_product_to_a_program_child_purchase_discount_is_a_400( admin_drf_client, ): @@ -1252,15 +1301,17 @@ def test_bulk_discount_create_rejects_ambiguous_code_sources(admin_drf_client, e pytest.param( {"redemption_type": REDEMPTION_TYPE_PROGRAM_CHILD_PURCHASE}, id="redemption" ), + pytest.param({"redemption_type": REDEMPTION_TYPE_INTERNAL}, id="internal"), ], ) def test_bulk_discount_create_rejects_the_new_discount_and_redemption_types( admin_drf_client, override ): """ - A paid-amount-off discount needs the matching redemption type, and a + A paid-amount-off discount needs the matching redemption type, a program-child-purchase discount needs automatic plus the program product - links, so bulk generation refuses both rather than raising its way to a 500. + links, and an internal discount is never learner-redeemable, so bulk + generation refuses all three rather than raising its way to a 500. """ resp = admin_drf_client.post( reverse("v0:discounts_api-create_batch"), diff --git a/main/middleware.py b/main/middleware.py index 43879b2e45..f84429425d 100644 --- a/main/middleware.py +++ b/main/middleware.py @@ -6,7 +6,7 @@ from django.conf import settings from django.http import HttpResponseRedirect -from django.middleware.csrf import CsrfViewMiddleware +from django.middleware.csrf import CsrfViewMiddleware, get_token from django.utils.deprecation import MiddlewareMixin log = logging.getLogger(__name__) @@ -72,19 +72,46 @@ def process_request(self, request): class HostBasedCSRFMiddleware(CsrfViewMiddleware): """ - CSRF middleware that changes the response cookie's domain property - to match the request's host if it exists in settings.CSRF_TRUSTED_ORIGINS + CSRF middleware that scopes the response cookie's domain to the origin of + the page that made the request, when that origin is one of + settings.CSRF_TRUSTED_ORIGINS. + + A frontend on another host (learn.mit.edu calling api.learn.mit.edu) can + only read the cookie from document.cookie if its Domain covers the + frontend, and the request's Host header names the API, not the frontend. """ def process_response(self, request, response): + # Django issues the CSRF cookie only when a request flags it, and on + # API paths only auth.login() does, once per session. A logged-in + # browser that never stored that cookie (its Domain was not settable + # from this host) would otherwise stay without one for the whole + # session. Flag it again so this response re-issues it. Only a + # session-cookie login can be in that state; token clients (OAuth2 + # bearer) never hold a CSRF cookie and would be re-issued one on every + # response. + user = getattr(request, "user", None) + if ( + user is not None + and user.is_authenticated + and settings.SESSION_COOKIE_NAME in request.COOKIES + and settings.CSRF_COOKIE_NAME not in request.COOKIES + ): + get_token(request) response = super().process_response(request, response) - referrer = request.headers.get("referer", None) - if settings.CSRF_COOKIE_NAME in response.cookies and referrer: - parsed_referrer = urlparse(referrer) - host = parsed_referrer.netloc + # Browsers send Origin on every cross-origin request regardless of the + # page's referrer policy, so it is present exactly when the cookie has + # to be scoped to a host other than this one. A request without it is + # same-origin or not from a browser, and the cookie keeps + # CSRF_COOKIE_DOMAIN. Referer is deliberately not consulted: it is + # subject to the referrer policy and can be absent on the very requests + # that need the rewrite. + origin = request.headers.get("origin") + if settings.CSRF_COOKIE_NAME in response.cookies and origin: + host = urlparse(origin).netloc csrf_trusted_hosts = [] - for origin in getattr(settings, "CSRF_TRUSTED_ORIGINS", []): - parsed_origin = urlparse(origin) + for trusted_origin in getattr(settings, "CSRF_TRUSTED_ORIGINS", []): + parsed_origin = urlparse(trusted_origin) if parsed_origin.netloc: csrf_trusted_hosts.append(parsed_origin.netloc) if host in csrf_trusted_hosts: diff --git a/main/middleware_test.py b/main/middleware_test.py index a265297056..b960562349 100644 --- a/main/middleware_test.py +++ b/main/middleware_test.py @@ -24,10 +24,12 @@ ("http://mitxonline.mit.edu:8080", ""), ("http://sub.sub.sub.learn.mit.edu", "sub.sub.sub.learn.mit.edu"), ("http://localhost", ""), + # Opaque origin (sandboxed iframe, some redirects) has no host to trust + ("null", ""), ], ) def test_host_based_csrf_middleware(mocker, rf, settings, host, expected_domain): - """Tests that the CSRF cookie domain is set correctly based on the request host.""" + """Tests that the CSRF cookie domain is set from the request's Origin header.""" settings.CSRF_COOKIE_NAME = "csrf_mitxonline" settings.CSRF_TRUSTED_ORIGINS = [ "https://mitxonline.mit.edu", @@ -38,7 +40,7 @@ def test_host_based_csrf_middleware(mocker, rf, settings, host, expected_domain) ] request = rf.get("/some/path") - request.META["HTTP_REFERER"] = host + request.META["HTTP_ORIGIN"] = host get_response = mocker.MagicMock() middleware = HostBasedCSRFMiddleware(get_response) @@ -138,13 +140,12 @@ def test_anonymous_basket_handoff_skips_session_write_when_authenticated(mocker, assert "anonymous_basket_id" not in request.session -def test_host_based_csrf_middleware_no_referer(mocker, rf, settings): - """Test that middleware handles missing referer header gracefully.""" +def test_host_based_csrf_middleware_no_origin(mocker, rf, settings): + """Without Origin the cookie keeps its default domain; Referer alone does not scope it.""" settings.CSRF_COOKIE_NAME = "csrf_mitxonline" settings.CSRF_TRUSTED_ORIGINS = ["https://mitxonline.mit.edu"] - request = rf.get("/some/path") - # No HTTP_REFERER set + request = rf.get("/some/path", HTTP_REFERER="https://mitxonline.mit.edu/") get_response = mocker.MagicMock() middleware = HostBasedCSRFMiddleware(get_response) @@ -154,5 +155,41 @@ def test_host_based_csrf_middleware_no_referer(mocker, rf, settings): processed_response = middleware.process_response(request, response) - # Domain should not be modified (should remain empty) assert processed_response.cookies[settings.CSRF_COOKIE_NAME]["domain"] == "" + + +@pytest.mark.parametrize( + ("authenticated", "has_session_cookie", "has_csrf_cookie", "expect_set_cookie"), + [ + (True, True, False, True), + (True, True, True, False), + # Authenticated by bearer token, not a session: nothing to heal + (True, False, False, False), + (False, True, False, False), + ], +) +def test_host_based_csrf_middleware_reissues_missing_cookie( # noqa: PLR0913 + rf, + settings, + authenticated, + has_session_cookie, + has_csrf_cookie, + expect_set_cookie, +): + """A session-logged-in request without the CSRF cookie gets it re-issued, scoped by Origin.""" + settings.CSRF_COOKIE_NAME = "csrf_mitxonline" + settings.CSRF_TRUSTED_ORIGINS = ["https://learn.mit.edu"] + request = rf.get("/api/v0/users/me", HTTP_ORIGIN="https://learn.mit.edu") + request.user = UserFactory.create() if authenticated else AnonymousUser() + if has_session_cookie: + request.COOKIES[settings.SESSION_COOKIE_NAME] = "session" + if has_csrf_cookie: + request.COOKIES[settings.CSRF_COOKIE_NAME] = "existing" + middleware = HostBasedCSRFMiddleware(lambda _request: None) + processed_response = middleware.process_response(request, HttpResponse()) + cookie = processed_response.cookies.get(settings.CSRF_COOKIE_NAME) + if expect_set_cookie: + assert cookie is not None + assert cookie["domain"] == "learn.mit.edu" + else: + assert cookie is None diff --git a/main/settings.py b/main/settings.py index fae7b97d16..422d9176ff 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.166.5" +VERSION = "1.166.7" log = logging.getLogger() diff --git a/openapi/specs/v0.yaml b/openapi/specs/v0.yaml index d4bcb31d17..85766f5b6d 100644 --- a/openapi/specs/v0.yaml +++ b/openapi/specs/v0.yaml @@ -1737,15 +1737,19 @@ paths: schema: type: string enum: + - internal - one-time - one-time-per-user - program-child-purchase - unlimited description: |- + 'internal' discounts are attached by application code that has verified the learner's eligibility (e.g. verified program enrollment). Learners cannot redeem them and pricing does not re-check the product. + * `one-time` - one-time * `one-time-per-user` - one-time-per-user * `unlimited` - unlimited * `program-child-purchase` - program-child-purchase + * `internal` - internal tags: - discounts responses: @@ -7038,7 +7042,16 @@ components: discount_type: $ref: '#/components/schemas/DiscountTypeEnum' redemption_type: - $ref: '#/components/schemas/RedemptionTypeEnum' + allOf: + - $ref: '#/components/schemas/RedemptionTypeEnum' + description: |- + 'internal' discounts are attached by application code that has verified the learner's eligibility (e.g. verified program enrollment). Learners cannot redeem them and pricing does not re-check the product. + + * `one-time` - one-time + * `one-time-per-user` - one-time-per-user + * `unlimited` - unlimited + * `program-child-purchase` - program-child-purchase + * `internal` - internal max_redemptions: type: integer maximum: 2147483647 @@ -8058,7 +8071,16 @@ components: discount_type: $ref: '#/components/schemas/DiscountTypeEnum' redemption_type: - $ref: '#/components/schemas/RedemptionTypeEnum' + allOf: + - $ref: '#/components/schemas/RedemptionTypeEnum' + description: |- + 'internal' discounts are attached by application code that has verified the learner's eligibility (e.g. verified program enrollment). Learners cannot redeem them and pricing does not re-check the product. + + * `one-time` - one-time + * `one-time-per-user` - one-time-per-user + * `unlimited` - unlimited + * `program-child-purchase` - program-child-purchase + * `internal` - internal payment_type: nullable: true oneOf: @@ -8089,8 +8111,7 @@ components: is_program_discount: type: boolean nullable: true - description: Discount is only for creating verified course run enrollments - for a program. + description: Deprecated and unused; superseded by redemption_type 'internal'. b2b_sheet_location: type: string nullable: true @@ -9246,7 +9267,16 @@ components: discount_type: $ref: '#/components/schemas/DiscountTypeEnum' redemption_type: - $ref: '#/components/schemas/RedemptionTypeEnum' + allOf: + - $ref: '#/components/schemas/RedemptionTypeEnum' + description: |- + 'internal' discounts are attached by application code that has verified the learner's eligibility (e.g. verified program enrollment). Learners cannot redeem them and pricing does not re-check the product. + + * `one-time` - one-time + * `one-time-per-user` - one-time-per-user + * `unlimited` - unlimited + * `program-child-purchase` - program-child-purchase + * `internal` - internal max_redemptions: type: integer maximum: 2147483647 @@ -9855,17 +9885,20 @@ components: - one-time-per-user - unlimited - program-child-purchase + - internal type: string description: |- * `one-time` - one-time * `one-time-per-user` - one-time-per-user * `unlimited` - unlimited * `program-child-purchase` - program-child-purchase + * `internal` - internal x-enum-descriptions: - one-time - one-time-per-user - unlimited - program-child-purchase + - internal RefundReasonEnum: enum: - not_enough_time @@ -10671,7 +10704,16 @@ components: discount_type: $ref: '#/components/schemas/DiscountTypeEnum' redemption_type: - $ref: '#/components/schemas/RedemptionTypeEnum' + allOf: + - $ref: '#/components/schemas/RedemptionTypeEnum' + description: |- + 'internal' discounts are attached by application code that has verified the learner's eligibility (e.g. verified program enrollment). Learners cannot redeem them and pricing does not re-check the product. + + * `one-time` - one-time + * `one-time-per-user` - one-time-per-user + * `unlimited` - unlimited + * `program-child-purchase` - program-child-purchase + * `internal` - internal max_redemptions: type: integer maximum: 2147483647 @@ -10721,7 +10763,16 @@ components: discount_type: $ref: '#/components/schemas/DiscountTypeEnum' redemption_type: - $ref: '#/components/schemas/RedemptionTypeEnum' + allOf: + - $ref: '#/components/schemas/RedemptionTypeEnum' + description: |- + 'internal' discounts are attached by application code that has verified the learner's eligibility (e.g. verified program enrollment). Learners cannot redeem them and pricing does not re-check the product. + + * `one-time` - one-time + * `one-time-per-user` - one-time-per-user + * `unlimited` - unlimited + * `program-child-purchase` - program-child-purchase + * `internal` - internal max_redemptions: type: integer maximum: 2147483647 diff --git a/openapi/specs/v1.yaml b/openapi/specs/v1.yaml index 8854f49ff1..7321944f7b 100644 --- a/openapi/specs/v1.yaml +++ b/openapi/specs/v1.yaml @@ -1737,15 +1737,19 @@ paths: schema: type: string enum: + - internal - one-time - one-time-per-user - program-child-purchase - unlimited description: |- + 'internal' discounts are attached by application code that has verified the learner's eligibility (e.g. verified program enrollment). Learners cannot redeem them and pricing does not re-check the product. + * `one-time` - one-time * `one-time-per-user` - one-time-per-user * `unlimited` - unlimited * `program-child-purchase` - program-child-purchase + * `internal` - internal tags: - discounts responses: @@ -7038,7 +7042,16 @@ components: discount_type: $ref: '#/components/schemas/DiscountTypeEnum' redemption_type: - $ref: '#/components/schemas/RedemptionTypeEnum' + allOf: + - $ref: '#/components/schemas/RedemptionTypeEnum' + description: |- + 'internal' discounts are attached by application code that has verified the learner's eligibility (e.g. verified program enrollment). Learners cannot redeem them and pricing does not re-check the product. + + * `one-time` - one-time + * `one-time-per-user` - one-time-per-user + * `unlimited` - unlimited + * `program-child-purchase` - program-child-purchase + * `internal` - internal max_redemptions: type: integer maximum: 2147483647 @@ -8058,7 +8071,16 @@ components: discount_type: $ref: '#/components/schemas/DiscountTypeEnum' redemption_type: - $ref: '#/components/schemas/RedemptionTypeEnum' + allOf: + - $ref: '#/components/schemas/RedemptionTypeEnum' + description: |- + 'internal' discounts are attached by application code that has verified the learner's eligibility (e.g. verified program enrollment). Learners cannot redeem them and pricing does not re-check the product. + + * `one-time` - one-time + * `one-time-per-user` - one-time-per-user + * `unlimited` - unlimited + * `program-child-purchase` - program-child-purchase + * `internal` - internal payment_type: nullable: true oneOf: @@ -8089,8 +8111,7 @@ components: is_program_discount: type: boolean nullable: true - description: Discount is only for creating verified course run enrollments - for a program. + description: Deprecated and unused; superseded by redemption_type 'internal'. b2b_sheet_location: type: string nullable: true @@ -9246,7 +9267,16 @@ components: discount_type: $ref: '#/components/schemas/DiscountTypeEnum' redemption_type: - $ref: '#/components/schemas/RedemptionTypeEnum' + allOf: + - $ref: '#/components/schemas/RedemptionTypeEnum' + description: |- + 'internal' discounts are attached by application code that has verified the learner's eligibility (e.g. verified program enrollment). Learners cannot redeem them and pricing does not re-check the product. + + * `one-time` - one-time + * `one-time-per-user` - one-time-per-user + * `unlimited` - unlimited + * `program-child-purchase` - program-child-purchase + * `internal` - internal max_redemptions: type: integer maximum: 2147483647 @@ -9855,17 +9885,20 @@ components: - one-time-per-user - unlimited - program-child-purchase + - internal type: string description: |- * `one-time` - one-time * `one-time-per-user` - one-time-per-user * `unlimited` - unlimited * `program-child-purchase` - program-child-purchase + * `internal` - internal x-enum-descriptions: - one-time - one-time-per-user - unlimited - program-child-purchase + - internal RefundReasonEnum: enum: - not_enough_time @@ -10671,7 +10704,16 @@ components: discount_type: $ref: '#/components/schemas/DiscountTypeEnum' redemption_type: - $ref: '#/components/schemas/RedemptionTypeEnum' + allOf: + - $ref: '#/components/schemas/RedemptionTypeEnum' + description: |- + 'internal' discounts are attached by application code that has verified the learner's eligibility (e.g. verified program enrollment). Learners cannot redeem them and pricing does not re-check the product. + + * `one-time` - one-time + * `one-time-per-user` - one-time-per-user + * `unlimited` - unlimited + * `program-child-purchase` - program-child-purchase + * `internal` - internal max_redemptions: type: integer maximum: 2147483647 @@ -10721,7 +10763,16 @@ components: discount_type: $ref: '#/components/schemas/DiscountTypeEnum' redemption_type: - $ref: '#/components/schemas/RedemptionTypeEnum' + allOf: + - $ref: '#/components/schemas/RedemptionTypeEnum' + description: |- + 'internal' discounts are attached by application code that has verified the learner's eligibility (e.g. verified program enrollment). Learners cannot redeem them and pricing does not re-check the product. + + * `one-time` - one-time + * `one-time-per-user` - one-time-per-user + * `unlimited` - unlimited + * `program-child-purchase` - program-child-purchase + * `internal` - internal max_redemptions: type: integer maximum: 2147483647 diff --git a/openapi/specs/v2.yaml b/openapi/specs/v2.yaml index 9b96ac1699..a624a11cd1 100644 --- a/openapi/specs/v2.yaml +++ b/openapi/specs/v2.yaml @@ -1737,15 +1737,19 @@ paths: schema: type: string enum: + - internal - one-time - one-time-per-user - program-child-purchase - unlimited description: |- + 'internal' discounts are attached by application code that has verified the learner's eligibility (e.g. verified program enrollment). Learners cannot redeem them and pricing does not re-check the product. + * `one-time` - one-time * `one-time-per-user` - one-time-per-user * `unlimited` - unlimited * `program-child-purchase` - program-child-purchase + * `internal` - internal tags: - discounts responses: @@ -7038,7 +7042,16 @@ components: discount_type: $ref: '#/components/schemas/DiscountTypeEnum' redemption_type: - $ref: '#/components/schemas/RedemptionTypeEnum' + allOf: + - $ref: '#/components/schemas/RedemptionTypeEnum' + description: |- + 'internal' discounts are attached by application code that has verified the learner's eligibility (e.g. verified program enrollment). Learners cannot redeem them and pricing does not re-check the product. + + * `one-time` - one-time + * `one-time-per-user` - one-time-per-user + * `unlimited` - unlimited + * `program-child-purchase` - program-child-purchase + * `internal` - internal max_redemptions: type: integer maximum: 2147483647 @@ -8058,7 +8071,16 @@ components: discount_type: $ref: '#/components/schemas/DiscountTypeEnum' redemption_type: - $ref: '#/components/schemas/RedemptionTypeEnum' + allOf: + - $ref: '#/components/schemas/RedemptionTypeEnum' + description: |- + 'internal' discounts are attached by application code that has verified the learner's eligibility (e.g. verified program enrollment). Learners cannot redeem them and pricing does not re-check the product. + + * `one-time` - one-time + * `one-time-per-user` - one-time-per-user + * `unlimited` - unlimited + * `program-child-purchase` - program-child-purchase + * `internal` - internal payment_type: nullable: true oneOf: @@ -8089,8 +8111,7 @@ components: is_program_discount: type: boolean nullable: true - description: Discount is only for creating verified course run enrollments - for a program. + description: Deprecated and unused; superseded by redemption_type 'internal'. b2b_sheet_location: type: string nullable: true @@ -9246,7 +9267,16 @@ components: discount_type: $ref: '#/components/schemas/DiscountTypeEnum' redemption_type: - $ref: '#/components/schemas/RedemptionTypeEnum' + allOf: + - $ref: '#/components/schemas/RedemptionTypeEnum' + description: |- + 'internal' discounts are attached by application code that has verified the learner's eligibility (e.g. verified program enrollment). Learners cannot redeem them and pricing does not re-check the product. + + * `one-time` - one-time + * `one-time-per-user` - one-time-per-user + * `unlimited` - unlimited + * `program-child-purchase` - program-child-purchase + * `internal` - internal max_redemptions: type: integer maximum: 2147483647 @@ -9855,17 +9885,20 @@ components: - one-time-per-user - unlimited - program-child-purchase + - internal type: string description: |- * `one-time` - one-time * `one-time-per-user` - one-time-per-user * `unlimited` - unlimited * `program-child-purchase` - program-child-purchase + * `internal` - internal x-enum-descriptions: - one-time - one-time-per-user - unlimited - program-child-purchase + - internal RefundReasonEnum: enum: - not_enough_time @@ -10671,7 +10704,16 @@ components: discount_type: $ref: '#/components/schemas/DiscountTypeEnum' redemption_type: - $ref: '#/components/schemas/RedemptionTypeEnum' + allOf: + - $ref: '#/components/schemas/RedemptionTypeEnum' + description: |- + 'internal' discounts are attached by application code that has verified the learner's eligibility (e.g. verified program enrollment). Learners cannot redeem them and pricing does not re-check the product. + + * `one-time` - one-time + * `one-time-per-user` - one-time-per-user + * `unlimited` - unlimited + * `program-child-purchase` - program-child-purchase + * `internal` - internal max_redemptions: type: integer maximum: 2147483647 @@ -10721,7 +10763,16 @@ components: discount_type: $ref: '#/components/schemas/DiscountTypeEnum' redemption_type: - $ref: '#/components/schemas/RedemptionTypeEnum' + allOf: + - $ref: '#/components/schemas/RedemptionTypeEnum' + description: |- + 'internal' discounts are attached by application code that has verified the learner's eligibility (e.g. verified program enrollment). Learners cannot redeem them and pricing does not re-check the product. + + * `one-time` - one-time + * `one-time-per-user` - one-time-per-user + * `unlimited` - unlimited + * `program-child-purchase` - program-child-purchase + * `internal` - internal max_redemptions: type: integer maximum: 2147483647