diff --git a/accounts/api/serializers.py b/accounts/api/serializers.py index ac0580d..de7f3a4 100644 --- a/accounts/api/serializers.py +++ b/accounts/api/serializers.py @@ -137,8 +137,10 @@ class PasswordResetSerializer(BasePasswordSerializer, serializers.ModelSerialize model = IAmPrincipal fields = ["password", "confirm_password"] + from phonenumbers import parse, phonenumberutil, NumberParseException + class ProfileSerializer(serializers.ModelSerializer): profile_photo = serializers.ImageField(required=False) principal_type_name = serializers.SerializerMethodField(read_only=True) @@ -256,6 +258,7 @@ class ProfileExtendedDataSerializer(serializers.ModelSerializer): "has_active_subscription": False, "in_grace_period": False, "grace_period_end_date": None, + "subscription_id": None, } today = timezone.now().date() @@ -265,6 +268,9 @@ class ProfileExtendedDataSerializer(serializers.ModelSerializer): print(f"subscrition record {latest_subscription}") if latest_subscription: + subscription_status["subscription_id"] = ( + latest_subscription.stripe_subscription_id + ) # Check if we're within the grace period if today <= latest_subscription.grace_period_end_date: subscription_status["has_active_subscription"] = ( @@ -387,4 +393,4 @@ class AppVersionSerializer(serializers.ModelSerializer): class IAmPrincipalExtendedDataSerializer(serializers.ModelSerializer): class Meta: model = IAmPrincipalExtendedData - fields = "__all__" \ No newline at end of file + fields = "__all__" diff --git a/accounts/context_processors.py b/accounts/context_processors.py index 98689de..0eecf0f 100644 --- a/accounts/context_processors.py +++ b/accounts/context_processors.py @@ -44,6 +44,7 @@ def compute_resource_action_constants(request): 'RESOURCE_MANAGE_NOTIFICATIONS': resource_action.RESOURCE_MANAGE_NOTIFICATIONS, 'RESOURCE_MANAGE_REFERRALS': resource_action.RESOURCE_MANAGE_REFERRALS, 'RESOURCE_MANAGE_FEEDBACK': resource_action.RESOURCE_MANAGE_FEEDBACK, + 'RESOURCE_MANAGE_COUPONS': resource_action.RESOURCE_MANAGE_COUPONS, 'RESOURCE_IAM_PRINCIPAL': resource_action.RESOURCE_IAM_PRINCIPAL, 'RESOURCE_IAM_PRINCIPAL_GROUP': resource_action.RESOURCE_IAM_PRINCIPAL_GROUP, 'RESOURCE_IAM_GROUP': resource_action.RESOURCE_IAM_GROUP, diff --git a/accounts/fixture_script.py b/accounts/fixture_script.py index 663ee5b..921f26d 100644 --- a/accounts/fixture_script.py +++ b/accounts/fixture_script.py @@ -27,7 +27,8 @@ from accounts.resource_action import ( RESOURCE_MANAGE_REFERRALS, RESOURCE_MANAGE_FEEDBACK, RESOURCE_MANAGE_WITHDRAWALS, - RESOURCE_MANAGE_BANK_ACCOUNTS + RESOURCE_MANAGE_BANK_ACCOUNTS, + RESOURCE_MANAGE_COUPONS ) # this variable store the data of model principaltype, action, resource fixture_data = [ @@ -334,4 +335,16 @@ fixture_data = [ "action": [1, 2, 3, 4], }, }, + { + "model": "accounts.iamappresource", + "pk": 18, + "fields": { + "name": RESOURCE_MANAGE_COUPONS, + "label": RESOURCE_MANAGE_COUPONS, + "slug": RESOURCE_MANAGE_COUPONS, + "created_on": "2023-09-28T16:17:42.815", + "modified_on": "2023-09-28T16:17:42.815", + "action": [1, 2, 3, 4], + }, + }, ] diff --git a/accounts/fixtures/resource_action_fixture.json b/accounts/fixtures/resource_action_fixture.json index df2e808..e48d27f 100644 --- a/accounts/fixtures/resource_action_fixture.json +++ b/accounts/fixtures/resource_action_fixture.json @@ -386,5 +386,22 @@ 4 ] } + }, + { + "model": "accounts.iamappresource", + "pk": 18, + "fields": { + "name": "manage_coupons", + "label": "manage_coupons", + "slug": "manage_coupons", + "created_on": "2023-09-28T16:17:42.815", + "modified_on": "2023-09-28T16:17:42.815", + "action": [ + 1, + 2, + 3, + 4 + ] + } } ] \ No newline at end of file diff --git a/accounts/resource_action.py b/accounts/resource_action.py index 3e10751..7bad7a7 100644 --- a/accounts/resource_action.py +++ b/accounts/resource_action.py @@ -28,6 +28,7 @@ RESOURCE_MANAGE_REFERRALS = "manage_referrals" RESOURCE_MANAGE_NOTIFICATIONS = "manage_notifications" RESOURCE_MANAGE_WITHDRAWALS = "manage_withdrawals" RESOURCE_MANAGE_BANK_ACCOUNTS = "manage_bank_accounts" +RESOURCE_MANAGE_COUPONS = "manage_coupons" # These constants are used solely for managing the active and inactive state of pages diff --git a/goodtimes/settings/base.py b/goodtimes/settings/base.py index b6abe36..c4a02c3 100644 --- a/goodtimes/settings/base.py +++ b/goodtimes/settings/base.py @@ -64,6 +64,7 @@ LOCAL_APPS = [ "manage_referrals", "manage_cms", "manage_communications", # for contact us, and feedback + "manage_coupons", "manage_notifications.apps.ManageNotificationsConfig", "chat", ] @@ -302,8 +303,11 @@ SIMPLE_JWT = { "JTI_CLAIM": "jti", } -STRIPE_SECRET_KEY = env.str("STRIPE_SECRET_KEY") -STRIPE_PUBLISH_KEY = env.str("STRIPE_PUBLISH_KEY") +STRIPE_SECRET_KEY = "sk_test_51OexsKCesU6kunsIsbSKSZc1BF4gjklniaue8lmpkGKqDzenQtMkR8tKAryxErJXqp0jPiu1Gg7papa4tqZfKL9G00qUM4toB2" +STRIPE_PUBLISH_KEY = "pk_test_51OexsKCesU6kunsINDvKUhbelxeUmDAVZGSOisZ6XXHCp3pKtl4vs0pR42w0OcjZhngmECsXQNbAKNPOhiFMTJ8o00sRZQG0lh" + +# STRIPE_SECRET_KEY = env.str("STRIPE_SECRET_KEY") +# STRIPE_PUBLISH_KEY = env.str("STRIPE_PUBLISH_KEY") ONE_SIGNAL_APP_ID = env.str("ONE_SIGNAL_APP_ID") diff --git a/goodtimes/settings/development.py b/goodtimes/settings/development.py index f33f818..0fc2e13 100644 --- a/goodtimes/settings/development.py +++ b/goodtimes/settings/development.py @@ -26,17 +26,17 @@ CORS_ORIGIN_ALLOW_ALL = True CORS_ORIGIN_WHITELIST = ("http://localhost:3000",) -if DEBUG: - MIDDLEWARE += [ - "debug_toolbar.middleware.DebugToolbarMiddleware", - ] - INSTALLED_APPS += [ - "debug_toolbar", - "django_extensions", - ] - INTERNAL_IPS = [ - "127.0.0.1", - ] +# if DEBUG: +# MIDDLEWARE += [ +# "debug_toolbar.middleware.DebugToolbarMiddleware", +# ] +# INSTALLED_APPS += [ +# "debug_toolbar", +# "django_extensions", +# ] +# INTERNAL_IPS = [ +# "127.0.0.1", +# ] BASE_DOMAIN = "http://192.168.29.219:8000" @@ -44,17 +44,15 @@ BASE_DOMAIN = "http://192.168.29.219:8000" # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/4.2/howto/static-files/ - MEDIA_URL = "/media/" MEDIA_ROOT = os.path.join(BASE_DIR, "media") -# STATIC_ROOT = os.path.join(BASE_DIR, "static") 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/" LOGO_PATH = "static" diff --git a/goodtimes/settings/production.py b/goodtimes/settings/production.py index c3ed2bf..edd145c 100644 --- a/goodtimes/settings/production.py +++ b/goodtimes/settings/production.py @@ -82,5 +82,6 @@ STRIPE_CHECKOUT_URL = ( 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/" LOGO_PATH = "/var/www/goodtimes_prod/goodtimes/static" diff --git a/goodtimes/settings/staging.py b/goodtimes/settings/staging.py index 315c495..ade2303 100644 --- a/goodtimes/settings/staging.py +++ b/goodtimes/settings/staging.py @@ -82,5 +82,6 @@ STRIPE_CHECKOUT_URL = ( 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/" LOGO_PATH = "/var/www/goodtimes/static" diff --git a/goodtimes/settings/wdipl.py b/goodtimes/settings/wdipl.py index fdc2f39..89d7593 100644 --- a/goodtimes/settings/wdipl.py +++ b/goodtimes/settings/wdipl.py @@ -5,7 +5,7 @@ import colorlog # from logging.handlers import TimedRotatingFileHandler -DEBUG = False +DEBUG = True ALLOWED_HOSTS = ["127.0.0.1", "goodtimes.betadelivery.com", "154.41.254.33"] @@ -60,7 +60,7 @@ ALLOWED_HOSTS = ["127.0.0.1", "goodtimes.betadelivery.com", "154.41.254.33"] # }, # } -# BASE_DOMAIN = "https://goodtimes.betadelivery.com" +BASE_DOMAIN = "https://goodtimes.betadelivery.com" # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/4.2/howto/static-files/ @@ -81,3 +81,11 @@ STRIPE_CHECKOUT_URL = ( STRIPE_FINAL_URL = ( "https://goodtimes.betadelivery.com/subscriptions/create-checkout-session/" ) +COUPON_VALIDITY_CHECK_URL = ( + "https://goodtimes.betadelivery.com/subscriptions/coupon-validity-check/" +) + +LOGO_PATH = "/var/www/goodtimes/static" + +STRIPE_TEST_MODE_SECRET_KEY = env.str("STRIPE_TEST_MODE_SECRET_KEY") +STRIPE_TEST_MODE_PUBLISH_KEY = env.str("STRIPE_TEST_MODE_PUBLISH_KEY") diff --git a/goodtimes/urls.py b/goodtimes/urls.py index 5414dc6..c31e7f0 100644 --- a/goodtimes/urls.py +++ b/goodtimes/urls.py @@ -53,6 +53,9 @@ urlpatterns = [ path("subscriptions/", include("manage_subscriptions.urls")), path("api/subscriptions/", include("manage_subscriptions.api.urls")), + path("coupons/", include("manage_coupons.urls")), + # path("api/coupons/", include("manage_coupons.api.urls")), + path("notifications/", include("manage_notifications.urls")), path("api/notifications/", include("manage_notifications.api.urls")), @@ -60,8 +63,9 @@ urlpatterns = [ # path('api/', include("accounts.api.urls")), ] -if settings.DEBUG: - import debug_toolbar +# if settings.DEBUG: +# import debug_toolbar - urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) - urlpatterns += [path("__debug__/", include(debug_toolbar.urls))] +# urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) +# urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) +# urlpatterns += [path("__debug__/", include(debug_toolbar.urls))] diff --git a/goodtimes/webhook.py b/goodtimes/webhook.py deleted file mode 100644 index 7824ddf..0000000 --- a/goodtimes/webhook.py +++ /dev/null @@ -1,306 +0,0 @@ -from django.conf import settings -from django.db import transaction -from django.shortcuts import get_object_or_404 -from datetime import timedelta -from django.utils import timezone -from onesignal_sdk.client import Client as OneSignalClient -from accounts.models import IAmPrincipal, IAmPrincipalOtp, IAmPrincipalType -from manage_notifications.models import ( - IAmPrincipalNotificationSettings, - InAppNotification, - NotificationCategoryChoices, -) -from manage_referrals.models import ( - GoodTimeCoins, - ReferralRecord, - ReferralRecordReward, - ReferralTracking, -) -from django.core.exceptions import ObjectDoesNotExist -from manage_subscriptions.models import PrincipalSubscription, Subscription -from manage_wallets.models import ( - TransactionStatus, - TransactionType, - Wallet, - Transaction, -) -import logging - - -logger = logging.getLogger(__name__) - - -class NotificationService: - def __init__(self): - self.client = OneSignalClient( - app_id=settings.ONE_SIGNAL_APP_ID, rest_api_key=settings.ONE_SIGNAL_API_KEY - ) - - def send_notification(self, title, message, player_id): - if player_id is None: - print("Player ID is None, skipping notification") - return - notification_payload = { - "headings": {"en": title}, - "contents": {"en": message}, - "include_player_ids": [player_id], - } - response = self.client.send_notification(notification_payload) - return response - - def save_notification(self, principal, title, message, notification_category): - InAppNotification.objects.create( - principal=principal, - title=title, - message=message, - notification_category=notification_category, - ) - - def payment_success_notification( - self, principal, subscription, principal_subscription, amount - ): - print("payment_success_notification: ", principal.player_id) - title = "Payment Successful" - end_date = principal_subscription.end_date - message = f"Your payment for {subscription} of ${amount} was successfully processed. Your subscription is valid till {end_date}" - self.send_notification(title, message, principal.player_id) - self.save_notification( - principal, title, message, NotificationCategoryChoices.TRANSACTION - ) - - def referral_received_notification(self, principal, amount, email): - print("referral_received_notification: ", principal.player_id) - title = "Congratulations! You got a referral G-Token." - message = f"Your referral {email} has subscribed to GoodTimesApp. You have received {amount} (£)" - self.save_notification( - principal, title, message, NotificationCategoryChoices.REFERRAL - ) - if not self.should_send_referral_notification(principal): - print("Referral notifications are disabled for this user") - return - self.send_notification(title, message, principal.player_id) - - def payment_failed_notification(self, principal, subscription, amount): - print("payment_failed_notification: ", principal.player_id) - title = "Payment Failed!" - message = f"Your payment for {subscription} of ${amount} was failed." - self.send_notification(title, message, principal.player_id) - self.save_notification( - principal, title, message, NotificationCategoryChoices.TRANSACTION - ) - - def should_send_referral_notification(self, principal): - notification_settings = get_object_or_404( - IAmPrincipalNotificationSettings, - principal=principal, - notification_category=NotificationCategoryChoices.REFERRAL, - ) - return notification_settings.is_enabled - - -class WebhookService: - def __init__(self, webhook_data): - self.webhook_data = webhook_data - self.event_type = webhook_data["type"] - self.charge_data = webhook_data["data"]["object"] - - def get_event_type(self): - return self.event_type - - def get_principal(self): - principal_id = self.charge_data["metadata"]["principal"] - try: - return IAmPrincipal.objects.get(id=int(principal_id)) - except (ObjectDoesNotExist, ValueError): - logger.error(f"Invalid principal ID: {principal_id}") - raise ValueError(f"Invalid principal ID: {principal_id}") - - def get_transaction(self): - transaction_id = self.charge_data["metadata"]["transaction_id"] - try: - return Transaction.objects.get(id=int(transaction_id)) - except (ObjectDoesNotExist, ValueError): - logger.error(f"Invalid transaction ID: {transaction_id}") - raise ValueError(f"Invalid transaction ID: {transaction_id}") - - def get_subscription(self): - subscription_id = self.charge_data["metadata"]["subscription_id"] - try: - return Subscription.objects.get(id=int(subscription_id)) - except (ObjectDoesNotExist, ValueError): - logger.error(f"Invalid subscription ID: {subscription_id}") - raise ValueError(f"Invalid subscription ID: {subscription_id}") - - def get_order_id(self): - return self.charge_data["metadata"]["order_id"] - - -class ReferralRewardService: - def __init__(self, principal, principal_subscription, subscription): - self.notification_service = NotificationService() - self.principal = principal - self.principal_subscription = principal_subscription - self.subscription = subscription - - def _fetch_referral_record(self): - return ReferralRecord.objects.filter( - referred_principal=self.principal, - is_completed=True, - active=True, # Assuming 'active' is a field determining if the record is currently relevant - deleted=False, # Assuming logical deletion is handled by a 'deleted' field - ).first() - - def _check_active_subscription(self, referrer_principal): - today = timezone.now().date() - return ( - PrincipalSubscription.objects.filter( - principal=referrer_principal, - is_paid=True, - end_date__gte=today, - cancelled=False, - deleted=False, - ) - .order_by("-end_date") - .first() - ) - - def _credit_reward(self, referral_record, subscription): - amount = subscription.referral_percentage * subscription.amount / 100 - ReferralRecordReward.objects.create( - referral_record=referral_record, - subscription=subscription, - coins=1, # This value could be dynamically calculated or configured elsewhere - value=amount, - ) - self._credit_transaction(referral_record.referrer_principal, amount) - self.notification_service.referral_received_notification( - referral_record.referrer_principal, amount, self.principal.email - ) - - def _credit_transaction(self, referrer_principal, amount): - print("_credit_transaction: ", referrer_principal) - Transaction.objects.create( - principal=referrer_principal, - transaction_type=TransactionType.CREDIT, - payment_method="", - transaction_status=TransactionStatus.SUCCESS, - amount=amount, - coins=1, - comment="Referral reward", - # Populate other fields as necessary, such as `order_id`, `product_id`, or `reference_id` if applicable - ) - - def _update_reward_status(self, referral_record, 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 - - is_referrer_subscribed = bool(active_subscription) - - ReferralTracking.objects.create( - referral_record=referral_record, - referrer_subscription_id=referrer_subscription_id, - referred_subscription_id=referred_subscription_id, - is_referrer_subscribed=is_referrer_subscribed, - ) - - def credit_referral_reward_if_applicable(self): - referral_record = self._fetch_referral_record() - if referral_record: - active_subscription = self._check_active_subscription( - referral_record.referrer_principal - ) - if active_subscription: - print("active_subscription: ", active_subscription) - if self.subscription: - print("self.subscription: ", self.subscription) - self._credit_reward(referral_record, self.subscription) - - self._update_reward_status(referral_record, active_subscription) - - -class SubscriptionService: - def __init__(self): - self.principal_subscription = None - - def create_principal_subscription(self, principal, subscription, order_id): - subscription_days = subscription.plan.days - today = timezone.now().date() - last_date = today + timedelta(days=subscription_days) - principal_subscription = PrincipalSubscription.objects.create( - principal=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 - - def update_transaction_success(self, principal_transaction, principal_subscription): - principal_transaction.transaction_status = TransactionStatus.SUCCESS - principal_transaction.principal_subscription = principal_subscription - principal_transaction.save() - - def update_transaction_failure(self, principal_transaction): - principal_transaction.transaction_status = TransactionStatus.FAIL - principal_transaction.save() - - -class PaymentProcessingService: - def __init__(self, webhook_data): - self.webhook_service = WebhookService(webhook_data) - self.notification_service = NotificationService() - # Retrieve objects - self.principal = self.webhook_service.get_principal() - self.transaction = self.webhook_service.get_transaction() - self.subscription = self.webhook_service.get_subscription() - self.order_id = self.webhook_service.get_order_id() - self.subscription_service = SubscriptionService() - self.principal_subscription = None - - def process_event(self): - if self.webhook_service.get_event_type() == "checkout.session.completed": - self.handle_success() - else: - self.handle_failure() - - def handle_success(self): - with transaction.atomic(): - # Create or update the principal subscription - self.principal_subscription = ( - self.subscription_service.create_principal_subscription( - self.principal, self.subscription, self.order_id - ) - ) - print("First Part Done....!!!!!") - # Update transaction status to success - self.subscription_service.update_transaction_success( - self.transaction, self.principal_subscription - ) - print("Second Part Done....!!!!!") - # Now handle referral rewards, if applicable - referral_service = ReferralRewardService( - self.principal, self.principal_subscription, self.subscription - ) - print("Above Third Part...!!!!!!!!!!!") - referral_service.credit_referral_reward_if_applicable() - print("Third Part Done....!!!!!") - self.notification_service.payment_success_notification( - self.principal, - self.subscription, - self.principal_subscription, - self.transaction.amount, - ) - - def handle_failure(self): - self.subscription_service.update_transaction_failure(self.transaction) - # self.notification_service.payment_failed_notification( - # self.principal, self.subscription, self.transaction.amount - # ) diff --git a/goodtimes/webhook/__init__.py b/goodtimes/webhook/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/goodtimes/webhook/notification_service.py b/goodtimes/webhook/notification_service.py new file mode 100644 index 0000000..0167e9f --- /dev/null +++ b/goodtimes/webhook/notification_service.py @@ -0,0 +1,80 @@ +from onesignal_sdk.client import Client as OneSignalClient +import logging +from manage_notifications.models import ( + IAmPrincipalNotificationSettings, + InAppNotification, + NotificationCategoryChoices, +) +from django.shortcuts import get_object_or_404 +from django.conf import settings + + +logger = logging.getLogger(__name__) + + +class NotificationService: + def __init__(self): + self.client = OneSignalClient( + app_id=settings.ONE_SIGNAL_APP_ID, rest_api_key=settings.ONE_SIGNAL_API_KEY + ) + + def send_notification(self, title, message, player_id): + if player_id is None: + print("Player ID is None, skipping notification") + return + notification_payload = { + "headings": {"en": title}, + "contents": {"en": message}, + "include_player_ids": [player_id], + } + response = self.client.send_notification(notification_payload) + return response + + def save_notification(self, principal, title, message, notification_category): + InAppNotification.objects.create( + principal=principal, + title=title, + message=message, + notification_category=notification_category, + ) + + def payment_success_notification( + self, principal, subscription, principal_subscription, amount + ): + print("payment_success_notification: ", principal.player_id) + title = "Payment Successful" + end_date = principal_subscription.end_date + message = f"Your payment for {subscription} of ${amount} was successfully processed. Your subscription is valid till {end_date}" + self.send_notification(title, message, principal.player_id) + self.save_notification( + principal, title, message, NotificationCategoryChoices.TRANSACTION + ) + + def referral_received_notification(self, principal, amount, email): + print("referral_received_notification: ", principal.player_id) + title = "Congratulations! You got a referral G-Token." + message = f"Your referral {email} has subscribed to GoodTimesApp. You have received {amount} (£)" + self.save_notification( + principal, title, message, NotificationCategoryChoices.REFERRAL + ) + if not self.should_send_referral_notification(principal): + print("Referral notifications are disabled for this user") + return + self.send_notification(title, message, principal.player_id) + + def payment_failed_notification(self, principal, subscription, amount): + print("payment_failed_notification: ", principal.player_id) + title = "Payment Failed!" + message = f"Your payment for {subscription} of ${amount} was failed." + self.send_notification(title, message, principal.player_id) + self.save_notification( + principal, title, message, NotificationCategoryChoices.TRANSACTION + ) + + def should_send_referral_notification(self, principal): + notification_settings = get_object_or_404( + IAmPrincipalNotificationSettings, + principal=principal, + notification_category=NotificationCategoryChoices.REFERRAL, + ) + return notification_settings.is_enabled diff --git a/goodtimes/webhook/payment_processing_service.py b/goodtimes/webhook/payment_processing_service.py new file mode 100644 index 0000000..5b075fd --- /dev/null +++ b/goodtimes/webhook/payment_processing_service.py @@ -0,0 +1,180 @@ +from venv import logger +from django.db import transaction + +from manage_wallets.models import ( + PaymentMethod, + Transaction, + TransactionStatus, + TransactionType, +) +from .notification_service import NotificationService +from .referral_reward_service import ReferralRewardService +from .subscription_service import SubscriptionService +from .webhook_service import WebhookService + + +class PaymentProcessingService: + def __init__( + self, + webhook_data, + stripe_subscription, + current_period_start, + current_period_end, + ): + self.webhook_service = WebhookService(webhook_data) + self._order_id = None + self.notification_service = NotificationService() + self.subscription_service = SubscriptionService() + self.stripe_subscription = stripe_subscription + self.current_period_start = current_period_start + self.current_period_end = current_period_end + + @property + def charge_data(self): + """Return charge data from the webhook service.""" + return self.webhook_service.charge_data + + @property + def principal(self): + """Return the principal from the webhook service.""" + return self.webhook_service.get_principal() + + @property + def subscription(self): + """Return the subscription from the webhook service.""" + return self.webhook_service.get_subscription() + + @property + def order_id(self): + """Return the order ID from the created transaction.""" + return self._order_id + + @order_id.setter + def order_id(self, value): + """Set the order ID.""" + self._order_id = value + + @property + def coupon(self): + """Return the coupon from the webhook service.""" + return self.webhook_service.get_coupon() + + @property + def amount(self): + """Return the final amount from the webhook service.""" + return self.webhook_service.get_final_amount() + + def create_transaction(self): + """Create a transaction based on webhook data.""" + transaction = Transaction.objects.create( + principal=self.principal, + principal_subscription=None, + transaction_type=TransactionType.PAYMENT, + payment_method=PaymentMethod.CARD, + transaction_status=TransactionStatus.INITIATE, + amount=self.amount, + # order_id=self.order_id, + comment="Principal Subscription Initiated", + ) + # Save the transaction to auto-generate the order_id + transaction.save() + + # Step 1: Update the order_id in PaymentProcessingService + self.order_id = transaction.order_id + + return transaction + + def process_event(self): + """Process the webhook event.""" + try: + with transaction.atomic(): + event_type = self.webhook_service.event_type + if event_type == "invoice.payment_succeeded" and self.charge_data.get("billing_reason") == "subscription_create": + logger.info(f"Skipping event {event_type} with billing reason 'subscription_create'") + return + + txn = self.create_transaction() + + if event_type in ["checkout.session.completed", "invoice.payment_succeeded"]: + self.handle_success(txn) + elif event_type in ["checkout.session.expired", "invoice.payment_failed"]: + self.handle_failure(txn) + else: + logger.warning(f"Unknown event type {event_type}. Skipping.") + return + + except Exception as e: + logger.error(f"Unexpected error: {str(e)}") + raise + + def handle_success(self, transaction): + """Handle a successful payment.""" + try: + self.create_principal_subscription(transaction) + self.process_referral_rewards() + self.send_success_notification(transaction) + self.update_transaction_status( + transaction, + TransactionStatus.SUCCESS, + self.subscription_service.principal_subscription, + ) + except Exception as e: + self.handle_failure(transaction, error_message=str(e)) + logger.error(f"Transaction Error: {str(e)}") + raise e + + def create_principal_subscription(self, transaction): + """Create or update the principal subscription.""" + self.subscription_service.principal_subscription = ( + self.subscription_service.create_principal_subscription( + principal=self.principal, + subscription=self.subscription, + stripe_subscription=self.stripe_subscription, + order_id=transaction.order_id, + current_period_start=self.current_period_start, + current_period_end=self.current_period_end, + coupon=self.coupon, + ) + ) + print("Principal Subscription Created") + + def process_referral_rewards(self): + """Handle referral rewards.""" + referral_service = ReferralRewardService( + self.principal, + self.subscription_service.principal_subscription, + self.subscription, + ) + referral_service.credit_referral_reward_if_applicable() + print("Referral Rewards Processed") + + def send_success_notification(self, transaction): + """Send a payment success notification.""" + self.notification_service.payment_success_notification( + self.principal, + self.subscription, + self.subscription_service.principal_subscription, + transaction.amount, + ) + print("Payment Success Notification Sent") + + def handle_failure(self, transaction, error_message=None): + """Handle a failed payment.""" + self.update_transaction_status( + transaction, TransactionStatus.FAIL, error_message=error_message + ) + self.notification_service.payment_failed_notification( + self.principal, self.subscription, transaction.amount + ) + print("Payment Failure Notification Sent") + + def update_transaction_status( + self, transaction, status, principal_subscription=None, error_message=None + ): + """Update the transaction status and associate with a subscription if provided.""" + transaction.transaction_status = status + if principal_subscription: + transaction.principal_subscription = principal_subscription + if error_message: + transaction.error_message = error_message + transaction.save() diff --git a/goodtimes/webhook/referral_reward_service.py b/goodtimes/webhook/referral_reward_service.py new file mode 100644 index 0000000..6ff1257 --- /dev/null +++ b/goodtimes/webhook/referral_reward_service.py @@ -0,0 +1,111 @@ +from .notification_service import NotificationService +from manage_referrals.models import ( + ReferralRecord, + ReferralRecordReward, + ReferralTracking, +) +from manage_wallets.models import Transaction, TransactionType, TransactionStatus +from django.utils import timezone +from manage_subscriptions.models import PrincipalSubscription + + +class ReferralRewardService: + def __init__(self, principal, principal_subscription, subscription): + self._notification_service = NotificationService() + self._principal = principal + self._principal_subscription = principal_subscription + self._subscription = subscription + + @property + def principal(self): + return self._principal + + @property + def principal_subscription(self): + return self._principal_subscription + + @property + def subscription(self): + return self._subscription + + @staticmethod + def _fetch_referral_record(principal): + """Fetch the referral record for the given principal.""" + return ReferralRecord.objects.filter( + referred_principal=principal, + is_completed=True, + active=True, + deleted=False, + ).first() + + @staticmethod + def _check_active_subscription(referrer_principal): + """Check if the referrer principal has an active subscription.""" + today = timezone.now().date() + return ( + PrincipalSubscription.objects.filter( + principal=referrer_principal, + is_paid=True, + end_date__gte=today, + cancelled=False, + deleted=False, + ) + .order_by("-end_date") + .first() + ) + + def _credit_reward(self, referral_record, subscription): + amount = subscription.referral_percentage * subscription.amount / 100 + ReferralRecordReward.objects.create( + referral_record=referral_record, + subscription=subscription, + coins=1, # This value could be dynamically calculated or configured elsewhere + value=amount, + ) + self._credit_transaction(referral_record.referrer_principal, amount) + self._notification_service.referral_received_notification( + referral_record.referrer_principal, amount, self.principal.email + ) + + def _credit_transaction(self, referrer_principal, amount): + """Create a transaction record for the referral reward.""" + print("referrer_principal: ", referrer_principal) + Transaction.objects.create( + principal=referrer_principal, + transaction_type=TransactionType.CREDIT, + payment_method="", + transaction_status=TransactionStatus.SUCCESS, + amount=amount, + coins=1, + comment="Referral reward", + ) + + def _update_reward_status(self, referral_record, active_subscription): + """Update the status of the referral reward.""" + 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 + + is_referrer_subscribed = bool(active_subscription) + + ReferralTracking.objects.create( + referral_record=referral_record, + referrer_subscription_id=referrer_subscription_id, + referred_subscription_id=referred_subscription_id, + is_referrer_subscribed=is_referrer_subscribed, + ) + + def credit_referral_reward_if_applicable(self): + """Credit referral reward if applicable based on the referral record.""" + referral_record = self._fetch_referral_record(self.principal) + if referral_record: + active_subscription = self._check_active_subscription( + referral_record.referrer_principal + ) + if active_subscription and self.subscription: + self._credit_reward(referral_record, self.subscription) + + self._update_reward_status(referral_record, active_subscription) diff --git a/goodtimes/webhook/subscription_service.py b/goodtimes/webhook/subscription_service.py new file mode 100644 index 0000000..68770f1 --- /dev/null +++ b/goodtimes/webhook/subscription_service.py @@ -0,0 +1,77 @@ +from datetime import timedelta +from django.utils import timezone +import datetime +from manage_subscriptions.models import PrincipalSubscription + + +class SubscriptionService: + def __init__(self): + self._principal_subscription = None + + @property + def principal_subscription(self): + """Get the current principal subscription.""" + return self._principal_subscription + + @principal_subscription.setter + def principal_subscription(self, value): + """Set the current principal subscription.""" + self._principal_subscription = value + + def create_principal_subscription( + self, + principal, + subscription, + stripe_subscription, + order_id, + current_period_start, + current_period_end, + coupon=None, + ): + """Create a principal subscription and return it.""" + start_date, end_date = self._calculate_dates( + current_period_start, current_period_end, subscription.plan.days + ) + + principal_subscription = PrincipalSubscription.objects.create( + principal=principal, + subscription=subscription, + stripe_subscription_id=stripe_subscription or "Non Recurring", + 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), + coupon_code=coupon.coupon_code if coupon else None, + ) + + if coupon: + self._update_coupon(coupon) + + self.principal_subscription = principal_subscription + return principal_subscription + + def _calculate_dates( + self, current_period_start, current_period_end, subscription_days + ): + """Calculate subscription start and end dates.""" + today = timezone.now().date() + start_date = ( + datetime.datetime.fromtimestamp(current_period_start).date() + if current_period_start + else today + ) + end_date = ( + datetime.datetime.fromtimestamp(current_period_end).date() + if current_period_end + else (today + timedelta(days=subscription_days)) + ) + return start_date, end_date + + def _update_coupon(self, coupon): + """Update coupon usage count.""" + coupon.no_of_redeems += 1 + coupon.save() + print("Coupon Saved Successfully!!!") diff --git a/goodtimes/webhook/webhook_service.py b/goodtimes/webhook/webhook_service.py new file mode 100644 index 0000000..ba8cd4f --- /dev/null +++ b/goodtimes/webhook/webhook_service.py @@ -0,0 +1,90 @@ +from decimal import Decimal +from django.conf import settings +import stripe +from django.core.exceptions import ObjectDoesNotExist +from accounts.models import IAmPrincipal +from manage_coupons.models import Coupon +from manage_subscriptions.models import Subscription +import logging + + +logger = logging.getLogger(__name__) + +stripe.api_key = settings.STRIPE_SECRET_KEY + + +class WebhookService: + def __init__(self, webhook_data): + self._webhook_data = webhook_data + self._event_type = webhook_data["type"] + self._charge_data = webhook_data["data"]["object"] + self._metadata = self._fetch_metadata() + + def _fetch_metadata(self): + """Fetch metadata based on the event type.""" + if self._event_type == "checkout.session.completed": + return self._charge_data.get("metadata", {}) + elif self._event_type == "invoice.payment_succeeded": + subscription_id = self._charge_data.get("subscription") + if subscription_id: + subscription = stripe.Subscription.retrieve(subscription_id) + return subscription.get("metadata", {}) + return {} + + @property + def event_type(self): + return self._event_type + + @property + def charge_data(self): + return self._charge_data + + def _get_object_from_metadata(self, model, id_key): + """Retrieve object from metadata.""" + obj_id = self._metadata.get(id_key) + if obj_id: + try: + return model.objects.get(id=int(obj_id)) + except (ObjectDoesNotExist, ValueError) as e: + logger.error(f"Invalid {model.__name__} ID: {obj_id}") + raise ValueError(f"Invalid {model.__name__} ID: {obj_id}") from e + return None + + def get_event_type(self): + return self.event_type + + def get_principal(self): + """Retrieve principal from metadata.""" + return self._get_object_from_metadata(IAmPrincipal, "principal") + + def get_subscription(self): + """Retrieve subscription from metadata.""" + return self._get_object_from_metadata(Subscription, "subscription_id") + + def get_coupon(self): + """Retrieve coupon from metadata.""" + coupon_code = self._metadata.get("couponCode") + print("get_coupon:coupon_code: ", coupon_code) + if coupon_code: + try: + return Coupon.objects.get(coupon_code=coupon_code) + except Coupon.DoesNotExist: + logger.error(f"Invalid coupon code: {coupon_code}") + raise ValueError(f"Invalid coupon code: {coupon_code}") + return None + + def get_final_amount(self): + """Retrieve Amount after coupon discount from either stripe event or metadata.""" + if self.event_type == "checkout.session.completed": + return ( + Decimal(self._charge_data.get("amount_total", 0)) / 100 + ) + elif self.event_type == "invoice.payment_succeeded": + return ( + Decimal(self._charge_data.get("amount_paid", 0)) / 100 + ) + + # Fallback: Try to get the amount from metadata + return ( + Decimal(self._metadata.get("metadata", {}).get("finalAmount", 0)) / 100 + ) diff --git a/manage_coupons/__init__.py b/manage_coupons/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/manage_coupons/admin.py b/manage_coupons/admin.py new file mode 100644 index 0000000..e195db7 --- /dev/null +++ b/manage_coupons/admin.py @@ -0,0 +1,40 @@ +from django.contrib import admin +from .models import Coupon + + +class CouponAdmin(admin.ModelAdmin): + list_display = ( + "id", + "title", + "coupon_id", + "coupon_code", + "discount_amount", + "discount_percentage", + "valid_from", + "valid_to", + "max_redeems", + "no_of_redeems", + "is_active", + ) + search_fields = ("title", "coupon_code") + list_filter = ("valid_from", "valid_to", "max_redeems") + readonly_fields = ("no_of_redeems",) + + fieldsets = ( + ( + None, + {"fields": ("title", "coupon_code", "coupon_id", "description", "image")}, + ), + ( + "Discount Information", + {"fields": ("discount_amount", "discount_percentage")}, + ), + ("Validity", {"fields": ("valid_from", "valid_to")}), + ("Redemption", {"fields": ("max_redeems", "no_of_redeems")}), + ) + + def is_active(self, obj): + return obj.is_valid() + + +admin.site.register(Coupon, CouponAdmin) diff --git a/manage_coupons/api/serializers.py b/manage_coupons/api/serializers.py new file mode 100644 index 0000000..e69de29 diff --git a/manage_coupons/api/urls.py b/manage_coupons/api/urls.py new file mode 100644 index 0000000..e69de29 diff --git a/manage_coupons/api/views.py b/manage_coupons/api/views.py new file mode 100644 index 0000000..e69de29 diff --git a/manage_coupons/apps.py b/manage_coupons/apps.py new file mode 100644 index 0000000..a1391cc --- /dev/null +++ b/manage_coupons/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ManageCouponsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "manage_coupons" diff --git a/manage_coupons/forms.py b/manage_coupons/forms.py new file mode 100644 index 0000000..22874e5 --- /dev/null +++ b/manage_coupons/forms.py @@ -0,0 +1,24 @@ +from django import forms +from django.core.exceptions import ValidationError +from manage_coupons.models import Coupon + + +class CouponForm(forms.ModelForm): + class Meta: + model = Coupon + fields = [ + "title", + "description", + # "image", + "discount_amount", + "discount_percentage", + "valid_from", + "valid_to", + "max_redeems", + ] + widgets = { + "valid_from": forms.DateTimeInput(attrs={"type": "datetime-local"}), + "valid_to": forms.DateTimeInput(attrs={"type": "datetime-local"}), + # "discount_amount": forms.NumberInput(attrs={'step': '0.01'}), + # "discount_percentage": forms.NumberInput(attrs={'step': '0.01'}), + } diff --git a/manage_coupons/migrations/0001_initial.py b/manage_coupons/migrations/0001_initial.py new file mode 100644 index 0000000..728c884 --- /dev/null +++ b/manage_coupons/migrations/0001_initial.py @@ -0,0 +1,81 @@ +# Generated by Django 5.0.2 on 2024-07-22 12:20 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Coupon", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("active", models.BooleanField(default=True)), + ("deleted", models.BooleanField(default=False)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("modified_on", models.DateTimeField(auto_now=True)), + ("title", models.CharField(max_length=255)), + ("coupon_code", models.CharField(max_length=50, unique=True)), + ("no_of_redeems", models.IntegerField(default=0)), + ("description", models.TextField(blank=True, null=True)), + ( + "image", + models.ImageField(blank=True, null=True, upload_to="coupon_img"), + ), + ( + "discount_amount", + models.DecimalField( + blank=True, decimal_places=2, max_digits=10, null=True + ), + ), + ( + "discount_percentage", + models.DecimalField( + blank=True, decimal_places=2, max_digits=5, null=True + ), + ), + ("valid_from", models.DateTimeField()), + ("valid_to", models.DateTimeField()), + ("max_redeems", models.IntegerField(default=0)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "modified_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_modified", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "coupon", + }, + ), + ] diff --git a/manage_coupons/migrations/0002_coupon_coupon_id.py b/manage_coupons/migrations/0002_coupon_coupon_id.py new file mode 100644 index 0000000..999883c --- /dev/null +++ b/manage_coupons/migrations/0002_coupon_coupon_id.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.2 on 2024-07-31 07:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("manage_coupons", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="coupon", + name="coupon_id", + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/manage_coupons/migrations/__init__.py b/manage_coupons/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/manage_coupons/models.py b/manage_coupons/models.py new file mode 100644 index 0000000..743ce43 --- /dev/null +++ b/manage_coupons/models.py @@ -0,0 +1,79 @@ +from django.db import models +from django.utils import timezone +from accounts.models import BaseModel, IAmPrincipalType +from django.core.exceptions import ValidationError + + +class Coupon(BaseModel): + title = models.CharField(max_length=255) + coupon_code = models.CharField(max_length=50, unique=True) + coupon_id = models.CharField(max_length=255, blank=True, null=True) + no_of_redeems = models.IntegerField(default=0) + 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 + ) + discount_percentage = models.DecimalField( + max_digits=5, decimal_places=2, null=True, blank=True + ) + valid_from = models.DateTimeField() + valid_to = models.DateTimeField() + max_redeems = models.IntegerField(default=0) + + class Meta: + db_table = "coupon" + + def clean(self): + """ + Validate the Coupon instance. Ensure that the `max_redeems` is greater than 0, + that either `discount_amount` or `discount_percentage` is set, and that + `valid_from` is earlier than `valid_to`. + """ + if self.max_redeems < 1: + raise ValidationError({"max_redeems": "Redeems must be more than 1."}) + + # Ensure discount_amount is non-negative + if self.discount_amount is not None and self.discount_amount < 1: + raise ValidationError( + {"discount_amount": "Discount amount must be more than 1."} + ) + + # Ensure discount_percentage is non-negative + if self.discount_percentage is not None and self.discount_percentage < 1: + raise ValidationError( + {"discount_percentage": "Discount percentage must be more than 1."} + ) + + if self.discount_amount and self.discount_percentage: + raise ValidationError( + "You can only set either a discount amount or a discount percentage, not both." + ) + + if not self.discount_amount and not self.discount_percentage: + raise ValidationError( + "You must set either a discount amount or a discount percentage." + ) + + if self.valid_from and self.valid_to and self.valid_from >= self.valid_to: + raise ValidationError( + "The valid_from date must be earlier than the valid_to date." + ) + + def save(self, *args, **kwargs): + self.clean() # Call clean before saving to ensure validation + super().save(*args, **kwargs) + + def __str__(self): + return self.coupon_code + + # 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) + ) diff --git a/manage_coupons/tests.py b/manage_coupons/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/manage_coupons/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/manage_coupons/urls.py b/manage_coupons/urls.py new file mode 100644 index 0000000..2d53400 --- /dev/null +++ b/manage_coupons/urls.py @@ -0,0 +1,23 @@ +from django.urls import path +from . import views + +app_name = "manage_coupons" + +urlpatterns = [ + path("coupon/list/", views.CouponView.as_view(), name="coupon_list"), + path( + "coupon/add/", + views.CouponCreateOrUpdateView.as_view(), + name="coupon_add", + ), + # path( + # "coupon/edit//", + # views.CouponCreateOrUpdateView.as_view(), + # name="coupon_edit", + # ), + path( + "coupon/delete//", + views.CouponDeleteView.as_view(), + name="coupon_delete", + ), +] diff --git a/manage_coupons/utils.py b/manage_coupons/utils.py new file mode 100644 index 0000000..2cacf99 --- /dev/null +++ b/manage_coupons/utils.py @@ -0,0 +1,47 @@ +import stripe +from decimal import Decimal + + +def handle_stripe_coupon(coupon_instance, stripe_secret_key): + """ + Handles the creation or updating of a Stripe coupon. + Returns True if successful, otherwise returns False. + """ + try: + stripe.api_key = stripe_secret_key + + # Prepare coupon data without setting the ID + coupon_data = { + "name": coupon_instance.title, + "metadata": { + "local_id": coupon_instance.id, + }, + "redeem_by": int(coupon_instance.valid_to.timestamp()), + "max_redemptions": ( + coupon_instance.max_redeems if coupon_instance.max_redeems > 0 else None + ), + "duration": "once", + } + + if coupon_instance.discount_amount: + coupon_data["amount_off"] = int( + coupon_instance.discount_amount * Decimal(100) + ) # Amount in cents/fils + coupon_data["currency"] = "gbp" + elif coupon_instance.discount_percentage: + coupon_data["percent_off"] = float(coupon_instance.discount_percentage) + + # Creating a new Stripe coupon + stripe_coupon = stripe.Coupon.create(**coupon_data) + # Using the Stripe-generated ID for coupon_code and coupon_id + coupon_instance.coupon_code = stripe_coupon.id + coupon_instance.coupon_id = stripe_coupon.id + + # Saving the coupon instance after successful Stripe operation + coupon_instance.save() + return True, "Coupon successfully created." + + except Exception as e: + error_message = f"Error creating Stripe coupon: {e}" + print(error_message) + return False, error_message diff --git a/manage_coupons/views.py b/manage_coupons/views.py new file mode 100644 index 0000000..8886aee --- /dev/null +++ b/manage_coupons/views.py @@ -0,0 +1,137 @@ +from django.conf import settings +from django.shortcuts import get_object_or_404, render, redirect +from django.views import generic +from django.contrib.auth.mixins import LoginRequiredMixin +import stripe +from accounts import resource_action +from django.urls import reverse_lazy +from django.contrib import messages +from goodtimes import constants +from manage_coupons.forms import CouponForm +from manage_coupons.models import Coupon +from manage_coupons.utils import handle_stripe_coupon + +# Create your views here. + + +class CouponView(LoginRequiredMixin, generic.ListView): + page_name = resource_action.RESOURCE_MANAGE_COUPONS + resource = resource_action.RESOURCE_MANAGE_COUPONS + action = resource_action.ACTION_READ + model = Coupon + template_name = "manage_coupons/coupon_list.html" + context_object_name = "coupon_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 CouponCreateOrUpdateView(LoginRequiredMixin, generic.View): + # Set the page_name and resource + page_name = resource_action.RESOURCE_MANAGE_COUPONS + resource = resource_action.RESOURCE_MANAGE_COUPONS + + # Initialize the action as ACTION_CREATE (can change based on logic) + action = resource_action.ACTION_CREATE + + template_name = "manage_coupons/coupon_add.html" + model = Coupon + form_class = CouponForm + success_url = reverse_lazy("manage_coupons:coupon_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, request.FILES, 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 = 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) + ) + + +class CouponDeleteView(LoginRequiredMixin, generic.View): + page_name = resource_action.RESOURCE_MANAGE_COUPONS + resource = resource_action.RESOURCE_MANAGE_COUPONS + action = resource_action.ACTION_DELETE + model = Coupon + success_url = reverse_lazy("manage_coupons:coupon_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) + + if type_obj.coupon_id: + stripe.api_key = settings.STRIPE_SECRET_KEY + try: + stripe.Coupon.delete(type_obj.coupon_id) + except stripe.error.StripeError as e: + # Handle Stripe errors + error_message = f"Stripe error: {e.user_message or e}" + messages.error(request, error_message) + return redirect(self.success_url) + + type_obj.deleted = True + type_obj.active = False + type_obj.save() + messages.success(request, self.success_message) + except self.model.DoesNotExist: + messages.warning(request, self.error_message) + + return redirect(self.success_url) diff --git a/manage_subscriptions/admin.py b/manage_subscriptions/admin.py index d0c6816..63c094d 100644 --- a/manage_subscriptions/admin.py +++ b/manage_subscriptions/admin.py @@ -2,6 +2,7 @@ from django.contrib import admin from .models import ( Plan, PrincipalSubscription, + StripeProduct, Subscription, WebhookEvent, ) # Update this with the correct import path for your models @@ -64,6 +65,26 @@ 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/urls.py b/manage_subscriptions/api/urls.py index fb63bae..22e94f0 100644 --- a/manage_subscriptions/api/urls.py +++ b/manage_subscriptions/api/urls.py @@ -1,8 +1,5 @@ from django.urls import path from . import views -from rest_framework_simplejwt.views import ( - TokenRefreshView, -) urlpatterns = [ diff --git a/manage_subscriptions/api/views.py b/manage_subscriptions/api/views.py index c583aa6..44acebb 100644 --- a/manage_subscriptions/api/views.py +++ b/manage_subscriptions/api/views.py @@ -1,5 +1,6 @@ from datetime import timedelta import datetime +import json from django.db import transaction from django.shortcuts import get_object_or_404 from django.utils import timezone @@ -7,9 +8,7 @@ from rest_framework import status from rest_framework.views import APIView from django.conf import settings import stripe -from accounts.models import IAmPrincipal -import json -from goodtimes import constants, services +from goodtimes import constants from manage_subscriptions.models import ( Subscription, PrincipalSubscription, @@ -35,7 +34,11 @@ from .serializers import PrincipalSubscriptionSerializer from django.views.decorators.csrf import csrf_exempt from django.utils.decorators import method_decorator from rest_framework.response import Response -from goodtimes.webhook import PaymentProcessingService +from goodtimes.webhook.payment_processing_service import PaymentProcessingService +import logging + + +logger = logging.getLogger(__name__) class CreatePrincipalSubscriptionApi(APIView): @@ -178,13 +181,29 @@ class StripeWebhookTest(APIView): event = stripe.Event.construct_from(json.loads(payload), stripe.api_key) event_id = event["id"] event_type = event["type"] - principal_id = event["data"]["object"]["metadata"]["principal"] + stripe_subscription_id = event["data"]["object"].get("subscription") + + stripe_subscription = ( + stripe.Subscription.retrieve(stripe_subscription_id) + if stripe_subscription_id + else None + ) + current_period_start = ( + stripe_subscription["current_period_start"] + if stripe_subscription + else None + ) + current_period_end = ( + stripe_subscription["current_period_end"] + if stripe_subscription + else None + ) webhook_event, created = WebhookEvent.objects.get_or_create( event_id=event_id, defaults={ "event_type": event_type, - "event_payload": json.loads(payload), + "event_payload": event, }, ) @@ -194,62 +213,55 @@ class StripeWebhookTest(APIView): message="Event already processed", ) - # Check if there is an active principal subscription - # if self._has_active_principal_subscription(principal_id): - # return ApiResponse.success( - # status=status.HTTP_208_ALREADY_REPORTED, - # message="Active principal subscription already exists", - # ) + payment_service = PaymentProcessingService( + webhook_data=event, + stripe_subscription=stripe_subscription_id, + current_period_start=current_period_start, + current_period_end=current_period_end, + ) - # payment_service = services.PaymentProcessingService(webhook_data=event) - payment_service = PaymentProcessingService(webhook_data=event) payment_service.process_event() - webhook_event = WebhookEvent.objects.get(event_id=event_id) webhook_event.status = "processed" - webhook_event.processed_at = timezone.now() # Make sure to import timezone + webhook_event.processed_at = timezone.now() webhook_event.save() + return ApiResponse.success( status=status.HTTP_200_OK, message="Event processed successfully" ) - except ValueError as e: - # Invalid payload - return ApiResponse.error( - status=status.HTTP_400_BAD_REQUEST, - message="Invalid payload", - errors=str(e), - ) except stripe.error.SignatureVerificationError as e: - # Invalid signature + logger.error(f"Invalid Stripe signature: {str(e)}") return ApiResponse.error( status=status.HTTP_400_BAD_REQUEST, message="Invalid signature", errors=str(e), ) - except Transaction.DoesNotExist: - # Handle case where the transaction does not exist + except ValueError as e: + logger.error(f"Invalid payload: {str(e)}") return ApiResponse.error( - status=status.HTTP_404_NOT_FOUND, message="Transaction not found" + status=status.HTTP_400_BAD_REQUEST, + message="Invalid payload", + errors=str(e), + ) + except Transaction.DoesNotExist as e: + logger.error(f"Transaction does not exist: {str(e)}") + return ApiResponse.error( + status=status.HTTP_404_NOT_FOUND, + message="Transaction not found", + errors=str(e), ) except Exception as e: - webhook_event.status = "failed" - webhook_event.error_message = str(e) - webhook_event.save() + logger.error(f"Error processing webhook event: {str(e)}") + if "webhook_event" in locals(): + webhook_event.status = "failed" + webhook_event.error_message = str(e) + webhook_event.save() return ApiResponse.error( status=status.HTTP_500_INTERNAL_SERVER_ERROR, message="Error processing event", errors=str(e), ) - def _has_active_principal_subscription(self, principal_id): - return PrincipalSubscription.objects.filter( - principal__id=principal_id, - active=True, - deleted=False, - is_paid=True, - end_date__gte=timezone.now().date(), - ).exists() - class LastActiveSubscriptionView(APIView): authentication_classes = [JWTAuthentication] @@ -281,3 +293,47 @@ class LastActiveSubscriptionView(APIView): message="No Active Subscription Found", errors="No Active Subscription Found", ) + + +class CancelSubscription(APIView): + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + + def post(self, request): + data = json.loads(request.body) + subscription_id = data.get("subscription_id") + + try: + subscription = PrincipalSubscription.objects.get( + id=subscription_id, principal=request.user + ) + except PrincipalSubscription.DoesNotExist: + return ApiResponse( + message=constants.FAILURE, + errors="Subscription not found.", + status=status.HTTP_404_NOT_FOUND, + ) + + with transaction.atomic(): + if subscription.is_stripe_subscription: + # Cancel Stripe subscription + try: + stripe.Subscription.modify(subscription.stripe_subscription_id, cancel_at_period_end=True) + except stripe.error.InvalidRequestError as e: + return ApiResponse( + message=constants.FAILURE, + errors=f"Stripe error: {str(e)}", + status=status.HTTP_400_BAD_REQUEST, + ) + + # Updating subscription status in the local database + subscription.status = SubscriptionStatus.INACTIVE + subscription.cancelled = True + subscription.cancelled_date_time = timezone.now() + subscription.save() + + return ApiResponse( + message=constants.SUCCESS, + data="Subscription cancelled successfully.", + status=status.HTTP_200_OK, + ) diff --git a/manage_subscriptions/forms.py b/manage_subscriptions/forms.py index 79ed3ac..d34f9cf 100644 --- a/manage_subscriptions/forms.py +++ b/manage_subscriptions/forms.py @@ -1,5 +1,11 @@ from django import forms -from manage_subscriptions.models import PrincipalSubscription, Subscription, Plan +from accounts.models import IAmPrincipalType +from manage_subscriptions.models import ( + PrincipalSubscription, + StripeProduct, + Subscription, + Plan, +) class PlanForm(forms.ModelForm): @@ -20,18 +26,38 @@ class SubscriptionForm(forms.ModelForm): model = Subscription fields = [ "title", + "stripe_product", "plan", "high_amount", "amount", "short_description", - # "long_description", - # "image", "principal_types", "referral_percentage", "active", "deleted", "is_free", - ] # Include all fields you want from the model + ] + + def __init__(self, *args, **kwargs): + super(SubscriptionForm, self).__init__(*args, **kwargs) + event_user = IAmPrincipalType.objects.get(name="event_user") + event_manager = IAmPrincipalType.objects.get(name="event_manager") + self.fields["principal_types"].queryset = IAmPrincipalType.objects.filter( + 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): @@ -44,3 +70,15 @@ class PrincipalSubscriptionForm(forms.ModelForm): "grace_period_end_date": forms.DateInput(attrs={"type": "date"}), "cancelled_date_time": forms.DateTimeInput(attrs={"type": "datetime"}), } + + +class StripeProductForm(forms.ModelForm): + class Meta: + model = StripeProduct + fields = [ + "title", + "description", + ] + widgets = { + "description": forms.Textarea(attrs={"rows": 3}), + } diff --git a/manage_subscriptions/migrations/0009_principalsubscription_coupon_code.py b/manage_subscriptions/migrations/0009_principalsubscription_coupon_code.py new file mode 100644 index 0000000..b693abb --- /dev/null +++ b/manage_subscriptions/migrations/0009_principalsubscription_coupon_code.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.2 on 2024-07-22 12:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("manage_subscriptions", "0008_subscription_is_free"), + ] + + operations = [ + migrations.AddField( + model_name="principalsubscription", + name="coupon_code", + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/manage_subscriptions/migrations/0010_principalsubscription_comments_and_more.py b/manage_subscriptions/migrations/0010_principalsubscription_comments_and_more.py new file mode 100644 index 0000000..20544a5 --- /dev/null +++ b/manage_subscriptions/migrations/0010_principalsubscription_comments_and_more.py @@ -0,0 +1,97 @@ +# Generated by Django 5.0.2 on 2024-07-31 07:34 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("manage_subscriptions", "0009_principalsubscription_coupon_code"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="principalsubscription", + name="comments", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="principalsubscription", + name="is_stripe_subscription", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="principalsubscription", + name="stripe_subscription_id", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="subscription", + name="price_id", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.CreateModel( + name="StripeProduct", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("active", models.BooleanField(default=True)), + ("deleted", models.BooleanField(default=False)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("modified_on", models.DateTimeField(auto_now=True)), + ("title", models.CharField(max_length=255)), + ("product_id", models.CharField(blank=True, max_length=255, 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(blank=True, max_length=255, null=True), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "modified_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_modified", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "stripe_product", + }, + ), + migrations.AddField( + model_name="subscription", + name="stripe_product", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="subscription_product", + to="manage_subscriptions.stripeproduct", + ), + ), + ] diff --git a/manage_subscriptions/models.py b/manage_subscriptions/models.py index 081862e..5e3686c 100644 --- a/manage_subscriptions/models.py +++ b/manage_subscriptions/models.py @@ -1,6 +1,7 @@ from datetime import timedelta from django.utils import timezone from django.db import models +from django.core.exceptions import ValidationError from accounts.models import BaseModel, IAmPrincipal, IAmPrincipalType from django.utils.translation import gettext_lazy as _ @@ -18,8 +19,31 @@ class Plan(BaseModel): 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): title = models.CharField(max_length=255) + price_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) @@ -32,7 +56,10 @@ class Subscription(BaseModel): IAmPrincipalType, related_name="principal_type_subscriptions", blank=True ) referral_percentage = models.DecimalField(max_digits=5, decimal_places=2) - is_free = models.BooleanField(default=False, help_text="Indicates whether this subscription is free and only accessible by administrators, not visible to regular users.") + is_free = models.BooleanField( + default=False, + help_text="Indicates whether this subscription is free and only accessible by administrators, not visible to regular users.", + ) class Meta: db_table = "subscription" @@ -40,6 +67,27 @@ class Subscription(BaseModel): def __str__(self): return self.title + def clean(self): + # Ensure amount is 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 stripe_product is compulsory present + # if not self.stripe_product: + # raise ValidationError( + # {"stripe_product": "Please select stripe product to create subscription."} + # ) + + def save(self, *args, **kwargs): + self.clean() # Call clean before saving to ensure validation + super().save(*args, **kwargs) + class SubscriptionStatus(models.TextChoices): ACTIVE = "active", _("Active") @@ -68,10 +116,14 @@ class PrincipalSubscription(BaseModel): 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 ) + coupon_code = models.CharField(max_length=255, null=True, blank=True) class Meta: db_table = "principal_subscription" diff --git a/manage_subscriptions/urls.py b/manage_subscriptions/urls.py index aa767f8..e26620d 100644 --- a/manage_subscriptions/urls.py +++ b/manage_subscriptions/urls.py @@ -12,15 +12,30 @@ urlpatterns = [ views.SubscriptionCreateOrUpdateView.as_view(), name="subscription_add", ), + path("subscription//", views.SubscriptionDetailView.as_view(), name="subscription_detail"), + # path( + # "subscription/edit//", + # views.SubscriptionCreateOrUpdateView.as_view(), + # name="subscription_edit", + # ), path( - "subscription/edit//", - views.SubscriptionCreateOrUpdateView.as_view(), - name="subscription_edit", + "subscription/delete/", + 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", ), # path( - # "subscription/delete/", - # views.SubscriptionDeleteView.as_view(), - # name="subscription_delete", + # "product/delete/", + # views.StripeProductDeleteView.as_view(), + # name="stripe_product_delete", # ), # PLANS path("plan/list/", views.PlanView.as_view(), name="plan_list"), @@ -55,6 +70,11 @@ urlpatterns = [ views.PrincipalSubscriptionCreateOrUpdateView.as_view(), name="principal_subscription_edit", ), + path( + "principal_subscription/", + views.PrincipalSubscriptionDetailView.as_view(), + name="principal_subscription_detail", + ), path( "principal_subscription/delete/", views.PrincipalSubscriptionDeleteView.as_view(), @@ -70,8 +90,17 @@ urlpatterns = [ views.create_checkout_session, name="create_checkout_session", ), + path( + "coupon-validity-check/", + views.validate_coupon, + name="validate_coupon", + ), 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("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"), + path("subscription-cancel-fails/", views.SubscriptionCancelFailsView.as_view(), name="subscription_cancel_fails"), # path("join-now/", views.IndexView.as_view(), name="index"), ] diff --git a/manage_subscriptions/views.py b/manage_subscriptions/views.py index 959cd39..6bcecde 100644 --- a/manage_subscriptions/views.py +++ b/manage_subscriptions/views.py @@ -1,18 +1,17 @@ -from datetime import timedelta +from decimal import Decimal import json -from django.http import HttpResponseBadRequest, JsonResponse, HttpResponseRedirect +from django.http import HttpResponseBadRequest, HttpResponseRedirect, JsonResponse from django.shortcuts import get_object_or_404, redirect, render import stripe from accounts import resource_action from accounts.models import IAmPrincipal -from goodtimes.utils import ApiResponse -from django.contrib.auth import authenticate, login +from django.contrib.auth import login import jwt -from rest_framework_simplejwt.tokens import AccessToken from django.utils import timezone from django.contrib.auth import get_user_model +from manage_coupons.models import Coupon from manage_subscriptions.forms import ( - PlanForm, + StripeProductForm, SubscriptionForm, PrincipalSubscriptionForm, ) @@ -22,22 +21,27 @@ from manage_wallets.models import ( TransactionStatus, TransactionType, ) -from .models import Plan, Subscription, PrincipalSubscription +from .models import ( + Plan, + StripeProduct, + Subscription, + PrincipalSubscription, + SubscriptionStatus, +) from django.views import generic -from rest_framework import status from django.contrib.auth.mixins import LoginRequiredMixin -from django.urls import reverse_lazy +from django.urls import reverse, reverse_lazy from django.contrib import messages from goodtimes import constants from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST from django.conf import settings -from rest_framework.permissions import IsAuthenticated from django.views.generic.base import TemplateView +from django.db.models import Q +from django.db import transaction # Create your views here. -from django.db.models import Q class SubscriptionCreateOrUpdateView(LoginRequiredMixin, generic.View): # Set the page_name and resource @@ -101,14 +105,63 @@ class SubscriptionCreateOrUpdateView(LoginRequiredMixin, generic.View): # This code ensures that only one free plan can be created by checking for existing free plans before saving a new one. if form.cleaned_data.get("is_free"): if self.model.objects.filter(Q(is_free=True) & Q(active=True)).exists(): - messages.error(self.request, "A free plan is already available. Please deactivate the existing one before creating a new one.") + messages.error( + self.request, + "A free plan is already available. Please deactivate the existing one before creating a new one.", + ) 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 @@ -119,7 +172,12 @@ class SubscriptionView(LoginRequiredMixin, generic.ListView): context_object_name = "subscription_obj" def get_queryset(self): - queryset = super().get_queryset().filter(deleted=False, active=True).prefetch_related("principal_types") + queryset = ( + super() + .get_queryset() + .filter(deleted=False, active=True) + .prefetch_related("principal_types") + ) return queryset.order_by("-created_on") def get_context_data(self, **kwargs): @@ -128,24 +186,219 @@ class SubscriptionView(LoginRequiredMixin, generic.ListView): return context -# class SubscriptionDeleteView(LoginRequiredMixin, generic.View): +class SubscriptionDetailView(LoginRequiredMixin, generic.DetailView): + page_name = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS + resource = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS + action = resource_action.ACTION_READ + model = Subscription + template_name = "manage_subscriptions/subscription_details.html" + context_object_name = "subscription" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["page_name"] = self.page_name + return context + + +class SubscriptionDeleteView(LoginRequiredMixin, generic.View): + page_name = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS + resource = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS + action = resource_action.ACTION_DELETE + model = Subscription + success_url = reverse_lazy("manage_subscriptions:subscription_list") + success_message = constants.RECORD_DELETED + error_message = constants.RECORD_NOT_FOUND + + def get(self, request, pk): + 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() + + messages.success(request, self.success_message) + + except self.model.DoesNotExist: + messages.error(request, self.error_message) + + 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 + + +""" 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 = Subscription -# success_url = reverse_lazy("manage_subscriptions:subscription_list") +# 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: -# type_obj = self.model.objects.get(id=pk) -# type_obj.deleted = True -# type_obj.active = False -# type_obj.save() +# # 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.success(request, self.error_message) +# messages.error(request, self.error_message) # return redirect(self.success_url) @@ -332,6 +585,20 @@ class PrincipalSubscriptionView(LoginRequiredMixin, generic.ListView): return context +class PrincipalSubscriptionDetailView(LoginRequiredMixin, generic.DetailView): + page_name = resource_action.RESOURCE_PRINCIPAL_SUBSCRIPTIONS + resource = resource_action.RESOURCE_PRINCIPAL_SUBSCRIPTIONS + action = resource_action.ACTION_READ + model = PrincipalSubscription + template_name = "manage_subscriptions/principal_subscription_details.html" + context_object_name = "principal_subscription_obj" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["page_name"] = self.page_name + return context + + class PrincipalSubscriptionDeleteView(LoginRequiredMixin, generic.View): page_name = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS resource = resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS @@ -357,8 +624,33 @@ class PrincipalSubscriptionDeleteView(LoginRequiredMixin, generic.View): class SubscriptionPageView(TemplateView): template_name = "stripe_html/index.html" + 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, + ) + + 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 + + +class ActiveSubscriptionView(TemplateView): + template_name = "stripe_html/active_subscription.html" + def get(self, request, *args, **kwargs): - # Example of extracting the token from a query parameter or cookie token = request.GET.get("token") or request.session.get("jwt") print("token: ", token) if token: @@ -368,46 +660,80 @@ class SubscriptionPageView(TemplateView): # Decode and validate token payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"]) print("payload: ", payload) - try: - UserModel = get_user_model() - user = UserModel.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: - # Handle expired token - return HttpResponseBadRequest("No Principal Found") - - except jwt.ExpiredSignatureError: - # Handle expired token - return HttpResponseBadRequest("Expired Signature Error") - except jwt.InvalidTokenError: - return HttpResponseBadRequest("Invalid Token Error") + user = get_user_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") + 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) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) request = self.request + today = timezone.now().date() 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 - ) - - if subscriptions.exists(): - context["subscriptions"] = subscriptions - context["stripeCheckoutUrl"] = settings.STRIPE_CHECKOUT_URL - context["stripeFinalUrl"] = settings.STRIPE_FINAL_URL - else: - # Handle the case where no subscriptions are found for the principal type. - context["error"] = "No subscriptions found for your user type." + 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 CancelSubscriptionView(LoginRequiredMixin, generic.View): + 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.active = 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 def stripe_config(request): if request.method == "GET": @@ -415,104 +741,163 @@ def stripe_config(request): return JsonResponse(stripe_config, safe=False) -def has_active_principal_subscription(principal_id): - return PrincipalSubscription.objects.filter( - principal__id=principal_id, - active=True, - deleted=False, - cancelled=False, - is_paid=True, - end_date__gte=timezone.now().date(), - ).exists() - - @csrf_exempt @require_POST -def create_checkout_session(request): - success_url = reverse_lazy("manage_subscriptions:stripe") - stripe.api_key = settings.STRIPE_SECRET_KEY +def validate_coupon(request): data = json.loads(request.body) - print("data: ", data) + coupon_code = data.get("couponCode", None) subscription_id = data.get("subscriptionId", None) - principal_id = request.user.id - - # if has_active_principal_subscription(principal_id): - # print("Active principal subscription already exists.") - # return JsonResponse( - # {"error": "Active principal subscription already exists"}, status=400 - # ) + final_amount = None try: subscription = Subscription.objects.get(id=subscription_id) except Subscription.DoesNotExist: return JsonResponse({"error": "Subscription not found."}, status=404) - order_id = ( - "order_" + str(timezone.localtime().timestamp()) + str(request.user.email) - ) - print("order_id: ", order_id) + # If no coupon code is provided, assume no discount and proceed + if not coupon_code: + return JsonResponse({"message": "No coupon code provided."}, status=200) - # Create a Transaction object with status INITIATE - transaction = Transaction.objects.create( - principal=request.user, - principal_subscription=None, # Since the subscription is not created yet - transaction_type=TransactionType.PAYMENT, # or PAYMENT, as applicable - payment_method=PaymentMethod.CARD, # Assuming CARD for this example - transaction_status=TransactionStatus.INITIATE, - amount=subscription.amount, # Fetching amount from the Subscription object - order_id=order_id, - comment="Principal Subscription Initiated", - ) + # Validating Coupon + try: + coupon = Coupon.objects.get(coupon_code=coupon_code) + + if not coupon.is_valid(): + return JsonResponse({"error": "Coupon is not valid."}, status=400) + + # Check discount amount + if coupon.discount_amount and coupon.discount_amount > subscription.amount: + final_amount = subscription.amount - coupon.discount_amount + return JsonResponse( + {"error": "Coupon discount amount exceeds subscription amount."}, + status=400, + ) + + # Check discount percentage + if coupon.discount_percentage: + discount = ( + coupon.discount_percentage / Decimal("100") + ) * subscription.amount + if discount > subscription.amount: + return JsonResponse( + { + "error": "Coupon discount percentage exceeds subscription amount." + }, + status=400, + ) + final_amount = subscription.amount - discount + # Retrieving coupon from Stripe if applicable + if coupon.coupon_id: + try: + stripe_coupon = stripe.Coupon.retrieve(coupon.coupon_id) + print("stripe_coupon: ", stripe_coupon) + if ( + stripe_coupon.max_redemptions + and stripe_coupon.times_redeemed >= stripe_coupon.max_redemptions + ): + return JsonResponse( + {"error": "Coupon max redeems reached."}, status=400 + ) + return JsonResponse( + {"data": {"coupon": stripe_coupon, "finalAmount": final_amount}}, + status=200, + ) + except stripe.error.InvalidRequestError: + return JsonResponse( + {"error": f"Invalid coupon code: {coupon_code}"}, status=400 + ) + except stripe.error.StripeError as e: + return JsonResponse({"error": f"Stripe error: {str(e)}"}, status=400) + else: + return JsonResponse( + { + "error": "Coupon is either invalid, expired, or not associated with Stripe." + }, + status=400, + ) + except Coupon.DoesNotExist: + return JsonResponse({"error": "Coupon not found."}, status=404) + + +@csrf_exempt +@require_POST +def create_checkout_session(request): + stripe.api_key = settings.STRIPE_SECRET_KEY + data = json.loads(request.body) + subscription_id = data.get("subscriptionId") + coupon_code = data.get("couponCode") + transaction_amount = data.get("discountAmount") + is_recurring = data.get("isRecurring") + principal_id = request.user.id try: - # customer = stripe.Customer.create( - # email=request.user.email, - # shipping={ - # "name": request.user.first_name, - # "address": { - # "line1": request.user.city, - # "city": request.user.city, - # "postal_code": "SW1A 2AA", - # "country": request.user.address_line1, # Adjust accordingly - # }, - # }, - # ) + subscription = Subscription.objects.get(id=subscription_id) + except Subscription.DoesNotExist: + return JsonResponse({"error": "Subscription not found."}, status=404) - # Create a checkout session - checkout_session = stripe.checkout.Session.create( - payment_method_types=["card"], - # customer=customer.id, # Optional: Link the session to the Stripe customer created above - line_items=[ + # 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(transaction_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 + ), + "couponCode": coupon_code if coupon_code else None, + }, + } + print("session_data: ", session_data) + # Coupon Handling + if coupon_code: + try: + stripe_coupon = stripe.Coupon.retrieve(coupon_code) + session_data["discounts"] = [{"coupon": stripe_coupon.id}] + except stripe.error.InvalidRequestError: + return JsonResponse( + {"error": f"Invalid coupon code: {coupon_code}"}, status=400 + ) + except stripe.error.StripeError as e: + return JsonResponse({"error": f"Stripe error: {str(e)}"}, status=400) + + # Creating the Stripe Checkout Session + try: + if is_recurring and subscription.price_id: + session_data["line_items"] = [ + { + "price": subscription.price_id, + "quantity": 1, + } + ] + session_data["mode"] = "subscription" + session_data["subscription_data"] = { + "metadata": session_data["metadata"], + } + else: + session_data["line_items"] = [ { "price_data": { "currency": "gbp", "product_data": { - "name": subscription.title, # Adjust with your subscription/product name + "name": subscription.title, + "description": subscription.short_description, }, - "unit_amount": int( - subscription.amount * 100 - ), # Unit amount in cents/pence - "tax_behavior": "inclusive", # or 'exclusive', based on your tax settings + "unit_amount": int(subscription.amount * 100), }, "quantity": 1, } - ], - # allow_promotion_codes=True, - mode="payment", - # discounts=[{"coupon": "VLMAsicx"}], - success_url=request.build_absolute_uri("/subscriptions/success/"), - cancel_url=request.build_absolute_uri("/subscriptions/cancel/"), - metadata={ - "principal": str(request.user.id), - "order_id": str(order_id), - "subscription_id": str(subscription.id), - "transaction_id": str(transaction.id), - # "principal_subscription_id": str(principal_subscription.id), - }, - ) + ] + session_data["mode"] = "payment" + checkout_session = stripe.checkout.Session.create(**session_data) return JsonResponse({"sessionId": checkout_session["id"]}) except Exception as e: - return JsonResponse({"error": str(e)}) + return JsonResponse({"error": str(e)}, status=500) class SuccessView(TemplateView): @@ -523,36 +908,9 @@ class CancelView(TemplateView): template_name = "stripe_html/cancel.html" -# class IndexView(TemplateView): -# template_name = "stripe_html/index.html" +class SubscriptionCancelSuccessView(TemplateView): + template_name = "stripe_html/subscription_cancel_success.html" -# def get(self, request, *args, **kwargs): -# # Example of extracting the token from a query parameter or cookie -# token = request.GET.get("token") -# # token = request.GET.get("token") or request.COOKIES.get("jwt") -# print("token: ", token) -# if token: -# try: -# # Decode and validate token -# payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"]) -# print("payload: ", payload) -# try: -# UserModel = get_user_model() -# user = UserModel.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: -# # Handle expired token -# return HttpResponseBadRequest("No Principal Found") - -# except jwt.ExpiredSignatureError: -# # Handle expired token -# return HttpResponseBadRequest("Expired Signature Error") -# except jwt.InvalidTokenError: -# return HttpResponseBadRequest("Invalid Token Error") - -# return super().get(request, *args, **kwargs) +class SubscriptionCancelFailsView(TemplateView): + template_name = "stripe_html/subscription_cancel_fails.html" diff --git a/requirements.txt b/requirements.txt index f3ae9d8..1eaff72 100644 --- a/requirements.txt +++ b/requirements.txt @@ -71,7 +71,7 @@ stripe==8.2.0 tqdm==4.66.2 tweepy==4.14.0 Twisted==23.10.0 -twisted-iocpsupport==1.0.4 +#twisted-iocpsupport==1.0.4 txaio==23.1.1 typing_extensions==4.9.0 tzdata==2024.1 diff --git a/static/src/assets/css/payment/style.css b/static/src/assets/css/payment/style.css index 43ef5f8..b58d7c4 100644 --- a/static/src/assets/css/payment/style.css +++ b/static/src/assets/css/payment/style.css @@ -818,4 +818,29 @@ div#accordionExample { .footer .store-app { margin-bottom: 16px; } +} + +.coupon-code-input, +.common-btn { + display: inline-block; + margin-right: 10px; +} + +.common-btn { + /* remove default button margins */ + margin: 0; +} +.feat-card input.form-control.coupon-code-input { + border-radius: 5px; + width: 100%; + background: transparent; + color: #fff; + margin-bottom: 20px; + margin-right: 0; + caret-color: #fff; + text-align: center; +} + +.feat-card input.form-control.coupon-code-input::placeholder { + color: #a5a4a4; } \ No newline at end of file diff --git a/templates/elements/sidebar.html b/templates/elements/sidebar.html index bd845d0..a493e8a 100644 --- a/templates/elements/sidebar.html +++ b/templates/elements/sidebar.html @@ -154,6 +154,17 @@ {% endif %} + {% if user|has_resource_permission:resource_context.RESOURCE_MANAGE_COUPONS %} + + {% endif %} {% if user|has_resource_permission:resource_context.RESOURCE_PRINCIPAL_SUBSCRIPTIONS %} - +
  • + + + visibility + + +
  • diff --git a/templates/manage_subscriptions/product_add.html b/templates/manage_subscriptions/product_add.html new file mode 100644 index 0000000..b5ac2b8 --- /dev/null +++ b/templates/manage_subscriptions/product_add.html @@ -0,0 +1,49 @@ +{% extends 'layout/base_template.html' %} +{% load static %} +{% block stylesheet %} + + + {% include "cdn_through_html/filepond_cdn_css.html" %} + {% include "cdn_through_html/quill_cdn_css.html" %} + {% include "cdn_through_html/tagify_cdn_css.html" %} + {{form.media}} + +{% endblock %} + +{% block content %} + +
    +
    +
    +
    +

    Add Product

    +
    +
    + +
    +
    +
    +
    +
    +
    + +
    + {% csrf_token %} + {% include 'includes/dynamic_template_form.html' with form=form %} +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    + + +{% endblock content %} diff --git a/templates/manage_subscriptions/product_list.html b/templates/manage_subscriptions/product_list.html new file mode 100644 index 0000000..3551f4b --- /dev/null +++ b/templates/manage_subscriptions/product_list.html @@ -0,0 +1,104 @@ +{% extends 'layout/base_template.html' %} +{% load static %} +{% block stylesheet %} + + {% include "cdn_through_html/datatable_cdn_css.html" %} + +{% endblock %} + +{% block content %} + +
    +
    +
    +
    +

    Manage Products

    +
    +
    + + + + + +
    +
    + + +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    + + {% endblock content %} + + {% block javascript %} + + {% include "cdn_through_html/datatable_cdn_js.html" %} + + + {% endblock %} \ No newline at end of file diff --git a/templates/manage_subscriptions/subscription_add.html b/templates/manage_subscriptions/subscription_add.html index 8da8df6..b35c711 100644 --- a/templates/manage_subscriptions/subscription_add.html +++ b/templates/manage_subscriptions/subscription_add.html @@ -16,7 +16,7 @@
    -

    {{operation}} {{page_name}}

    +

    Add Subscription

    --> + Products Add Subscriptions + Add Stripe Product
    @@ -56,6 +58,9 @@ Free for Admin + Stripe Product Active {{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 new file mode 100644 index 0000000..c6bef02 --- /dev/null +++ b/templates/stripe_html/active_subscription.html @@ -0,0 +1,158 @@ + + + + + + + Active Subscription + + + + + + +
    +

    Your Active Subscription

    +
    + +
    +
    +
    +

    {{ active_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" }}

    + + {% if active_subscription.coupon_code %} +

    Coupon Code: {{ active_subscription.coupon_code }}

    + {% endif %} + + {% if active_subscription.cancelled %} +
    +

    Cancellation Details

    +

    Cancelled: Yes

    +

    Cancellation Date: {{ active_subscription.cancelled_date_time }}

    +

    Grace Period Ends: {{ active_subscription.grace_period_end_date }}

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

    Cancel Subscription

    +
    + {% csrf_token %} + + +
    +
    + {% endif %} +
    +
    +
    + + + + + + + diff --git a/templates/stripe_html/index.html b/templates/stripe_html/index.html index 5016178..d811599 100644 --- a/templates/stripe_html/index.html +++ b/templates/stripe_html/index.html @@ -1,17 +1,17 @@ -{% load static %} - + + Good times + {% load static %} - Good times @@ -128,15 +128,24 @@
    + + +
    + + +
    - + + +
    -
    + {% empty %}

    No subscriptions available.

    {% endfor %} @@ -517,50 +526,96 @@ console.log("Sanity check!"); var stripeCheckoutUrl = "{{ stripeCheckoutUrl }}"; var stripeFinalUrl = "{{ stripeFinalUrl }}"; + var couponValidityCheckUrl = "{{ couponValidityCheckUrl }}"; console.log("stripeCheckoutUrl: ", stripeCheckoutUrl); console.log("stripeFinalUrl: ", stripeFinalUrl); - // Get Stripe publishable key + console.log("couponValidityCheckUrl: ", couponValidityCheckUrl); + // Geting Stripe publishable key fetch(stripeCheckoutUrl) - .then((result) => { return result.json(); }) + .then((result) => { + return result.json(); + }) .then((data) => { - // Initialize Stripe.js + // Initializing Stripe.js -- getting stripe public key to generate stripe object for creating checkout session const stripe = Stripe(data.publicKey); console.log("loaded stripe public key"); document.querySelectorAll(".subscribe-btn").forEach(button => { button.addEventListener("click", () => { const subscriptionId = button.getAttribute("data-subscription-id"); - + const priceId = button.getAttribute("data-price-id"); + const recurringCheckbox = button.closest('.feat-card').querySelector(".recurring-checkbox"); + const couponCode = button.previousElementSibling.value; + const errorMessageContainer = button.nextElementSibling; console.log("subscriptionId: ", subscriptionId); + console.log("couponCode: ", couponCode); + console.log("priceId: ", priceId); + console.log("recurring: ", recurringCheckbox.checked); // Checking if the checkbox is checked button.disabled = true; - // Create checkout session for the selected subscription - fetch(stripeFinalUrl, { - method: "POST", - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ subscriptionId: subscriptionId }), - }) - .then((result) => { return result.json(); }) - .then((data) => { - // Redirect to Stripe Checkout - return stripe.redirectToCheckout({ sessionId: data.sessionId }) + button.previousElementSibling.value = ""; + + // Handling any coupon validation errors here before creating stripe final checkout session + fetch(couponValidityCheckUrl, { + method: "POST", + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + subscriptionId: subscriptionId, + couponCode: couponCode, + isRecurring: recurringCheckbox.checked + }), + }) + .then(response => { + if (!response.ok) { + return response.json().then(data => { + throw new Error(data.error); + }); + } + return response.json(); + }) + .then(data => { + // Creating checkout session for the selected subscription + console.log("Data:", data); + console.log("data.coupon:", data.coupon); + const finalAmount = data.finalAmount; + console.log("data.finalAmount:", finalAmount); + fetch(stripeFinalUrl, { + method: "POST", + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + subscriptionId: subscriptionId, + couponCode: couponCode, + priceId: priceId, + finalAmount: finalAmount, + isRecurring: recurringCheckbox.checked + }), + }) + .then((result) => { + return result.json(); + }) + .then((data) => { + console.log("data: ", data); + console.log("data.sessionId: ", data.sessionId); + // Redirects to Stripe Checkout + return stripe.redirectToCheckout({ + sessionId: data.sessionId + }) + }) + .catch((error) => { + console.error("Error:", error); + button.disabled = false; + }); }) .catch((error) => { console.error("Error:", error); - // const errorMessageElement = document.getElementById('already-active-subscription'); - // if (errorMessageElement) { - // errorMessageElement.innerText = "Error: " + error.message; // Display the error in the specific div - // errorMessageElement.style.color = 'red'; // Set text color to red - // errorMessageElement.style.fontWeight = 'bold'; // Set text to bold - // } + errorMessageContainer.style.display = 'block'; + errorMessageContainer.innerText = error.message; button.disabled = false; }); }); }); - - - - }); diff --git a/templates/stripe_html/subscribe.html b/templates/stripe_html/subscribe.html index 1b38896..7442179 100644 --- a/templates/stripe_html/subscribe.html +++ b/templates/stripe_html/subscribe.html @@ -1,9 +1,11 @@ +{% load static %} + Goodtimes @@ -22,7 +24,7 @@ console.log("Sanity check!"); // Get Stripe publishable key - fetch("https://goodtimes.betadelivery.com/subscriptions/stripe-subscription/") + fetch("http://localhost:8000/subscriptions/stripe-subscription/") .then((result) => { return result.json(); }) .then((data) => { // Initialize Stripe.js @@ -32,7 +34,7 @@ // Event handler document.querySelector("#submitBtn").addEventListener("click", () => { // Get Checkout Session ID - fetch("https://goodtimes.betadelivery.com/subscriptions/create-checkout-session/") + fetch("http://localhost:8000/subscriptions/create-checkout-session/") .then((result) => { return result.json(); }) .then((data) => { console.log(data); diff --git a/templates/stripe_html/subscription_cancel_fails.html b/templates/stripe_html/subscription_cancel_fails.html new file mode 100644 index 0000000..fbf0c60 --- /dev/null +++ b/templates/stripe_html/subscription_cancel_fails.html @@ -0,0 +1,13 @@ + + + + + + Goodtimes + + +

    + Subscription Cancellation Failed +

    + + \ No newline at end of file diff --git a/templates/stripe_html/subscription_cancel_success.html b/templates/stripe_html/subscription_cancel_success.html new file mode 100644 index 0000000..8ed5b83 --- /dev/null +++ b/templates/stripe_html/subscription_cancel_success.html @@ -0,0 +1,13 @@ + + + + + + Goodtimes + + +

    + Subscription Successfully Cancelled +

    + + \ No newline at end of file