From 36b8a328725b363c4f299fc81c6deb6d4e0830d7 Mon Sep 17 00:00:00 2001 From: cp-at-mit Date: Mon, 4 Nov 2024 14:25:20 -0500 Subject: [PATCH] 5830 add basic discount models and apis (#164) * Create discount model * Add discount models and methods * Add API endpoint * Add some more helper methods * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * more updates * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add discount form to cart template * remove console log * Can add discount to basket * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix clear button * Remove unused discounts from basket * format * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Clean up migrations * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add some enhancements to UI, fix migration * ruff * Try fixing openapi * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix * fix * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * generate open API * Fix tests * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix more tests * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix tests * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Auto apply * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Remove prints * Code review comments * code review comments * Add api tests * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * tests * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add negative tests * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add more tests * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add more tests * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * ruff * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * lint * lint * Fix tests * Add comments * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * lint * lint * fix * update open API spec * prevent default * Fix exception --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- cart/templates/cart.html | 50 ++- openapi/specs/v0.yaml | 41 +++ payments/admin.py | 28 ++ payments/api.py | 26 ++ payments/api_test.py | 125 +++++++- payments/hooks/post_sale_test.py | 6 +- ...counted_price_redeemeddiscount_and_more.py | 177 ++++++++++ .../0007_populate_line_discount_price.py | 17 + ...remove_discount_assigned_users_and_more.py | 28 ++ payments/models.py | 273 +++++++++++++++- payments/models_test.py | 302 +++++++++++++++++- payments/serializers/v0/__init__.py | 2 +- payments/utils.py | 26 ++ payments/utils_test.py | 37 +++ payments/views/v0/__init__.py | 55 +++- payments/views/v0/urls.py | 6 + 16 files changed, 1175 insertions(+), 24 deletions(-) create mode 100644 payments/migrations/0006_discount_line_discounted_price_redeemeddiscount_and_more.py create mode 100644 payments/migrations/0007_populate_line_discount_price.py create mode 100644 payments/migrations/0008_remove_discount_assigned_users_and_more.py create mode 100644 payments/utils.py create mode 100644 payments/utils_test.py diff --git a/cart/templates/cart.html b/cart/templates/cart.html index 26be9365..1ec1cf8a 100644 --- a/cart/templates/cart.html +++ b/cart/templates/cart.html @@ -21,6 +21,7 @@ Product Price Quantity + Discount code Total @@ -35,7 +36,8 @@ {{ item.product }} {{ item.product.price }} {{ item.quantity }} - {{ item.product.price }} + {{ item.best_discount_for_item_from_basket.discount_code }} + {{ item.price }} {% endfor %} @@ -73,6 +75,31 @@ +
+

Add a discount code to the basket:

+ +
+ {% csrf_token %} + +
+ +
+
+ + + + + + + + {% for discount in basket.discounts.all %} + + + + {% endfor %} + +
Discount codes
{{ discount }}
+
{% endblock body %} diff --git a/openapi/specs/v0.yaml b/openapi/specs/v0.yaml index e69d5f50..1937579a 100644 --- a/openapi/specs/v0.yaml +++ b/openapi/specs/v0.yaml @@ -349,6 +349,26 @@ paths: schema: $ref: '#/components/schemas/BasketWithProduct' description: '' + /api/v0/payments/baskets/add_discount/{system_slug}/: + post: + operationId: payments_baskets_add_discount_create + description: Creates or updates a basket for the current user, adding the discount + if valid. + parameters: + - in: path + name: system_slug + schema: + type: string + required: true + tags: + - payments + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/BasketWithProduct' + description: '' /api/v0/payments/baskets/clear/{system_slug}/: delete: operationId: payments_baskets_clear_destroy @@ -449,9 +469,25 @@ components: id: type: integer readOnly: true + price: + type: number + format: double + description: Return the total price of the basket item with discounts. + readOnly: true + discounted_price: + type: number + format: double + description: |- + Get the price of the basket item with applicable discounts. + + Returns: + Decimal: The price of the basket item reduced by an applicable discount. + readOnly: true required: - basket + - discounted_price - id + - price - product BasketWithProduct: type: object @@ -561,8 +597,13 @@ components: type: integer integrated_system: type: integer + discounts: + type: array + items: + type: integer required: - created_on + - discounts - id - integrated_system - updated_on diff --git a/payments/admin.py b/payments/admin.py index 1f617523..9001681c 100644 --- a/payments/admin.py +++ b/payments/admin.py @@ -140,3 +140,31 @@ class RefundedOrderAdmin(BaseOrderAdmin): def get_queryset(self, request): """Filter only to refunded orders""" return super().get_queryset(request).filter(state=models.Order.STATE.REFUNDED) + + +@admin.register(models.Discount) +class DiscountAdmin(admin.ModelAdmin): + model = models.Discount + search_fields = ["discount_type", "redemption_type", "discount_code"] + list_display = [ + "id", + "discount_code", + "discount_type", + "amount", + "redemption_type", + "payment_type", + ] + list_filter = ["discount_type", "redemption_type", "payment_type"] + + +@admin.register(models.RedeemedDiscount) +class RedeemedDiscountAdmin(admin.ModelAdmin): + model = models.RedeemedDiscount + search_fields = ["discount", "order", "user"] + list_display = [ + "id", + "discount", + "order", + "user", + ] + list_filter = ["discount", "order", "user"] diff --git a/payments/api.py b/payments/api.py index 06efdafb..32c1d9b2 100644 --- a/payments/api.py +++ b/payments/api.py @@ -5,6 +5,7 @@ from django.conf import settings from django.core.exceptions import ObjectDoesNotExist, PermissionDenied, ValidationError from django.db import transaction +from django.db.models import Q, QuerySet from django.urls import reverse from ipware import get_client_ip from mitol.payment_gateway.api import CartItem as GatewayCartItem @@ -14,6 +15,7 @@ from payments.exceptions import PaymentGatewayError, PaypalRefundError from payments.models import ( Basket, + Discount, FulfilledOrder, Order, PendingOrder, @@ -436,3 +438,27 @@ def process_post_sale_webhooks(order_id, source): continue send_post_sale_webhook.delay(system.id, order.id, source) + + +def get_auto_apply_discounts_for_basket(basket_id: int) -> QuerySet[Discount]: + """ + Get the auto-apply discounts that can be applied to a basket. + + Args: + basket_id (int): The ID of the basket to get the auto-apply discounts for. + + Returns: + QuerySet: The auto-apply discounts that can be applied to the basket. + """ + basket = Basket.objects.get(pk=basket_id) + return ( + Discount.objects.filter( + Q(product__in=basket.get_products()) | Q(product__isnull=True) + ) + .filter( + Q(integrated_system=basket.integrated_system) + | Q(integrated_system__isnull=True) + ) + .filter(Q(assigned_users=basket.user) | Q(assigned_users__isnull=True)) + .filter(automatic=True) + ) diff --git a/payments/api_test.py b/payments/api_test.py index f8c0b72f..a0858cf3 100644 --- a/payments/api_test.py +++ b/payments/api_test.py @@ -15,6 +15,7 @@ from payments.api import ( check_and_process_pending_orders_for_resolution, generate_checkout_payload, + get_auto_apply_discounts_for_basket, process_cybersource_payment_response, process_post_sale_webhooks, refund_order, @@ -22,6 +23,8 @@ from payments.constants import PAYMENT_HOOK_ACTION_POST_SALE from payments.exceptions import PaymentGatewayError, PaypalRefundError from payments.factories import ( + BasketFactory, + BasketItemFactory, LineFactory, OrderFactory, TransactionFactory, @@ -29,6 +32,7 @@ from payments.models import ( Basket, BasketItem, + Discount, FulfilledOrder, Order, Transaction, @@ -37,6 +41,7 @@ from system_meta.factories import ProductFactory from system_meta.models import IntegratedSystem from unified_ecommerce.constants import ( + DISCOUNT_TYPE_DOLLARS_OFF, POST_SALE_SOURCE_BACKOFFICE, POST_SALE_SOURCE_REDIRECT, TRANSACTION_TYPE_PAYMENT, @@ -108,7 +113,11 @@ def fulfilled_complete_order(): product = ProductFactory.create() product_version = Version.objects.get_for_object(product).first() - LineFactory.create(order=order, product_version=product_version) + LineFactory.create( + order=order, + product_version=product_version, + discounted_price=product_version.field_dict["price"], + ) return order @@ -623,7 +632,11 @@ def test_integrated_system_webhook_multisystem( product = ProductFactory.create() product_version = Version.objects.get_for_object(product).first() - LineFactory.create(order=fulfilled_complete_order, product_version=product_version) + LineFactory.create( + order=fulfilled_complete_order, + product_version=product_version, + discounted_price=product_version.field_dict["price"], + ) mocked_request = mocker.patch("requests.post") @@ -655,3 +668,111 @@ def test_integrated_system_webhook_multisystem( assert mocked_request.call_count == 2 mocked_request.assert_has_calls(serialized_calls, any_order=True) + + +def test_get_auto_apply_discount_for_basket_auto_discount_exists_for_integrated_system(): + """ + Test that get_auto_apply_discount_for_basket returns the auto discount + when it exists for the basket's integrated system. + """ + basket = BasketFactory.create() + auto_discount = Discount.objects.create( + automatic=True, + amount=10, + discount_type=DISCOUNT_TYPE_DOLLARS_OFF, + integrated_system=basket.integrated_system, + ) + + discount = get_auto_apply_discounts_for_basket(basket.id) + assert discount[0] == auto_discount + + +def test_get_auto_apply_discount_for_basket_auto_discount_exists_for_product(): + """ + Test that get_auto_apply_discount_for_basket returns the auto discount + when it exists for the basket's - basket item - product. + """ + basket_item = BasketItemFactory.create() + auto_discount = Discount.objects.create( + automatic=True, + amount=10, + discount_type=DISCOUNT_TYPE_DOLLARS_OFF, + product=basket_item.product, + ) + + discount = get_auto_apply_discounts_for_basket(basket_item.basket.id) + assert discount[0] == auto_discount + + +def test_get_auto_apply_discount_for_basket_auto_discount_exists_for_user(): + """ + Test that get_auto_apply_discount_for_basket returns the auto discount + when it exists for the basket's user. + """ + basket = BasketFactory.create() + auto_discount = Discount.objects.create( + automatic=True, + amount=10, + discount_type=DISCOUNT_TYPE_DOLLARS_OFF, + ) + basket.user.discounts.add(auto_discount) + discount = get_auto_apply_discounts_for_basket(basket.id) + assert discount[0] == auto_discount + + +def test_get_auto_apply_discount_for_basket_multiple_auto_discount_exists_for_user_product_system(): + """ + Test that get_auto_apply_discount_for_basket returns multiple auto discount + when they exist for the basket's - basket item - product, basket's user, and basket's integrated system. + """ + basket_item = BasketItemFactory.create() + user_discount = Discount.objects.create( + automatic=True, + amount=10, + discount_type=DISCOUNT_TYPE_DOLLARS_OFF, + ) + basket_item.basket.user.discounts.add(user_discount) + Discount.objects.create( + automatic=True, + amount=10, + discount_type=DISCOUNT_TYPE_DOLLARS_OFF, + integrated_system=basket_item.basket.integrated_system, + ) + Discount.objects.create( + automatic=True, + amount=10, + discount_type=DISCOUNT_TYPE_DOLLARS_OFF, + product=basket_item.product, + ) + + discount = get_auto_apply_discounts_for_basket(basket_item.basket.id) + assert discount.count() == 3 + + +def test_get_auto_apply_discount_for_basket_no_auto_discount_exists(): + """ + Test that get_auto_apply_discount_for_basket returns the no discount + when no auto discount exists for the basket. + """ + basket_item = BasketItemFactory.create() + user_discount = Discount.objects.create( + automatic=False, + amount=10, + discount_type=DISCOUNT_TYPE_DOLLARS_OFF, + ) + basket_item.basket.user.discounts.add(user_discount) + Discount.objects.create( + automatic=False, + amount=10, + discount_type=DISCOUNT_TYPE_DOLLARS_OFF, + integrated_system=basket_item.basket.integrated_system, + ) + Discount.objects.create( + automatic=False, + amount=10, + discount_type=DISCOUNT_TYPE_DOLLARS_OFF, + product=basket_item.product, + ) + + discount = get_auto_apply_discounts_for_basket(basket_item.basket.id) + assert discount.count() == 0 diff --git a/payments/hooks/post_sale_test.py b/payments/hooks/post_sale_test.py index 9baa85e1..5fde671a 100644 --- a/payments/hooks/post_sale_test.py +++ b/payments/hooks/post_sale_test.py @@ -21,7 +21,11 @@ def pending_complete_order(): product = ProductFactory.create() product_version = Version.objects.get_for_object(product).first() - LineFactory.create(order=order, product_version=product_version) + LineFactory.create( + order=order, + product_version=product_version, + discounted_price=product_version.field_dict["price"], + ) return order diff --git a/payments/migrations/0006_discount_line_discounted_price_redeemeddiscount_and_more.py b/payments/migrations/0006_discount_line_discounted_price_redeemeddiscount_and_more.py new file mode 100644 index 00000000..88bc17f2 --- /dev/null +++ b/payments/migrations/0006_discount_line_discounted_price_redeemeddiscount_and_more.py @@ -0,0 +1,177 @@ +# Generated by Django 4.2.16 on 2024-10-29 13:30 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("system_meta", "0005_integratedsystem_payment_process_redirect_url"), + ("payments", "0005_basket_integrated_system_alter_basket_user"), + ] + + operations = [ + migrations.CreateModel( + name="Discount", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("updated_on", models.DateTimeField(auto_now=True)), + ("amount", models.DecimalField(decimal_places=2, max_digits=20)), + ("automatic", models.BooleanField(default=False)), + ( + "discount_type", + models.CharField( + choices=[ + ("percent-off", "percent-off"), + ("dollars-off", "dollars-off"), + ("fixed-price", "fixed-price"), + ], + max_length=30, + ), + ), + ( + "redemption_type", + models.CharField( + choices=[ + ("one-time", "one-time"), + ("one-time-per-user", "one-time-per-user"), + ("unlimited", "unlimited"), + ], + max_length=30, + ), + ), + ( + "payment_type", + models.CharField( + choices=[ + ("marketing", "marketing"), + ("sales", "sales"), + ("financial-assistance", "financial-assistance"), + ("customer-support", "customer-support"), + ("staff", "staff"), + ("legacy", "legacy"), + ], + max_length=30, + null=True, + ), + ), + ("max_redemptions", models.PositiveIntegerField(default=0, null=True)), + ("discount_code", models.CharField(max_length=100)), + ( + "activation_date", + models.DateTimeField( + blank=True, + help_text="If set, this discount code will not be redeemable before this date.", # noqa: E501 + null=True, + ), + ), + ( + "expiration_date", + models.DateTimeField( + blank=True, + help_text="If set, this discount code will not be redeemable after this date.", # noqa: E501 + null=True, + ), + ), + ("is_bulk", models.BooleanField(default=False)), + ( + "assigned_users", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="discounts", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "integrated_system", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="discounts", + to="system_meta.integratedsystem", + ), + ), + ( + "product", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="discounts", + to="system_meta.product", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.AddField( + model_name="line", + name="discounted_price", + field=models.DecimalField(decimal_places=2, default=0, max_digits=20), + preserve_default=False, + ), + migrations.CreateModel( + name="RedeemedDiscount", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("updated_on", models.DateTimeField(auto_now=True)), + ( + "discount", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="redeemed_discounts", + to="payments.discount", + ), + ), + ( + "order", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="redeemed_discounts", + to="payments.order", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="redeemed_discounts", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.AddField( + model_name="basket", + name="discounts", + field=models.ManyToManyField(related_name="basket", to="payments.discount"), + ), + ] diff --git a/payments/migrations/0007_populate_line_discount_price.py b/payments/migrations/0007_populate_line_discount_price.py new file mode 100644 index 00000000..c8d2a29c --- /dev/null +++ b/payments/migrations/0007_populate_line_discount_price.py @@ -0,0 +1,17 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("payments", "0006_discount_line_discounted_price_redeemeddiscount_and_more"), + ] + + def _populate_existing_line_discount_price(apps, scheme_editor): # noqa: ARG002, N805 + model = apps.get_model("payments", "line") + for line in model.objects.all(): + line.discounted_price = line.total_price + line.save() + + operations = [ + migrations.RunPython(_populate_existing_line_discount_price), + ] diff --git a/payments/migrations/0008_remove_discount_assigned_users_and_more.py b/payments/migrations/0008_remove_discount_assigned_users_and_more.py new file mode 100644 index 00000000..f5a64f67 --- /dev/null +++ b/payments/migrations/0008_remove_discount_assigned_users_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.16 on 2024-10-31 13:28 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("payments", "0007_populate_line_discount_price"), + ] + + operations = [ + migrations.RemoveField( + model_name="discount", + name="assigned_users", + ), + migrations.AddField( + model_name="discount", + name="assigned_users", + field=models.ManyToManyField( + blank=True, + null=True, + related_name="discounts", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/payments/models.py b/payments/models.py index 834365e4..66c901ea 100644 --- a/payments/models.py +++ b/payments/models.py @@ -4,8 +4,10 @@ import logging import re import uuid +from datetime import datetime from decimal import Decimal +import pytz from django.conf import settings from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError @@ -14,9 +16,13 @@ from mitol.common.models import TimestampedModel from reversion.models import Version +from payments.utils import product_price_with_discount from system_meta.models import IntegratedSystem, Product from unified_ecommerce.constants import ( + DISCOUNT_TYPES, + PAYMENT_TYPES, POST_SALE_SOURCE_REDIRECT, + REDEMPTION_TYPES, TRANSACTION_TYPE_PAYMENT, TRANSACTION_TYPE_REFUND, TRANSACTION_TYPES, @@ -28,6 +34,146 @@ pm = get_plugin_manager() +class Discount(TimestampedModel): + """Discount model""" + + amount = models.DecimalField( + decimal_places=2, + max_digits=20, + ) + 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) + 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) + activation_date = models.DateTimeField( + null=True, + blank=True, + help_text="If set, this discount code will not be redeemable before this date.", + ) + expiration_date = models.DateTimeField( + null=True, + blank=True, + help_text="If set, this discount code will not be redeemable after this date.", + ) + is_bulk = models.BooleanField(default=False) + integrated_system = models.ForeignKey( + IntegratedSystem, + on_delete=models.PROTECT, + related_name="discounts", + blank=True, + null=True, + ) + product = models.ForeignKey( + Product, + on_delete=models.PROTECT, + related_name="discounts", + blank=True, + null=True, + ) + assigned_users = models.ManyToManyField( + User, + related_name="discounts", + blank=True, + null=True, + ) + + def is_valid(self, basket) -> bool: + """ + Check if the discount is valid for the basket. + + Args: + basket (Basket): The basket to check the discount against. + Returns: + bool: True if the discount is valid for the basket, False otherwise. + + """ + + def _discount_product_in_basket() -> bool: + """ + Check if the discount is associated to the product in the basket. + + Returns: + bool: True if the discount is associated to the product in the basket, + or not associated with any product. + """ + return self.product is None or self.product in basket.get_products() + + def _discount_user_has_discount() -> bool: + """ + Check if the discount is associated with the basket's user. + + Returns: + bool: True if the discount is associated with the basket's user, + or not associated with any user. + """ + return self.assigned_users.count() == 0 or self.assigned_users.contains( + basket.user + ) + + def _discount_redemption_limit_valid() -> bool: + """ + Check if the discount has been redeemed less than the maximum number + of times. + + Returns: + bool: True if the discount has been redeemed less than the maximum + number of times, or the maximum number of redemptions is 0. + """ + return ( + self.max_redemptions == 0 + or self.redeemed_discounts.count() < self.max_redemptions + ) + + def _discount_activation_date_valid() -> bool: + """ + Check if the discount's activation date is in the past. + + Returns: + bool: True if the discount's activation date is in the past, or the + activation date is None. + """ + now = datetime.now(tz=pytz.timezone(settings.TIME_ZONE)) + return self.activation_date is None or now >= self.activation_date + + def _discount_expiration_date_valid() -> bool: + """ + Check if the discount's expiration date is in the future. + + Returns: + bool: True if the discount's expiration date is in the future, or the + expiration date is None. + """ + now = datetime.now(tz=pytz.timezone(settings.TIME_ZONE)) + return self.expiration_date is None or now <= self.expiration_date + + def _discount_integrated_system_found_in_basket_or_none() -> bool: + """ + Check if the discount's integrated system is the same as the basket's + integrated system. + Returns: + bool: True if the discount's integrated system is the same as the + basket's integrated system, or the discount's integrated system is None. + """ + return ( + self.integrated_system is None + or self.integrated_system == basket.integrated_system + ) + + return ( + _discount_product_in_basket() + and _discount_user_has_discount() + and _discount_redemption_limit_valid() + and _discount_activation_date_valid() + and _discount_expiration_date_valid() + and _discount_integrated_system_found_in_basket_or_none() + ) + + def __str__(self): + return f"{self.amount} {self.discount_type} {self.redemption_type} - {self.discount_code}" # noqa: E501 + + class Basket(TimestampedModel): """Represents a User's basket.""" @@ -35,6 +181,7 @@ class Basket(TimestampedModel): integrated_system = models.ForeignKey( IntegratedSystem, on_delete=models.CASCADE, related_name="basket" ) + discounts = models.ManyToManyField(Discount, related_name="basket") def compare_to_order(self, order): """ @@ -78,6 +225,17 @@ def establish_basket(request, integrated_system: IntegratedSystem): return basket + def apply_discount_to_basket(self, discount: Discount): + """ + Apply a discount to a basket. + + Args: + discount (Discount): The Discount to apply to the basket. + """ + if discount.is_valid(self): + self.discounts.add(discount) + self.save() + constraints = [ models.UniqueConstraint( fields=["user", "integrated_system"], @@ -98,19 +256,56 @@ class BasketItem(TimestampedModel): quantity = models.PositiveIntegerField(default=1) @cached_property - def discounted_price(self): + def discounted_price(self) -> Decimal: + """ + Get the price of the basket item with applicable discounts. + + Returns: + Decimal: The price of the basket item reduced by an applicable discount. + """ + # Check if discounts exist + # check if the discount is applicable to the product + # check if the discount is applicable to the the product's integrated system + # if discount doesn't have product or integrated system, apply it + price_with_best_discount = self.product.price + if self.best_discount_for_item_from_basket: + price_with_best_discount = product_price_with_discount( + self.best_discount_for_item_from_basket, self.product + ) + return round(price_with_best_discount, 2) + + @cached_property + def best_discount_for_item_from_basket(self) -> Discount: """ - Return the price of the product with discounts. + Get the best discount from the basket - TODO: we don't have discounts yet, so this needs to be filled out when we do. + Returns: + Discount: The best discount, associated with the basket, for the basket + item. """ - return self.base_price + best_discount = None + best_discount_price = self.product.price + for discount in self.basket.discounts.all(): + if (discount.product is None or discount.product == self.product) and ( + discount.integrated_system is None + or discount.integrated_system == self.basket.integrated_system + ): + discounted_price = product_price_with_discount(discount, self.product) + if best_discount is None or discounted_price < best_discount_price: + best_discount = discount + best_discount_price = discounted_price + return best_discount @cached_property def base_price(self): """Return the total price of the basket item without discounts.""" return self.product.price * self.quantity + @cached_property + def price(self) -> Decimal: + """Return the total price of the basket item with discounts.""" + return self.discounted_price * self.quantity + class Order(TimestampedModel): """An order containing information for a purchase.""" @@ -291,12 +486,16 @@ def send_ecommerce_order_receipt(self): TODO: add email """ + def delete_redeemed_discounts(self): + """Delete redeemed discounts""" + self.redeemed_discounts.all().delete() + class PendingOrder(Order): """An order that is pending payment""" @transaction.atomic - def _get_or_create(self, products: list[Product], user: User): + def _get_or_create(self, basket: Basket): """ Return a singleton PendingOrder for the given products and user. @@ -312,6 +511,7 @@ def _get_or_create(self, products: list[Product], user: User): PendingOrder: the retrieved or created PendingOrder. """ try: + products = basket.get_products() # Get the details from each Product. product_versions = [ Version.objects.get_for_object(product).first() for product in products @@ -322,7 +522,7 @@ def _get_or_create(self, products: list[Product], user: User): orders = Order.objects.select_for_update().filter( lines__product_version__in=product_versions, state=Order.STATE.PENDING, - purchaser=user, + purchaser=basket.user, ) # Previously, multiple PendingOrders could be created for a single user # for the same product, if multiple exist, grab the first. @@ -334,7 +534,7 @@ def _get_or_create(self, products: list[Product], user: User): else: order = Order.objects.create( state=Order.STATE.PENDING, - purchaser=user, + purchaser=basket.user, total_price_paid=0, ) @@ -343,14 +543,20 @@ def _get_or_create(self, products: list[Product], user: User): # Create or get Line for each product. # Calculate the Order total based on Lines and discount. total = 0 + used_discounts = [] for product_version in product_versions: + basket_item = basket.basket_items.get( + product=product_version.field_dict["id"] + ) line, created = order.lines.get_or_create( order=order, product_version=product_version, defaults={ "quantity": 1, + "discounted_price": basket_item.discounted_price, }, ) + used_discounts.append(basket_item.best_discount_for_item_from_basket) total += line.discounted_price log.debug( "%s line %s product %s", @@ -367,6 +573,11 @@ def _get_or_create(self, products: list[Product], user: User): order.save() + # delete unused discounts from basket + for discount in basket.discounts.all(): + if discount not in used_discounts: + basket.discounts.remove(discount) + return order @classmethod @@ -384,7 +595,16 @@ def create_from_basket(cls, basket: Basket): log.debug("Products to add to order: %s", products) - return cls._get_or_create(cls, products, basket.user) + order = cls._get_or_create(cls, basket) + + for discount in basket.discounts.all(): + RedeemedDiscount.objects.create( + discount=discount, + order=order, + user=basket.user, + ) + + return order @classmethod def create_from_product(cls, product: Product, user: User): @@ -477,6 +697,9 @@ class CanceledOrder(Order): The state of this can't be altered further. """ + def __init__(self): + self.delete_redeemed_discounts() + class Meta: """Model meta options.""" @@ -503,6 +726,9 @@ class DeclinedOrder(Order): The state of this can't be altered further. """ + def __init__(self): + self.delete_redeemed_discounts() + class Meta: """Model meta options.""" @@ -516,6 +742,9 @@ class ErroredOrder(Order): The state of this can't be altered further. """ + def __init__(self): + self.delete_redeemed_discounts() + class Meta: """Model meta options.""" @@ -553,6 +782,10 @@ def _order_line_product_versions(): on_delete=models.CASCADE, ) quantity = models.PositiveIntegerField() + discounted_price = models.DecimalField( + decimal_places=2, + max_digits=20, + ) class Meta: """Model meta options.""" @@ -579,11 +812,6 @@ def total_price(self) -> Decimal: """Return the price of the product""" return self.unit_price * self.quantity - @cached_property - def discounted_price(self) -> Decimal: - """Return the price of the product with discounts""" - return self.total_price - @cached_property def product(self) -> Product: """Return the product associated with the line""" @@ -618,3 +846,22 @@ class Transaction(TimestampedModel): max_length=20, ) reason = models.CharField(max_length=255, blank=True) + + +class RedeemedDiscount(TimestampedModel): + """Redeemed Discount model""" + + discount = models.ForeignKey( + Discount, on_delete=models.PROTECT, related_name="redeemed_discounts" + ) + order = models.ForeignKey( + Order, on_delete=models.PROTECT, related_name="redeemed_discounts" + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="redeemed_discounts", + ) + + def __str__(self): + return f"{self.discount} {self.user}" diff --git a/payments/models_test.py b/payments/models_test.py index 684a8351..8edcc36a 100644 --- a/payments/models_test.py +++ b/payments/models_test.py @@ -1,11 +1,26 @@ """Tests for payment models.""" +from datetime import datetime, timedelta + import pytest +import pytz import reversion from payments import models -from payments.factories import BasketFactory, BasketItemFactory, LineFactory -from system_meta.factories import ProductVersionFactory +from payments.factories import ( + BasketFactory, + BasketItemFactory, + LineFactory, + OrderFactory, +) +from system_meta.factories import ( + IntegratedSystemFactory, + ProductFactory, + ProductVersionFactory, +) +from unified_ecommerce import settings +from unified_ecommerce.constants import DISCOUNT_TYPE_DOLLARS_OFF +from unified_ecommerce.factories import UserFactory pytestmark = [pytest.mark.django_db] @@ -28,6 +43,74 @@ def test_basket_compare_to_order_match(): assert basket.compare_to_order(order) +def test_redeemed_discounts_created_when_creating_pending_order_from_basket(): + """ + Test that redeemed discounts are created when creating a pending order from a basket. + """ + + basket = BasketFactory.create() + with reversion.create_revision(): + BasketItemFactory.create_batch(2, basket=basket) + discount = models.Discount.objects.create( + amount=10, + product=basket.basket_items.first().product, + ) + basket.discounts.add(discount) + order = models.PendingOrder.create_from_basket(basket) + + assert models.RedeemedDiscount.objects.filter( + discount=discount, user=basket.user, order=order + ).exists() + + +def test_unused_discounts_do_not_create_redeemed_discounts_when_creating_pending_order_from_basket(): + """ + Test that redeemed discounts are not created when creating a pending order from a basket if the discount is not used. + """ + + basket = BasketFactory.create() + unused_product = ProductFactory.create() + with reversion.create_revision(): + BasketItemFactory.create_batch(2, basket=basket) + discount_used = models.Discount.objects.create( + amount=10, + product=basket.basket_items.first().product, + ) + discount_not_used = models.Discount.objects.create( + amount=10, + product=unused_product, + ) + basket.discounts.add(discount_used, discount_not_used) + models.PendingOrder.create_from_basket(basket) + + assert models.RedeemedDiscount.objects.filter(user=basket.user).count() == 1 + assert basket.discounts.count() == 1 + + +def test_only_best_discounts_create_redeemed_discounts_when_creating_pending_order_from_basket(): + """ + Test that only the best discounts result in RedeemedDiscounts when creating a pending order from a basket. + """ + + basket = BasketFactory.create() + with reversion.create_revision(): + BasketItemFactory.create_batch(2, basket=basket) + discount_used = models.Discount.objects.create( + amount=10, + discount_type=DISCOUNT_TYPE_DOLLARS_OFF, + product=basket.basket_items.first().product, + ) + discount_not_used = models.Discount.objects.create( + amount=5, + product=basket.basket_items.first().product, + discount_type=DISCOUNT_TYPE_DOLLARS_OFF, + ) + basket.discounts.add(discount_used, discount_not_used) + models.PendingOrder.create_from_basket(basket) + + assert models.RedeemedDiscount.objects.filter(user=basket.user).count() == 1 + + @pytest.mark.parametrize( ("add_or_del", "in_basket"), [ @@ -52,8 +135,11 @@ def test_basket_compare_to_order_line_mismatch(add_or_del, in_basket): if in_basket: if add_or_del: + product_version = ProductVersionFactory.create() LineFactory.create( - order=order, product_version=ProductVersionFactory.create() + order=order, + product_version=ProductVersionFactory.create(), + discounted_price=product_version.field_dict["price"], ) else: order.lines.first().delete() @@ -66,3 +152,213 @@ def test_basket_compare_to_order_line_mismatch(add_or_del, in_basket): order.refresh_from_db() assert not basket.compare_to_order(order) + + +@pytest.mark.parametrize("is_none", [True, False]) +def test_discount_with_product_value_is_valid_for_basket(is_none): + """Test that a discount is valid for a basket.""" + basket_item = BasketItemFactory.create() + + product = None if is_none else basket_item.product + discount = models.Discount.objects.create( + product=product, + amount=10, + ) + assert discount.is_valid(basket_item.basket) + + +@pytest.mark.parametrize("is_none", [True, False]) +def test_discount_with_user_value_is_valid_for_basket(is_none): + """Test that a discount is valid for a basket.""" + basket_item = BasketItemFactory.create() + + discount = models.Discount.objects.create( + amount=10, + ) + if not is_none: + basket_item.basket.user.discounts.add(discount) + assert discount.is_valid(basket_item.basket) + + +@pytest.mark.parametrize("is_none", [True, False]) +def test_discount_with_integrated_system_value_is_valid_for_basket(is_none): + """Test that a discount is valid for a basket.""" + basket_item = BasketItemFactory.create() + + integrated_system = None if is_none else basket_item.basket.integrated_system + discount = models.Discount.objects.create( + integrated_system=integrated_system, + amount=10, + ) + assert discount.is_valid(basket_item.basket) + + +@pytest.mark.parametrize("is_none", [True, False]) +def test_discount_with_max_redemptions_is_valid_for_basket(is_none): + """Test that a discount is valid for a basket.""" + basket_item = BasketItemFactory.create() + + discount = models.Discount.objects.create( + max_redemptions=2, + amount=10, + ) + if not is_none: + order = OrderFactory.create(purchaser=basket_item.basket.user) + models.RedeemedDiscount.objects.create( + discount=discount, + user=basket_item.basket.user, + order=order, + ) + assert discount.is_valid(basket_item.basket) + + +@pytest.mark.parametrize("is_none", [True, False]) +def test_discount_with_activation_date_in_past_is_valid_for_basket(is_none): + """Test that a discount is valid for a basket.""" + basket_item = BasketItemFactory.create() + activation_date = ( + None + if is_none + else datetime.now(tz=pytz.timezone(settings.TIME_ZONE)) - timedelta(days=100) + ) + discount = models.Discount.objects.create( + activation_date=activation_date, + amount=10, + ) + assert discount.is_valid(basket_item.basket) + + +@pytest.mark.parametrize("is_none", [True, False]) +def test_discount_with_expiration_date_in_future_is_valid_for_basket(is_none): + """Test that a discount is valid for a basket.""" + basket_item = BasketItemFactory.create() + expiration_date = ( + None + if is_none + else datetime.now(tz=pytz.timezone(settings.TIME_ZONE)) + timedelta(days=100) + ) + discount = models.Discount.objects.create( + expiration_date=expiration_date, + amount=10, + ) + assert discount.is_valid(basket_item.basket) + + +def test_discount_with_unmatched_product_value_is_not_valid_for_basket(): + """Test that a discount is not valid for a basket.""" + basket_item = BasketItemFactory.create() + + product = ProductFactory.create() + discount = models.Discount.objects.create( + product=product, + amount=10, + ) + assert not discount.is_valid(basket_item.basket) + + +def test_discount_with_unmatched_user_value_is_not_valid_for_basket(): + """Test that a discount is not valid for a basket.""" + basket_item = BasketItemFactory.create() + + discount = models.Discount.objects.create( + amount=10, + ) + user = UserFactory.create() + user.discounts.add(discount) + assert not discount.is_valid(basket_item.basket) + + +def test_discount_with_unmatched_integrated_system_value_is_not_valid_for_basket(): + """Test that a discount is not valid for a basket.""" + basket_item = BasketItemFactory.create() + integrated_system = IntegratedSystemFactory.create() + + discount = models.Discount.objects.create( + integrated_system=integrated_system, + amount=10, + ) + assert not discount.is_valid(basket_item.basket) + + +def test_discount_with_max_redemptions_is_not_valid_for_basket(): + """Test that a discount is not valid for a basket.""" + basket_item = BasketItemFactory.create() + + discount = models.Discount.objects.create( + max_redemptions=1, + amount=10, + ) + + order = OrderFactory.create(purchaser=basket_item.basket.user) + models.RedeemedDiscount.objects.create( + discount=discount, + user=basket_item.basket.user, + order=order, + ) + assert not discount.is_valid(basket_item.basket) + + +def test_discount_with_activation_date_in_future_is_not_valid_for_basket(): + """Test that a discount is not valid for a basket.""" + basket_item = BasketItemFactory.create() + activation_date = datetime.now(tz=pytz.timezone(settings.TIME_ZONE)) + timedelta( + days=100 + ) + discount = models.Discount.objects.create( + activation_date=activation_date, + amount=10, + ) + assert not discount.is_valid(basket_item.basket) + + +def test_discount_with_expiration_date_in_past_is_not_valid_for_basket(): + """Test that a discount is not valid for a basket.""" + basket_item = BasketItemFactory.create() + expiration_date = datetime.now(tz=pytz.timezone(settings.TIME_ZONE)) - timedelta( + days=100 + ) + discount = models.Discount.objects.create( + expiration_date=expiration_date, + amount=10, + ) + assert not discount.is_valid(basket_item.basket) + + +def test_discounted_price_for_multiple_discounts_for_product(): + """Test that the discounted price is calculated correctly.""" + basket_item = BasketItemFactory.create() + basket = BasketFactory.create() + basket.basket_items.add(basket_item) + discount_1 = models.Discount.objects.create( + amount=10, + product=basket_item.product, + discount_type=DISCOUNT_TYPE_DOLLARS_OFF, + ) + discount_2 = models.Discount.objects.create( + amount=5, + product=basket_item.product, + discount_type=DISCOUNT_TYPE_DOLLARS_OFF, + ) + basket.discounts.add(discount_1, discount_2) + + assert basket_item.discounted_price == (basket_item.base_price - discount_1.amount) + + +def test_discounted_price_for_multiple_discounts_for_integrated_system(): + """Test that the discounted price is calculated correctly.""" + basket_item = BasketItemFactory.create() + basket = BasketFactory.create() + basket.basket_items.add(basket_item) + discount_1 = models.Discount.objects.create( + amount=10, + integrated_system=basket.integrated_system, + discount_type=DISCOUNT_TYPE_DOLLARS_OFF, + ) + discount_2 = models.Discount.objects.create( + amount=5, + integrated_system=basket.integrated_system, + discount_type=DISCOUNT_TYPE_DOLLARS_OFF, + ) + basket.discounts.add(discount_1, discount_2) + + assert basket_item.discounted_price == (basket_item.base_price - discount_1.amount) diff --git a/payments/serializers/v0/__init__.py b/payments/serializers/v0/__init__.py index b11aa7ab..d03516ab 100644 --- a/payments/serializers/v0/__init__.py +++ b/payments/serializers/v0/__init__.py @@ -95,7 +95,7 @@ class Meta: """Meta options for BasketItemWithProductSerializer""" model = BasketItem - fields = ["basket", "product", "id"] + fields = ["basket", "product", "id", "price", "discounted_price"] depth = 1 diff --git a/payments/utils.py b/payments/utils.py new file mode 100644 index 00000000..bc0ba512 --- /dev/null +++ b/payments/utils.py @@ -0,0 +1,26 @@ +from system_meta.models import Product +from unified_ecommerce.constants import ( + DISCOUNT_TYPE_DOLLARS_OFF, + DISCOUNT_TYPE_FIXED_PRICE, + DISCOUNT_TYPE_PERCENT_OFF, +) + + +def product_price_with_discount(discount, product: Product) -> float: + """ + Return the price of the product with the discount applied + + Args: + discount (Discount): The discount to apply to the product + product (Product): The product to apply the discount to + Returns: + float: The price of the product with the discount applied, or the price of the + product if the discount type is not recognized. + """ + if discount.discount_type == DISCOUNT_TYPE_PERCENT_OFF: + return product.price * (1 - discount.amount / 100) + if discount.discount_type == DISCOUNT_TYPE_DOLLARS_OFF: + return product.price - discount.amount + if discount.discount_type == DISCOUNT_TYPE_FIXED_PRICE: + return discount.amount + return product.price diff --git a/payments/utils_test.py b/payments/utils_test.py new file mode 100644 index 00000000..b78b6a21 --- /dev/null +++ b/payments/utils_test.py @@ -0,0 +1,37 @@ +import pytest + +from payments import models, utils +from system_meta.factories import ProductFactory +from unified_ecommerce.constants import ( + DISCOUNT_TYPE_DOLLARS_OFF, + DISCOUNT_TYPE_FIXED_PRICE, + DISCOUNT_TYPE_PERCENT_OFF, +) + +pytestmark = [pytest.mark.django_db] + + +@pytest.mark.parametrize( + "discount_type", + [DISCOUNT_TYPE_PERCENT_OFF, DISCOUNT_TYPE_DOLLARS_OFF, DISCOUNT_TYPE_FIXED_PRICE], +) +def test_product_price_with_discount(discount_type): + """ + Test that the product price with discount is calculated correctly. + + Args: + discount_type (str): String representing the type of discount to apply to the product + """ + product = ProductFactory.create( + price=100, + ) + discount = models.Discount.objects.create( + amount=10, + discount_type=discount_type, + ) + if discount_type == DISCOUNT_TYPE_PERCENT_OFF: + assert utils.product_price_with_discount(discount, product) == 90 + if discount_type == DISCOUNT_TYPE_DOLLARS_OFF: + assert utils.product_price_with_discount(discount, product) == 90 + if discount_type == DISCOUNT_TYPE_FIXED_PRICE: + assert utils.product_price_with_discount(discount, product) == 10 diff --git a/payments/views/v0/__init__.py b/payments/views/v0/__init__.py index 27fe1b43..36749f21 100644 --- a/payments/views/v0/__init__.py +++ b/payments/views/v0/__init__.py @@ -28,7 +28,7 @@ ) from payments import api -from payments.models import Basket, BasketItem, Order +from payments.models import Basket, BasketItem, Discount, Order from payments.serializers.v0 import ( BasketWithProductSerializer, OrderHistorySerializer, @@ -120,6 +120,9 @@ def create_basket_from_product(request, system_slug: str, sku: str): (_, created) = BasketItem.objects.update_or_create( basket=basket, product=product, defaults={"quantity": quantity} ) + auto_apply_discount_discounts = api.get_auto_apply_discounts_for_basket(basket.id) + for discount in auto_apply_discount_discounts: + basket.apply_discount_to_basket(discount) basket.refresh_from_db() if checkout: @@ -385,3 +388,53 @@ def get_queryset(self): .order_by("-created_on") .all() ) + + +@extend_schema( + description=( + "Creates or updates a basket for the current user, " + "adding the discount if valid." + ), + methods=["POST"], + request=None, + responses=BasketWithProductSerializer, +) +@api_view(["POST"]) +@permission_classes((IsAuthenticated,)) +def add_discount_to_basket(request, system_slug: str): + """ + Add a discount to the basket for the currently logged in user. + + Args: + system_slug (str): system slug + + POST Args: + discount_code (str): discount code to apply to the basket + + Returns: + Response: HTTP response + """ + system = IntegratedSystem.objects.get(slug=system_slug) + basket = Basket.establish_basket(request, system) + discount_code = request.data.get("discount_code") + + try: + discount = Discount.objects.get(discount_code=discount_code) + except Discount.DoesNotExist: + return Response( + {"error": "Discount not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + + try: + basket.apply_discount_to_basket(discount) + except ValueError: + return Response( + {"error": "An error occurred while applying the discount."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + return Response( + BasketWithProductSerializer(basket).data, + status=status.HTTP_200_OK, + ) diff --git a/payments/views/v0/urls.py b/payments/views/v0/urls.py index acedcc84..17fea9a6 100644 --- a/payments/views/v0/urls.py +++ b/payments/views/v0/urls.py @@ -8,6 +8,7 @@ CheckoutApiViewSet, CheckoutCallbackView, OrderHistoryViewSet, + add_discount_to_basket, clear_basket, create_basket_from_product, ) @@ -32,6 +33,11 @@ clear_basket, name="clear_basket", ), + path( + "baskets/add_discount//", + add_discount_to_basket, + name="add_discount", + ), path( "checkout/callback/", BackofficeCallbackView.as_view(),