diff --git a/CHANGELOG b/CHANGELOG index 9b74393..3af503c 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,10 @@ django-plans changelog ====================== +1.1.0 (unreleased) +------------------ +* Add `AbstractOrder.return_order()` + 1.0.6 ----- * Simplify the query generated by get_initial_number diff --git a/demo/example/wsgi.py b/demo/example/wsgi.py index bc477b0..c73c1b9 100644 --- a/demo/example/wsgi.py +++ b/demo/example/wsgi.py @@ -13,6 +13,7 @@ framework. """ + import os from django.core.wsgi import get_wsgi_application diff --git a/docs/source/index.rst b/docs/source/index.rst index 40d13a0..0dc6877 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -47,6 +47,7 @@ Contributors: * Victor Safronovich * Dominik Kozaczko * Petr DlouhĂ˝ +* BlenderKit Source code: https://github.com/cypreess/django-plans 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 a7295ba..f004eee 100644 --- a/plans/base/models.py +++ b/plans/base/models.py @@ -310,6 +310,13 @@ def get_plan_extended_until(self, plan, pricing): return self.expire return self.get_plan_extended_from(plan) + timedelta(days=pricing.period) + def get_plan_reduced_until(self, pricing): + if self.expire is None: + return self.expire + if pricing is None: + return self.expire + return self.expire - timedelta(days=pricing.period) + def plan_autorenew_at(self): """ Helper function which calculates when the plan autorenewal will occur @@ -430,6 +437,16 @@ def extend_account(self, plan, pricing): return status + def reduce_account(self, pricing): + """ + Manages reducing account after returning an order + :param pricing: if pricing is None then nothing is changed + :return: + """ + if pricing is not None: + self.expire = self.get_plan_reduced_until(pricing) + self.save() + def expire_account(self): """manages account expiration""" @@ -831,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 5584dd4..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 @@ -29,6 +30,7 @@ AbstractOrder, AbstractPlan, AbstractPlanPricing, + AbstractPricing, AbstractUserPlan, ) from plans.plan_change import PlanChangePolicy, StandardPlanChangePolicy @@ -44,6 +46,7 @@ Order = AbstractOrder.get_concrete_model() Plan = AbstractPlan.get_concrete_model() UserPlan = AbstractUserPlan.get_concrete_model() +Pricing = AbstractPricing.get_concrete_model() class PlansTestCase(TestCase): @@ -211,6 +214,37 @@ def test_extend_account_other_expire_future(self): self.assertEqual(u.userplan.active, False) self.assertEqual(len(mail.outbox), 0) + def test_reduce_account_future(self): + u = User.objects.get(username="test1") + u.userplan.expire = date.today() + timedelta(days=50) + u.save() + pricing = Pricing.objects.get(planpricing__plan=u.userplan.plan, period=30) + u.userplan.reduce_account(pricing) + self.assertEqual(u.userplan.expire, date.today() + timedelta(days=20)) + + def test_reduce_account_before(self): + u = User.objects.get(username="test1") + u.userplan.expire = date.today() - timedelta(days=50) + u.save() + pricing = Pricing.objects.get(planpricing__plan=u.userplan.plan, period=30) + u.userplan.reduce_account(pricing) + self.assertEqual(u.userplan.expire, date.today() - timedelta(days=80)) + + def test_reduce_account_expire_none(self): + u = User.objects.get(username="test1") + u.userplan.expire = None + u.save() + pricing = Pricing.objects.get(planpricing__plan=u.userplan.plan, period=30) + u.userplan.reduce_account(pricing) + self.assertIsNone(u.userplan.expire) + + def test_reduce_account_pricing_none(self): + u = User.objects.get(username="test1") + u.userplan.expire = date.today() + timedelta(days=50) + u.save() + u.userplan.reduce_account(None) + self.assertEqual(u.userplan.expire, date.today() + timedelta(days=50)) + def test_expire_account(self): u = User.objects.get(username="test1") u.userplan.expire = date.today() + timedelta(days=50) @@ -824,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)