Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add plan add-ons #1547

Open
wants to merge 5 commits into
base: development
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

90 changes: 56 additions & 34 deletions breathecode/payments/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@
from dateutil.relativedelta import relativedelta
from django.contrib.auth.models import User
from django.core.handlers.wsgi import WSGIRequest
from django.db.models import QuerySet, Sum
from django.db.models.query_utils import Q
from django.db.models import Q, QuerySet, Sum
from django.http import HttpRequest
from django.utils import timezone
from pytz import UTC
Expand All @@ -27,6 +26,7 @@

from .models import (
SERVICE_UNITS,
AcademyService,
Bag,
CohortSet,
Consumable,
Expand Down Expand Up @@ -411,7 +411,7 @@ def _get_service_items_that_not_found(self):
else:
kwargs["slug"] = service_item["service"]

if not Service.objects.filter(**kwargs):
if Service.objects.filter(**kwargs).count() == 0:
self.service_items_not_found.add(service_item["service"])

def _get_plans_that_not_found(self):
Expand All @@ -431,7 +431,7 @@ def _get_plans_that_not_found(self):
elif self.selected_cohort_set and isinstance(self.selected_cohort_set, str):
kwargs["cohort_set__slug"] = self.selected_cohort_set

if not Plan.objects.filter(**kwargs).exclude(**exclude):
if Plan.objects.filter(**kwargs).exclude(**exclude).count() == 0:
self.plans_not_found.add(plan)

def _report_items_not_found(self):
Expand All @@ -450,6 +450,27 @@ def _report_items_not_found(self):

def _add_service_items_to_bag(self):
if isinstance(self.service_items, list):
add_ons: dict[int, AcademyService] = {}

for plan in self.bag.plans.all():
for add_on in plan.add_ons.all():
add_ons[add_on.service.id] = add_on

for service_item in self.service_items:

if service_item["service"] not in add_ons:
self.bag.service_items.filter(service__id=service_item["service"]).delete()
raise ValidationException(
translation(
self.lang,
en=f"The service {service_item['service']} is not available for the selected plans",
es=f"El servicio {service_item['service']} no está disponible para los planes seleccionados",
),
slug="service-item-not-valid",
)

add_ons[service_item["service"]].validate_transaction(service_item["how_many"], lang=self.lang)

for service_item in self.service_items:
args, kwargs = self._lookups(service_item["service"])

Expand All @@ -476,18 +497,6 @@ def _validate_just_one_plan(self):

raise ValidationException(self._more_than_one_generator(en="plan", es="plan"), code=400)

def _validate_buy_plans_or_service_items(self):
if self.bag.plans.count() and self.bag.service_items.count():
raise ValidationException(
translation(
self.lang,
en="You can't select a plan and a services at the same time",
es="No puedes seleccionar un plan y servicios al mismo tiempo",
slug="one-plan-and-many-services",
),
code=400,
)

def _ask_to_add_plan_and_charge_it_in_the_bag(self):
for plan in self.bag.plans.all():
ask_to_add_plan_and_charge_it_in_the_bag(plan, self.bag.user, self.lang)
Expand All @@ -501,12 +510,10 @@ def execute(self):
self._get_service_items_that_not_found()
self._get_plans_that_not_found()
self._report_items_not_found()
self._add_service_items_to_bag()
self._add_plans_to_bag()
self._add_service_items_to_bag()
self._validate_just_one_plan()

self._validate_buy_plans_or_service_items()

self._ask_to_add_plan_and_charge_it_in_the_bag()

self.bag.save()
Expand All @@ -526,30 +533,49 @@ def get_amount(bag: Bag, currency: Currency, lang: str) -> tuple[float, float, f
if not currency:
currency, _ = Currency.objects.get_or_create(code="USD", name="United States dollar")

for service_item in bag.service_items.all():
if service_item.service.currency != currency:
bag.service_items.remove(service_item)
continue

price_per_month += service_item.service.price_per_unit * service_item.how_many
price_per_quarter += service_item.service.price_per_unit * service_item.how_many * 3
price_per_half += service_item.service.price_per_unit * service_item.how_many * 6
price_per_year += service_item.service.price_per_unit * service_item.how_many * 12

for plan in bag.plans.all():
if plan.currency != currency:
bag.plans.remove(plan)
continue

must_it_be_charged = ask_to_add_plan_and_charge_it_in_the_bag(plan, user, lang)

# this prices is just used if it are generating a subscription
if not bag.how_many_installments and (bag.chosen_period != "NO_SET" or must_it_be_charged):
price_per_month += plan.price_per_month or 0
price_per_quarter += plan.price_per_quarter or 0
price_per_half += plan.price_per_half or 0
price_per_year += plan.price_per_year or 0

plans = bag.plans.all()
add_ons: dict[int, AcademyService] = {}
for plan in plans:
for add_on in plan.add_ons.filter(currency=currency):
if add_on.service.id not in add_ons:
add_ons[add_on.service.id] = add_on

for service_item in bag.service_items.all():
if service_item.service.id in add_ons:
add_on = add_ons[service_item.service.id]

try:
add_on.validate_transaction(service_item.how_many, lang)
except Exception as e:
bag.service_items.filter().delete()
bag.plans.filter().delete()
raise e

if price_per_month != 0:
price_per_month += add_on.get_discounted_price(service_item.how_many) * 1

if price_per_quarter != 0:
price_per_quarter += add_on.get_discounted_price(service_item.how_many) * 3

if price_per_half != 0:
price_per_half += add_on.get_discounted_price(service_item.how_many) * 6

if price_per_year != 0:
price_per_year += add_on.get_discounted_price(service_item.how_many) * 12

return price_per_month, price_per_quarter, price_per_half, price_per_year


Expand Down Expand Up @@ -755,7 +781,6 @@ def get_balance_by_resource(
ids = {getattr(x, key).id for x in queryset}
for id in ids:
current = queryset.filter(**{f"{key}__id": id})
# current_virtual = [x for x in x if x[key] == id]

instance = current.first()
balance = {}
Expand All @@ -767,9 +792,6 @@ def get_balance_by_resource(
-1 if per_unit.filter(how_many=-1).exists() else per_unit.aggregate(Sum("how_many"))["how_many__sum"]
)

# for unit in current_virtual:
# ...

for x in queryset:
valid_until = x.valid_until
if valid_until:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Generated by Django 5.1.6 on 2025-02-11 23:42

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("payments", "0055_alter_serviceitemfeature_one_line_desc_and_more"),
]

operations = [
migrations.AddField(
model_name="academyservice",
name="available_cohort_sets",
field=models.ManyToManyField(
blank=True,
help_text="Available cohort sets to be sold in this service and plan",
to="payments.cohortset",
),
),
migrations.AddField(
model_name="plan",
name="add_ons",
field=models.ManyToManyField(
blank=True,
help_text="Service item bundles that can be purchased with this plan",
to="payments.academyservice",
),
),
]
59 changes: 53 additions & 6 deletions breathecode/payments/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -438,20 +438,23 @@ class EventTypeSetTranslation(models.Model):


class AcademyService(models.Model):
service = models.OneToOneField(Service, on_delete=models.CASCADE, help_text="Service")
academy = models.ForeignKey(Academy, on_delete=models.CASCADE, help_text="Academy")
_price: float | None = None

price_per_unit = models.FloatField(default=1, help_text="Price per unit (e.g. 1, 2, 3, ...)")
academy = models.ForeignKey(Academy, on_delete=models.CASCADE, help_text="Academy")
currency = models.ForeignKey(Currency, on_delete=models.CASCADE, help_text="Currency")
service = models.OneToOneField(Service, on_delete=models.CASCADE, help_text="Service")

price_per_unit = models.FloatField(default=1, help_text="Price per unit (e.g. 1, 2, 3, ...)")
bundle_size = models.FloatField(
default=1,
help_text="Minimum unit size allowed to be bought, example: bundle_size=5, then you are "
"allowed to buy a minimum of 5 units. Related to the discount ratio",
)

max_items = models.FloatField(
default=1, help_text="How many items can be bought in total, it doesn't matter the bundle size"
)

max_amount = models.FloatField(default=1, help_text="Limit total amount, it doesn't matter the bundle size")
discount_ratio = models.FloatField(default=1, help_text="Will be used when calculated by the final price")

Expand All @@ -465,13 +468,52 @@ class AcademyService(models.Model):
EventTypeSet, blank=True, help_text="Available mentorship service sets to be sold in this service and plan"
)

available_cohort_sets = models.ManyToManyField(
CohortSet, blank=True, help_text="Available cohort sets to be sold in this service and plan"
)

def __str__(self) -> str:
return f"{self.academy.slug} -> {self.service.slug}"

def get_discounted_price(self, num_items) -> float:
if num_items > self.max_items:
raise ValueError("num_items cannot be greater than max_items")
def validate_transaction(self, total_items: float, lang: Optional[str] = "en") -> None:
if total_items < self.bundle_size:
raise ValidationException(
translation(
lang,
en=f"The amount of items is too low (min {self.bundle_size})",
es=f"La cantidad de elementos es demasiado baja (min {self.bundle_size})",
slug="the-amount-of-items-is-too-low",
),
code=400,
)

if total_items > self.max_items:
raise ValidationException(
translation(
lang,
en=f"The amount of items is too high (max {self.max_items})",
es=f"La cantidad de elementos es demasiado alta (máx {self.max_items})",
slug="the-amount-of-items-is-too-high",
),
code=400,
)

amount = self._price if self._price is not None else self.get_discounted_price(total_items)

if amount > self.max_amount:
raise ValidationException(
translation(
lang,
en=f"The amount of items is too high (max {self.max_amount})",
es=f"La cantidad de elementos es demasiado alta (máx {self.max_amount})",
slug="the-amount-is-too-high",
),
code=400,
)

self._price = amount

def get_discounted_price(self, num_items: float) -> float:
total_discount_ratio = 0
current_discount_ratio = self.discount_ratio
discount_nerf = 0.1
Expand Down Expand Up @@ -520,6 +562,7 @@ def clean(self) -> None:

def save(self, *args, **kwargs) -> None:
self.full_clean()
self._price = None
return super().save(*args, **kwargs)


Expand Down Expand Up @@ -576,6 +619,10 @@ class Plan(AbstractPriceByTime):
ServiceItem, blank=True, through="PlanServiceItem", through_fields=("plan", "service_item")
)

add_ons = models.ManyToManyField(
AcademyService, blank=True, help_text="Service item bundles that can be purchased with this plan"
)

owner = models.ForeignKey(Academy, on_delete=models.CASCADE, blank=True, null=True, help_text="Academy owner")
is_onboarding = models.BooleanField(default=False, help_text="Is onboarding plan?", db_index=True)
has_waiting_list = models.BooleanField(default=False, help_text="Has waiting list?")
Expand Down
1 change: 1 addition & 0 deletions breathecode/payments/tests/urls/tests_academy_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ def post_serializer(currency, service=None, academy=None, service_items=[], fina

return {
"id": 0,
"add_ons": [],
"slug": "",
"currency": currency.id,
"financing_options": [x.id for x in financing_options],
Expand Down
1 change: 1 addition & 0 deletions breathecode/payments/tests/urls/tests_academy_plan_id.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ def put_serializer(
):

return {
"add_ons": [],
"id": event.id,
"slug": event.slug,
"currency": currency.id,
Expand Down
Loading
Loading