From 4866f0a5d4df5caec481e060677c037d83ee5e6d Mon Sep 17 00:00:00 2001 From: bobbyvish Date: Tue, 20 Aug 2024 16:57:19 +0530 Subject: [PATCH 1/3] refactor(subscription): removed unnecessary field and complexity --- goodtimes/services.py | 371 ++++++++--------- goodtimes/settings/development.py | 5 +- goodtimes/settings/production.py | 3 - goodtimes/settings/staging.py | 3 - goodtimes/settings/wdipl.py | 3 - goodtimes/webhook/referral_reward_service.py | 3 +- goodtimes/webhook/subscription_service.py | 7 +- manage_subscriptions/forms.py | 20 +- .../0011_subscription_product_id.py | 18 + ...on_interval_subscription_interval_count.py | 24 ++ manage_subscriptions/models.py | 109 ++++- manage_subscriptions/urls.py | 29 +- manage_subscriptions/views.py | 373 +++++------------- .../subscription_list.html | 18 +- .../stripe_html/active_subscription.html | 47 +-- templates/stripe_html/index.html | 182 +++++---- templates/stripe_html/webview_404.html | 129 ++++++ 17 files changed, 663 insertions(+), 681 deletions(-) create mode 100644 manage_subscriptions/migrations/0011_subscription_product_id.py create mode 100644 manage_subscriptions/migrations/0012_subscription_interval_subscription_interval_count.py create mode 100644 templates/stripe_html/webview_404.html diff --git a/goodtimes/services.py b/goodtimes/services.py index 6488f7e..f27fc41 100644 --- a/goodtimes/services.py +++ b/goodtimes/services.py @@ -1,6 +1,8 @@ import random import requests import googlemaps +import stripe +import stripe.error import tweepy from django.conf import settings from django.core.files.uploadedfile import UploadedFile @@ -13,12 +15,7 @@ from django.db.models import Case, When from smtplib import SMTPException from accounts.models import IAmPrincipal, IAmPrincipalOtp, IAmPrincipalType -from manage_referrals.models import ( - GoodTimeCoins, - ReferralRecord, - ReferralRecordReward, - ReferralTracking, -) + from manage_subscriptions.models import PrincipalSubscription, Subscription from manage_wallets.models import ( TransactionStatus, @@ -208,195 +205,6 @@ class SMSService: # self.send(phone_numbers, body) return otp_code - -class PaymentProcessingService: - def __init__(self, webhook_data): - self.webhook_data = webhook_data - self.event_type = webhook_data["type"] - self.charge_data = webhook_data["data"]["object"] - self.customer_id = self._get_customer_id() - self.transaction = self._get_transaction_by_id() - self.principal = self.transaction.principal - self.principal_subscription = None - - def _get_customer_id(self): - # Access the customer ID from the charge object - return self.charge_data.get("customer", None) - - def _get_transaction_by_id(self): - logger.debug("self.metadata: ", self.charge_data["metadata"]) - logger.debug("transaction_id: ", self.charge_data["metadata"]["transaction_id"]) - transaction_id = self.charge_data["metadata"]["transaction_id"] - if transaction_id: - try: - logger.debug("_get_transaction_by_id: ", transaction_id) - return Transaction.objects.get(id=int(transaction_id)) - except Transaction.DoesNotExist: - logger.error(f"Transaction ID {transaction_id} not found.") - return None - - def _get_subscription(self): - logger.debug( - "subscription_id: ", self.charge_data["metadata"]["subscription_id"] - ) - subscription_id = self.charge_data["metadata"]["subscription_id"] - if subscription_id: - try: - return Subscription.objects.get(id=int(subscription_id)) - except Subscription.DoesNotExist: - logger.error(f"Subscription ID {subscription_id} not found.") - return None - - def _create_principal_subscription(self): - order_id = self.charge_data["metadata"]["order_id"] - try: - subscription = self._get_subscription() - - subscription_days = subscription.plan.days - today = timezone.now().date() - last_date = today + timedelta(days=int(subscription_days)) - - principal_subscription = PrincipalSubscription.objects.create( - principal=self.principal, - subscription=subscription, - is_paid=True, - order_id=order_id, - start_date=today, - end_date=last_date, - grace_period_end_date=last_date + timedelta(days=15), - ) - self.principal_subscription = principal_subscription - return principal_subscription - except Subscription.DoesNotExist: - logger.error( - "SOmething Went Wrong inside _create_principal_subscription()." - ) - - return None - - def process_event(self): - if self.event_type == "checkout.session.completed": - self._handle_success() - else: - self._handle_failure() - - def _handle_success(self): - with transaction.atomic(): - self._create_principal_subscription() - self._update_transaction_success() - self._credit_referral_reward_if_applicable() - - def _credit_referral_reward_if_applicable(self): - # Step 1: Check for an existing, completed referral record - referral_record = ReferralRecord.objects.filter( - active=True, - deleted=False, - referred_principal_id=self.principal.id, - is_completed=True, - ).first() - - if referral_record: - # Step 2: Check for an active subscription of the referrer - today = timezone.now().date() - active_subscription = ( - PrincipalSubscription.objects.filter( - principal=referral_record.referrer_principal, - is_paid=True, - end_date__gte=today, - cancelled=False, - deleted=False, - ) - .order_by("-end_date") - .first() - ) - if active_subscription: - subscription = self._get_subscription() - if subscription: - # Calculate the reward value - percentage = ( - subscription.referral_percentage * subscription.amount / 100 - ) - - # Create a reward entry - ReferralRecordReward.objects.create( - referral_record=referral_record, - subscription=subscription, - coins=1, # Assuming this is a default or a calculated value - value=percentage, - ) - - self._credit_good_time_coin( - referral_record.referrer_principal, percentage - ) - # Here's where you call _update_reward - self._update_reward( - referral_record=referral_record, - active_subscription=active_subscription, - create_subscription_method=self.principal_subscription, - has_active_subscription=True, - ) - else: - # If there is no active subscription, still need to update reward without active_subscription - self._update_reward( - referral_record=referral_record, - active_subscription=None, - create_subscription_method=self.principal_subscription, - has_active_subscription=False, - ) - - def _credit_good_time_coin(self, referrer_principal, percentage): - # wallet, created = Wallet.objects.get_or_create(principal=referrer_principal) - # wallet.coins += 1 - # wallet.save() - Transaction.objects.create( - principal=referrer_principal, - transaction_type=TransactionType.CREDIT, - payment_method="", - transaction_status=TransactionStatus.SUCCESS, - amount=percentage, - coins=1, - comment="Referral reward", - # Populate other fields as necessary, such as `order_id`, `product_id`, or `reference_id` if applicable - ) - - def _handle_failure(self): - # Implement any necessary logic to handle a failed payment - self._update_transaction_failure() - - def _update_reward( - self, - referral_record, - active_subscription, - create_subscription_method, - has_active_subscription, - ): - # Check if the referrer has an active subscription and get its ID if it exists - referrer_subscription_id = ( - active_subscription.id if active_subscription else None - ) - - # Create a new subscription for the referred principal - referred_subscription_id = self.principal_subscription.id - - # Create or update the ReferralTracking record - ReferralTracking.objects.create( - referral_record=referral_record, - referrer_subscription_id=referrer_subscription_id, - referred_subscription_id=referred_subscription_id, - is_referrer_subscribed=has_active_subscription, - ) - - def _update_transaction_success(self): - principal_subscription = self.principal_subscription - self.transaction.transaction_status = TransactionStatus.SUCCESS - self.transaction.principal_subscription = principal_subscription - self.transaction.save() - - def _update_transaction_failure(self): - self.transaction.transaction_status = TransactionStatus.FAIL - self.transaction.save() - - class InteractionCalculator: def __init__(self, event): self.event = event @@ -708,7 +516,7 @@ class GoogleMapsservice: dict: Distance matrix response from Google Maps API. """ return self.client.distance_matrix(origin, destination) - + def search_address(self, address): """ Search for a list of addresses matching the given address string. @@ -1089,4 +897,173 @@ class InstagramPoster: result = self.instagram_api.post_image_with_caption(image_path, caption) if not result: return {'success': False, 'message': 'Error posting photo in Instagram.'} - return {'success': True, 'message': 'Photo posted successfully'} \ No newline at end of file + return {'success': True, 'message': 'Photo posted successfully'} + + +class StripeService: + stripe.api_key = settings.STRIPE_SECRET_KEY + + @staticmethod + def create_product(name: str, description: str = None, metadata: dict = None): + """ + Create a Stripe Product. + + :param name: Name of the product, meant to be displayable to the customer. + :param description: An optional description of the product. + :param metadata: An optional dictionary of key-value pairs to attach to the product. + :return: The created Stripe product object. + + See: https://docs.stripe.com/api/products/create?lang=python + """ + try: + product = stripe.Product.create(name=name, description=description, metadata=metadata) + return {'success': True, 'data': product} + except stripe.error.StripeError as e: + return {'success': False, 'message': f"Error creating product: {e}"} + + @staticmethod + def retrive_product(product_id: str): + """ + Retrieve a Stripe Product by its ID. + + :param product_id: The ID of the product to retrieve. + :return: The retrieved Stripe Product object. + + See: https://docs.stripe.com/api/products/update?lang=python + """ + try: + product = stripe.Product.retrieve(product_id) + return {'success': True, 'data': product} + except stripe.error.StripeError as e: + return {'success': False, 'message': f"Error retriving product: {e}"} + + @staticmethod + def update_product(product_id: str, **kwargs): + """ + Update a Stripe Product by its ID. + + :param product_id: The ID of the product to update. + :param kwargs: Optional paramters to update the product, such as: + - name : The new name of the product. + - description : The new description of the product. + - active : A boolean flag indicating if the product is active. + - metadata : A dictionary of key-value pairs to attach to the product. + :return: The updated Stripe Product object. + + See: https://docs.stripe.com/api/products/update?lang=python + """ + try: + product = stripe.Product.modify(product_id, **kwargs) + return {'success': True, 'data': product} + except stripe.error.StripeError as e: + return {'success': False, 'message': f"Error updating product: {e}"} + + @staticmethod + def delete_product(product_id: str): + """ + Delete a Stripe Product by its ID. + + :param product_id: ID of the product to delete. + :return: The deleted Stripe Product object. + + See: https://docs.stripe.com/api/products/delete?lang=python + """ + try: + product = stripe.Product.delete(product_id) + return {'success': True, 'data': product} + except stripe.error.StripeError as e: + return {'success': False, 'message': f"Error deleting product: {e}"} + + @staticmethod + def create_price(product_id: str = None, product_data: dict = None, unit_amount: int = None, currency: str = 'gbp', recurring: dict = None, metadata: dict = None): + """ + Create a Stripe Price for a product. + + :param product_id: ID of the product for which the price is being created. + :param product_data: A dictionary with product details to create a new product on the fly. Example: + - name : The name of the product. + - description : The description of the product. + :param unit_amount: The amount to be charged.(in cents) + :param currency: The currency of the price. + :param recurring: A dictionary with recurring pricing details. Example: + - interval : The interval at which the price is charged (e.g., 'day', 'week', 'month', 'year'. + - interval_count : The number of intervals at which the price is charged. + :param metadata: An optional dictionary of key-value pairs to attach to the price. + :return: The created Stripe Price object. + :raise ValueError: If neither product_id nor product_data is provided. + + See: https://docs.stripe.com/api/prices/create?lang=python + """ + if not product_id and not product_data: + raise ValueError("Either product_id or product_data must be provided to create a price.") + + price_data = { + 'unit_amount': unit_amount, + 'currency': currency, + 'recurring': recurring, + 'metadata': metadata + } + + if product_id: + price_data['product'] = product_id + elif product_data: + price_data['product'] = stripe.Product.create(**product_data).id + try: + price = stripe.Price.create(**price_data) + return {'success': True, 'data': price} + except stripe.error.StripeError as e: + return {'success': False, 'message': f"Error creating price: {e}"} + + @staticmethod + def retrieve_price(price_id: str): + """ + Retrieve a Stripe Price by its ID. + :param price_id: ID of the price to retrive + :return: The retrieved Stripe Price object + + See: https://docs.stripe.com/api/prices/retrieve?lang=python + """ + try: + price = stripe.Price.retrieve(price_id) + return {'success': True, 'data': price} + except stripe.error.StripeError as e: + return {'success': False, 'message': f"Error retrieving price: {e}"} + + @staticmethod + def update_price(price_id: str, **kwargs): + """ + Update a Stripe Price by its ID. + :param price_id: ID of the price to update + :param kwargs: Optional parameters to update the price, such as: + - active: A boolean flag indicating if the price is active. + - nickname: A nickname for the price, useful for labeling and organizing. + - metadata: A set of key-value pairs to attach to the price object. + :return: The updated Stripe Price object + + See: https://docs.stripe.com/api/prices/update?lang=python + """ + try: + price = stripe.Price.modify(price_id, **kwargs) + return {'success': True, 'data': price} + except stripe.error.StripeError as e: + return {'success': False, 'message': f"Error updating price: {e}"} + + # stipe not provide to delete the price + + @staticmethod + def cancel_auto_renew_subscription(subscription_id: str) -> dict: + """ + 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. + """ + try: + # Update the subscription to cancel at the end of the current period + subscription = stripe.Subscription.modify( + subscription_id, + cancel_at_period_end=True + ) + return {'success': True, 'data': subscription} + except stripe.error.StripeError as e: + return {'success': False, 'message': f'Error cancelling subscription auto-renewal: {e}'} \ No newline at end of file diff --git a/goodtimes/settings/development.py b/goodtimes/settings/development.py index 0fc2e13..42b58d3 100644 --- a/goodtimes/settings/development.py +++ b/goodtimes/settings/development.py @@ -51,8 +51,7 @@ STATIC_URL = "/static/" STATICFILES_DIRS = [BASE_DIR.joinpath("static")] -STRIPE_CHECKOUT_URL = "http://localhost:8000/subscriptions/stripe-subscription/" -STRIPE_FINAL_URL = "http://localhost:8000/subscriptions/create-checkout-session/" -COUPON_VALIDITY_CHECK_URL = "http://localhost:8000/subscriptions/coupon-validity-check/" +STRIPE_CHECKOUT_URL = "https://c2f5-122-179-140-110.ngrok-free.app/subscriptions/create-checkout-session/" +COUPON_VALIDITY_CHECK_URL = "https://c2f5-122-179-140-110.ngrok-free.app/subscriptions/coupon-validity-check/" LOGO_PATH = "static" diff --git a/goodtimes/settings/production.py b/goodtimes/settings/production.py index edd145c..9efa105 100644 --- a/goodtimes/settings/production.py +++ b/goodtimes/settings/production.py @@ -77,9 +77,6 @@ STATIC_URL = "/static/" STATICFILES_DIRS = [BASE_DIR.joinpath("static")] STRIPE_CHECKOUT_URL = ( - "https://admin.goodtimesltd.co.uk/subscriptions/stripe-subscription/" -) -STRIPE_FINAL_URL = ( "https://admin.goodtimesltd.co.uk/subscriptions/create-checkout-session/" ) COUPON_VALIDITY_CHECK_URL = "https://admin.goodtimesltd.co.uk/subscriptions/coupon-validity-check/" diff --git a/goodtimes/settings/staging.py b/goodtimes/settings/staging.py index ade2303..71641cd 100644 --- a/goodtimes/settings/staging.py +++ b/goodtimes/settings/staging.py @@ -77,9 +77,6 @@ STATICFILES_DIRS = [BASE_DIR.joinpath("static")] STRIPE_CHECKOUT_URL = ( - "https://staging.goodtimesltd.co.uk/subscriptions/stripe-subscription/" -) -STRIPE_FINAL_URL = ( "https://staging.goodtimesltd.co.uk/subscriptions/create-checkout-session/" ) COUPON_VALIDITY_CHECK_URL = "https://staging.goodtimesltd.co.uk/subscriptions/coupon-validity-check/" diff --git a/goodtimes/settings/wdipl.py b/goodtimes/settings/wdipl.py index 89d7593..da93a03 100644 --- a/goodtimes/settings/wdipl.py +++ b/goodtimes/settings/wdipl.py @@ -76,9 +76,6 @@ STATIC_URL = "/static/" STATICFILES_DIRS = [BASE_DIR.joinpath("static")] STRIPE_CHECKOUT_URL = ( - "https://goodtimes.betadelivery.com/subscriptions/stripe-subscription/" -) -STRIPE_FINAL_URL = ( "https://goodtimes.betadelivery.com/subscriptions/create-checkout-session/" ) COUPON_VALIDITY_CHECK_URL = ( diff --git a/goodtimes/webhook/referral_reward_service.py b/goodtimes/webhook/referral_reward_service.py index 6ff1257..31198c2 100644 --- a/goodtimes/webhook/referral_reward_service.py +++ b/goodtimes/webhook/referral_reward_service.py @@ -47,8 +47,7 @@ class ReferralRewardService: principal=referrer_principal, is_paid=True, end_date__gte=today, - cancelled=False, - deleted=False, + active=True, ) .order_by("-end_date") .first() diff --git a/goodtimes/webhook/subscription_service.py b/goodtimes/webhook/subscription_service.py index 68770f1..3a3f3c3 100644 --- a/goodtimes/webhook/subscription_service.py +++ b/goodtimes/webhook/subscription_service.py @@ -30,20 +30,19 @@ class SubscriptionService: ): """Create a principal subscription and return it.""" start_date, end_date = self._calculate_dates( - current_period_start, current_period_end, subscription.plan.days + current_period_start, current_period_end, subscription.calulate_days() ) principal_subscription = PrincipalSubscription.objects.create( principal=principal, subscription=subscription, - stripe_subscription_id=stripe_subscription or "Non Recurring", + stripe_subscription_id=stripe_subscription, is_paid=True, auto_renew=bool(stripe_subscription), - is_stripe_subscription=bool(stripe_subscription), order_id=order_id, start_date=start_date, end_date=end_date, - grace_period_end_date=end_date + timedelta(days=15), + grace_period_end_date=PrincipalSubscription.generate_grace_period_end_date(end_date), coupon_code=coupon.coupon_code if coupon else None, ) diff --git a/manage_subscriptions/forms.py b/manage_subscriptions/forms.py index d34f9cf..bd4a7b6 100644 --- a/manage_subscriptions/forms.py +++ b/manage_subscriptions/forms.py @@ -26,15 +26,14 @@ class SubscriptionForm(forms.ModelForm): model = Subscription fields = [ "title", - "stripe_product", - "plan", + "short_description", + "interval", + "interval_count", "high_amount", "amount", - "short_description", "principal_types", "referral_percentage", "active", - "deleted", "is_free", ] @@ -46,19 +45,6 @@ class SubscriptionForm(forms.ModelForm): id__in=[event_user.id, event_manager.id] ) - def clean(self): - cleaned_data = super().clean() - - stripe_product = cleaned_data.get("stripe_product") - - if not stripe_product: - self.add_error( - "stripe_product", - "Please select a Stripe product to create a subscription.", - ) - - return cleaned_data - class PrincipalSubscriptionForm(forms.ModelForm): class Meta: diff --git a/manage_subscriptions/migrations/0011_subscription_product_id.py b/manage_subscriptions/migrations/0011_subscription_product_id.py new file mode 100644 index 0000000..5a478b1 --- /dev/null +++ b/manage_subscriptions/migrations/0011_subscription_product_id.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.2 on 2024-08-18 18:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('manage_subscriptions', '0010_principalsubscription_comments_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='subscription', + name='product_id', + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/manage_subscriptions/migrations/0012_subscription_interval_subscription_interval_count.py b/manage_subscriptions/migrations/0012_subscription_interval_subscription_interval_count.py new file mode 100644 index 0000000..2a9979b --- /dev/null +++ b/manage_subscriptions/migrations/0012_subscription_interval_subscription_interval_count.py @@ -0,0 +1,24 @@ +# Generated by Django 5.0.2 on 2024-08-19 09:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('manage_subscriptions', '0011_subscription_product_id'), + ] + + operations = [ + migrations.AddField( + model_name='subscription', + name='interval', + field=models.CharField(choices=[('month', 'month'), ('day', 'day'), ('week', 'week'), ('year', 'year')], default='month', max_length=10), + preserve_default=False, + ), + migrations.AddField( + model_name='subscription', + name='interval_count', + field=models.IntegerField(default=1), + ), + ] diff --git a/manage_subscriptions/models.py b/manage_subscriptions/models.py index b353ead..6201e02 100644 --- a/manage_subscriptions/models.py +++ b/manage_subscriptions/models.py @@ -35,8 +35,20 @@ class StripeProduct(BaseModel): class Subscription(BaseModel): + MONTH = "month" + DAY = "day" + WEEK = "week" + YEAR = "year" + + INTERVAL_TYPES = [ + (MONTH, "month"), + (DAY, "day"), + (WEEK, "week"), + (YEAR, "year"), + ] title = models.CharField(max_length=255) price_id = models.CharField(max_length=255, blank=True, null=True) + product_id = models.CharField(max_length=255, blank=True, null=True) stripe_product = models.ForeignKey( StripeProduct, related_name="subscription_product", @@ -50,6 +62,8 @@ class Subscription(BaseModel): plan = models.ForeignKey( Plan, related_name="subscription_plan", on_delete=models.CASCADE ) + interval = models.CharField(max_length=10, choices=INTERVAL_TYPES) + interval_count = models.IntegerField(default=1) high_amount = models.DecimalField(max_digits=14, decimal_places=2, default=0.00) amount = models.DecimalField(max_digits=14, decimal_places=2, default=0.00) principal_types = models.ManyToManyField( @@ -69,25 +83,67 @@ class Subscription(BaseModel): def clean(self): # Ensure amount is greater than 1 - if self.amount <= 1: - raise ValidationError({"amount": "Amount must be greater than 1."}) + if not self.delete: + 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 stripe_product is compulsory present - # if not self.stripe_product: - # raise ValidationError( - # {"stripe_product": "Please select stripe product to create subscription."} - # ) + # 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): - self.clean() # Call clean before saving to ensure validation + from goodtimes.services import StripeService + self.clean() + if not self.is_free: + if self.price_id: + # Stipe dont provide to update the price record except active and deactive + price = StripeService.retrieve_price(self.price_id) + if not price["success"]: + raise Exception(price['message']) + + if self.active != price["data"].active: + StripeService.update_price(price_id=self.price_id, active=self.active) + else: + # Create new product and price + price = StripeService.create_price( + product_data={ + "name": self.title, + "description": self.short_description, + }, + unit_amount=int(self.amount * 100), + currency="gbp", + recurring={ + "interval": self.plan.title, + "interval_count": self.interval_count, + }, + metadata={ + "subscription_id": self.id + } + + ) + if not price["success"]: + raise Exception(price['message']) + + # add the id in record + self.price_id = price["data"].id + self.product_id = price["data"].product + super().save(*args, **kwargs) + def calculate_date(self): + count = { + self.DAY: 1, + self.MONTH: 30, # assuming a month is 30 days + self.YEAR: 365, + self.WEEK: 7 + } + + return count[self.interval] * self.interval_count + + + class SubscriptionStatus(models.TextChoices): ACTIVE = "active", _("Active") @@ -139,7 +195,18 @@ class PrincipalSubscription(BaseModel): @classmethod def has_principal_subscription(cls, principal): - return cls.get_active_princial_subscription(principal).exists() + return cls.get_grace_period_princial_subscription(principal).exists() + + @classmethod + def get_grace_period_princial_subscription(cls, principal): + return cls.objects.filter( + principal=principal, + is_paid=True, + # cancelled=False, + active=True, + # status=SubscriptionStatus.ACTIVE, + grace_period_end_date__gt=timezone.now().date(), + ) @classmethod def get_active_princial_subscription(cls, principal): @@ -147,24 +214,28 @@ class PrincipalSubscription(BaseModel): principal=principal, is_paid=True, # cancelled=False, - deleted=False, active=True, # status=SubscriptionStatus.ACTIVE, - grace_period_end_date__gt=timezone.now().date(), + end_date__gt=timezone.now().date(), ) - @classmethod def get_principal_subscription(cls, principal): return cls.objects.filter( principal=principal, is_paid=True, # cancelled=False, - deleted=False, active=True, # 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() + class WebhookEvent(BaseModel): event_id = models.CharField(max_length=255, unique=True, db_index=True) diff --git a/manage_subscriptions/urls.py b/manage_subscriptions/urls.py index e26620d..bc69c93 100644 --- a/manage_subscriptions/urls.py +++ b/manage_subscriptions/urls.py @@ -13,11 +13,6 @@ urlpatterns = [ name="subscription_add", ), path("subscription//", views.SubscriptionDetailView.as_view(), name="subscription_detail"), - # path( - # "subscription/edit//", - # views.SubscriptionCreateOrUpdateView.as_view(), - # name="subscription_edit", - # ), path( "subscription/delete/", views.SubscriptionDeleteView.as_view(), @@ -32,28 +27,9 @@ urlpatterns = [ views.StripeProductCreateOrUpdateView.as_view(), name="stripe_product_add", ), - # path( - # "product/delete/", - # views.StripeProductDeleteView.as_view(), - # name="stripe_product_delete", - # ), # PLANS path("plan/list/", views.PlanView.as_view(), name="plan_list"), - # path( - # "plan/add/", - # views.PlanCreateOrUpdateView.as_view(), - # name="plan_add", - # ), - # path( - # "plan/edit//", - # views.PlanCreateOrUpdateView.as_view(), - # name="plan_edit", - # ), - # path( - # "plan/delete/", - # views.PlanDeleteView.as_view(), - # name="plan_delete", - # ), + # Principal Subscription path( "principal_subscription/list/", @@ -97,7 +73,8 @@ urlpatterns = [ ), path("stripe/", views.SubscriptionPageView.as_view(), name="stripe"), path("active/", views.ActiveSubscriptionView.as_view(), name="active"), - path("cancel-subscription/", views.CancelSubscriptionView.as_view(), name="cancel_subscription"), + path("cancel-subscription/", views.CancelAutoSubscriptionView.as_view(), name="cancel_subscription"), + path("404/", views.ErrorView.as_view(), name="error"), path("success/", views.SuccessView.as_view(), name="success"), path("cancel/", views.CancelView.as_view(), name="cancel"), path("subscription-cancel-success/", views.SubscriptionCancelSuccessView.as_view(), name="subscription_cancel_success"), diff --git a/manage_subscriptions/views.py b/manage_subscriptions/views.py index ee5cd54..13a3c31 100644 --- a/manage_subscriptions/views.py +++ b/manage_subscriptions/views.py @@ -9,6 +9,7 @@ from django.contrib.auth import login import jwt from django.utils import timezone from django.contrib.auth import get_user_model +from goodtimes.services import StripeService from manage_coupons.models import Coupon from manage_subscriptions.forms import ( StripeProductForm, @@ -112,57 +113,10 @@ class SubscriptionCreateOrUpdateView(LoginRequiredMixin, generic.View): context = self.get_context_data(form=form) return render(request, self.template_name, context=context) - # Processing Stripe price creation and handling free subscription - success, message = self.handle_stripe_price(form) - if not success: - messages.error(self.request, message) - context = self.get_context_data(form=form) - return render(request, self.template_name, context=context) - form.save() messages.success(self.request, self.get_success_message()) return redirect(self.success_url) - def handle_stripe_price(self, form): - try: - stripe.api_key = settings.STRIPE_SECRET_KEY - stripe_product_id = ( - form.instance.stripe_product.product_id - if form.instance.stripe_product - else None - ) - # creating Stripe price only if the subscription is not free - if not form.cleaned_data.get("is_free") and stripe_product_id: - # Getting Stripe Product ID - stripe_product = form.instance.stripe_product - plan = form.instance.plan - - # Map the Plan interval to Stripe's recurring interval - # It will only work if the plan title is 'month', 'year' 'week' or 'day' - stripe_interval = plan.title - # Create the Stripe price - stripe_price = stripe.Price.create( - unit_amount=int( - form.cleaned_data["amount"] * 100 - ), # Amount in cents - currency="gbp", # Adjust the currency as needed - recurring={ - "interval": stripe_interval - }, # Use the interval from Plan - product=stripe_product.product_id, - ) - # Assign the Stripe price ID to the subscription - form.instance.price_id = stripe_price.id - else: - form.instance.price_id = None # No price ID for free subscriptions - - return True, "" # Success - except stripe.error.StripeError as e: - return False, f"Stripe error: {str(e)}" - except Exception as e: - return False, f"An error occurred: {str(e)}" - - class SubscriptionView(LoginRequiredMixin, generic.ListView): page_name = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS resource = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS @@ -213,21 +167,6 @@ class SubscriptionDeleteView(LoginRequiredMixin, generic.View): try: # Retrieve the subscription object subscription = self.model.objects.get(id=pk) - - # Checking if there is a Stripe Price ID associated with the subscription - stripe_price_id = subscription.price_id - if stripe_price_id: - stripe.api_key = settings.STRIPE_SECRET_KEY - - try: - # Updating the Stripe price to mark it as inactive - stripe.Price.modify(stripe_price_id, active=False) - except stripe.error.StripeError as e: - # Handle Stripe errors - messages.error(request, f"Stripe error: {str(e)}") - return redirect(self.success_url) - - # Updating the subscription model record subscription.deleted = True subscription.active = False subscription.save() @@ -346,126 +285,6 @@ class StripeProductView(LoginRequiredMixin, generic.ListView): return context -""" we are not using product delete functionality because there may be multiple stripe's prices - attached to one product and in case of any error it will mismatch the stripe's price with - our database Subscription objects""" -# class StripeProductDeleteView(LoginRequiredMixin, generic.View): -# page_name = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS -# resource = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS -# action = resource_action.ACTION_DELETE -# model = StripeProduct -# success_url = reverse_lazy("manage_subscriptions:stripe_product_list") -# success_message = constants.RECORD_DELETED -# error_message = constants.RECORD_NOT_FOUND - -# def get(self, request, pk): -# try: -# # Retrieve the subscription object -# product = self.model.objects.get(id=pk) - -# # Fetching the related subscriptions (prices) -# related_subscriptions = Subscription.objects.filter(stripe_product=product) - -# # Checking if there is a Stripe Product ID associated with the subscription -# stripe_product_id = product.product_id -# if stripe_product_id: -# stripe.api_key = settings.STRIPE_SECRET_KEY - -# # Deactivating related prices on Stripe first -# for subscription in related_subscriptions: -# price_id = subscription.price_id -# if price_id: -# try: -# stripe.Price.modify(price_id, active=False) -# except stripe.error.StripeError as e: -# # Handle Stripe errors -# messages.error(request, f"Stripe error: {str(e)}") -# return redirect(self.success_url) - -# try: -# # Updating the Stripe price to mark it as inactive -# stripe.Product.modify(stripe_product_id, active=False) -# except stripe.error.StripeError as e: -# # Handle Stripe errors -# messages.error(request, f"Stripe error: {str(e)}") -# return redirect(self.success_url) - -# # Updating the subscription model record -# product.deleted = True -# product.active = False -# product.save() - -# messages.success(request, self.success_message) - -# except self.model.DoesNotExist: -# messages.error(request, self.error_message) - -# return redirect(self.success_url) - - -# class PlanCreateOrUpdateView(LoginRequiredMixin, generic.View): -# # Set the page_name and resource -# page_name = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS -# resource = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS - -# # Initialize the action as ACTION_CREATE (can change based on logic) -# action = resource_action.ACTION_CREATE # Default action - -# template_name = "manage_subscriptions/plan_add.html" -# model = Plan -# form_class = PlanForm -# success_url = reverse_lazy("manage_subscriptions:plan_list") -# error_message = "An error occurred while saving the data." - -# # Determine the success message dynamically based on whether it's an update or create -# def get_success_message(self): -# self.success_message = ( -# constants.RECORD_CREATED if not self.object else constants.RECORD_UPDATED -# ) -# return self.success_message - -# # Get the object (if exists) based on URL parameter 'pk' -# def get_object(self): -# pk = self.kwargs.get("pk") -# return get_object_or_404(self.model, pk=pk) if pk else None - -# # Add page_name and operation to the context -# def get_context_data(self, **kwargs): -# context = { -# "page_name": self.page_name, -# "operation": "Add" if not self.object else "Edit", -# } -# context.update(kwargs) # Include any additional context data passed to the view -# return context - -# def get(self, request, *args, **kwargs): -# self.object = self.get_object() - -# # If an object is found, change action to ACTION_UPDATE -# if self.object is not None: -# self.action = resource_action.ACTION_UPDATE - -# form = self.form_class(instance=self.object) -# context = self.get_context_data(form=form) -# return render(request, self.template_name, context=context) - -# def post(self, request, *args, **kwargs): -# self.object = self.get_object() - -# # If an object is found, change action to ACTION_UPDATE -# if self.object is not None: -# self.action = resource_action.ACTION_UPDATE - -# form = self.form_class(request.POST, instance=self.object) -# if not form.is_valid(): -# print(form.errors) -# context = self.get_context_data(form=form) -# return render(request, self.template_name, context=context) -# form.save() -# messages.success(self.request, self.get_success_message()) -# return redirect(self.success_url) - - class PlanView(LoginRequiredMixin, generic.ListView): page_name = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS resource = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS @@ -483,28 +302,6 @@ class PlanView(LoginRequiredMixin, generic.ListView): return context -# class PlanDeleteView(LoginRequiredMixin, generic.View): -# page_name = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS -# resource = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS -# action = resource_action.ACTION_DELETE -# model = Plan -# success_url = reverse_lazy("manage_subscriptions:plan_list") -# success_message = constants.RECORD_DELETED -# error_message = constants.RECORD_NOT_FOUND - -# def get(self, request, pk): -# try: -# type_obj = self.model.objects.get(id=pk) -# type_obj.deleted = True -# type_obj.active = False -# type_obj.save() -# messages.success(request, self.success_message) -# except self.model.DoesNotExist: -# messages.success(request, self.error_message) - -# return redirect(self.success_url) - - class PrincipalSubscriptionCreateOrUpdateView(LoginRequiredMixin, generic.View): # Set the page_name and resource page_name = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS @@ -621,117 +418,135 @@ class PrincipalSubscriptionDeleteView(LoginRequiredMixin, generic.View): return redirect(self.success_url) -class SubscriptionPageView(TemplateView): +class SubscriptionPageView(generic.View): template_name = "stripe_html/index.html" + model = Subscription + error_url = reverse_lazy("manage_subscriptions:error") - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - request = self.request - if request.user.is_authenticated: - print("request.user: ", request.user) - subscriptions = Subscription.objects.filter( - principal_types=request.user.principal_type, - active=True, - deleted=False, - is_free=False, - ) + def get(self, request): + if not request.user.is_authenticated: + return HttpResponseRedirect(self.error_url) - if subscriptions.exists(): - context["subscriptions"] = subscriptions - context["stripeCheckoutUrl"] = settings.STRIPE_CHECKOUT_URL - context["stripeFinalUrl"] = settings.STRIPE_FINAL_URL - context["couponValidityCheckUrl"] = settings.COUPON_VALIDITY_CHECK_URL - else: - # Handling the case where no subscriptions are found for the principal type. - context["error"] = "No subscriptions found for your user type." - return context + print("request user is :", request.user) + obj = self.model.objects.filter( + principal_types=request.user.principal_type, + active=True, + is_free=False, + ) + if not obj.exists(): + print(f"No pre-define subscription details found in {self.model} table for user_type {request.user.principal_type}") + return HttpResponseRedirect(self.error_url) -class ActiveSubscriptionView(TemplateView): + context = { + "subscriptions": obj, + # "stripeCheckoutUrl": request.build_absolute_uri(reverse("manage_subscriptions:create_checkout_session")), + # "couponValidityCheckUrl": request.build_absolute_uri(reverse("manage_subscriptions:validate_coupon")), + "stripeCheckoutUrl": settings.STRIPE_CHECKOUT_URL, + "couponValidityCheckUrl": settings.COUPON_VALIDITY_CHECK_URL, + "stripe_public_key": settings.STRIPE_PUBLISH_KEY + } + return render(request, self.template_name, context=context) + +class ActiveSubscriptionView(generic.View): template_name = "stripe_html/active_subscription.html" + model = IAmPrincipal def get(self, request, *args, **kwargs): - token = request.GET.get("token") or request.session.get("jwt") + token = request.GET.get("token") print("token: ", token) + if token: - request.session["jwt"] = token - print("request.session: ", request.session) try: # Decode and validate token payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"]) - print("payload: ", payload) - user = get_user_model().objects.get(id=payload["user_id"]) + user = self.model.objects.get(id=payload["user_id"]) # Manually specify the authentication backend user.backend = "django.contrib.auth.backends.ModelBackend" # Log the user in login(request, user) - print("Logged in user: ", user) except ( IAmPrincipal.DoesNotExist, jwt.ExpiredSignatureError, jwt.InvalidTokenError, ): - return HttpResponseBadRequest("Invalid token or user not found") + return HttpResponseRedirect(reverse("manage_subscriptions:error")) + today = timezone.now().date() if request.user.is_authenticated: latest_subscription = PrincipalSubscription.objects.filter( principal=request.user, is_paid=True, - deleted=False, end_date__gte=today, ).order_by('-end_date').last() if not latest_subscription: return HttpResponseRedirect(reverse("manage_subscriptions:stripe")) - return super().get(request, *args, **kwargs) + + return render(request, self.template_name, context={"subscription": latest_subscription}) + return HttpResponseRedirect(reverse("manage_subscriptions:error")) - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - request = self.request - today = timezone.now().date() - if request.user.is_authenticated: - latest_subscription = PrincipalSubscription.objects.filter( - principal=request.user, - is_paid=True, - deleted=False, - end_date__gte=today, - ).order_by('-end_date').last() - context["active_subscription"] = latest_subscription - return context +class CancelAutoSubscriptionView(LoginRequiredMixin, generic.View): + model = PrincipalSubscription + error_url = reverse_lazy("manage_subscriptions:error") - -class CancelSubscriptionView(LoginRequiredMixin, generic.View): - def post(self, request, *args, **kwargs): - subscription_id = request.POST.get("subscription_id") + def get(self, request, *args, **kwargs): + subscription_id = self.kwargs.get("subscription_id") try: - subscription = PrincipalSubscription.objects.get( + subscription = self.model.objects.get( id=subscription_id, principal=request.user ) - except PrincipalSubscription.DoesNotExist: + except self.model.DoesNotExist: messages.error(request, "Subscription not found.") - return redirect("manage_subscriptions:cancel") + return redirect("manage_subscriptions:error") try: - with transaction.atomic(): - if subscription.is_stripe_subscription: - # Cancel Stripe subscription - stripe.Subscription.modify( - subscription.stripe_subscription_id, cancel_at_period_end=True - ) + if subscription.stripe_subscription_id: + data = StripeService.cancel_auto_renew_subscription(subscription.stripe_subscription_id) + if not data["success"]: + return redirect(self.error_url) - # Updating subscription status in the local database - subscription.status = SubscriptionStatus.INACTIVE - subscription.cancelled = True - subscription.auto_renew = False - subscription.cancelled_date_time = timezone.now() - subscription.save() + self.model.cancel_stipe_auto_renew_subscription(subscription) - messages.success(request, "Subscription cancelled successfully.") - return redirect("manage_subscriptions:subscription_cancel_success") - except stripe.error.InvalidRequestError as e: - messages.error(request, f"Stripe error: {str(e)}") - return redirect("manage_subscriptions:subscription_cancel_fails") + except Exception as e: + print(f'an error occur {str(e)}') + messages.error(request, f"An error occurred while cancelling the subscription {str(e)}") + return redirect(self.error_url) + + return redirect(reverse_lazy("manage_subscriptions:active")) + + # def post(self, request, *args, **kwargs): + # subscription_id = request.POST.get("subscription_id") + + # try: + # subscription = PrincipalSubscription.objects.get( + # id=subscription_id, principal=request.user + # ) + # except PrincipalSubscription.DoesNotExist: + # messages.error(request, "Subscription not found.") + # return redirect("manage_subscriptions:cancel") + + # try: + # with transaction.atomic(): + # if subscription.is_stripe_subscription: + # # Cancel Stripe subscription + # stripe.Subscription.modify( + # subscription.stripe_subscription_id, cancel_at_period_end=True + # ) + + # # Updating subscription status in the local database + # subscription.status = SubscriptionStatus.INACTIVE + # subscription.cancelled = True + # subscription.auto_renew = False + # subscription.cancelled_date_time = timezone.now() + # subscription.save() + + # messages.success(request, "Subscription cancelled successfully.") + # return redirect("manage_subscriptions:subscription_cancel_success") + # except stripe.error.InvalidRequestError as e: + # messages.error(request, f"Stripe error: {str(e)}") + # return redirect("manage_subscriptions:subscription_cancel_fails") @csrf_exempt @@ -826,10 +641,12 @@ def create_checkout_session(request): data = json.loads(request.body) subscription_id = data.get("subscriptionId") coupon_code = data.get("couponCode") - transaction_amount = data.get("discountAmount") + transaction_amount = data.get("finalAmount") is_recurring = data.get("isRecurring") principal_id = request.user.id + print(f"subscription data is {subscription_id}, {coupon_code}, { is_recurring}") + try: subscription = Subscription.objects.get(id=subscription_id) except Subscription.DoesNotExist: @@ -842,14 +659,10 @@ def create_checkout_session(request): "success_url": request.build_absolute_uri("/subscriptions/success/"), "cancel_url": request.build_absolute_uri("/subscriptions/cancel/"), "metadata": { - "transaction_amount": str(transaction_amount), + "transaction_amount": str(subscription.amount), "principal": str(request.user.id), "subscription_id": str(subscription.id), - "product_id": str( - subscription.stripe_product.product_id - if subscription.stripe_product - else None - ), + "product_id": subscription.product_id, "couponCode": coupon_code if coupon_code else None, }, } @@ -900,6 +713,8 @@ def create_checkout_session(request): return JsonResponse({"error": str(e)}, status=500) +class ErrorView(TemplateView): + template_name = "stripe_html/webview_404.html" class SuccessView(TemplateView): template_name = "stripe_html/success.html" diff --git a/templates/manage_subscriptions/subscription_list.html b/templates/manage_subscriptions/subscription_list.html index 41afe54..77c1843 100644 --- a/templates/manage_subscriptions/subscription_list.html +++ b/templates/manage_subscriptions/subscription_list.html @@ -48,7 +48,10 @@ style="width: 69.2656px;"> Title Plan Days + style="width: 69.2656px;"> Interval + Interval Count Amount @@ -58,9 +61,6 @@ Free for Admin - Stripe Product Active {{data_obj.id}} {{data_obj.title}} - {{data_obj.plan.days}} + {{data_obj.interval | capfirst}} + {{data_obj.interval_count }} {{data_obj.amount}} {% if data_obj.principal_types.all %} @@ -88,13 +89,6 @@ {{data_obj.is_free}} - - {% if data_obj.stripe_product %} - {{ data_obj.stripe_product.product_id }} - {% else %} - N/A - {% endif %} - {{data_obj.active}} diff --git a/templates/stripe_html/active_subscription.html b/templates/stripe_html/active_subscription.html index c6bef02..5970cf9 100644 --- a/templates/stripe_html/active_subscription.html +++ b/templates/stripe_html/active_subscription.html @@ -16,14 +16,13 @@ } .container { - padding: 40px 15px; + padding: 0 15px 20px; } .card { background-color: var(--light-black); border: 1px solid var(--main-yellow); border-radius: 8px; - margin-top: 20px; padding: 20px; } @@ -93,7 +92,7 @@ border-bottom: 2px solid #d4af37 !important; } .btn-outline-gold { - color: #d4af37; + color: #000; border-color: #d4af37; } .btn-outline-gold:hover { @@ -107,44 +106,40 @@
-

Your Active Subscription

+

Subscription Details

-

{{ active_subscription.subscription.title }}

+

{{ subscription.subscription.title }}

Full Name:
-

{{ active_subscription.principal.first_name }} {{ active_subscription.principal.last_name }}

-

Status: {{ active_subscription.get_status_display }}

-

Start Date: {{ active_subscription.start_date }}

-

End Date: {{ active_subscription.end_date }}

-

Auto Renew: {{ active_subscription.auto_renew|yesno:"Yes,No" }}

+

{{ subscription.principal.first_name }} {{ subscription.principal.last_name }}

+

Status: {{ subscription.get_status_display }}

+

Start Date: {{ subscription.start_date }}

+

End Date: {{ subscription.end_date }}

+

Auto Renew: {{ subscription.auto_renew|yesno:"Yes,No" }}

- {% if active_subscription.coupon_code %} -

Coupon Code: {{ active_subscription.coupon_code }}

+ {% if subscription.coupon_code %} +

Coupon Code: {{ subscription.coupon_code }}

{% endif %} - {% if active_subscription.cancelled %} + {% if subscription.cancelled_date_time %}
-

Cancellation Details

-

Cancelled: Yes

-

Cancellation Date: {{ active_subscription.cancelled_date_time }}

-

Grace Period Ends: {{ active_subscription.grace_period_end_date }}

+

Auto renew cancellation details

+

Cancellation Date: {{ subscription.cancelled_date_time }}

+

Grace Period End Date: {{ subscription.grace_period_end_date }}

{% endif %} - {% if active_subscription.auto_renew and not active_subscription.cancelled %} -
-

Cancel Subscription

-
- {% csrf_token %} - - -
-
+ {% if subscription.auto_renew and not subscription.cancelled_date_time %} +
+

Cancel Auto-Renewing Subscription

+

Click the button below to cancel your auto-renewing subscription. This will prevent future payments from being processed.

+ Cancel Auto-Renewal +
{% endif %}
diff --git a/templates/stripe_html/index.html b/templates/stripe_html/index.html index d811599..61bc410 100644 --- a/templates/stripe_html/index.html +++ b/templates/stripe_html/index.html @@ -120,34 +120,25 @@ {% else %}

£ {{ subscription.amount }}

{% endif %} - {% if subscription.plan.days %} -

Days of Subscription: {{ subscription.plan.days }}

- {% else %} -

Days of Subscription: Not available

- {% endif %} +

Subscription Cycle: {{subscription.interval_count}} {{ subscription.interval | capfirst }}

- + {% comment %} {% endcomment %}
- -
- +
- - - - {% empty %} -

No subscriptions available.

{% endfor %} @@ -523,38 +514,82 @@ diff --git a/templates/stripe_html/webview_404.html b/templates/stripe_html/webview_404.html new file mode 100644 index 0000000..803c753 --- /dev/null +++ b/templates/stripe_html/webview_404.html @@ -0,0 +1,129 @@ + + + + + + + Active Subscription + + + + + + + {% comment %}
+

Your Active Subscription

+
{% endcomment %} + +
+
+
+

404

+
+
+
An error occurred. Please try again later.
+
+
+
+ + + + + + + From 5d107ad17a2826e82173b462931b91fe3c119786 Mon Sep 17 00:00:00 2001 From: bobbyvish Date: Wed, 21 Aug 2024 23:24:47 +0530 Subject: [PATCH 2/3] 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, From 1f580c099dd684db7b6a196707cb3f3e8e9995da Mon Sep 17 00:00:00 2001 From: bobbyvish Date: Fri, 23 Aug 2024 12:26:09 +0530 Subject: [PATCH 3/3] refactor(subscription): removed unnecessary code --- goodtimes/settings/development.py | 4 +- goodtimes/webhook/subscription_service.py | 4 +- .../management/commands/one_week_alert.py | 1 - manage_subscriptions/admin.py | 44 +----- manage_subscriptions/api/views.py | 4 +- manage_subscriptions/forms.py | 26 ---- .../0013_remove_subscription_plan_and_more.py | 43 ++++++ manage_subscriptions/models.py | 66 ++------ manage_subscriptions/urls.py | 21 --- manage_subscriptions/utils.py | 20 --- manage_subscriptions/views.py | 141 +----------------- templates/elements/sidebar.html | 4 +- templates/manage_subscriptions/plan_add.html | 135 ----------------- templates/manage_subscriptions/plan_list.html | 107 ------------- .../principal_subscription_details.html | 7 +- .../manage_subscriptions/product_add.html | 49 ------ .../manage_subscriptions/product_list.html | 104 ------------- .../subscription_list.html | 10 -- templates/stripe_html/webview_404.html | 2 +- 19 files changed, 75 insertions(+), 717 deletions(-) create mode 100644 manage_subscriptions/migrations/0013_remove_subscription_plan_and_more.py delete mode 100644 templates/manage_subscriptions/plan_add.html delete mode 100644 templates/manage_subscriptions/plan_list.html delete mode 100644 templates/manage_subscriptions/product_add.html delete mode 100644 templates/manage_subscriptions/product_list.html diff --git a/goodtimes/settings/development.py b/goodtimes/settings/development.py index 42b58d3..92b63e2 100644 --- a/goodtimes/settings/development.py +++ b/goodtimes/settings/development.py @@ -51,7 +51,7 @@ STATIC_URL = "/static/" STATICFILES_DIRS = [BASE_DIR.joinpath("static")] -STRIPE_CHECKOUT_URL = "https://c2f5-122-179-140-110.ngrok-free.app/subscriptions/create-checkout-session/" -COUPON_VALIDITY_CHECK_URL = "https://c2f5-122-179-140-110.ngrok-free.app/subscriptions/coupon-validity-check/" +STRIPE_CHECKOUT_URL = "https://deciding-firmly-fly.ngrok-free.app/subscriptions/create-checkout-session/" +COUPON_VALIDITY_CHECK_URL = "https://deciding-firmly-fly.ngrok-free.app/subscriptions/coupon-validity-check/" LOGO_PATH = "static" diff --git a/goodtimes/webhook/subscription_service.py b/goodtimes/webhook/subscription_service.py index 45e9d06..ef9ba32 100644 --- a/goodtimes/webhook/subscription_service.py +++ b/goodtimes/webhook/subscription_service.py @@ -30,10 +30,10 @@ class SubscriptionService: ): """Create a principal subscription and return it.""" start_date, end_date = self._calculate_dates( - current_period_start, current_period_end, subscription.calulate_days() + current_period_start, current_period_end, subscription.calculate_days() ) - PrincipalSubscription.objects.filter(principal=principal, status=SubscriptionStatus.ACTIVE).update(status=SubscriptionStatus.EXPIRED) + PrincipalSubscription.objects.filter(principal=principal, status=SubscriptionStatus.ACTIVE).update(status=SubscriptionStatus.EXPIRED, active=False) principal_subscription = PrincipalSubscription.objects.create( principal=principal, diff --git a/manage_notifications/management/commands/one_week_alert.py b/manage_notifications/management/commands/one_week_alert.py index a81d9ff..4dfbb9d 100644 --- a/manage_notifications/management/commands/one_week_alert.py +++ b/manage_notifications/management/commands/one_week_alert.py @@ -110,7 +110,6 @@ class Command(BaseCommand): return IAmPrincipalNotificationSettings.objects.filter( principal__principal_subscription__end_date=target_date, principal__principal_subscription__status=SubscriptionStatus.ACTIVE, - principal__principal_subscription__cancelled=False, principal__principal_subscription__deleted=False, notification_category=NotificationCategoryChoices.SUBSCRIPTION, # is_enabled=True, diff --git a/manage_subscriptions/admin.py b/manage_subscriptions/admin.py index 63c094d..c3fe4ca 100644 --- a/manage_subscriptions/admin.py +++ b/manage_subscriptions/admin.py @@ -1,32 +1,19 @@ from django.contrib import admin from .models import ( - Plan, PrincipalSubscription, - StripeProduct, Subscription, WebhookEvent, ) # Update this with the correct import path for your models -# Plan ModelAdmin -class PlanAdmin(admin.ModelAdmin): - list_display = ("id", "title", "days") # Include 'id' field here - search_fields = ("title",) # Add search functionality by title - - -# Register Plan with the admin site -admin.site.register(Plan, PlanAdmin) - - # Subscription ModelAdmin class SubscriptionAdmin(admin.ModelAdmin): - list_display = ("id", "title", "plan", "amount") # Include 'id' field here - list_select_related = ("plan",) # Optimizes queries for the plan field + list_display = ("id", "title", "interval", "amount") # Include 'id' field here + list_select_related = ("interval",) # Optimizes queries for the interval field search_fields = ( "title", - "plan__title", - ) # Add search functionality by title and plan's title - raw_id_fields = ("plan",) # Use a raw ID widget for the plan ForeignKey field + "interval", + ) # Add search functionality by title and interval's title # Register Subscription with the admin site @@ -48,7 +35,7 @@ class PrincipalSubscriptionAdmin(admin.ModelAdmin): "is_paid", "auto_renew", "status", - "cancelled", + # "cancelled", ) # Enable filtering by these fields search_fields = ( "subscription__title", @@ -64,27 +51,6 @@ class PrincipalSubscriptionAdmin(admin.ModelAdmin): admin.site.register(PrincipalSubscription, PrincipalSubscriptionAdmin) - -class StripeProductAdmin(admin.ModelAdmin): - list_display = ("id", "title", "product_id", "default_price_id") - search_fields = ("title", "product_id", "description") - list_filter = ("default_price_id",) - readonly_fields = ("product_id", "default_price_id") - fields = ( - "title", - "description", - "metadata", - "image_url", - "product_id", - "default_price_id", - "active", - "deleted", - ) - - -admin.site.register(StripeProduct, StripeProductAdmin) - - @admin.register(WebhookEvent) class WebhookEventAdmin(admin.ModelAdmin): list_display = ("event_id", "received_at", "event_type", "processed_at", "status") diff --git a/manage_subscriptions/api/views.py b/manage_subscriptions/api/views.py index 44acebb..cb32eab 100644 --- a/manage_subscriptions/api/views.py +++ b/manage_subscriptions/api/views.py @@ -315,7 +315,7 @@ class CancelSubscription(APIView): ) with transaction.atomic(): - if subscription.is_stripe_subscription: + if subscription.stripe_subscription_id: # Cancel Stripe subscription try: stripe.Subscription.modify(subscription.stripe_subscription_id, cancel_at_period_end=True) @@ -328,7 +328,7 @@ class CancelSubscription(APIView): # Updating subscription status in the local database subscription.status = SubscriptionStatus.INACTIVE - subscription.cancelled = True + # subscription.cancelled = True subscription.cancelled_date_time = timezone.now() subscription.save() diff --git a/manage_subscriptions/forms.py b/manage_subscriptions/forms.py index 1802c77..ececb47 100644 --- a/manage_subscriptions/forms.py +++ b/manage_subscriptions/forms.py @@ -2,25 +2,9 @@ from django import forms from accounts.models import IAmPrincipalType from manage_subscriptions.models import ( PrincipalSubscription, - StripeProduct, Subscription, - Plan, ) - -class PlanForm(forms.ModelForm): - class Meta: - model = Plan - fields = ["title", "days"] # Include all fields you want from the model - - # You can add custom validation for Plan fields here if needed - # Example: - # def clean_title(self): - # title = self.cleaned_data.get('title') - # # Add your validation logic here - # return title - - class SubscriptionForm(forms.ModelForm): class Meta: model = Subscription @@ -66,13 +50,3 @@ class PrincipalSubscriptionForm(forms.ModelForm): } -class StripeProductForm(forms.ModelForm): - class Meta: - model = StripeProduct - fields = [ - "title", - "description", - ] - widgets = { - "description": forms.Textarea(attrs={"rows": 3}), - } diff --git a/manage_subscriptions/migrations/0013_remove_subscription_plan_and_more.py b/manage_subscriptions/migrations/0013_remove_subscription_plan_and_more.py new file mode 100644 index 0000000..be3ba14 --- /dev/null +++ b/manage_subscriptions/migrations/0013_remove_subscription_plan_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 5.0.2 on 2024-08-21 18:10 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('manage_subscriptions', '0012_subscription_interval_subscription_interval_count'), + ] + + operations = [ + migrations.RemoveField( + model_name='subscription', + name='plan', + ), + migrations.RemoveField( + model_name='stripeproduct', + name='created_by', + ), + migrations.RemoveField( + model_name='stripeproduct', + name='modified_by', + ), + migrations.RemoveField( + model_name='subscription', + name='stripe_product', + ), + migrations.RemoveField( + model_name='principalsubscription', + name='cancelled', + ), + migrations.RemoveField( + model_name='principalsubscription', + name='is_stripe_subscription', + ), + migrations.DeleteModel( + name='Plan', + ), + migrations.DeleteModel( + name='StripeProduct', + ), + ] diff --git a/manage_subscriptions/models.py b/manage_subscriptions/models.py index e21b816..b14e238 100644 --- a/manage_subscriptions/models.py +++ b/manage_subscriptions/models.py @@ -5,34 +5,6 @@ from django.core.exceptions import ValidationError from accounts.models import BaseModel, IAmPrincipal, IAmPrincipalType from django.utils.translation import gettext_lazy as _ -# Create your models here. - - -class Plan(BaseModel): - title = models.CharField(max_length=255) - days = models.PositiveIntegerField() - - class Meta: - db_table = "plan" - - def __str__(self): - return self.title - - -class StripeProduct(BaseModel): - title = models.CharField(max_length=255) - product_id = models.CharField(max_length=255, blank=True, null=True) - description = models.TextField(blank=True, null=True) - metadata = models.JSONField(blank=True, null=True) - image_url = models.URLField(blank=True, null=True) - default_price_id = models.CharField(max_length=255, blank=True, null=True) - - class Meta: - db_table = "stripe_product" - - def __str__(self): - return self.title - class Subscription(BaseModel): MONTH = "month" @@ -49,19 +21,9 @@ class Subscription(BaseModel): title = models.CharField(max_length=255) price_id = models.CharField(max_length=255, blank=True, null=True) product_id = models.CharField(max_length=255, blank=True, null=True) - stripe_product = models.ForeignKey( - StripeProduct, - related_name="subscription_product", - on_delete=models.CASCADE, - null=True, - blank=True, - ) short_description = models.CharField(max_length=255, null=True, blank=True) long_description = models.TextField(null=True, blank=True) image = models.ImageField(upload_to="subscription_img", null=True, blank=True) - plan = models.ForeignKey( - Plan, related_name="subscription_plan", on_delete=models.CASCADE - ) interval = models.CharField(max_length=10, choices=INTERVAL_TYPES) interval_count = models.IntegerField(default=1) high_amount = models.DecimalField(max_digits=14, decimal_places=2, default=0.00) @@ -110,13 +72,13 @@ class Subscription(BaseModel): # Create new product and price price = StripeService.create_price( product_data={ - "name": self.title, + "name": self.txitle, "description": self.short_description, }, unit_amount=int(self.amount * 100), currency="gbp", recurring={ - "interval": self.plan.title, + "interval": self.interval, "interval_count": self.interval_count, }, metadata={ @@ -133,15 +95,14 @@ class Subscription(BaseModel): super().save(*args, **kwargs) - def calculate_date(self): - count = { - self.DAY: 1, - self.MONTH: 30, # assuming a month is 30 days - self.YEAR: 365, - self.WEEK: 7 - } - - return count[self.interval] * self.interval_count + def calculate_days(self): + count = { + self.DAY: 1, + self.MONTH: 30, # assuming a month is 30 days + self.YEAR: 365, + self.WEEK: 7 + } + return count[self.interval] * self.interval_count @@ -169,13 +130,11 @@ class PrincipalSubscription(BaseModel): start_date = models.DateField() end_date = models.DateField() order_id = models.CharField(max_length=255, null=True, blank=True) - cancelled = models.BooleanField(default=False) cancelled_date_time = models.DateTimeField(null=True, blank=True) grace_period_end_date = models.DateField(null=True, blank=True) stripe_customer_id = models.CharField(max_length=255, null=True, blank=True) stripe_subscription_id = models.CharField(max_length=255, null=True, blank=True) comments = models.CharField(max_length=255, null=True, blank=True) - is_stripe_subscription = models.BooleanField(default=False) payment_intent_id = models.CharField(max_length=255, null=True, blank=True) payment_intent_client_secret = models.CharField( max_length=255, null=True, blank=True @@ -203,7 +162,6 @@ class PrincipalSubscription(BaseModel): return cls.objects.filter( principal=principal, is_paid=True, - # cancelled=False, active=True, status=SubscriptionStatus.ACTIVE, grace_period_end_date__gt=timezone.now().date(), @@ -214,18 +172,16 @@ class PrincipalSubscription(BaseModel): return cls.objects.filter( principal=principal, is_paid=True, - # cancelled=False, active=True, status=SubscriptionStatus.ACTIVE, end_date__gt=timezone.now().date(), - ) + ).order_by('-end_date').last() @classmethod def get_principal_subscription(cls, principal): return cls.objects.filter( principal=principal, is_paid=True, - # cancelled=False, active=True, status=SubscriptionStatus.ACTIVE, ).order_by("-grace_period_end_date").first() diff --git a/manage_subscriptions/urls.py b/manage_subscriptions/urls.py index bc69c93..6e69838 100644 --- a/manage_subscriptions/urls.py +++ b/manage_subscriptions/urls.py @@ -18,17 +18,6 @@ urlpatterns = [ views.SubscriptionDeleteView.as_view(), name="subscription_delete", ), - # Stripe Products - path( - "product/list/", views.StripeProductView.as_view(), name="stripe_product_list" - ), - path( - "product/add/", - views.StripeProductCreateOrUpdateView.as_view(), - name="stripe_product_add", - ), - # PLANS - path("plan/list/", views.PlanView.as_view(), name="plan_list"), # Principal Subscription path( @@ -36,11 +25,6 @@ urlpatterns = [ views.PrincipalSubscriptionView.as_view(), name="principal_subscriptions_list", ), - # path( - # "principal_subscription/add/", - # views.PrincipalSubscriptionCreateOrUpdateView.as_view(), - # name="principal_subscription_add", - # ), path( "principal_subscription/edit//", views.PrincipalSubscriptionCreateOrUpdateView.as_view(), @@ -56,11 +40,6 @@ urlpatterns = [ views.PrincipalSubscriptionDeleteView.as_view(), name="principal_subscription_delete", ), - path( - "stripe-subscription/", - views.stripe_config, - name="stripe_subscription", - ), path( "create-checkout-session/", views.create_checkout_session, diff --git a/manage_subscriptions/utils.py b/manage_subscriptions/utils.py index f69e4b6..a7dbd83 100644 --- a/manage_subscriptions/utils.py +++ b/manage_subscriptions/utils.py @@ -8,26 +8,6 @@ API_KEY = settings.GOOGLE_MAPS_API_KEY gmaps = googlemaps.Client(key=API_KEY) -def get_active_subscription_id_for_principal(principal): - # Filter subscriptions for the principal that are active and not cancelled - active_subscriptions = PrincipalSubscription.objects.filter( - principal=principal, - status=SubscriptionStatus.ACTIVE, - is_paid=True, - cancelled=False, - deleted=False, - active=True, - end_date__gte=now().date(), # Ensure the subscription hasn't expired - ).order_by( - "-end_date" - ) # Order by end_date to get the most recent active subscription - - if active_subscriptions.exists(): - # Return the ID of the most recent active subscription - return active_subscriptions.first().id - return None - - def get_location_info(latitude, longitude): reverse_geocode_result = gmaps.reverse_geocode((latitude, longitude)) diff --git a/manage_subscriptions/views.py b/manage_subscriptions/views.py index ebc04a2..ab5f782 100644 --- a/manage_subscriptions/views.py +++ b/manage_subscriptions/views.py @@ -12,7 +12,6 @@ from django.contrib.auth import get_user_model from goodtimes.services import StripeService from manage_coupons.models import Coupon from manage_subscriptions.forms import ( - StripeProductForm, SubscriptionForm, PrincipalSubscriptionForm, ) @@ -23,8 +22,6 @@ from manage_wallets.models import ( TransactionType, ) from .models import ( - Plan, - StripeProduct, Subscription, PrincipalSubscription, SubscriptionStatus, @@ -179,129 +176,6 @@ class SubscriptionDeleteView(LoginRequiredMixin, generic.View): return redirect(self.success_url) -class StripeProductCreateOrUpdateView(LoginRequiredMixin, generic.View): - # Set the page_name and resource - page_name = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS - resource = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS - - # Initialize the action as ACTION_CREATE (can change based on logic) - action = resource_action.ACTION_CREATE # Default action - - template_name = "manage_subscriptions/product_add.html" - model = StripeProduct - form_class = StripeProductForm - success_url = reverse_lazy("manage_subscriptions:stripe_product_list") - error_message = "An error occurred while saving the data." - - # Determine the success message dynamically based on whether it's an update or create - def get_success_message(self): - self.success_message = ( - constants.RECORD_CREATED if not self.object else constants.RECORD_UPDATED - ) - return self.success_message - - # Get the object (if exists) based on URL parameter 'pk' - def get_object(self): - pk = self.kwargs.get("pk") - return get_object_or_404(self.model, pk=pk) if pk else None - - # Add page_name and operation to the context - def get_context_data(self, **kwargs): - context = { - "page_name": self.page_name, - "operation": "Add" if not self.object else "Edit", - } - context.update(kwargs) # Include any additional context data passed to the view - return context - - def get(self, request, *args, **kwargs): - self.object = self.get_object() - - # If an object is found, change action to ACTION_UPDATE - if self.object is not None: - self.action = resource_action.ACTION_UPDATE - - form = self.form_class(instance=self.object) - context = self.get_context_data(form=form) - return render(request, self.template_name, context=context) - - def post(self, request, *args, **kwargs): - self.object = self.get_object() - - # If an object is found, change action to ACTION_UPDATE - if self.object is not None: - self.action = resource_action.ACTION_UPDATE - - form = self.form_class(request.POST, instance=self.object) - if not form.is_valid(): - print(form.errors) - context = self.get_context_data(form=form) - return render(request, self.template_name, context=context) - - success, message = self.handle_stripe_product(form) - if not success: - messages.error(self.request, message) - context = self.get_context_data(form=form) - return render(request, self.template_name, context=context) - - form.save() - messages.success(self.request, self.get_success_message()) - return redirect(self.success_url) - - def handle_stripe_product(self, form): - try: - stripe.api_key = settings.STRIPE_SECRET_KEY - - stripe_product = stripe.Product.create( - name=form.cleaned_data.get("title"), - description=form.cleaned_data.get("description"), - ) - - # Save Stripe Product ID to the form instance - form.instance.product_id = stripe_product.id - - return True, "" # Success - except stripe.error.StripeError as e: - return False, f"Stripe error: {str(e)}" - except Exception as e: - return False, f"An error occurred: {str(e)}" - - -class StripeProductView(LoginRequiredMixin, generic.ListView): - page_name = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS - resource = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS - action = resource_action.ACTION_READ - model = StripeProduct - template_name = "manage_subscriptions/product_list.html" - context_object_name = "product_obj" - - def get_queryset(self): - queryset = super().get_queryset().filter(deleted=False, active=True) - return queryset.order_by("-created_on") - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["page_name"] = self.page_name - return context - - -class PlanView(LoginRequiredMixin, generic.ListView): - page_name = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS - resource = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS - action = resource_action.ACTION_READ - model = Plan - template_name = "manage_subscriptions/plan_list.html" - context_object_name = "plan_obj" - - def get_queryset(self): - return super().get_queryset().filter(deleted=False) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["page_name"] = self.page_name - return context - - class PrincipalSubscriptionCreateOrUpdateView(LoginRequiredMixin, generic.View): # Set the page_name and resource page_name = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS @@ -474,11 +348,9 @@ class ActiveSubscriptionView(generic.View): today = timezone.now().date() if request.user.is_authenticated: - latest_subscription = PrincipalSubscription.objects.filter( - principal=request.user, - is_paid=True, - end_date__gte=today, - ).order_by('-end_date').last() + latest_subscription = PrincipalSubscription.get_active_princial_subscription(request.user) + + print(f"latest subscription reodr is {latest_subscription}") if not latest_subscription: return HttpResponseRedirect(reverse("manage_subscriptions:stripe")) @@ -549,13 +421,6 @@ class CancelAutoSubscriptionView(LoginRequiredMixin, generic.View): # return redirect("manage_subscriptions:subscription_cancel_fails") -@csrf_exempt -def stripe_config(request): - if request.method == "GET": - stripe_config = {"publicKey": settings.STRIPE_PUBLISH_KEY} - return JsonResponse(stripe_config, safe=False) - - @csrf_exempt @require_POST def validate_coupon(request): diff --git a/templates/elements/sidebar.html b/templates/elements/sidebar.html index a493e8a..4e9564a 100644 --- a/templates/elements/sidebar.html +++ b/templates/elements/sidebar.html @@ -154,7 +154,7 @@ {% endif %} - {% if user|has_resource_permission:resource_context.RESOURCE_MANAGE_COUPONS %} + {% comment %} {% if user|has_resource_permission:resource_context.RESOURCE_MANAGE_COUPONS %} - {% endif %} + {% endif %} {% endcomment %} {% if user|has_resource_permission:resource_context.RESOURCE_PRINCIPAL_SUBSCRIPTIONS %}