diff --git a/backend/igny8_core/auth/admin.py b/backend/igny8_core/auth/admin.py index be023f40..4375a8cc 100644 --- a/backend/igny8_core/auth/admin.py +++ b/backend/igny8_core/auth/admin.py @@ -214,6 +214,7 @@ class AccountAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode 'bulk_add_credits', 'bulk_subtract_credits', 'bulk_soft_delete', + 'bulk_hard_delete', ] def get_queryset(self, request): @@ -454,14 +455,32 @@ class AccountAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode bulk_subtract_credits.short_description = 'Subtract credits from accounts' def bulk_soft_delete(self, request, queryset): - """Soft delete selected accounts""" + """Soft delete selected accounts and all related data""" count = 0 for account in queryset: if account.slug != 'aws-admin': # Protect admin account - account.delete() # Soft delete via SoftDeletableModel + account.delete() # Soft delete via SoftDeletableModel (now cascades) count += 1 - self.message_user(request, f'{count} account(s) soft deleted.', messages.SUCCESS) - bulk_soft_delete.short_description = 'Soft delete selected accounts' + self.message_user(request, f'{count} account(s) and all related data soft deleted.', messages.SUCCESS) + bulk_soft_delete.short_description = 'Soft delete accounts (with cascade)' + + def bulk_hard_delete(self, request, queryset): + """PERMANENTLY delete selected accounts and ALL related data - cannot be undone!""" + count = 0 + errors = [] + for account in queryset: + if account.slug != 'aws-admin': # Protect admin account + try: + account.hard_delete_with_cascade() # Permanently delete everything + count += 1 + except Exception as e: + errors.append(f'{account.name}: {str(e)}') + + if count > 0: + self.message_user(request, f'{count} account(s) and ALL related data permanently deleted.', messages.SUCCESS) + if errors: + self.message_user(request, f'Errors: {"; ".join(errors)}', messages.ERROR) + bulk_hard_delete.short_description = '⚠️ PERMANENTLY delete accounts (irreversible!)' class SubscriptionResource(resources.ModelResource): diff --git a/backend/igny8_core/auth/models.py b/backend/igny8_core/auth/models.py index 80a7a675..29cfa6b4 100644 --- a/backend/igny8_core/auth/models.py +++ b/backend/igny8_core/auth/models.py @@ -153,12 +153,144 @@ class Account(SoftDeletableModel): # System accounts bypass all filtering restrictions return self.slug in ['aws-admin', 'default-account', 'default'] - def soft_delete(self, user=None, reason=None, retention_days=None): + def soft_delete(self, user=None, reason=None, retention_days=None, cascade=True): + """ + Soft delete the account and optionally cascade to all related objects. + Args: + user: User performing the deletion + reason: Reason for deletion + retention_days: Days before permanent deletion + cascade: If True, also soft-delete related objects that support soft delete, + and hard-delete objects that don't support soft delete + """ if self.is_system_account(): from django.core.exceptions import PermissionDenied raise PermissionDenied("System account cannot be deleted.") + + if cascade: + self._cascade_delete_related(user=user, reason=reason, retention_days=retention_days, hard_delete=False) + return super().soft_delete(user=user, reason=reason, retention_days=retention_days) + def _cascade_delete_related(self, user=None, reason=None, retention_days=None, hard_delete=False): + """ + Delete all related objects when account is deleted. + For soft delete: soft-deletes objects with SoftDeletableModel, hard-deletes others + For hard delete: hard-deletes everything + """ + from igny8_core.common.soft_delete import SoftDeletableModel + + # List of related objects to delete (in order to avoid FK constraint issues) + # Related names from Account reverse relations + related_names = [ + # Content & Planning related (delete first due to dependencies) + 'contentclustermap_set', + 'contentattribute_set', + 'contenttaxonomy_set', + 'content_set', + 'images_set', + 'contentideas_set', + 'tasks_set', + 'keywords_set', + 'clusters_set', + 'strategy_set', + # Automation + 'automation_runs', + 'automation_configs', + # Publishing & Integration + 'syncevent_set', + 'publishingsettings_set', + 'publishingrecord_set', + 'deploymentrecord_set', + 'siteintegration_set', + # Notifications & Optimization + 'notification_set', + 'optimizationtask_set', + # AI & Settings + 'aitasklog_set', + 'aiprompt_set', + 'aisettings_set', + 'authorprofile_set', + # Billing (preserve invoices/payments for audit, delete others) + 'planlimitusage_set', + 'creditusagelog_set', + 'credittransaction_set', + 'accountpaymentmethod_set', + 'payment_set', + 'invoice_set', + # Settings + 'modulesettings_set', + 'moduleenablesettings_set', + 'integrationsettings_set', + 'user_settings', + 'accountsettings_set', + # Core (last due to dependencies) + 'sector_set', + 'site_set', + # Subscription (OneToOne) + 'subscription', + ] + + for related_name in related_names: + try: + related = getattr(self, related_name, None) + if related is None: + continue + + # Handle OneToOne fields (subscription) + if hasattr(related, 'pk'): + # It's a single object (OneToOneField) + if hard_delete: + related.hard_delete() if hasattr(related, 'hard_delete') else related.delete() + elif isinstance(related, SoftDeletableModel): + related.soft_delete(user=user, reason=reason, retention_days=retention_days) + else: + # Non-soft-deletable single object - hard delete + related.delete() + else: + # It's a RelatedManager (ForeignKey) + queryset = related.all() + if queryset.exists(): + if hard_delete: + # Hard delete all + if hasattr(queryset, 'hard_delete'): + queryset.hard_delete() + else: + for obj in queryset: + if hasattr(obj, 'hard_delete'): + obj.hard_delete() + else: + obj.delete() + else: + # Soft delete if supported, otherwise hard delete + model = queryset.model + if issubclass(model, SoftDeletableModel): + for obj in queryset: + obj.soft_delete(user=user, reason=reason, retention_days=retention_days) + else: + queryset.delete() + except Exception as e: + # Log but don't fail - some relations may not exist + import logging + logger = logging.getLogger(__name__) + logger.warning(f"Failed to delete related {related_name} for account {self.pk}: {e}") + + def hard_delete_with_cascade(self, using=None, keep_parents=False): + """ + Permanently delete the account and ALL related objects. + This bypasses soft-delete and removes everything from the database. + USE WITH CAUTION - this cannot be undone! + """ + if self.is_system_account(): + from django.core.exceptions import PermissionDenied + raise PermissionDenied("System account cannot be deleted.") + + # Cascade hard-delete all related objects first + self._cascade_delete_related(hard_delete=True) + + # Finally hard-delete the account itself + return super().hard_delete(using=using, keep_parents=keep_parents) + def delete(self, using=None, keep_parents=False): return self.soft_delete() diff --git a/backend/igny8_core/auth/serializers.py b/backend/igny8_core/auth/serializers.py index aa14465d..2df3c492 100644 --- a/backend/igny8_core/auth/serializers.py +++ b/backend/igny8_core/auth/serializers.py @@ -406,11 +406,20 @@ class RegisterSerializer(serializers.Serializer): ) # Generate unique slug for account - base_slug = account_name.lower().replace(' ', '-').replace('_', '-')[:50] or 'account' - slug = base_slug + # Clean the base slug: lowercase, replace spaces and underscores with hyphens + import re + import random + import string + base_slug = re.sub(r'[^a-z0-9-]', '', account_name.lower().replace(' ', '-').replace('_', '-'))[:40] or 'account' + + # Add random suffix to prevent collisions (especially during concurrent registrations) + random_suffix = ''.join(random.choices(string.ascii_lowercase + string.digits, k=6)) + slug = f"{base_slug}-{random_suffix}" + + # Ensure uniqueness with fallback counter counter = 1 while Account.objects.filter(slug=slug).exists(): - slug = f"{base_slug}-{counter}" + slug = f"{base_slug}-{random_suffix}-{counter}" counter += 1 # Create account with status and credits seeded (0 for paid pending) diff --git a/backend/igny8_core/auth/urls.py b/backend/igny8_core/auth/urls.py index 21f9def4..e16e65c3 100644 --- a/backend/igny8_core/auth/urls.py +++ b/backend/igny8_core/auth/urls.py @@ -109,16 +109,70 @@ class RegisterView(APIView): refresh_expires_at = timezone.now() + get_refresh_token_expiry() user_serializer = UserSerializer(user) + + # Build response data + response_data = { + 'user': user_serializer.data, + 'tokens': { + 'access': access_token, + 'refresh': refresh_token, + 'access_expires_at': access_expires_at.isoformat(), + 'refresh_expires_at': refresh_expires_at.isoformat(), + } + } + + # For Stripe payment method with paid plan, create checkout session + payment_method = request.data.get('payment_method', '') + import logging + logger = logging.getLogger(__name__) + logger.info(f"Registration: payment_method={payment_method}, account_status={account.status if account else 'no account'}") + + if account and account.status == 'pending_payment' and payment_method == 'stripe': + try: + from igny8_core.business.billing.services.stripe_service import StripeService + stripe_service = StripeService() + logger.info(f"Creating Stripe checkout for account {account.id}, plan {account.plan.name}") + checkout_data = stripe_service.create_checkout_session( + account=account, + plan=account.plan, + ) + logger.info(f"Stripe checkout created: {checkout_data}") + response_data['checkout_url'] = checkout_data.get('checkout_url') + response_data['checkout_session_id'] = checkout_data.get('session_id') + except Exception as e: + logger.error(f"Failed to create Stripe checkout session: {e}", exc_info=True) + # Don't fail registration, just log the error + # User can still complete payment from the plans page + + # For PayPal payment method with paid plan, create PayPal order + elif account and account.status == 'pending_payment' and payment_method == 'paypal': + try: + from django.conf import settings + from igny8_core.business.billing.services.paypal_service import PayPalService + paypal_service = PayPalService() + frontend_url = getattr(settings, 'FRONTEND_URL', 'https://app.igny8.com') + + logger.info(f"Creating PayPal order for account {account.id}, amount {account.plan.price}") + order = paypal_service.create_order( + account=account, + amount=float(account.plan.price), + description=f'{account.plan.name} Plan Subscription', + return_url=f'{frontend_url}/account/plans?paypal=success&plan_id={account.plan.id}', + cancel_url=f'{frontend_url}/account/plans?paypal=cancel', + metadata={ + 'plan_id': str(account.plan.id), + 'type': 'subscription', + } + ) + logger.info(f"PayPal order created: {order}") + response_data['checkout_url'] = order.get('approval_url') + response_data['paypal_order_id'] = order.get('order_id') + except Exception as e: + logger.error(f"Failed to create PayPal order: {e}", exc_info=True) + # Don't fail registration, just log the error + return success_response( - data={ - 'user': user_serializer.data, - 'tokens': { - 'access': access_token, - 'refresh': refresh_token, - 'access_expires_at': access_expires_at.isoformat(), - 'refresh_expires_at': refresh_expires_at.isoformat(), - } - }, + data=response_data, message='Registration successful', status_code=status.HTTP_201_CREATED, request=request diff --git a/backend/igny8_core/business/billing/models.py b/backend/igny8_core/business/billing/models.py index 3f7ed6ab..385c9758 100644 --- a/backend/igny8_core/business/billing/models.py +++ b/backend/igny8_core/business/billing/models.py @@ -578,8 +578,10 @@ class CreditPackage(models.Model): class PaymentMethodConfig(models.Model): """ - Configure payment methods availability per country - Allows enabling/disabling manual payments by region + Configure payment methods availability per country. + + For online payments (stripe, paypal): Credentials stored in IntegrationProvider. + For manual payments (bank_transfer, local_wallet): Bank/wallet details stored here. """ # Use centralized choices PAYMENT_METHOD_CHOICES = PAYMENT_METHOD_CHOICES @@ -587,7 +589,7 @@ class PaymentMethodConfig(models.Model): country_code = models.CharField( max_length=2, db_index=True, - help_text="ISO 2-letter country code (e.g., US, GB, IN)" + help_text="ISO 2-letter country code (e.g., US, GB, PK) or '*' for global" ) payment_method = models.CharField(max_length=50, choices=PAYMENT_METHOD_CHOICES) is_enabled = models.BooleanField(default=True) @@ -596,21 +598,17 @@ class PaymentMethodConfig(models.Model): display_name = models.CharField(max_length=100, blank=True) instructions = models.TextField(blank=True, help_text="Payment instructions for users") - # Manual payment details (for bank_transfer/local_wallet) + # Manual payment details (for bank_transfer only) bank_name = models.CharField(max_length=255, blank=True) account_number = models.CharField(max_length=255, blank=True) - routing_number = models.CharField(max_length=255, blank=True) - swift_code = models.CharField(max_length=255, blank=True) + account_title = models.CharField(max_length=255, blank=True, help_text="Account holder name") + routing_number = models.CharField(max_length=255, blank=True, help_text="Routing/Sort code") + swift_code = models.CharField(max_length=255, blank=True, help_text="SWIFT/BIC code for international") + iban = models.CharField(max_length=255, blank=True, help_text="IBAN for international transfers") # Additional fields for local wallets - wallet_type = models.CharField(max_length=100, blank=True, help_text="E.g., PayTM, PhonePe, etc.") - wallet_id = models.CharField(max_length=255, blank=True) - - # Webhook configuration (Stripe/PayPal) - webhook_url = models.URLField(blank=True, help_text="Webhook URL for payment gateway callbacks") - webhook_secret = models.CharField(max_length=255, blank=True, help_text="Webhook secret for signature verification") - api_key = models.CharField(max_length=255, blank=True, help_text="API key for payment gateway integration") - api_secret = models.CharField(max_length=255, blank=True, help_text="API secret for payment gateway integration") + wallet_type = models.CharField(max_length=100, blank=True, help_text="E.g., JazzCash, EasyPaisa, etc.") + wallet_id = models.CharField(max_length=255, blank=True, help_text="Mobile number or wallet ID") # Order/priority sort_order = models.IntegerField(default=0) diff --git a/backend/igny8_core/business/billing/services/invoice_service.py b/backend/igny8_core/business/billing/services/invoice_service.py index 08af0662..79f77db0 100644 --- a/backend/igny8_core/business/billing/services/invoice_service.py +++ b/backend/igny8_core/business/billing/services/invoice_service.py @@ -14,6 +14,40 @@ from ....auth.models import Account, Subscription class InvoiceService: """Service for managing invoices""" + @staticmethod + def get_pending_invoice(subscription: Subscription) -> Optional[Invoice]: + """ + Get pending invoice for a subscription. + Used to find existing invoice during payment processing instead of creating duplicates. + """ + return Invoice.objects.filter( + subscription=subscription, + status='pending' + ).order_by('-created_at').first() + + @staticmethod + def get_or_create_subscription_invoice( + subscription: Subscription, + billing_period_start: datetime, + billing_period_end: datetime + ) -> tuple[Invoice, bool]: + """ + Get existing pending invoice or create new one. + Returns tuple of (invoice, created) where created is True if new invoice was created. + """ + # First try to find existing pending invoice for this subscription + existing = InvoiceService.get_pending_invoice(subscription) + if existing: + return existing, False + + # Create new invoice if none exists + invoice = InvoiceService.create_subscription_invoice( + subscription=subscription, + billing_period_start=billing_period_start, + billing_period_end=billing_period_end + ) + return invoice, True + @staticmethod def generate_invoice_number(account: Account) -> str: """ @@ -52,6 +86,10 @@ class InvoiceService: ) -> Invoice: """ Create invoice for subscription billing period + + Currency logic: + - USD for online payments (stripe, paypal) + - Local currency (PKR) only for bank_transfer in applicable countries """ account = subscription.account plan = subscription.plan @@ -74,12 +112,22 @@ class InvoiceService: invoice_date = timezone.now().date() due_date = invoice_date + timedelta(days=INVOICE_DUE_DATE_OFFSET) - # Get currency based on billing country + # Determine currency based on payment method: + # - Online payments (stripe, paypal): Always USD + # - Manual payments (bank_transfer, local_wallet): Local currency for applicable countries from igny8_core.business.billing.utils.currency import get_currency_for_country, convert_usd_to_local - currency = get_currency_for_country(account.billing_country) - # Convert plan price to local currency - local_price = convert_usd_to_local(float(plan.price), account.billing_country) + payment_method = account.payment_method + online_payment_methods = ['stripe', 'paypal'] + + if payment_method in online_payment_methods: + # Online payments are always in USD + currency = 'USD' + local_price = float(plan.price) + else: + # Manual payments use local currency for applicable countries + currency = get_currency_for_country(account.billing_country) + local_price = convert_usd_to_local(float(plan.price), account.billing_country) invoice = Invoice.objects.create( account=account, @@ -95,7 +143,8 @@ class InvoiceService: 'billing_period_end': billing_period_end.isoformat(), 'subscription_id': subscription.id, # Keep in metadata for backward compatibility 'usd_price': str(plan.price), # Store original USD price - 'exchange_rate': str(local_price / float(plan.price) if plan.price > 0 else 1.0) + 'exchange_rate': str(local_price / float(plan.price) if plan.price > 0 else 1.0), + 'payment_method': payment_method } ) @@ -120,16 +169,28 @@ class InvoiceService: ) -> Invoice: """ Create invoice for credit package purchase + + Currency logic: + - USD for online payments (stripe, paypal) + - Local currency (PKR) only for bank_transfer in applicable countries """ from igny8_core.business.billing.config import INVOICE_DUE_DATE_OFFSET invoice_date = timezone.now().date() - # Get currency based on billing country + # Determine currency based on payment method from igny8_core.business.billing.utils.currency import get_currency_for_country, convert_usd_to_local - currency = get_currency_for_country(account.billing_country) - # Convert credit package price to local currency - local_price = convert_usd_to_local(float(credit_package.price), account.billing_country) + payment_method = account.payment_method + online_payment_methods = ['stripe', 'paypal'] + + if payment_method in online_payment_methods: + # Online payments are always in USD + currency = 'USD' + local_price = float(credit_package.price) + else: + # Manual payments use local currency for applicable countries + currency = get_currency_for_country(account.billing_country) + local_price = convert_usd_to_local(float(credit_package.price), account.billing_country) invoice = Invoice.objects.create( account=account, @@ -143,7 +204,8 @@ class InvoiceService: 'credit_package_id': credit_package.id, 'credit_amount': credit_package.credits, 'usd_price': str(credit_package.price), # Store original USD price - 'exchange_rate': str(local_price / float(credit_package.price) if credit_package.price > 0 else 1.0) + 'exchange_rate': str(local_price / float(credit_package.price) if credit_package.price > 0 else 1.0), + 'payment_method': payment_method }, ) diff --git a/backend/igny8_core/business/billing/views/paypal_views.py b/backend/igny8_core/business/billing/views/paypal_views.py index 6efee027..616ead61 100644 --- a/backend/igny8_core/business/billing/views/paypal_views.py +++ b/backend/igny8_core/business/billing/views/paypal_views.py @@ -627,10 +627,9 @@ def _process_subscription_payment(account, plan_id: str, capture_result: dict) - } ) - # Create invoice - invoice = InvoiceService.create_subscription_invoice( - account=account, - plan=plan, + # Get existing pending invoice or create new one (avoids duplicates) + invoice, invoice_created = InvoiceService.get_or_create_subscription_invoice( + subscription=subscription, billing_period_start=now, billing_period_end=period_end, ) diff --git a/backend/igny8_core/business/billing/views/stripe_views.py b/backend/igny8_core/business/billing/views/stripe_views.py index 09c6d491..638897a0 100644 --- a/backend/igny8_core/business/billing/views/stripe_views.py +++ b/backend/igny8_core/business/billing/views/stripe_views.py @@ -10,7 +10,7 @@ Endpoints: """ import stripe import logging -from datetime import datetime +from datetime import datetime, timezone as dt_timezone, timedelta from django.conf import settings from django.utils import timezone from django.views.decorators.csrf import csrf_exempt @@ -402,6 +402,7 @@ def _activate_subscription(account, stripe_subscription_id: str, plan_id: str, s # Get subscription details from Stripe try: stripe_sub = stripe.Subscription.retrieve(stripe_subscription_id) + logger.info(f"Retrieved Stripe subscription: {stripe_subscription_id}") except Exception as e: logger.error(f"Failed to retrieve Stripe subscription {stripe_subscription_id}: {e}") return @@ -413,6 +414,40 @@ def _activate_subscription(account, stripe_subscription_id: str, plan_id: str, s logger.error(f"Plan {plan_id} not found for subscription activation") return + # Extract period dates from Stripe subscription + # Note: In newer Stripe API, current_period_start/end are on subscription items, not the subscription + try: + # Convert to dict for easier access + sub_dict = stripe_sub.to_dict_recursive() if hasattr(stripe_sub, 'to_dict_recursive') else dict(stripe_sub) + + # Try to get period from subscription items first (new API) + items = sub_dict.get('items', {}).get('data', []) + if items: + first_item = items[0] + period_start = first_item.get('current_period_start') + period_end = first_item.get('current_period_end') + else: + # Fallback to subscription level (old API) + period_start = sub_dict.get('current_period_start') + period_end = sub_dict.get('current_period_end') + + # If still not found, use billing_cycle_anchor or start_date + if not period_start: + period_start = sub_dict.get('billing_cycle_anchor') or sub_dict.get('start_date') or sub_dict.get('created') + if not period_end: + # Default to 30 days from start + period_end = period_start + (30 * 24 * 60 * 60) if period_start else None + + cancel_at_end = sub_dict.get('cancel_at_period_end', False) + + logger.info(f"Extracted period: start={period_start}, end={period_end}, cancel_at_end={cancel_at_end}") + except Exception as e: + logger.error(f"Failed to extract period dates from Stripe subscription: {e}") + # Use current time as fallback + period_start = timezone.now().timestamp() + period_end = (timezone.now() + timedelta(days=30)).timestamp() + cancel_at_end = False + with transaction.atomic(): # Create or update subscription subscription, created = Subscription.objects.update_or_create( @@ -422,27 +457,29 @@ def _activate_subscription(account, stripe_subscription_id: str, plan_id: str, s 'stripe_subscription_id': stripe_subscription_id, 'status': 'active', 'current_period_start': datetime.fromtimestamp( - stripe_sub.current_period_start, - tz=timezone.utc + period_start, + tz=dt_timezone.utc ), 'current_period_end': datetime.fromtimestamp( - stripe_sub.current_period_end, - tz=timezone.utc + period_end, + tz=dt_timezone.utc ), - 'cancel_at_period_end': stripe_sub.cancel_at_period_end, + 'cancel_at_period_end': cancel_at_end, } ) - # Create invoice record + # Get existing pending invoice or create new one (avoids duplicates) amount = session.get('amount_total', 0) / 100 # Convert from cents currency = session.get('currency', 'usd').upper() - invoice = InvoiceService.create_subscription_invoice( - account=account, - plan=plan, + invoice, invoice_created = InvoiceService.get_or_create_subscription_invoice( + subscription=subscription, billing_period_start=subscription.current_period_start, billing_period_end=subscription.current_period_end, ) + + if not invoice_created: + logger.info(f"Found existing pending invoice {invoice.invoice_number} for subscription") # Mark invoice as paid InvoiceService.mark_paid( @@ -481,10 +518,15 @@ def _activate_subscription(account, stripe_subscription_id: str, plan_id: str, s } ) - # Update account status if needed + # Update account status and plan + update_fields = ['updated_at'] if account.status != 'active': account.status = 'active' - account.save(update_fields=['status', 'updated_at']) + update_fields.append('status') + if account.plan_id != plan.id: + account.plan = plan + update_fields.append('plan') + account.save(update_fields=update_fields) logger.info( f"Subscription activated for account {account.id}: " @@ -659,15 +701,33 @@ def _handle_subscription_updated(subscription_data: dict): stripe_status = subscription_data.get('status') new_status = status_map.get(stripe_status, 'active') - # Update period dates - subscription.current_period_start = datetime.fromtimestamp( - subscription_data.get('current_period_start'), - tz=timezone.utc - ) - subscription.current_period_end = datetime.fromtimestamp( - subscription_data.get('current_period_end'), - tz=timezone.utc - ) + # Extract period dates - check subscription items first (new API), then subscription level + items = subscription_data.get('items', {}).get('data', []) + if items: + first_item = items[0] + period_start = first_item.get('current_period_start') + period_end = first_item.get('current_period_end') + else: + period_start = subscription_data.get('current_period_start') + period_end = subscription_data.get('current_period_end') + + # Fallback to billing_cycle_anchor if period dates not found + if not period_start: + period_start = subscription_data.get('billing_cycle_anchor') or subscription_data.get('start_date') + if not period_end and period_start: + period_end = period_start + (30 * 24 * 60 * 60) # Default 30 days + + # Only update period dates if we have valid values + if period_start: + subscription.current_period_start = datetime.fromtimestamp( + period_start, + tz=dt_timezone.utc + ) + if period_end: + subscription.current_period_end = datetime.fromtimestamp( + period_end, + tz=dt_timezone.utc + ) subscription.cancel_at_period_end = subscription_data.get('cancel_at_period_end', False) subscription.status = new_status subscription.save() diff --git a/backend/igny8_core/modules/billing/admin.py b/backend/igny8_core/modules/billing/admin.py index 1bd46f91..49602230 100644 --- a/backend/igny8_core/modules/billing/admin.py +++ b/backend/igny8_core/modules/billing/admin.py @@ -519,6 +519,30 @@ class PaymentMethodConfigAdmin(Igny8ModelAdmin): search_fields = ['country_code', 'display_name', 'payment_method'] list_editable = ['is_enabled', 'sort_order'] readonly_fields = ['created_at', 'updated_at'] + + fieldsets = ( + ('Payment Method', { + 'fields': ('country_code', 'payment_method', 'display_name', 'is_enabled', 'sort_order') + }), + ('Instructions', { + 'fields': ('instructions',), + 'description': 'Instructions shown to users for this payment method' + }), + ('Bank Transfer Details', { + 'fields': ('bank_name', 'account_title', 'account_number', 'routing_number', 'swift_code', 'iban'), + 'classes': ('collapse',), + 'description': 'Only for bank_transfer payment method' + }), + ('Local Wallet Details', { + 'fields': ('wallet_type', 'wallet_id'), + 'classes': ('collapse',), + 'description': 'Only for local_wallet payment method (JazzCash, EasyPaisa, etc.)' + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) @admin.register(AccountPaymentMethod) diff --git a/backend/igny8_core/modules/billing/migrations/0028_cleanup_payment_method_config.py b/backend/igny8_core/modules/billing/migrations/0028_cleanup_payment_method_config.py new file mode 100644 index 00000000..95bfe4b5 --- /dev/null +++ b/backend/igny8_core/modules/billing/migrations/0028_cleanup_payment_method_config.py @@ -0,0 +1,64 @@ +# Generated by Django 5.2.9 on 2026-01-07 03:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('billing', '0027_model_schema_update'), + ] + + operations = [ + migrations.RemoveField( + model_name='paymentmethodconfig', + name='api_key', + ), + migrations.RemoveField( + model_name='paymentmethodconfig', + name='api_secret', + ), + migrations.RemoveField( + model_name='paymentmethodconfig', + name='webhook_secret', + ), + migrations.RemoveField( + model_name='paymentmethodconfig', + name='webhook_url', + ), + migrations.AddField( + model_name='paymentmethodconfig', + name='account_title', + field=models.CharField(blank=True, help_text='Account holder name', max_length=255), + ), + migrations.AddField( + model_name='paymentmethodconfig', + name='iban', + field=models.CharField(blank=True, help_text='IBAN for international transfers', max_length=255), + ), + migrations.AlterField( + model_name='paymentmethodconfig', + name='country_code', + field=models.CharField(db_index=True, help_text="ISO 2-letter country code (e.g., US, GB, PK) or '*' for global", max_length=2), + ), + migrations.AlterField( + model_name='paymentmethodconfig', + name='routing_number', + field=models.CharField(blank=True, help_text='Routing/Sort code', max_length=255), + ), + migrations.AlterField( + model_name='paymentmethodconfig', + name='swift_code', + field=models.CharField(blank=True, help_text='SWIFT/BIC code for international', max_length=255), + ), + migrations.AlterField( + model_name='paymentmethodconfig', + name='wallet_id', + field=models.CharField(blank=True, help_text='Mobile number or wallet ID', max_length=255), + ), + migrations.AlterField( + model_name='paymentmethodconfig', + name='wallet_type', + field=models.CharField(blank=True, help_text='E.g., JazzCash, EasyPaisa, etc.', max_length=100), + ), + ] diff --git a/backend/igny8_core/settings.py b/backend/igny8_core/settings.py index 87093cb6..19691eed 100644 --- a/backend/igny8_core/settings.py +++ b/backend/igny8_core/settings.py @@ -846,3 +846,6 @@ STRIPE_WEBHOOK_SECRET = os.getenv('STRIPE_WEBHOOK_SECRET', '') PAYPAL_CLIENT_ID = os.getenv('PAYPAL_CLIENT_ID', '') PAYPAL_CLIENT_SECRET = os.getenv('PAYPAL_CLIENT_SECRET', '') PAYPAL_API_BASE = os.getenv('PAYPAL_API_BASE', 'https://api-m.sandbox.paypal.com') + +# Frontend URL for redirects (Stripe/PayPal success/cancel URLs) +FRONTEND_URL = os.getenv('FRONTEND_URL', 'https://app.igny8.com') diff --git a/backend/igny8_core/urls.py b/backend/igny8_core/urls.py index ec8c9125..1c22808a 100644 --- a/backend/igny8_core/urls.py +++ b/backend/igny8_core/urls.py @@ -26,6 +26,7 @@ from igny8_core.auth.views import ( industrysector_csv_template, industrysector_csv_import, seedkeyword_csv_template, seedkeyword_csv_import ) +from igny8_core.utils.geo_views import GeoDetectView urlpatterns = [ # CSV Import/Export for admin - MUST come before admin/ to avoid being caught by admin.site.urls @@ -49,6 +50,7 @@ urlpatterns = [ path('api/v1/optimizer/', include('igny8_core.modules.optimizer.urls')), # Optimizer endpoints path('api/v1/publisher/', include('igny8_core.modules.publisher.urls')), # Publisher endpoints path('api/v1/integration/', include('igny8_core.modules.integration.urls')), # Integration endpoints + path('api/v1/geo/detect/', GeoDetectView.as_view(), name='geo-detect'), # Geo detection for signup routing # OpenAPI Schema and Documentation path('api/schema/', SpectacularAPIView.as_view(), name='schema'), path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), diff --git a/backend/igny8_core/utils/geo_views.py b/backend/igny8_core/utils/geo_views.py new file mode 100644 index 00000000..f08fc279 --- /dev/null +++ b/backend/igny8_core/utils/geo_views.py @@ -0,0 +1,42 @@ +""" +Utility views for miscellaneous API endpoints +""" +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import permissions + + +class GeoDetectView(APIView): + """ + Detect user's country from request headers. + + Uses Cloudflare's CF-IPCountry header if behind Cloudflare, + otherwise falls back to checking X-Country-Code or other headers. + + This endpoint is used by the frontend to determine which signup page + to show (/signup for global, /signup/pk for Pakistan). + """ + permission_classes = [permissions.AllowAny] + + def get(self, request): + # Try Cloudflare header first (most reliable when behind CF) + country_code = request.META.get('HTTP_CF_IPCOUNTRY', '') + + # Fallback to X-Country-Code header (can be set by load balancer/CDN) + if not country_code: + country_code = request.META.get('HTTP_X_COUNTRY_CODE', '') + + # Fallback to GeoIP2 if available (Django GeoIP2 middleware) + if not country_code: + country_code = getattr(request, 'country_code', '') + + # Default to empty string (frontend will treat as global) + country_code = country_code.upper() if country_code else '' + + return Response({ + 'success': True, + 'data': { + 'country_code': country_code, + 'detected': bool(country_code), + } + }) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 191fd374..8e5bb34c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,6 +15,7 @@ import SuspenseLoader from "./components/common/SuspenseLoader"; // Auth pages - loaded immediately (needed for login) import SignIn from "./pages/AuthPages/SignIn"; import SignUp from "./pages/AuthPages/SignUp"; +import SignUpPK from "./pages/AuthPages/SignUpPK"; import Payment from "./pages/Payment"; import NotFound from "./pages/OtherPage/NotFound"; @@ -138,6 +139,7 @@ export default function App() { {/* Auth Routes - Public */} } /> } /> + } /> } /> {/* Legal Pages - Public */} diff --git a/frontend/src/components/auth/SignUpFormUnified.tsx b/frontend/src/components/auth/SignUpFormUnified.tsx index 6c339613..d6202662 100644 --- a/frontend/src/components/auth/SignUpFormUnified.tsx +++ b/frontend/src/components/auth/SignUpFormUnified.tsx @@ -1,6 +1,10 @@ /** * Unified Signup Form with Integrated Pricing Selection * Combines free and paid signup flows in one modern interface + * + * Payment Methods: + * - Most countries: Credit/Debit Card (Stripe) + PayPal + * - Pakistan (PK): Credit/Debit Card (Stripe) + Bank Transfer */ import { useState, useEffect } from 'react'; @@ -14,6 +18,13 @@ import Button from '../ui/button/Button'; import SelectDropdown from '../form/SelectDropdown'; import { useAuthStore } from '../../store/authStore'; +// PayPal icon component +const PayPalIcon = ({ className }: { className?: string }) => ( + + + +); + interface Plan { id: number; name: string; @@ -38,11 +49,21 @@ interface PaymentMethodConfig { is_enabled: boolean; } +// Payment method option type +interface PaymentOption { + id: string; + type: string; + name: string; + description: string; + icon: React.ReactNode; +} + interface SignUpFormUnifiedProps { plans: Plan[]; selectedPlan: Plan | null; onPlanSelect: (plan: Plan) => void; plansLoading: boolean; + countryCode?: string; // Optional: 'PK' for Pakistan-specific, empty for global } export default function SignUpFormUnified({ @@ -50,6 +71,7 @@ export default function SignUpFormUnified({ selectedPlan, onPlanSelect, plansLoading, + countryCode = '', // Default to global (empty = show Credit Card + PayPal) }: SignUpFormUnifiedProps) { const [showPassword, setShowPassword] = useState(false); const [isChecked, setIsChecked] = useState(false); @@ -61,11 +83,12 @@ export default function SignUpFormUnified({ email: '', password: '', accountName: '', - billingCountry: 'US', + billingCountry: countryCode || 'US', }); - const [selectedPaymentMethod, setSelectedPaymentMethod] = useState(''); - const [paymentMethods, setPaymentMethods] = useState([]); + const [selectedPaymentMethod, setSelectedPaymentMethod] = useState('stripe'); + const [availablePaymentOptions, setAvailablePaymentOptions] = useState([]); + const [backendPaymentMethods, setBackendPaymentMethods] = useState([]); const [paymentMethodsLoading, setPaymentMethodsLoading] = useState(false); const [error, setError] = useState(''); @@ -74,6 +97,9 @@ export default function SignUpFormUnified({ const isPaidPlan = selectedPlan && parseFloat(String(selectedPlan.price || 0)) > 0; + // Determine if this is a Pakistan-specific signup + const isPakistanSignup = countryCode === 'PK'; + // Update URL when plan changes useEffect(() => { if (selectedPlan) { @@ -83,10 +109,10 @@ export default function SignUpFormUnified({ } }, [selectedPlan]); - // Load payment methods for paid plans + // Load payment methods from backend and determine available options useEffect(() => { if (!isPaidPlan) { - setPaymentMethods([]); + setAvailablePaymentOptions([]); return; } @@ -94,8 +120,7 @@ export default function SignUpFormUnified({ setPaymentMethodsLoading(true); try { const API_BASE_URL = import.meta.env.VITE_BACKEND_URL || 'https://api.igny8.com/api'; - const country = formData.billingCountry || 'US'; - const response = await fetch(`${API_BASE_URL}/v1/billing/payment-configs/payment-methods/?country=${country}`); + const response = await fetch(`${API_BASE_URL}/v1/billing/payment-configs/payment-methods/`); if (!response.ok) { throw new Error('Failed to load payment methods'); @@ -113,22 +138,79 @@ export default function SignUpFormUnified({ } const enabledMethods = methodsList.filter((m: PaymentMethodConfig) => m.is_enabled); - setPaymentMethods(enabledMethods); + setBackendPaymentMethods(enabledMethods); - if (enabledMethods.length > 0 && !selectedPaymentMethod) { - setSelectedPaymentMethod(enabledMethods[0].payment_method); + // Build payment options based on signup type (PK vs Global) + const options: PaymentOption[] = []; + + // Always show Credit/Debit Card (Stripe) if enabled + const stripeEnabled = enabledMethods.some(m => m.payment_method === 'stripe'); + if (stripeEnabled) { + options.push({ + id: 'stripe', + type: 'stripe', + name: 'Credit/Debit Card', + description: 'Pay securely with Visa, Mastercard, or other cards', + icon: , + }); + } + + // For Pakistan signup (/signup/pk): show Bank Transfer + // For Global signup (/signup): show PayPal + if (isPakistanSignup) { + // Pakistan: show Bank Transfer as 2nd option + const bankTransferEnabled = enabledMethods.some( + m => m.payment_method === 'bank_transfer' && (!m.country_code || m.country_code === 'PK') + ); + if (bankTransferEnabled) { + options.push({ + id: 'bank_transfer', + type: 'bank_transfer', + name: 'Bank Transfer', + description: 'Pay via bank transfer (PKR)', + icon: , + }); + } + } else { + // Global: show PayPal as 2nd option + const paypalEnabled = enabledMethods.some(m => m.payment_method === 'paypal'); + if (paypalEnabled) { + options.push({ + id: 'paypal', + type: 'paypal', + name: 'PayPal', + description: 'Pay with your PayPal account', + icon: , + }); + } + } + + setAvailablePaymentOptions(options); + + // Set default payment method + if (options.length > 0 && !options.find(o => o.type === selectedPaymentMethod)) { + setSelectedPaymentMethod(options[0].type); } } catch (err: any) { console.error('Failed to load payment methods:', err); - // Don't set error for free plans or if payment methods fail to load - // Just log it and continue + // Fallback to default options + setAvailablePaymentOptions([ + { + id: 'stripe', + type: 'stripe', + name: 'Credit/Debit Card', + description: 'Pay securely with Visa, Mastercard, or other cards', + icon: , + } + ]); + setSelectedPaymentMethod('stripe'); } finally { setPaymentMethodsLoading(false); } }; loadPaymentMethods(); - }, [isPaidPlan, formData.billingCountry]); + }, [isPaidPlan, isPakistanSignup]); const handleChange = (e: React.ChangeEvent) => { const { name, value } = e.target; @@ -180,27 +262,33 @@ export default function SignUpFormUnified({ const user = (await register(registerPayload)) as any; + // Log full registration response for debugging + console.log('Registration response:', { + user: user, + checkoutUrl: user?.checkout_url, + selectedPaymentMethod: selectedPaymentMethod, + accountStatus: user?.account?.status + }); + // CRITICAL: Verify auth state is actually set in Zustand store - // The register function should have already set isAuthenticated=true const currentAuthState = useAuthStore.getState(); console.log('Post-registration auth state check:', { isAuthenticated: currentAuthState.isAuthenticated, hasUser: !!currentAuthState.user, hasToken: !!currentAuthState.token, - userData: user + userData: user, + checkoutUrl: user?.checkout_url }); // If for some reason state wasn't set, force set it again if (!currentAuthState.isAuthenticated || !currentAuthState.user || !currentAuthState.token) { console.error('Auth state not properly set after registration, forcing update...'); - // Extract tokens from user data if available const tokenData = user?.tokens || {}; const accessToken = user?.access || tokenData.access || localStorage.getItem('access_token'); const refreshToken = user?.refresh || tokenData.refresh || localStorage.getItem('refresh_token'); - // Force set the state useAuthStore.setState({ user: user, token: accessToken, @@ -209,7 +297,6 @@ export default function SignUpFormUnified({ loading: false }); - // Wait a bit for state to propagate await new Promise((resolve) => setTimeout(resolve, 500)); } @@ -219,30 +306,46 @@ export default function SignUpFormUnified({ throw new Error('Failed to authenticate after registration. Please try logging in manually.'); } - const status = user?.account?.status; - if (status === 'pending_payment') { - navigate('/account/plans', { replace: true }); - } else { - navigate('/sites', { replace: true }); + // Handle payment gateway redirects + const checkoutUrl = user?.checkout_url; + + console.log('Payment redirect decision:', { + checkoutUrl, + selectedPaymentMethod, + isPaidPlan, + fullUserResponse: user, + }); + + // For Stripe or PayPal with checkout URL - redirect to payment gateway + if (checkoutUrl && (selectedPaymentMethod === 'stripe' || selectedPaymentMethod === 'paypal')) { + console.log(`Redirecting to ${selectedPaymentMethod} checkout:`, checkoutUrl); + window.location.href = checkoutUrl; + return; } + + // For bank_transfer ONLY - go to plans page to show payment instructions + // This is the expected flow for bank transfer + if (selectedPaymentMethod === 'bank_transfer') { + console.log('Bank transfer selected, redirecting to plans page for payment confirmation'); + navigate('/account/plans', { replace: true }); + return; + } + + // If Stripe/PayPal but no checkout URL (error case) - still go to plans page + // User can retry payment from there + if (isPaidPlan && !checkoutUrl) { + console.warn('Paid plan selected but no checkout URL received - going to plans page'); + navigate('/account/plans', { replace: true }); + return; + } + + // For free plans - go to sites page + navigate('/sites', { replace: true }); } catch (err: any) { setError(err.message || 'Registration failed. Please try again.'); } }; - const getPaymentIcon = (method: string) => { - switch (method) { - case 'stripe': - return ; - case 'bank_transfer': - return ; - case 'local_wallet': - return ; - default: - return ; - } - }; - const formatNumber = (num: number): string => { if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`; if (num >= 1000) return `${(num / 1000).toFixed(0)}K`; @@ -426,78 +529,72 @@ export default function SignUpFormUnified({ {isPaidPlan && (
-
-
- - setFormData((prev) => ({ ...prev, billingCountry: value }))} - className="text-base" - /> -

Payment methods filtered by country

-
- -
- - {paymentMethodsLoading ? ( -
- -
- ) : paymentMethods.length === 0 ? ( -
-

No payment methods available

-
- ) : ( - ({ - value: m.payment_method, - label: m.display_name - }))} - value={selectedPaymentMethod} - onChange={(value) => setSelectedPaymentMethod(value)} - className="text-base" - /> - )} -

How you'd like to pay

-
-
- - {/* Payment Method Details - Full Width Below */} - {selectedPaymentMethod && paymentMethods.length > 0 && ( -
- {paymentMethods.filter(m => m.payment_method === selectedPaymentMethod).map((method) => ( - method.instructions && ( -
+ + {paymentMethodsLoading ? ( +
+ + Loading payment options... +
+ ) : availablePaymentOptions.length === 0 ? ( +
+

No payment methods available for your region

+
+ ) : ( +
+ {availablePaymentOptions.map((option) => ( + + ))} +
+ )} +
+ + {/* Pakistan signup notice */} + {isPakistanSignup && ( +

+ 🇵🇰 Pakistan - Bank transfer available +

)}
)} diff --git a/frontend/src/components/billing/PendingPaymentBanner.tsx b/frontend/src/components/billing/PendingPaymentBanner.tsx index f6265f5e..4fbdcd44 100644 --- a/frontend/src/components/billing/PendingPaymentBanner.tsx +++ b/frontend/src/components/billing/PendingPaymentBanner.tsx @@ -38,6 +38,14 @@ export default function PendingPaymentBanner({ className = '' }: PendingPaymentB const accountStatus = user?.account?.status; const isPendingPayment = accountStatus === 'pending_payment'; + // Clear dismissed state when account is no longer pending payment + // This ensures the banner shows again if account reverts to pending + useEffect(() => { + if (!isPendingPayment) { + sessionStorage.removeItem('payment-banner-dismissed'); + } + }, [isPendingPayment]); + useEffect(() => { if (isPendingPayment && !dismissed) { loadPendingInvoice(); diff --git a/frontend/src/pages/AuthPages/SignUp.tsx b/frontend/src/pages/AuthPages/SignUp.tsx index 1890e93f..b4c6cb56 100644 --- a/frontend/src/pages/AuthPages/SignUp.tsx +++ b/frontend/src/pages/AuthPages/SignUp.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from "react"; -import { Link } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import PageMeta from "../../components/common/PageMeta"; import SignUpFormUnified from "../../components/auth/SignUpFormUnified"; @@ -19,6 +19,7 @@ interface Plan { } export default function SignUp() { + const navigate = useNavigate(); const planSlug = useMemo(() => { const params = new URLSearchParams(window.location.search); return params.get("plan") || ""; @@ -27,6 +28,38 @@ export default function SignUp() { const [plans, setPlans] = useState([]); const [plansLoading, setPlansLoading] = useState(true); const [selectedPlan, setSelectedPlan] = useState(null); + const [geoChecked, setGeoChecked] = useState(false); + + // Check geo location and redirect PK users to /signup/pk + // Using free public API: https://api.country.is (no signup required, CORS enabled) + useEffect(() => { + const checkGeoAndRedirect = async () => { + try { + // Free public geo API - no signup required + const response = await fetch('https://api.country.is/', { + signal: AbortSignal.timeout(3000), // 3 second timeout + }); + + if (response.ok) { + const data = await response.json(); + const countryCode = data?.country; + + if (countryCode === 'PK') { + // Preserve query params when redirecting + const queryString = window.location.search; + navigate(`/signup/pk${queryString}`, { replace: true }); + return; + } + } + } catch (err) { + // Silently fail - continue with global signup + console.log('Geo detection failed, using global signup'); + } + setGeoChecked(true); + }; + + checkGeoAndRedirect(); + }, [navigate]); useEffect(() => { const fetchPlans = async () => { @@ -68,6 +101,15 @@ export default function SignUp() { fetchPlans(); }, [planSlug]); + // Don't render until geo check is complete (prevents flash) + if (!geoChecked) { + return ( +
+
+
+ ); + } + return ( <> { + const params = new URLSearchParams(window.location.search); + return params.get("plan") || ""; + }, []); + + const [plans, setPlans] = useState([]); + const [plansLoading, setPlansLoading] = useState(true); + const [selectedPlan, setSelectedPlan] = useState(null); + + useEffect(() => { + const fetchPlans = async () => { + setPlansLoading(true); + try { + const API_BASE_URL = import.meta.env.VITE_BACKEND_URL || "https://api.igny8.com/api"; + const res = await fetch(`${API_BASE_URL}/v1/auth/plans/`); + const data = await res.json(); + const allPlans = data?.results || []; + + // Show all active plans (including free plan) + const publicPlans = allPlans + .filter((p: Plan) => p.is_active) + .sort((a: Plan, b: Plan) => { + const priceA = typeof a.price === 'number' ? a.price : parseFloat(String(a.price || 0)); + const priceB = typeof b.price === 'number' ? b.price : parseFloat(String(b.price || 0)); + return priceA - priceB; + }); + + setPlans(publicPlans); + + // Auto-select plan from URL or default to first plan + if (planSlug) { + const plan = publicPlans.find((p: Plan) => p.slug === planSlug); + if (plan) { + setSelectedPlan(plan); + } else { + setSelectedPlan(publicPlans[0] || null); + } + } else { + setSelectedPlan(publicPlans[0] || null); + } + } catch (e) { + console.error('Failed to load plans:', e); + } finally { + setPlansLoading(false); + } + }; + fetchPlans(); + }, [planSlug]); + + return ( + <> + +
+
+ {/* Left Side - Signup Form with Pakistan-specific payment options */} + + + {/* Right Side - Pricing Plans */} +
+ {/* Logo - Top Right */} + + IGNY8 + + +
+ {/* Pricing Plans Component Will Load Here */} +
+ {/* Plans will be rendered by SignUpFormUnified */} +
+
+
+
+
+ + ); +} diff --git a/frontend/src/pages/Settings/Plans.tsx b/frontend/src/pages/Settings/Plans.tsx index ec500fc4..d549d237 100644 --- a/frontend/src/pages/Settings/Plans.tsx +++ b/frontend/src/pages/Settings/Plans.tsx @@ -1,10 +1,12 @@ import { useState, useEffect } from 'react'; +import { useSearchParams } from 'react-router-dom'; import PageMeta from '../../components/common/PageMeta'; import { useToast } from '../../components/ui/toast/ToastContainer'; import { fetchAPI } from '../../services/api'; import { PricingPlan } from '../../components/ui/pricing-table'; import PricingTable1 from '../../components/ui/pricing-table/pricing-table-1'; import { usePageLoading } from '../../context/PageLoadingContext'; +import { useAuthStore } from '../../store/authStore'; interface Plan { id: number; @@ -105,6 +107,45 @@ export default function Plans() { const toast = useToast(); const { startLoading, stopLoading } = usePageLoading(); const [plans, setPlans] = useState([]); + const [searchParams, setSearchParams] = useSearchParams(); + const { refreshUser } = useAuthStore(); + + // Handle payment success redirect from Stripe + useEffect(() => { + const success = searchParams.get('success'); + const sessionId = searchParams.get('session_id'); + + if (success === 'true') { + // Clear the query params to avoid re-triggering + setSearchParams({}); + + // Refresh user data to get updated account status + refreshUser().then(() => { + toast.success('Payment successful! Your subscription is now active.'); + }).catch((err) => { + console.error('Failed to refresh user after payment:', err); + // Still show success message since payment was processed + toast.success('Payment successful! Please refresh the page to see updates.'); + }); + } + }, [searchParams, setSearchParams, refreshUser, toast]); + + // Handle PayPal success redirect + useEffect(() => { + const paypal = searchParams.get('paypal'); + + if (paypal === 'success') { + setSearchParams({}); + refreshUser().then(() => { + toast.success('PayPal payment successful! Your subscription is now active.'); + }).catch(() => { + toast.success('PayPal payment successful! Please refresh the page to see updates.'); + }); + } else if (paypal === 'cancel') { + setSearchParams({}); + toast.info('Payment was cancelled.'); + } + }, [searchParams, setSearchParams, refreshUser, toast]); useEffect(() => { loadPlans(); diff --git a/frontend/src/pages/account/PlansAndBillingPage.tsx b/frontend/src/pages/account/PlansAndBillingPage.tsx index 9cf81798..dac1656e 100644 --- a/frontend/src/pages/account/PlansAndBillingPage.tsx +++ b/frontend/src/pages/account/PlansAndBillingPage.tsx @@ -36,6 +36,7 @@ import PageMeta from '../../components/common/PageMeta'; import PageHeader from '../../components/common/PageHeader'; import { useToast } from '../../components/ui/toast/ToastContainer'; import { usePageLoading } from '../../context/PageLoadingContext'; +import { formatCurrency } from '../../utils'; import { getCreditBalance, getCreditPackages, @@ -711,7 +712,7 @@ export default function PlansAndBillingPage() { {new Date(invoice.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} - ${invoice.total_amount} + {formatCurrency(invoice.total_amount, invoice.currency)} {invoice.status} diff --git a/frontend/src/store/authStore.ts b/frontend/src/store/authStore.ts index 1d0edcb7..0178ef74 100644 --- a/frontend/src/store/authStore.ts +++ b/frontend/src/store/authStore.ts @@ -357,8 +357,20 @@ export const useAuthStore = create()( throw new Error('Failed to save login session. Please try again.'); } - // Return user data for success handling - return userData; + // Return full response data for success handling (includes checkout_url for payment redirects) + console.log('Extracting checkout_url:', { + 'responseData.checkout_url': responseData.checkout_url, + 'data.checkout_url': data.checkout_url, + 'data.data?.checkout_url': data.data?.checkout_url, + responseData: responseData, + }); + + return { + ...userData, + checkout_url: responseData.checkout_url || data.checkout_url || data.data?.checkout_url, + checkout_session_id: responseData.checkout_session_id || data.checkout_session_id || data.data?.checkout_session_id, + paypal_order_id: responseData.paypal_order_id || data.paypal_order_id || data.data?.paypal_order_id, + }; } catch (error: any) { // ALWAYS reset loading on error - critical to prevent stuck state set({ loading: false }); diff --git a/frontend/src/utils/currency.ts b/frontend/src/utils/currency.ts new file mode 100644 index 00000000..4196fccd --- /dev/null +++ b/frontend/src/utils/currency.ts @@ -0,0 +1,68 @@ +/** + * Currency utilities for formatting amounts with proper symbols + */ + +// Currency symbols map +const CURRENCY_SYMBOLS: Record = { + USD: '$', + EUR: '€', + GBP: '£', + INR: '₹', + JPY: '¥', + CNY: '¥', + AUD: 'A$', + CAD: 'C$', + CHF: 'Fr', + SEK: 'kr', + NOK: 'kr', + DKK: 'kr', + PLN: 'zł', + BRL: 'R$', + ZAR: 'R', + AED: 'د.إ', + SAR: 'ر.س', + PKR: '₨', +}; + +/** + * Get currency symbol for a currency code + */ +export const getCurrencySymbol = (currencyCode?: string): string => { + if (!currencyCode) return '$'; + const code = currencyCode.toUpperCase(); + return CURRENCY_SYMBOLS[code] || `${code} `; +}; + +/** + * Format an amount with the proper currency symbol + * @param amount - The amount to format (string or number) + * @param currency - The currency code (e.g., 'USD', 'PKR') + * @param showDecimals - Whether to show decimal places (default: true for most currencies) + */ +export const formatCurrency = ( + amount: string | number, + currency?: string, + showDecimals: boolean = true +): string => { + const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount; + if (isNaN(numAmount)) return '-'; + + const symbol = getCurrencySymbol(currency); + + // For zero-decimal currencies like JPY, don't show decimals + const zeroDecimalCurrencies = ['JPY', 'KRW', 'VND']; + const shouldShowDecimals = showDecimals && !zeroDecimalCurrencies.includes(currency?.toUpperCase() || ''); + + const formatted = shouldShowDecimals + ? numAmount.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + : numAmount.toLocaleString('en-US', { maximumFractionDigits: 0 }); + + return `${symbol}${formatted}`; +}; + +/** + * Format price for display (typically USD) + */ +export const formatPrice = (price: string | number): string => { + return formatCurrency(price, 'USD'); +}; diff --git a/frontend/src/utils/date.ts b/frontend/src/utils/date.ts index f9bd7ee2..0bd140cb 100644 --- a/frontend/src/utils/date.ts +++ b/frontend/src/utils/date.ts @@ -50,3 +50,46 @@ export function formatRelativeDate(dateString: string | Date): string { } } +/** + * Format date to a standard display format + * @param dateString - ISO date string or Date object + * @param options - Intl.DateTimeFormat options + * @returns Formatted date string (e.g., "Jan 7, 2026") + */ +export function formatDate( + dateString: string | Date | null | undefined, + options: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', year: 'numeric' } +): string { + if (!dateString) return '-'; + + const date = typeof dateString === 'string' ? new Date(dateString) : dateString; + + if (isNaN(date.getTime())) return '-'; + + return date.toLocaleDateString('en-US', options); +} + +/** + * Format date and time to a standard display format + * @param dateString - ISO date string or Date object + * @returns Formatted date and time string (e.g., "Jan 7, 2026, 3:30 PM") + */ +export function formatDateTime( + dateString: string | Date | null | undefined +): string { + if (!dateString) return '-'; + + const date = typeof dateString === 'string' ? new Date(dateString) : dateString; + + if (isNaN(date.getTime())) return '-'; + + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true + }); +} + diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index edc33d3e..d17a9b1e 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -27,5 +27,12 @@ export { formatDateTime, } from './date'; +// Currency utilities +export { + getCurrencySymbol, + formatCurrency, + formatPrice, +} from './currency'; + // Add other global utilities here as needed