From 5d107ad17a2826e82173b462931b91fe3c119786 Mon Sep 17 00:00:00 2001 From: bobbyvish Date: Wed, 21 Aug 2024 23:24:47 +0530 Subject: [PATCH] refactor(subscription): removed unnecessary complexity --- goodtimes/services.py | 105 +++++++++++++++++- goodtimes/webhook/subscription_service.py | 4 +- ...3_alter_coupon_discount_amount_and_more.py | 33 ++++++ manage_coupons/models.py | 57 +++++++--- manage_coupons/views.py | 14 +-- manage_subscriptions/forms.py | 12 +- manage_subscriptions/models.py | 26 ++--- manage_subscriptions/views.py | 3 +- 8 files changed, 207 insertions(+), 47 deletions(-) create mode 100644 manage_coupons/migrations/0003_alter_coupon_discount_amount_and_more.py diff --git a/goodtimes/services.py b/goodtimes/services.py index f27fc41..fbf3ef8 100644 --- a/goodtimes/services.py +++ b/goodtimes/services.py @@ -1051,10 +1051,111 @@ class StripeService: # stipe not provide to delete the price @staticmethod - def cancel_auto_renew_subscription(subscription_id: str) -> dict: + def create_coupon( + amount_off: int = None, + percent_off: float = None, + duration: str = "once", + name: str = None, + currency: str = None, + redeem_by: datetime = None, + max_redemptions: int = 0, + metadata: dict = None + ) -> dict: + """ + Creates a Stripe Coupon with either a fixed amount off or a percentage off. + + :param amount_off: The discount amount to be applied (in the smallest currency unit, e.g., cents). This cannot be used in conjunction with `percent_off`. + :param percent_off: The discount percentage to be applied to the price. This cannot be used in conjunction with `amount_off`. + :param duration: The duration for which the coupon is valid. Valid values are: + - "once": The coupon will apply to the next invoice only. + :param name: An optional name for the coupon. + :param currency: The currency in which the `amount_off` is specified. Required if `amount_off` is used. + :param redeem_by: A timestamp at which the coupon will no longer be redeemable. + The coupon can still be applied to invoices created after the `redeem_by` date, + if the subscription was active prior to the date. + :param max_redemptions: The maximum number of times this coupon can be redeemed in total. + Defaults to 0, meaning unlimited redemptions. + :param metadata: A set of key-value pairs to store additional information about the coupon in Stripe. + + :return: A dictionary containing: + - 'success': Boolean indicating the success of the operation. + - 'data': The created Stripe Coupon object if successful. + - 'message': Error message if the operation failed. + + :raises ValueError: If both `amount_off` and `percent_off` are provided, or if neither is provided. + Also raised if `amount_off` is provided without a corresponding `currency`. + :raises stripe.error.StripeError: If an error occurs while creating the coupon via the Stripe API. + + See: https://docs.stripe.com/api/coupons/create?lang=python + """ + if amount_off and percent_off: + raise ValueError("You can provide either `amount_off` or `percent_off`, but not both.") + + if not amount_off and not percent_off: + raise ValueError("You must provide either `amount_off` or `percent_off`.") + + if amount_off and not currency: + raise ValueError("Currency must be provided when `amount_off` is specified.") + + coupon_data = { + "duration": duration, + "name": name, + "redeem_by": redeem_by, + "max_redemptions": max_redemptions, + "metadata": metadata, + } + + if amount_off: + coupon_data.update({ + "amount_off": amount_off, + "currency": currency, + }) + elif percent_off: + coupon_data.update({ + "percent_off": percent_off, + }) + + try: + coupon = stripe.Coupon.create(**coupon_data) + return {'success': True, 'data': coupon} + except stripe.error.StripeError as e: + return {'success': False, 'message': f"Error creating coupon: {e}"} + + @staticmethod + def retrieve_coupon(coupon_id: str): + """ + Retrieve a Stripe Coupon by its ID. + + :param coupon_id: The ID of the coupon to retrieve. + :return: The retrieved Stripe Coupon object. + + See: https://docs.stripe.com/api/coupons/retrieve?lang=python + """ + try: + coupon = stripe.Coupon.retrieve(coupon_id) + return {'success': True, 'data': coupon} + except stripe.error.StripeError as e: + return {'success': False, 'message': f"Error retrieving coupon: {e}"} + + @staticmethod + def delete_coupon(coupon_id: str): + """ + Retrieve a Stripe Coupon by its ID. + + :param coupon_id: The ID of the coupon to retrieve. + :return: The retrieved Stripe Coupon object. + """ + try: + coupon = stripe.Coupon.delete(coupon_id) + return {'success': True, 'data': coupon} + except stripe.error.StripeError as e: + return {'success': False, 'message': f"Error deleting coupon: {e}"} + + @staticmethod + def cancel_auto_renew_subscription(subscription_id: str): """ Cancels the auto-renewal of a Stripe subscription. - + :param subscription_id: The ID of the subscription to cancel auto-renewal for. :return: A dictionary with success status and the updated subscription object or an error message. """ diff --git a/goodtimes/webhook/subscription_service.py b/goodtimes/webhook/subscription_service.py index 3a3f3c3..45e9d06 100644 --- a/goodtimes/webhook/subscription_service.py +++ b/goodtimes/webhook/subscription_service.py @@ -1,7 +1,7 @@ from datetime import timedelta from django.utils import timezone import datetime -from manage_subscriptions.models import PrincipalSubscription +from manage_subscriptions.models import PrincipalSubscription, SubscriptionStatus class SubscriptionService: @@ -33,6 +33,8 @@ class SubscriptionService: current_period_start, current_period_end, subscription.calulate_days() ) + PrincipalSubscription.objects.filter(principal=principal, status=SubscriptionStatus.ACTIVE).update(status=SubscriptionStatus.EXPIRED) + principal_subscription = PrincipalSubscription.objects.create( principal=principal, subscription=subscription, diff --git a/manage_coupons/migrations/0003_alter_coupon_discount_amount_and_more.py b/manage_coupons/migrations/0003_alter_coupon_discount_amount_and_more.py new file mode 100644 index 0000000..c18f000 --- /dev/null +++ b/manage_coupons/migrations/0003_alter_coupon_discount_amount_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.0.2 on 2024-08-21 10:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('manage_coupons', '0002_coupon_coupon_id'), + ] + + operations = [ + migrations.AlterField( + model_name='coupon', + name='discount_amount', + field=models.DecimalField(blank=True, decimal_places=2, help_text='Representing the amount to subtract from an invoice total (required if discount_percentage is not passed)', max_digits=10, null=True), + ), + migrations.AlterField( + model_name='coupon', + name='discount_percentage', + field=models.DecimalField(blank=True, decimal_places=2, help_text='A positive float larger than 0, and smaller or equal to 100, that represents the discount the coupon will apply (required if discount_amount is not passed).', max_digits=5, null=True), + ), + migrations.AlterField( + model_name='coupon', + name='max_redeems', + field=models.IntegerField(default=1), + ), + migrations.AlterField( + model_name='coupon', + name='valid_to', + field=models.DateTimeField(help_text='Datetime for the last redeemable date. After this, the coupon is invalid for new customers.'), + ), + ] diff --git a/manage_coupons/models.py b/manage_coupons/models.py index 743ce43..08cb2b7 100644 --- a/manage_coupons/models.py +++ b/manage_coupons/models.py @@ -1,3 +1,4 @@ +from decimal import Decimal from django.db import models from django.utils import timezone from accounts.models import BaseModel, IAmPrincipalType @@ -12,18 +13,21 @@ class Coupon(BaseModel): description = models.TextField(null=True, blank=True) image = models.ImageField(upload_to="coupon_img", null=True, blank=True) discount_amount = models.DecimalField( - max_digits=10, decimal_places=2, null=True, blank=True + max_digits=10, decimal_places=2, null=True, blank=True, help_text="Representing the amount to subtract from an invoice total (required if discount_percentage is not passed)" ) discount_percentage = models.DecimalField( - max_digits=5, decimal_places=2, null=True, blank=True + max_digits=5, decimal_places=2, null=True, blank=True, help_text="A positive float larger than 0, and smaller or equal to 100, that represents the discount the coupon will apply (required if discount_amount is not passed)." ) valid_from = models.DateTimeField() - valid_to = models.DateTimeField() - max_redeems = models.IntegerField(default=0) + valid_to = models.DateTimeField(help_text="Datetime for the last redeemable date. After this, the coupon is invalid for new customers.") + max_redeems = models.IntegerField(default=1) class Meta: db_table = "coupon" + def __str__(self): + return self.coupon_code + def clean(self): """ Validate the Coupon instance. Ensure that the `max_redeems` is greater than 0, @@ -61,19 +65,40 @@ class Coupon(BaseModel): ) def save(self, *args, **kwargs): - self.clean() # Call clean before saving to ensure validation - super().save(*args, **kwargs) + from goodtimes.services import StripeService + if not self.delete: + self.clean() # Call clean before saving to ensure validation - def __str__(self): - return self.coupon_code + if not self.pk and not self.coupon_id: + amount_off = int(self.discount_amount * Decimal(100)) if self.discount_amount else None + percent_off = float(self.discount_percentage) if self.discount_percentage else None + + result = StripeService.create_coupon( + amount_off=amount_off, + percent_off=percent_off, + duration="once", + name=self.title, + redeem_by=int(self.valid_to.timestamp()), + max_redemptions=self.max_redeems, + currency='gbp', + metadata={"local_id": self.id} + ) + + if not result["success"]: + raise ValueError(f"Failed to create Stripe coupon: {result['message']}") + + self.coupon_code = result['data'].id + self.coupon_id = result["data"].id + + super().save(*args, **kwargs) # If max_redeems is 0, it means that we are allowing unlimited redeems - def is_valid(self): - now = timezone.now() - return ( - self.active - and not self.deleted - and self.valid_from <= now <= self.valid_to - and (self.max_redeems == 0 or self.no_of_redeems < self.max_redeems) - ) + # def is_valid(self): + # now = timezone.now() + # return ( + # self.active + # and not self.deleted + # and self.valid_from <= now <= self.valid_to + # and (self.max_redeems == 0 or self.no_of_redeems < self.max_redeems) + # ) diff --git a/manage_coupons/views.py b/manage_coupons/views.py index 8886aee..754951e 100644 --- a/manage_coupons/views.py +++ b/manage_coupons/views.py @@ -91,17 +91,9 @@ class CouponCreateOrUpdateView(LoginRequiredMixin, generic.View): context = self.get_context_data(form=form) return render(request, self.template_name, context=context) - success, message = handle_stripe_coupon( - form.instance, settings.STRIPE_SECRET_KEY - ) - if success: - messages.success(self.request, message) - return redirect(self.success_url) - else: - messages.error(self.request, message) - return render( - request, self.template_name, context=self.get_context_data(form=form) - ) + form.save() + messages.success(request, self.get_success_message) + return redirect(self.success_url) class CouponDeleteView(LoginRequiredMixin, generic.View): diff --git a/manage_subscriptions/forms.py b/manage_subscriptions/forms.py index bd4a7b6..1802c77 100644 --- a/manage_subscriptions/forms.py +++ b/manage_subscriptions/forms.py @@ -49,12 +49,20 @@ class SubscriptionForm(forms.ModelForm): class PrincipalSubscriptionForm(forms.ModelForm): class Meta: model = PrincipalSubscription - fields = "__all__" # Includes all fields from the model + fields = [ + "subscription", + "principal", + "status", + "start_date", + "end_date", + "grace_period_end_date", + "comments", + "coupon_code" + ] # Includes all fields from the model widgets = { "start_date": forms.DateInput(attrs={"type": "date"}), "end_date": forms.DateInput(attrs={"type": "date"}), "grace_period_end_date": forms.DateInput(attrs={"type": "date"}), - "cancelled_date_time": forms.DateTimeInput(attrs={"type": "datetime"}), } diff --git a/manage_subscriptions/models.py b/manage_subscriptions/models.py index 6201e02..e21b816 100644 --- a/manage_subscriptions/models.py +++ b/manage_subscriptions/models.py @@ -83,19 +83,20 @@ class Subscription(BaseModel): def clean(self): # Ensure amount is greater than 1 - if not self.delete: - if self.amount <= 1: - raise ValidationError({"amount": "Amount must be greater than 1."}) + if self.amount <= 1: + raise ValidationError({"amount": "Amount must be greater than 1."}) - # Ensure high_amount is greater than amount - if self.high_amount <= self.amount: - raise ValidationError( - {"high_amount": "High amount must be greater than amount."} - ) + # Ensure high_amount is greater than amount + if self.high_amount <= self.amount: + raise ValidationError( + {"high_amount": "High amount must be greater than amount."} + ) def save(self, *args, **kwargs): from goodtimes.services import StripeService - self.clean() + if not self.delete: + self.clean() + if not self.is_free: if self.price_id: # Stipe dont provide to update the price record except active and deactive @@ -204,7 +205,7 @@ class PrincipalSubscription(BaseModel): is_paid=True, # cancelled=False, active=True, - # status=SubscriptionStatus.ACTIVE, + status=SubscriptionStatus.ACTIVE, grace_period_end_date__gt=timezone.now().date(), ) @@ -215,7 +216,7 @@ class PrincipalSubscription(BaseModel): is_paid=True, # cancelled=False, active=True, - # status=SubscriptionStatus.ACTIVE, + status=SubscriptionStatus.ACTIVE, end_date__gt=timezone.now().date(), ) @@ -226,12 +227,11 @@ class PrincipalSubscription(BaseModel): is_paid=True, # cancelled=False, active=True, - # status=SubscriptionStatus.ACTIVE, + status=SubscriptionStatus.ACTIVE, ).order_by("-grace_period_end_date").first() @classmethod def cancel_stipe_auto_renew_subscription(cls, subscription): - subscription.status = SubscriptionStatus.INACTIVE subscription.auto_renew = False subscription.cancelled_date_time = timezone.now() subscription.save() diff --git a/manage_subscriptions/views.py b/manage_subscriptions/views.py index 13a3c31..ebc04a2 100644 --- a/manage_subscriptions/views.py +++ b/manage_subscriptions/views.py @@ -653,14 +653,13 @@ def create_checkout_session(request): return JsonResponse({"error": "Subscription not found."}, status=404) # Default transaction amount based on subscription amount - print("Before Session Data") session_data = { "payment_method_types": ["card"], "success_url": request.build_absolute_uri("/subscriptions/success/"), "cancel_url": request.build_absolute_uri("/subscriptions/cancel/"), "metadata": { "transaction_amount": str(subscription.amount), - "principal": str(request.user.id), + "principal": str(principal_id), "subscription_id": str(subscription.id), "product_id": subscription.product_id, "couponCode": coupon_code if coupon_code else None,