From bceb031fb3d4023293e7e4e81819a9f152c2554e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radek=20Hol=C3=BD?= Date: Tue, 19 Mar 2024 15:25:23 +0100 Subject: [PATCH] add AbstractOrder.return_order --- CHANGELOG | 2 +- plans/admin.py | 10 +- plans/base/models.py | 28 ++++++ plans/tests/tests.py | 234 ++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 270 insertions(+), 4 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 6506110..3af503c 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -3,7 +3,7 @@ django-plans changelog 1.1.0 (unreleased) ------------------ -* Add `AbstractUserPlan.reduce_account()` +* Add `AbstractOrder.return_order()` 1.0.6 ----- diff --git a/plans/admin.py b/plans/admin.py index 556f654..e19ab4a 100644 --- a/plans/admin.py +++ b/plans/admin.py @@ -151,6 +151,14 @@ def make_order_completed(modeladmin, request, queryset): make_order_completed.short_description = _("Make selected orders completed") +def make_order_returned(modeladmin, request, queryset): + for order in queryset: + order.return_order() + + +make_order_returned.short_description = _("Make selected orders returned") + + def make_order_invoice(modeladmin, request, queryset): for order in queryset: if ( @@ -192,7 +200,7 @@ class OrderAdmin(admin.ModelAdmin): ) readonly_fields = ("created", "updated_at") list_display_links = list_display - actions = [make_order_completed, make_order_invoice] + actions = [make_order_completed, make_order_returned, make_order_invoice] inlines = (InvoiceInline,) def queryset(self, request): diff --git a/plans/base/models.py b/plans/base/models.py index 658d443..f004eee 100644 --- a/plans/base/models.py +++ b/plans/base/models.py @@ -848,6 +848,34 @@ def complete_order(self): else: return False + def return_order(self): + if self.status != self.STATUS.RETURNED: + if self.status == self.STATUS.COMPLETED: + if self.pricing is not None: + extended_from = self.plan_extended_from + if extended_from is None: + extended_from = self.completed + # Should never happen, but make sure we reduce for the same number of days as we extended. + if ( + self.plan_extended_until is None + or extended_from is None + or self.plan_extended_until - extended_from + != timedelta(days=self.pricing.period) + ): + raise ValueError( + f"Invalid order state: completed={self.completed}, " + f"plan_extended_from={self.plan_extended_from}, " + f"plan_extended_until={self.plan_extended_until}, " + f"pricing.period={self.pricing.period}" + ) + self.user.userplan.reduce_account(self.pricing) + elif self.status != self.STATUS.NOT_VALID: + raise ValueError( + f"Cannot return order with status other than COMPLETED and NOT_VALID: {self.status}" + ) + self.status = self.STATUS.RETURNED + self.save() + def get_invoices_proforma(self): return AbstractInvoice.get_concrete_model().proforma.filter(order=self) diff --git a/plans/tests/tests.py b/plans/tests/tests.py index f87722b..1121229 100644 --- a/plans/tests/tests.py +++ b/plans/tests/tests.py @@ -1,5 +1,6 @@ import random -from datetime import date, timedelta +import re +from datetime import date, datetime, time, timedelta from decimal import Decimal from io import StringIO from unittest import mock @@ -14,7 +15,7 @@ from django.core.exceptions import ImproperlyConfigured, ValidationError from django.core.management import call_command from django.db import transaction -from django.db.models import Q +from django.db.models import Exists, OuterRef, Q from django.test import RequestFactory, TestCase, TransactionTestCase, override_settings from django.urls import reverse from django_concurrent_tests.helpers import call_concurrently @@ -857,6 +858,235 @@ def test_order_complete_order_completed(self): ) self.assertFalse(order.complete_order()) + def test_return_order_new(self): + u = User.objects.get(username="test1") + u.userplan.expire = date.today() + timedelta(days=50) + u.userplan.save() + plan_pricing = PlanPricing.objects.filter( + plan=u.userplan.plan, pricing__period__gt=0 + ).first() + order = Order.objects.create( + user=u, + pricing=plan_pricing.pricing, + amount=100, + plan=plan_pricing.plan, + status=Order.STATUS.NEW, + ) + + with self.assertRaisesRegex( + ValueError, + rf"^Cannot return order with status other than COMPLETED and NOT_VALID: " + rf"{re.escape(str(Order.STATUS.NEW))}$", + ): + order.return_order() + self.assertEqual(order.status, Order.STATUS.NEW) + self.assertEqual(u.userplan.plan, plan_pricing.plan) + self.assertEqual(u.userplan.expire, date.today() + timedelta(days=50)) + + def test_return_order_completed(self): + u = User.objects.get(username="test1") + u.userplan.plan = Plan.objects.filter(planpricing__isnull=True).first() + u.userplan.expire = None + u.userplan.save() + plan_pricing = PlanPricing.objects.filter(pricing__period__gt=0).first() + order = Order.objects.create( + user=u, + pricing=plan_pricing.pricing, + amount=100, + plan=plan_pricing.plan, + status=Order.STATUS.NEW, + ) + order.complete_order() + + order.return_order() + + self.assertEqual(order.status, Order.STATUS.RETURNED) + self.assertEqual(u.userplan.plan, plan_pricing.plan) + self.assertEqual(u.userplan.expire, date.today()) + + def test_return_order_completed_then_same_plan(self): + u = User.objects.get(username="test1") + u.userplan.plan = ( + Plan.objects.annotate( + planpricing_pricing_period_eq_30_exists=Exists( + PlanPricing.objects.filter(plan=OuterRef("pk"), pricing__period=30) + ), + planpricing_pricing_period_gt_30_exists=Exists( + PlanPricing.objects.filter( + plan=OuterRef("pk"), pricing__period__gt=30 + ) + ), + ) + .filter( + planpricing_pricing_period_eq_30_exists=True, + planpricing_pricing_period_gt_30_exists=True, + ) + .first() + ) + u.userplan.expire = date.today() + timedelta(days=50) + u.userplan.save() + plan_pricing = PlanPricing.objects.filter( + plan=u.userplan.plan, pricing__period__gt=30 + ).first() + order = Order.objects.create( + user=u, + pricing=plan_pricing.pricing, + amount=100, + plan=plan_pricing.plan, + status=Order.STATUS.NEW, + ) + order.complete_order() + plan_pricing_then = PlanPricing.objects.get( + plan=plan_pricing.plan, pricing__period=30 + ) + order_then = Order.objects.create( + user=u, + pricing=plan_pricing_then.pricing, + amount=100, + plan=plan_pricing_then.plan, + status=Order.STATUS.NEW, + ) + order_then.complete_order() + + order.return_order() + + self.assertEqual(order.status, Order.STATUS.RETURNED) + self.assertEqual(u.userplan.plan, plan_pricing_then.plan) + self.assertEqual(u.userplan.expire, date.today() + timedelta(days=50 + 30)) + + def test_return_order_completed_then_paid_plan(self): + u = User.objects.get(username="test1") + u.userplan.expire = date.today() + timedelta(days=50) + u.userplan.save() + plan_pricing = PlanPricing.objects.get(plan=u.userplan.plan, pricing__period=30) + order = Order.objects.create( + user=u, + pricing=plan_pricing.pricing, + amount=100, + plan=plan_pricing.plan, + status=Order.STATUS.NEW, + ) + order.complete_order() + plan_pricing_then = ( + PlanPricing.objects.exclude(plan=plan_pricing.plan) + .filter(pricing__period=365) + .first() + ) + order_then = Order.objects.create( + user=u, + pricing=plan_pricing_then.pricing, + amount=100, + plan=plan_pricing_then.plan, + status=Order.STATUS.NEW, + ) + with freeze_time(datetime.combine(u.userplan.expire, time())): + order_then.complete_order() + + order.return_order() + + self.assertEqual(order.status, Order.STATUS.RETURNED) + self.assertEqual(u.userplan.plan, plan_pricing_then.plan) + self.assertEqual(u.userplan.expire, date.today() + timedelta(days=50 + 365)) + + def test_return_order_completed_then_free_plan(self): + u = User.objects.get(username="test1") + u.userplan.expire = date.today() + timedelta(days=50) + u.userplan.save() + plan_pricing = PlanPricing.objects.get(plan=u.userplan.plan, pricing__period=30) + order = Order.objects.create( + user=u, + pricing=plan_pricing.pricing, + amount=100, + plan=plan_pricing.plan, + status=Order.STATUS.NEW, + ) + order.complete_order() + plan_then = ( + Plan.objects.exclude(pk=plan_pricing.plan.pk) + .filter(planpricing__isnull=True) + .first() + ) + u.userplan.extend_account(plan_then, None) + + order.return_order() + + self.assertEqual(order.status, Order.STATUS.RETURNED) + self.assertEqual(u.userplan.plan, plan_then) + self.assertIsNone(u.userplan.expire) + + def test_return_order_not_valid(self): + u = User.objects.get(username="test1") + u.userplan.expire = date.today() + timedelta(days=50) + u.userplan.save() + plan_user = u.userplan.plan + plan_pricing = ( + PlanPricing.objects.exclude(plan=u.userplan.plan) + .filter(pricing__period__gt=0) + .first() + ) + order = Order.objects.create( + user=u, + pricing=plan_pricing.pricing, + amount=100, + plan=plan_pricing.plan, + status=Order.STATUS.NEW, + ) + order.complete_order() + + order.return_order() + + self.assertEqual(order.status, Order.STATUS.RETURNED) + self.assertEqual(u.userplan.plan, plan_user) + self.assertEqual(u.userplan.expire, date.today() + timedelta(days=50)) + + def test_return_order_canceled(self): + u = User.objects.get(username="test1") + u.userplan.expire = date.today() + timedelta(days=50) + u.userplan.save() + plan_pricing = PlanPricing.objects.filter( + plan=u.userplan.plan, pricing__period__gt=0 + ).first() + order = Order.objects.create( + user=u, + pricing=plan_pricing.pricing, + amount=100, + plan=plan_pricing.plan, + status=Order.STATUS.CANCELED, + ) + + with self.assertRaisesRegex( + ValueError, + rf"^Cannot return order with status other than COMPLETED and NOT_VALID: " + rf"{re.escape(str(Order.STATUS.CANCELED))}$", + ): + order.return_order() + self.assertEqual(order.status, Order.STATUS.CANCELED) + self.assertEqual(u.userplan.plan, plan_pricing.plan) + self.assertEqual(u.userplan.expire, date.today() + timedelta(days=50)) + + def test_return_order_returned(self): + u = User.objects.get(username="test1") + u.userplan.expire = date.today() + timedelta(days=50) + u.userplan.save() + plan_pricing = PlanPricing.objects.filter( + plan=u.userplan.plan, pricing__period__gt=0 + ).first() + order = Order.objects.create( + user=u, + pricing=plan_pricing.pricing, + amount=100, + plan=plan_pricing.plan, + status=Order.STATUS.NEW, + ) + order.complete_order() + order.return_order() + + order.return_order() + + self.assertEqual(order.status, Order.STATUS.RETURNED) + self.assertEqual(u.userplan.plan, plan_pricing.plan) + self.assertEqual(u.userplan.expire, date.today() + timedelta(days=50)) + def test_amount_taxed_none(self): o = Order() o.amount = Decimal(123)