diff --git a/breathecode/payments/actions.py b/breathecode/payments/actions.py index e00175682..11e5a202a 100644 --- a/breathecode/payments/actions.py +++ b/breathecode/payments/actions.py @@ -3,20 +3,25 @@ from functools import lru_cache from typing import Optional, Type +from adrf.requests import AsyncRequest 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.http import HttpRequest from django.utils import timezone from pytz import UTC from rest_framework.request import Request -from breathecode.admissions.models import Cohort, CohortUser, Syllabus -from breathecode.authenticate.actions import get_user_settings +from breathecode.admissions.models import Academy, Cohort, CohortUser, Syllabus +from breathecode.authenticate.actions import get_user_language, get_user_settings from breathecode.authenticate.models import UserSetting +from breathecode.payments import tasks from breathecode.utils import getLogger from breathecode.utils.i18n import translation +from breathecode.utils.validate_conversion_info import validate_conversion_info +from capyc.core.shorteners import C from capyc.rest_framework.exceptions import ValidationException from .models import ( @@ -25,6 +30,7 @@ Consumable, Coupon, Currency, + Invoice, Plan, PlanFinancing, Service, @@ -859,3 +865,329 @@ def get_discounted_price(price: float, coupons: list[Coupon]) -> float: price = 0 return price + + +def add_invoice_externally_managed( + request: dict | WSGIRequest | AsyncRequest | HttpRequest, staff_user: User, academy_id: int +): + settings = get_user_settings(staff_user.id) + lang = settings.lang + + if isinstance(request, (WSGIRequest, AsyncRequest, HttpRequest)): + data = request.data + else: + data = request + + id = request.data.get("id") + type = request.data.get("type") + available_types = ["SUBSCRIPTION", "PLAN_FINANCING"] + + if type not in available_types: + raise ValidationException( + translation( + lang, + en="type must be one of: {types}".format(types=", ".join(available_types)), + es="type debe ser uno de: {types}".format(types=", ".join(available_types)), + slug="invalid-type", + ), + code=400, + ) + + resource = None + + if type == "SUBSCRIPTION": + resource = Subscription.objects.filter(id=id).first() + # bag, amount = get_bag_from_subs(subscription) + + elif type == "PLAN_FINANCING": + resource = PlanFinancing.objects.filter(id=id).first() + + if resource is None: + raise ValidationException( + translation( + lang, + en="Subscription not found", + es="Suscripción no encontrada", + slug="subscription-not-found", + ), + code=404, + ) + + academy = Academy.objects.filter(id=academy_id).first() + if academy is None: + raise ValidationException( + translation( + lang, + en="Academy not found", + es="Academia no encontrada", + slug="academy-not-found", + ), + code=404, + ) + + errors = [] + users_found = [] + + users = data.get("users", []) + for user_data in users: + args = [] + kwargs = {} + if isinstance(user_data, int): + kwargs["id"] = user_data + else: + args.append(Q(email=user_data) | Q(username=user_data)) + + user = User.objects.filter(*args, **kwargs).first() + if user is None: + errors.append( + C( + translation( + lang, + en=f"User not found: {user_data}", + es=f"Usuario no encontrado: {user_data}", + slug="user-not-found", + ), + code=404, + ) + ) + + users_found.append(user) + + if errors: + raise ValidationException(errors, code=400) + + for user in users_found: + if type == "SUBSCRIPTION": + handler = get_bag_from_subscription + + elif type == "PLAN_FINANCING": + handler = get_bag_from_plan_financing + + try: + bag = handler(resource, settings) + except Exception as e: + resource.status = "ERROR" + resource.status_message = str(e) + resource.save() + raise ValidationException( + translation( + lang, + en=f"Error getting bag from {type.lower().replace('_', ' ')} {resource.id}: {e}", + es=f"Error al obtener el paquete de {type.lower().replace('_', ' ')} {resource.id}: {e}", + slug="error-getting-bag", + ), + code=500, + ) + + if type == "SUBSCRIPTION": + amount = get_amount_by_chosen_period(bag, bag.chosen_period, settings.lang) + + elif type == "PLAN_FINANCING": + amount = resource.monthly_price + + utc_now = timezone.now() + + invoice = Invoice( + amount=amount, + paid_at=utc_now, + user=user, + bag=bag, + academy=bag.academy, + status="FULFILLED", + currency=bag.academy.main_currency, + externally_managed=True, + ) + invoice.save() + + +def validate_and_create_subscriptions( + request: dict | WSGIRequest | AsyncRequest | HttpRequest, staff_user: User, academy_id: int +): + if isinstance(request, (WSGIRequest, AsyncRequest, HttpRequest)): + data = request.data + lang = get_user_language(request) + + else: + data = request + settings = get_user_settings(staff_user.id) + lang = settings.lang + + how_many_installments = request.data.get("how_many_installments") + if how_many_installments is not None and ( + isinstance(how_many_installments, int) is False or how_many_installments < 1 + ): + raise ValidationException( + translation( + lang, + en="how_many_installments must be a positive integer", + es="how_many_installments debe ser un número entero positivo", + slug="invalid-how-many-installments", + ), + code=400, + ) + + chosen_period = data.get("chosen_period", "").upper() + chosen_periods = [x for x, y in Bag.ChosenPeriod.choices if x != "NO_SET"] + + if chosen_period and chosen_period not in chosen_periods: + raise ValidationException( + translation( + lang, + en="chosen_period must be one of: {periods}".format(periods=", ".join(chosen_periods)), + es="chosen_period debe ser uno de: {periods}".format(periods=", ".join(chosen_periods)), + slug="invalid-chosen-period", + ), + code=400, + ) + + if not chosen_period and not how_many_installments: + raise ValidationException( + translation( + lang, + en="Either chosen_period or how_many_installments must be provided", + es="Debe proporcionar chosen_period o how_many_installments", + slug="invalid-chosen-period-or-how-many-installments", + ), + code=400, + ) + + plans = data.get("plans", []) + plans = Plan.objects.filter(slug__in=plans) + if plans.count() != 1: + raise ValidationException( + translation( + lang, + en="Exactly one plan must be provided", + es="Debe proporcionar exactamente un plan", + slug="exactly-one-plan-must-be-provided", + ), + code=400, + ) + + if "coupons" in data and not isinstance(data["coupons"], list): + raise ValidationException( + translation( + lang, + en="Coupons must be a list of strings", + es="Cupones debe ser una lista de cadenas", + slug="invalid-coupons", + ), + code=400, + ) + + if "coupons" in data and len(data["coupons"]) > (max := max_coupons_allowed()): + raise ValidationException( + translation( + lang, + en=f"Too many coupons (max {max})", + es=f"Demasiados cupones (max {max})", + slug="too-many-coupons", + ), + code=400, + ) + + plan = plans[0] + coupons = get_available_coupons(plan, data.get("coupons", [])) + + if ( + how_many_installments + and (option := plan.financing_options.filter(how_many_months=how_many_installments).first()) is None + ): + raise ValidationException( + translation( + lang, + en=f"Financing option not found for {how_many_installments} installments", + es=f"Opción de financiamiento no encontrada para {how_many_installments} cuotas", + slug="financing-option-not-found", + ), + code=404, + ) + + conversion_info = data["conversion_info"] if "conversion_info" in data else None + validate_conversion_info(conversion_info, lang) + + academy = Academy.objects.filter(id=academy_id).first() + if academy is None: + raise ValidationException( + translation( + lang, + en="Academy not found", + es="Academia no encontrada", + slug="academy-not-found", + ), + code=404, + ) + + errors = [] + users_found = [] + + users = data.get("users", []) + for user_data in users: + args = [] + kwargs = {} + if isinstance(user_data, int): + kwargs["id"] = user_data + else: + args.append(Q(email=user_data) | Q(username=user_data)) + + user = User.objects.filter(*args, **kwargs).first() + if user is None: + errors.append( + C( + translation( + lang, + en=f"User not found: {user_data}", + es=f"Usuario no encontrado: {user_data}", + slug="user-not-found", + ), + code=404, + ) + ) + + users_found.append(user) + + if errors: + raise ValidationException(errors, code=400) + + for user in users_found: + bag = Bag() + bag.type = Bag.Type.BAG + bag.user = user + bag.currency = academy.main_currency + bag.status = Bag.Status.PAID + bag.academy = academy + bag.is_recurrent = True + + if chosen_period: + bag.chosen_period = chosen_period + + amount = get_amount_by_chosen_period(bag, chosen_period, lang) + amount = get_discounted_price(amount, coupons) + + if how_many_installments: + bag.how_many_installments = how_many_installments + amount = get_discounted_price(option.monthly_price, coupons) + bag.monthly_price = option.monthly_price + + bag.save() + bag.plans.set(plans) + + utc_now = timezone.now() + + invoice = Invoice( + amount=amount, + paid_at=utc_now, + user=user, + bag=bag, + academy=bag.academy, + status="FULFILLED", + currency=bag.academy.main_currency, + externally_managed=True, + ) + invoice.save() + + if bag.how_many_installments > 0: + tasks.build_plan_financing.delay(bag.id, invoice.id, conversion_info=conversion_info) + + else: + tasks.build_subscription.delay(bag.id, invoice.id, conversion_info=conversion_info) diff --git a/breathecode/payments/models.py b/breathecode/payments/models.py index 6af7233b1..4b89109f1 100644 --- a/breathecode/payments/models.py +++ b/breathecode/payments/models.py @@ -868,48 +868,36 @@ def limit_coupon_choices(): ) -RENEWAL = "RENEWAL" -CHECKING = "CHECKING" -PAID = "PAID" -BAG_STATUS = [ - (RENEWAL, "Renewal"), - (CHECKING, "Checking"), - (PAID, "Paid"), -] - -BAG = "BAG" -CHARGE = "CHARGE" -PREVIEW = "PREVIEW" -INVITED = "INVITED" -BAG_TYPE = [ - (BAG, "Bag"), - (CHARGE, "Charge"), - (PREVIEW, "Preview"), - (INVITED, "Invited"), -] +class Bag(AbstractAmountByTime): + """Represents a credit that can be used by a user to use a service.""" -NO_SET = "NO_SET" -QUARTER = "QUARTER" -HALF = "HALF" -YEAR = "YEAR" -CHOSEN_PERIOD = [ - (NO_SET, "No set"), - (MONTH, "Month"), - (QUARTER, "Quarter"), - (HALF, "Half"), - (YEAR, "Year"), -] + class Status(models.TextChoices): + RENEWAL = ("RENEWAL", "Renewal") + CHECKING = ("CHECKING", "Checking") + PAID = ("PAID", "Paid") + # UNMANAGED = ("UNMANAGED", "Unmanaged") + class Type(models.TextChoices): + BAG = ("BAG", "Bag") + CHARGE = ("CHARGE", "Charge") + PREVIEW = ("PREVIEW", "Preview") + INVITED = ("INVITED", "Invited") -class Bag(AbstractAmountByTime): - """Represents a credit that can be used by a user to use a service.""" + class ChosenPeriod(models.TextChoices): + NO_SET = ("NO_SET", "No set") + MONTH = ("MONTH", "Month") + QUARTER = ("QUARTER", "Quarter") + HALF = ("HALF", "Half") + YEAR = ("YEAR", "Year") - status = models.CharField(max_length=8, choices=BAG_STATUS, default=CHECKING, help_text="Bag status", db_index=True) - type = models.CharField(max_length=7, choices=BAG_TYPE, default=BAG, help_text="Bag type") + status = models.CharField( + max_length=8, choices=Status, default=Status.CHECKING, help_text="Bag status", db_index=True + ) + type = models.CharField(max_length=7, choices=Type, default=Type.BAG, help_text="Bag type") chosen_period = models.CharField( max_length=7, - choices=CHOSEN_PERIOD, - default=NO_SET, + choices=ChosenPeriod, + default=ChosenPeriod.NO_SET, help_text="Chosen period used to calculate the amount and build the subscription", ) how_many_installments = models.IntegerField( @@ -984,12 +972,15 @@ class Invoice(models.Model): max_length=17, choices=INVOICE_STATUS, default=PENDING, db_index=True, help_text="Invoice status" ) - bag = models.ForeignKey("Bag", on_delete=models.CASCADE, help_text="Bag") + bag = models.ForeignKey("Bag", on_delete=models.CASCADE, help_text="Bag", related_name="invoices") + externally_managed = models.BooleanField( + default=False, help_text="If the billing is managed externally outside of the system" + ) - # actually return 27 characters + # it has 27 characters right now stripe_id = models.CharField(max_length=32, null=True, default=None, blank=True, help_text="Stripe id") - # actually return 27 characters + # it has 27 characters right now refund_stripe_id = models.CharField( max_length=32, null=True, default=None, blank=True, help_text="Stripe id for refunding" ) @@ -1044,6 +1035,10 @@ class AbstractIOweYou(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE, help_text="Customer") academy = models.ForeignKey(Academy, on_delete=models.CASCADE, help_text="Academy owner") + externally_managed = models.BooleanField( + default=False, help_text="If the billing is managed externally outside of the system" + ) + selected_cohort_set = models.ForeignKey( CohortSet, on_delete=models.CASCADE, diff --git a/breathecode/payments/tasks.py b/breathecode/payments/tasks.py index 44d90f159..bb913a19c 100644 --- a/breathecode/payments/tasks.py +++ b/breathecode/payments/tasks.py @@ -264,6 +264,30 @@ def charge_subscription(self, subscription_id: int, **_: Any): logger.info(f"Starting charge_subscription for subscription {subscription_id}") + def alert_payment_issue(message: str, button: str) -> None: + subject = translation( + settings.lang, + en="Your 4Geeks subscription could not be renewed", + es="Tu suscripción 4Geeks no pudo ser renovada", + ) + + notify_actions.send_email_message( + "message", + subscription.user.email, + { + "SUBJECT": subject, + "MESSAGE": message, + "BUTTON": button, + "LINK": f"{get_app_url()}/subscription/{subscription.id}", + }, + academy=subscription.academy, + ) + + bag.delete() + + subscription.status = "PAYMENT_ISSUE" + subscription.save() + client = None if IS_DJANGO_REDIS: client = get_redis_connection("default") @@ -283,53 +307,60 @@ def charge_subscription(self, subscription_id: int, **_: Any): settings = get_user_settings(subscription.user.id) - try: - bag = actions.get_bag_from_subscription(subscription, settings) - except Exception as e: - subscription.status = "ERROR" - subscription.status_message = str(e) - subscription.save() - raise AbortTask(f"Error getting bag from subscription {subscription_id}: {e}") + if subscription.externally_managed: + invoice = ( + subscription.invoices.filter(paid_at__lte=utc_now, bag__was_delivered=False) + .order_by("-paid_at") + .first() + ) - amount = actions.get_amount_by_chosen_period(bag, bag.chosen_period, settings.lang) + if invoice is None: + message = translation( + settings.lang, + en="Please make your payment in your academy", + es="Por favor realiza tu pago en tu academia", + ) - try: - s = Stripe() - s.set_language_from_settings(settings) - invoice = s.pay(subscription.user, bag, amount, currency=bag.currency) + button = translation( + settings.lang, + en="Please make your payment in your academy", + es="Por favor realiza tu pago en tu academia", + ) + alert_payment_issue(message, button) + return - except Exception: - subject = translation( - settings.lang, - en="Your 4Geeks subscription could not be renewed", - es="Tu suscripción 4Geeks no pudo ser renovada", - ) + bag = invoice.bag - message = translation( - settings.lang, en="Please update your payment methods", es="Por favor actualiza tus métodos de pago" - ) + else: + try: + bag = actions.get_bag_from_subscription(subscription, settings) + except Exception as e: + subscription.status = "ERROR" + subscription.status_message = str(e) + subscription.save() + raise AbortTask(f"Error getting bag from subscription {subscription_id}: {e}") - button = translation( - settings.lang, en="Please update your payment methods", es="Por favor actualiza tus métodos de pago" - ) + amount = actions.get_amount_by_chosen_period(bag, bag.chosen_period, settings.lang) - notify_actions.send_email_message( - "message", - subscription.user.email, - { - "SUBJECT": subject, - "MESSAGE": message, - "BUTTON": button, - "LINK": f"{get_app_url()}/subscription/{subscription.id}", - }, - academy=subscription.academy, - ) + try: + s = Stripe() + s.set_language_from_settings(settings) + invoice = s.pay(subscription.user, bag, amount, currency=bag.currency) - bag.delete() + except Exception: + message = translation( + settings.lang, + en="Please update your payment methods", + es="Por favor actualiza tus métodos de pago", + ) - subscription.status = "PAYMENT_ISSUE" - subscription.save() - return + button = translation( + settings.lang, + en="Please update your payment methods", + es="Por favor actualiza tus métodos de pago", + ) + alert_payment_issue(message, button) + return subscription.paid_at = utc_now delta = actions.calculate_relative_delta(subscription.pay_every, subscription.pay_every_unit) @@ -367,6 +398,9 @@ def charge_subscription(self, subscription_id: int, **_: Any): academy=subscription.academy, ) + bag.was_delivered = True + bag.save() + renew_subscription_consumables.delay(subscription.id) except LockError: @@ -406,6 +440,30 @@ def charge_plan_financing(self, plan_financing_id: int, **_: Any): logger.info(f"Starting charge_plan_financing for id {plan_financing_id}") + def alert_payment_issue(message: str, button: str) -> None: + subject = translation( + settings.lang, + en="Your 4Geeks subscription could not be renewed", + es="Tu suscripción 4Geeks no pudo ser renovada", + ) + + notify_actions.send_email_message( + "message", + plan_financing.user.email, + { + "SUBJECT": subject, + "MESSAGE": message, + "BUTTON": button, + "LINK": f"{get_app_url()}/subscription/{plan_financing.id}", + }, + academy=plan_financing.academy, + ) + + bag.delete() + + plan_financing.status = "PAYMENT_ISSUE" + plan_financing.save() + client = None if IS_DJANGO_REDIS: client = get_redis_connection("default") @@ -426,15 +484,6 @@ def charge_plan_financing(self, plan_financing_id: int, **_: Any): settings = get_user_settings(plan_financing.user.id) - try: - bag = actions.get_bag_from_plan_financing(plan_financing, settings) - except Exception as e: - plan_financing.status = "ERROR" - plan_financing.status_message = str(e) - plan_financing.save() - - raise AbortTask(f"Error getting bag from plan financing {plan_financing_id}: {e}") - amount = plan_financing.monthly_price invoices = plan_financing.invoices.order_by("created_at") @@ -449,48 +498,60 @@ def charge_plan_financing(self, plan_financing_id: int, **_: Any): remaining_installments = installments - invoices.count() if remaining_installments > 0: - try: - s = Stripe() - s.set_language_from_settings(settings) - - invoice = s.pay(plan_financing.user, bag, amount, currency=bag.currency) - - except Exception: - subject = translation( - settings.lang, - en="Your 4Geeks subscription could not be renewed", - es="Tu suscripción 4Geeks no pudo ser renovada", + if plan_financing.externally_managed: + invoice = ( + plan_financing.invoices.filter(paid_at__lte=utc_now, bag__was_delivered=False) + .order_by("-paid_at") + .first() ) - message = translation( - settings.lang, - en="Please update your payment methods", - es="Por favor actualiza tus métodos de pago", - ) - - button = translation( - settings.lang, - en="Please update your payment methods", - es="Por favor actualiza tus métodos de pago", - ) - - notify_actions.send_email_message( - "message", - plan_financing.user.email, - { - "SUBJECT": subject, - "MESSAGE": message, - "BUTTON": button, - "LINK": f"{get_app_url()}/plan-financing/{plan_financing.id}", - }, - academy=plan_financing.academy, - ) - - bag.delete() - - plan_financing.status = "PAYMENT_ISSUE" - plan_financing.save() - return + if invoice is None: + message = translation( + settings.lang, + en="Please make your payment in your academy", + es="Por favor realiza tu pago en tu academia", + ) + + button = translation( + settings.lang, + en="Please make your payment in your academy", + es="Por favor realiza tu pago en tu academia", + ) + alert_payment_issue(message, button) + return + + bag = invoice.bag + + else: + try: + bag = actions.get_bag_from_plan_financing(plan_financing, settings) + except Exception as e: + plan_financing.status = "ERROR" + plan_financing.status_message = str(e) + plan_financing.save() + + raise AbortTask(f"Error getting bag from plan financing {plan_financing_id}: {e}") + + try: + s = Stripe() + s.set_language_from_settings(settings) + + invoice = s.pay(plan_financing.user, bag, amount, currency=bag.currency) + + except Exception: + message = translation( + settings.lang, + en="Please update your payment methods", + es="Por favor actualiza tus métodos de pago", + ) + + button = translation( + settings.lang, + en="Please update your payment methods", + es="Por favor actualiza tus métodos de pago", + ) + alert_payment_issue(message, button) + return if utc_now > plan_financing.valid_until: remaining_installments -= 1 @@ -530,6 +591,9 @@ def charge_plan_financing(self, plan_financing_id: int, **_: Any): plan_financing.next_payment_at += delta plan_financing.save() + bag.was_delivered = True + bag.save() + renew_plan_financing_consumables.delay(plan_financing.id) except LockError: @@ -776,8 +840,6 @@ def build_plan_financing( event_type_set = None mentorship_service_set = None - print("conversion_info") - print(conversion_info) parsed_conversion_info = ast.literal_eval(conversion_info) if conversion_info != "" else None financing = PlanFinancing.objects.create( user=bag.user, diff --git a/breathecode/payments/urls.py b/breathecode/payments/urls.py index e6c666814..9e136c339 100644 --- a/breathecode/payments/urls.py +++ b/breathecode/payments/urls.py @@ -4,6 +4,7 @@ AcademyAcademyServiceView, AcademyCohortSetCohortView, AcademyInvoiceView, + AcademyPlanSubscriptionView, AcademyPlanView, AcademyServiceView, AcademySubscriptionView, @@ -22,12 +23,12 @@ MeSubscriptionCancelView, MeSubscriptionChargeView, MeSubscriptionView, + PaymentMethodView, PayView, PlanOfferView, PlanView, ServiceItemView, ServiceView, - PaymentMethodView, ) app_name = "payments" @@ -85,5 +86,6 @@ path("bag//coupon", BagCouponView.as_view(), name="bag_id_coupon"), path("checking", CheckingView.as_view(), name="checking"), path("pay", PayView.as_view(), name="pay"), + path("academy/plan//subscription", AcademyPlanSubscriptionView.as_view(), name="pay"), path("methods", PaymentMethodView.as_view(), name="methods"), ] diff --git a/breathecode/payments/views.py b/breathecode/payments/views.py index 24a8b39da..58d082f2b 100644 --- a/breathecode/payments/views.py +++ b/breathecode/payments/views.py @@ -19,6 +19,7 @@ from breathecode.payments import actions, tasks from breathecode.payments.actions import ( PlanFinder, + add_invoice_externally_managed, add_items_to_bag, filter_consumables, filter_void_consumable_balance, @@ -28,6 +29,7 @@ get_balance_by_resource, get_discounted_price, max_coupons_allowed, + validate_and_create_subscriptions, ) from breathecode.payments.caches import PlanOfferCache from breathecode.payments.models import ( @@ -78,7 +80,7 @@ from breathecode.utils.decorators.capable_of import capable_of from breathecode.utils.i18n import translation from breathecode.utils.redis import Lock -from breathecode.utils.shorteners import C +from capyc.core.shorteners import C from capyc.rest_framework.exceptions import PaymentException, ValidationException logger = getLogger(__name__) @@ -1050,6 +1052,11 @@ def get(self, request, invoice_id=None, academy_id=None): return handler.response(serializer.data) + @capable_of("crud_invoice") + def post(self, request, academy_id=None): + add_invoice_externally_managed(request, request.user, academy_id) + return Response({"status": "ok"}) + class CardView(APIView): extensions = APIViewExtensions(sort="-id", paginate=True) @@ -2057,6 +2064,16 @@ def post(self, request): raise e +class AcademyPlanSubscriptionView(APIView): + + extensions = APIViewExtensions(sort="-id", paginate=True) + + @capable_of("crud_subscription") + def post(self, request, plan_slug: str, academy_id: int): + validate_and_create_subscriptions(request, plan_slug, academy_id) + return Response({"status": "ok"}) + + class PaymentMethodView(APIView): def get(self, request):