refactor(subscription): removed unnecessary complexity

This commit is contained in:
bobbyvish
2024-08-21 23:24:47 +05:30
parent 4866f0a5d4
commit 5d107ad17a
8 changed files with 207 additions and 47 deletions

View File

@@ -1051,10 +1051,111 @@ class StripeService:
# stipe not provide to delete the price # stipe not provide to delete the price
@staticmethod @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. Cancels the auto-renewal of a Stripe subscription.
:param subscription_id: The ID of the subscription to cancel auto-renewal for. :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. :return: A dictionary with success status and the updated subscription object or an error message.
""" """

View File

@@ -1,7 +1,7 @@
from datetime import timedelta from datetime import timedelta
from django.utils import timezone from django.utils import timezone
import datetime import datetime
from manage_subscriptions.models import PrincipalSubscription from manage_subscriptions.models import PrincipalSubscription, SubscriptionStatus
class SubscriptionService: class SubscriptionService:
@@ -33,6 +33,8 @@ class SubscriptionService:
current_period_start, current_period_end, subscription.calulate_days() 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_subscription = PrincipalSubscription.objects.create(
principal=principal, principal=principal,
subscription=subscription, subscription=subscription,

View File

@@ -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.'),
),
]

View File

@@ -1,3 +1,4 @@
from decimal import Decimal
from django.db import models from django.db import models
from django.utils import timezone from django.utils import timezone
from accounts.models import BaseModel, IAmPrincipalType from accounts.models import BaseModel, IAmPrincipalType
@@ -12,18 +13,21 @@ class Coupon(BaseModel):
description = models.TextField(null=True, blank=True) description = models.TextField(null=True, blank=True)
image = models.ImageField(upload_to="coupon_img", null=True, blank=True) image = models.ImageField(upload_to="coupon_img", null=True, blank=True)
discount_amount = models.DecimalField( 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( 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_from = models.DateTimeField()
valid_to = models.DateTimeField() 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=0) max_redeems = models.IntegerField(default=1)
class Meta: class Meta:
db_table = "coupon" db_table = "coupon"
def __str__(self):
return self.coupon_code
def clean(self): def clean(self):
""" """
Validate the Coupon instance. Ensure that the `max_redeems` is greater than 0, 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): def save(self, *args, **kwargs):
self.clean() # Call clean before saving to ensure validation from goodtimes.services import StripeService
super().save(*args, **kwargs) if not self.delete:
self.clean() # Call clean before saving to ensure validation
def __str__(self): if not self.pk and not self.coupon_id:
return self.coupon_code 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 # If max_redeems is 0, it means that we are allowing unlimited redeems
def is_valid(self): # def is_valid(self):
now = timezone.now() # now = timezone.now()
return ( # return (
self.active # self.active
and not self.deleted # and not self.deleted
and self.valid_from <= now <= self.valid_to # and self.valid_from <= now <= self.valid_to
and (self.max_redeems == 0 or self.no_of_redeems < self.max_redeems) # and (self.max_redeems == 0 or self.no_of_redeems < self.max_redeems)
) # )

View File

@@ -91,17 +91,9 @@ class CouponCreateOrUpdateView(LoginRequiredMixin, generic.View):
context = self.get_context_data(form=form) context = self.get_context_data(form=form)
return render(request, self.template_name, context=context) return render(request, self.template_name, context=context)
success, message = handle_stripe_coupon( form.save()
form.instance, settings.STRIPE_SECRET_KEY messages.success(request, self.get_success_message)
) return redirect(self.success_url)
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)
)
class CouponDeleteView(LoginRequiredMixin, generic.View): class CouponDeleteView(LoginRequiredMixin, generic.View):

View File

@@ -49,12 +49,20 @@ class SubscriptionForm(forms.ModelForm):
class PrincipalSubscriptionForm(forms.ModelForm): class PrincipalSubscriptionForm(forms.ModelForm):
class Meta: class Meta:
model = PrincipalSubscription 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 = { widgets = {
"start_date": forms.DateInput(attrs={"type": "date"}), "start_date": forms.DateInput(attrs={"type": "date"}),
"end_date": forms.DateInput(attrs={"type": "date"}), "end_date": forms.DateInput(attrs={"type": "date"}),
"grace_period_end_date": forms.DateInput(attrs={"type": "date"}), "grace_period_end_date": forms.DateInput(attrs={"type": "date"}),
"cancelled_date_time": forms.DateTimeInput(attrs={"type": "datetime"}),
} }

View File

@@ -83,19 +83,20 @@ class Subscription(BaseModel):
def clean(self): def clean(self):
# Ensure amount is greater than 1 # Ensure amount is greater than 1
if not self.delete: if self.amount <= 1:
if self.amount <= 1: raise ValidationError({"amount": "Amount must be greater than 1."})
raise ValidationError({"amount": "Amount must be greater than 1."})
# Ensure high_amount is greater than amount # Ensure high_amount is greater than amount
if self.high_amount <= self.amount: if self.high_amount <= self.amount:
raise ValidationError( raise ValidationError(
{"high_amount": "High amount must be greater than amount."} {"high_amount": "High amount must be greater than amount."}
) )
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
from goodtimes.services import StripeService from goodtimes.services import StripeService
self.clean() if not self.delete:
self.clean()
if not self.is_free: if not self.is_free:
if self.price_id: if self.price_id:
# Stipe dont provide to update the price record except active and deactive # Stipe dont provide to update the price record except active and deactive
@@ -204,7 +205,7 @@ class PrincipalSubscription(BaseModel):
is_paid=True, is_paid=True,
# cancelled=False, # cancelled=False,
active=True, active=True,
# status=SubscriptionStatus.ACTIVE, status=SubscriptionStatus.ACTIVE,
grace_period_end_date__gt=timezone.now().date(), grace_period_end_date__gt=timezone.now().date(),
) )
@@ -215,7 +216,7 @@ class PrincipalSubscription(BaseModel):
is_paid=True, is_paid=True,
# cancelled=False, # cancelled=False,
active=True, active=True,
# status=SubscriptionStatus.ACTIVE, status=SubscriptionStatus.ACTIVE,
end_date__gt=timezone.now().date(), end_date__gt=timezone.now().date(),
) )
@@ -226,12 +227,11 @@ class PrincipalSubscription(BaseModel):
is_paid=True, is_paid=True,
# cancelled=False, # cancelled=False,
active=True, active=True,
# status=SubscriptionStatus.ACTIVE, status=SubscriptionStatus.ACTIVE,
).order_by("-grace_period_end_date").first() ).order_by("-grace_period_end_date").first()
@classmethod @classmethod
def cancel_stipe_auto_renew_subscription(cls, subscription): def cancel_stipe_auto_renew_subscription(cls, subscription):
subscription.status = SubscriptionStatus.INACTIVE
subscription.auto_renew = False subscription.auto_renew = False
subscription.cancelled_date_time = timezone.now() subscription.cancelled_date_time = timezone.now()
subscription.save() subscription.save()

View File

@@ -653,14 +653,13 @@ def create_checkout_session(request):
return JsonResponse({"error": "Subscription not found."}, status=404) return JsonResponse({"error": "Subscription not found."}, status=404)
# Default transaction amount based on subscription amount # Default transaction amount based on subscription amount
print("Before Session Data")
session_data = { session_data = {
"payment_method_types": ["card"], "payment_method_types": ["card"],
"success_url": request.build_absolute_uri("/subscriptions/success/"), "success_url": request.build_absolute_uri("/subscriptions/success/"),
"cancel_url": request.build_absolute_uri("/subscriptions/cancel/"), "cancel_url": request.build_absolute_uri("/subscriptions/cancel/"),
"metadata": { "metadata": {
"transaction_amount": str(subscription.amount), "transaction_amount": str(subscription.amount),
"principal": str(request.user.id), "principal": str(principal_id),
"subscription_id": str(subscription.id), "subscription_id": str(subscription.id),
"product_id": subscription.product_id, "product_id": subscription.product_id,
"couponCode": coupon_code if coupon_code else None, "couponCode": coupon_code if coupon_code else None,