From 144e955b924677a742c9da8d45b62dc2cb999546 Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Mon, 8 Dec 2025 14:12:08 +0000 Subject: [PATCH] Fixing PLans page --- backend/create_api_test_data.py | 185 ++++ backend/igny8_core/api/throttles.py | 6 +- .../management/commands/cleanup_sessions.py | 82 ++ backend/igny8_core/auth/middleware.py | 12 +- .../migrations/0008_add_plan_is_internal.py | 26 + ...9_add_plan_annual_discount_and_featured.py | 36 + backend/igny8_core/auth/models.py | 9 + backend/igny8_core/auth/serializers.py | 3 +- backend/igny8_core/auth/views.py | 13 +- backend/igny8_core/business/billing/views.py | 32 +- backend/igny8_core/modules/billing/views.py | 172 +++ backend/igny8_core/settings.py | 45 +- .../migrations/0013_add_plan_is_internal.py | 18 + backend/test_session_contamination.py | 141 +++ frontend/src/App.tsx | 6 - .../src/components/auth/ProtectedRoute.tsx | 3 +- frontend/src/components/auth/SignUpForm.tsx | 2 +- .../ui/pricing-table/PricingTable.tsx | 11 +- frontend/src/layout/AppSidebar.tsx | 35 +- .../src/pages/account/AccountBillingPage.tsx | 625 ----------- .../src/pages/account/PlansAndBillingPage.tsx | 631 ++++------- .../account/PlansAndBillingPage.tsx.backup | 980 ++++++++++++++++++ frontend/src/services/billing.api.ts | 5 +- frontend/src/store/authStore.ts | 19 +- 24 files changed, 1992 insertions(+), 1105 deletions(-) create mode 100644 backend/create_api_test_data.py create mode 100644 backend/igny8_core/auth/management/commands/cleanup_sessions.py create mode 100644 backend/igny8_core/auth/migrations/0008_add_plan_is_internal.py create mode 100644 backend/igny8_core/auth/migrations/0009_add_plan_annual_discount_and_featured.py create mode 100644 backend/migrations/0013_add_plan_is_internal.py create mode 100644 backend/test_session_contamination.py delete mode 100644 frontend/src/pages/account/AccountBillingPage.tsx create mode 100644 frontend/src/pages/account/PlansAndBillingPage.tsx.backup diff --git a/backend/create_api_test_data.py b/backend/create_api_test_data.py new file mode 100644 index 00000000..77d66f1a --- /dev/null +++ b/backend/create_api_test_data.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python +""" +Create API test data for billing endpoints +All test records are marked with 'API_TEST' in name/description/notes +""" +import os +import sys +import django + +# Setup Django +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'igny8_core.settings') +django.setup() + +from django.utils import timezone +from django.contrib.auth import get_user_model +from igny8_core.auth.models import Account, Plan +from igny8_core.business.billing.models import ( + Invoice, Payment, CreditTransaction, AccountPaymentMethod, PaymentMethodConfig +) +from decimal import Decimal +from datetime import timedelta + +User = get_user_model() + +print("Creating API test data...") + +# Get or create test account +try: + account = Account.objects.get(name__icontains='scale') + print(f"✓ Using existing account: {account.name} (ID: {account.id})") +except Account.DoesNotExist: + # Get a plan + plan = Plan.objects.filter(is_active=True).first() + account = Account.objects.create( + name='API_TEST_ACCOUNT', + slug='api-test-account', + plan=plan, + credits=5000, + status='active' + ) + print(f"✓ Created test account: {account.name} (ID: {account.id})") + +# Create test invoices +invoice1, created = Invoice.objects.get_or_create( + account=account, + invoice_number='INV-API-TEST-001', + defaults={ + 'status': 'pending', + 'subtotal': Decimal('99.99'), + 'tax': Decimal('0.00'), + 'total': Decimal('99.99'), + 'currency': 'USD', + 'invoice_date': timezone.now().date(), + 'due_date': (timezone.now() + timedelta(days=30)).date(), + 'billing_email': 'test@igny8.com', + 'notes': 'API_TEST: Invoice for approval test', + 'line_items': [{'description': 'API Test Service', 'amount': 99.99, 'quantity': 1}], + } +) +if created: + print(f"✓ Created test invoice 1 (ID: {invoice1.id})") +else: + print(f"✓ Existing test invoice 1 (ID: {invoice1.id})") + +invoice2, created = Invoice.objects.get_or_create( + account=account, + invoice_number='INV-API-TEST-002', + defaults={ + 'status': 'pending', + 'subtotal': Decimal('49.99'), + 'tax': Decimal('0.00'), + 'total': Decimal('49.99'), + 'currency': 'USD', + 'invoice_date': timezone.now().date(), + 'due_date': (timezone.now() + timedelta(days=30)).date(), + 'billing_email': 'test@igny8.com', + 'notes': 'API_TEST: Invoice for rejection test', + 'line_items': [{'description': 'API Test Service', 'amount': 49.99, 'quantity': 1}], + } +) +if created: + print(f"✓ Created test invoice 2 (ID: {invoice2.id})") +else: + print(f"✓ Existing test invoice 2 (ID: {invoice2.id})") + +# Create test payment for approval +pending_payment, created = Payment.objects.get_or_create( + account=account, + invoice=invoice1, + manual_reference='API_TEST_REF_001', + defaults={ + 'status': 'pending_approval', + 'payment_method': 'bank_transfer', + 'amount': Decimal('99.99'), + 'currency': 'USD', + 'manual_notes': 'API_TEST: Test payment for approval endpoint', + } +) +if created: + print(f"✓ Created pending payment (ID: {pending_payment.id}) for approve_payment endpoint") +else: + print(f"✓ Existing pending payment (ID: {pending_payment.id})") + +# Create test payment for rejection +reject_payment, created = Payment.objects.get_or_create( + account=account, + invoice=invoice2, + manual_reference='API_TEST_REF_002', + defaults={ + 'status': 'pending_approval', + 'payment_method': 'manual', + 'amount': Decimal('49.99'), + 'currency': 'USD', + 'manual_notes': 'API_TEST: Test payment for rejection endpoint', + } +) +if created: + print(f"✓ Created pending payment (ID: {reject_payment.id}) for reject_payment endpoint") +else: + print(f"✓ Existing pending payment (ID: {reject_payment.id})") + +# Get or create test payment method config +configs = PaymentMethodConfig.objects.filter(payment_method='bank_transfer') +if configs.exists(): + config = configs.first() + print(f"✓ Using existing payment method config (ID: {config.id})") + created = False +else: + config = PaymentMethodConfig.objects.create( + payment_method='bank_transfer', + display_name='API_TEST Bank Transfer', + instructions='API_TEST: Transfer to account 123456789', + is_enabled=True, + sort_order=1, + ) + print(f"✓ Created payment method config (ID: {config.id})") + created = True + +# Create test account payment method +account_method, created = AccountPaymentMethod.objects.get_or_create( + account=account, + type='bank_transfer', + defaults={ + 'display_name': 'API_TEST Account Bank Transfer', + 'instructions': 'API_TEST: Test account-specific payment method', + 'is_default': True, + } +) +if created: + print(f"✓ Created account payment method (ID: {account_method.id})") +else: + print(f"✓ Existing account payment method (ID: {account_method.id})") + +# Create test credit transaction +transaction, created = CreditTransaction.objects.get_or_create( + account=account, + transaction_type='adjustment', + amount=1000, + defaults={ + 'balance_after': account.credits, + 'description': 'API_TEST: Test credit adjustment', + 'metadata': {'test': True, 'reason': 'API testing'}, + } +) +if created: + print(f"✓ Created credit transaction (ID: {transaction.id})") +else: + print(f"✓ Existing credit transaction (ID: {transaction.id})") + +print("\n" + "="*60) +print("API Test Data Summary:") +print("="*60) +print(f"Account ID: {account.id}") +print(f"Pending Payment (approve): ID {pending_payment.id}") +print(f"Pending Payment (reject): ID {reject_payment.id}") +print(f"Payment Method Config: ID {config.id}") +print(f"Account Payment Method: ID {account_method.id}") +print(f"Credit Transaction: ID {transaction.id}") +print("="*60) +print("\nTest endpoints:") +print(f"POST /v1/admin/billing/{pending_payment.id}/approve_payment/") +print(f"POST /v1/admin/billing/{reject_payment.id}/reject_payment/") +print(f"POST /v1/admin/users/{account.id}/adjust-credits/") +print(f"GET /v1/billing/payment-methods/{account_method.id}/set_default/") +print("="*60) diff --git a/backend/igny8_core/api/throttles.py b/backend/igny8_core/api/throttles.py index 30616e3a..64e6381a 100644 --- a/backend/igny8_core/api/throttles.py +++ b/backend/igny8_core/api/throttles.py @@ -22,9 +22,11 @@ class DebugScopedRateThrottle(ScopedRateThrottle): def allow_request(self, request, view): """ Check if request should be throttled. - Bypasses for: DEBUG mode, superusers, developers, system accounts, and public requests. - Enforces per-account throttling for regular users. + DISABLED - Always allow all requests. """ + return True + + # OLD CODE BELOW (DISABLED) # Bypass for superusers and developers if request.user and hasattr(request.user, 'is_authenticated') and request.user.is_authenticated: if getattr(request.user, 'is_superuser', False): diff --git a/backend/igny8_core/auth/management/commands/cleanup_sessions.py b/backend/igny8_core/auth/management/commands/cleanup_sessions.py new file mode 100644 index 00000000..983db538 --- /dev/null +++ b/backend/igny8_core/auth/management/commands/cleanup_sessions.py @@ -0,0 +1,82 @@ +""" +Management command to clean up expired and orphaned sessions +Helps prevent session contamination and reduces DB bloat +""" +from django.core.management.base import BaseCommand +from django.contrib.sessions.models import Session +from django.contrib.auth import get_user_model +from datetime import datetime, timedelta + +User = get_user_model() + +class Command(BaseCommand): + help = 'Clean up expired sessions and detect session contamination' + + def add_arguments(self, parser): + parser.add_argument( + '--dry-run', + action='store_true', + help='Show what would be deleted without actually deleting', + ) + parser.add_argument( + '--days', + type=int, + default=7, + help='Delete sessions older than X days (default: 7)', + ) + + def handle(self, *args, **options): + dry_run = options['dry_run'] + days = options['days'] + cutoff_date = datetime.now() - timedelta(days=days) + + # Get all sessions + all_sessions = Session.objects.all() + expired_sessions = Session.objects.filter(expire_date__lt=datetime.now()) + old_sessions = Session.objects.filter(expire_date__lt=cutoff_date) + + self.stdout.write(f"\n📊 Session Statistics:") + self.stdout.write(f" Total sessions: {all_sessions.count()}") + self.stdout.write(f" Expired sessions: {expired_sessions.count()}") + self.stdout.write(f" Sessions older than {days} days: {old_sessions.count()}") + + # Count sessions by user + user_sessions = {} + for session in all_sessions: + try: + data = session.get_decoded() + user_id = data.get('_auth_user_id') + if user_id: + user = User.objects.get(id=user_id) + key = f"{user.username} ({user.account.slug if user.account else 'no-account'})" + user_sessions[key] = user_sessions.get(key, 0) + 1 + except: + pass + + if user_sessions: + self.stdout.write(f"\n📈 Active sessions by user:") + for user_key, count in sorted(user_sessions.items(), key=lambda x: x[1], reverse=True)[:10]: + indicator = "⚠️ " if count > 20 else " " + self.stdout.write(f"{indicator}{user_key}: {count} sessions") + + # Delete expired sessions + if expired_sessions.exists(): + if dry_run: + self.stdout.write(self.style.WARNING(f"\n[DRY RUN] Would delete {expired_sessions.count()} expired sessions")) + else: + count = expired_sessions.delete()[0] + self.stdout.write(self.style.SUCCESS(f"\n✓ Deleted {count} expired sessions")) + else: + self.stdout.write(f"\n✓ No expired sessions to clean") + + # Detect potential contamination + warnings = [] + for user_key, count in user_sessions.items(): + if count > 50: + warnings.append(f"User '{user_key}' has {count} active sessions (potential proliferation)") + + if warnings: + self.stdout.write(self.style.WARNING(f"\n⚠️ Contamination Warnings:")) + for warning in warnings: + self.stdout.write(self.style.WARNING(f" {warning}")) + self.stdout.write(f"\n💡 Consider running: python manage.py clearsessions") diff --git a/backend/igny8_core/auth/middleware.py b/backend/igny8_core/auth/middleware.py index b6ab19df..f74cf22d 100644 --- a/backend/igny8_core/auth/middleware.py +++ b/backend/igny8_core/auth/middleware.py @@ -35,13 +35,11 @@ class AccountContextMiddleware(MiddlewareMixin): # This ensures changes to account/plan are reflected immediately without re-login try: from .models import User as UserModel - # Refresh user from DB with account and plan relationships to get latest data - # This is important so account/plan changes are reflected immediately - user = UserModel.objects.select_related('account', 'account__plan').get(id=request.user.id) - # Update request.user with fresh data - request.user = user - # Get account from refreshed user - user_account = getattr(user, 'account', None) + # CRITICAL FIX: Never mutate request.user - it causes session contamination + # Instead, just read the current user and set request.account + # Django's session middleware already sets request.user correctly + user = request.user # Use the user from session, don't overwrite it + validation_error = self._validate_account_and_plan(request, user) if validation_error: return validation_error diff --git a/backend/igny8_core/auth/migrations/0008_add_plan_is_internal.py b/backend/igny8_core/auth/migrations/0008_add_plan_is_internal.py new file mode 100644 index 00000000..5e05df90 --- /dev/null +++ b/backend/igny8_core/auth/migrations/0008_add_plan_is_internal.py @@ -0,0 +1,26 @@ +# Generated by Django 5.2.8 on 2025-12-08 13:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('igny8_core_auth', '0007_add_payment_method_fields'), + ] + + operations = [ + migrations.RemoveIndex( + model_name='account', + name='auth_acc_payment_idx', + ), + migrations.RemoveIndex( + model_name='subscription', + name='auth_sub_payment_idx', + ), + migrations.AddField( + model_name='plan', + name='is_internal', + field=models.BooleanField(default=False, help_text='Internal-only plan (Free/Internal) - hidden from public plan listings'), + ), + ] diff --git a/backend/igny8_core/auth/migrations/0009_add_plan_annual_discount_and_featured.py b/backend/igny8_core/auth/migrations/0009_add_plan_annual_discount_and_featured.py new file mode 100644 index 00000000..652ca525 --- /dev/null +++ b/backend/igny8_core/auth/migrations/0009_add_plan_annual_discount_and_featured.py @@ -0,0 +1,36 @@ +# Generated manually + +from django.db import migrations, models +import django.core.validators + + +class Migration(migrations.Migration): + + dependencies = [ + ('igny8_core_auth', '0008_add_plan_is_internal'), + ] + + operations = [ + migrations.AddField( + model_name='plan', + name='annual_discount_percent', + field=models.DecimalField( + decimal_places=2, + default=15.0, + help_text='Annual subscription discount percentage (default 15%)', + max_digits=5, + validators=[ + django.core.validators.MinValueValidator(0), + django.core.validators.MaxValueValidator(100) + ] + ), + ), + migrations.AddField( + model_name='plan', + name='is_featured', + field=models.BooleanField( + default=False, + help_text='Highlight this plan as popular/recommended' + ), + ), + ] diff --git a/backend/igny8_core/auth/models.py b/backend/igny8_core/auth/models.py index 4268eedb..00406aad 100644 --- a/backend/igny8_core/auth/models.py +++ b/backend/igny8_core/auth/models.py @@ -154,8 +154,17 @@ class Plan(models.Model): slug = models.SlugField(unique=True, max_length=255) price = models.DecimalField(max_digits=10, decimal_places=2) billing_cycle = models.CharField(max_length=20, choices=BILLING_CYCLE_CHOICES, default='monthly') + annual_discount_percent = models.DecimalField( + max_digits=5, + decimal_places=2, + default=15.00, + validators=[MinValueValidator(0), MaxValueValidator(100)], + help_text="Annual subscription discount percentage (default 15%)" + ) + is_featured = models.BooleanField(default=False, help_text="Highlight this plan as popular/recommended") features = models.JSONField(default=list, blank=True, help_text="Plan features as JSON array (e.g., ['ai_writer', 'image_gen', 'auto_publish'])") is_active = models.BooleanField(default=True) + is_internal = models.BooleanField(default=False, help_text="Internal-only plan (Free/Internal) - hidden from public plan listings") created_at = models.DateTimeField(auto_now_add=True) # Account Management Limits (kept - not operation limits) diff --git a/backend/igny8_core/auth/serializers.py b/backend/igny8_core/auth/serializers.py index 375e36f9..aee09f95 100644 --- a/backend/igny8_core/auth/serializers.py +++ b/backend/igny8_core/auth/serializers.py @@ -10,7 +10,8 @@ class PlanSerializer(serializers.ModelSerializer): class Meta: model = Plan fields = [ - 'id', 'name', 'slug', 'price', 'billing_cycle', 'features', 'is_active', + 'id', 'name', 'slug', 'price', 'billing_cycle', 'annual_discount_percent', + 'is_featured', 'features', 'is_active', 'max_users', 'max_sites', 'max_industries', 'max_author_profiles', 'included_credits', 'extra_credit_price', 'allow_credit_topup', 'auto_credit_topup_threshold', 'auto_credit_topup_amount', diff --git a/backend/igny8_core/auth/views.py b/backend/igny8_core/auth/views.py index 817cefcc..e4c34413 100644 --- a/backend/igny8_core/auth/views.py +++ b/backend/igny8_core/auth/views.py @@ -440,9 +440,10 @@ class SiteUserAccessViewSet(AccountModelViewSet): class PlanViewSet(viewsets.ReadOnlyModelViewSet): """ ViewSet for listing active subscription plans. + Excludes internal-only plans (Free/Internal) from public listings. Unified API Standard v1.0 compliant """ - queryset = Plan.objects.filter(is_active=True) + queryset = Plan.objects.filter(is_active=True, is_internal=False) serializer_class = PlanSerializer permission_classes = [permissions.AllowAny] pagination_class = CustomPageNumberPagination @@ -450,6 +451,16 @@ class PlanViewSet(viewsets.ReadOnlyModelViewSet): throttle_scope = None throttle_classes: list = [] + def list(self, request, *args, **kwargs): + """Override list to return paginated response with unified format""" + queryset = self.filter_queryset(self.get_queryset()) + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + serializer = self.get_serializer(queryset, many=True) + return success_response(data={'results': serializer.data}, request=request) + def retrieve(self, request, *args, **kwargs): """Override retrieve to return unified format""" try: diff --git a/backend/igny8_core/business/billing/views.py b/backend/igny8_core/business/billing/views.py index 7cbf470d..7dde751e 100644 --- a/backend/igny8_core/business/billing/views.py +++ b/backend/igny8_core/business/billing/views.py @@ -200,7 +200,7 @@ class InvoiceViewSet(AccountModelViewSet): # Serialize invoice data results = [] - for invoice in page: + for invoice in (page if page is not None else []): results.append({ 'id': invoice.id, 'invoice_number': invoice.invoice_number, @@ -218,8 +218,10 @@ class InvoiceViewSet(AccountModelViewSet): 'created_at': invoice.created_at.isoformat(), }) - paginated_data = paginator.get_paginated_response({'results': results}).data - return paginated_response(paginated_data, request=request) + return paginated_response( + {'count': paginator.page.paginator.count, 'next': paginator.get_next_link(), 'previous': paginator.get_previous_link(), 'results': results}, + request=request + ) def retrieve(self, request, pk=None): """Get invoice detail""" @@ -291,7 +293,7 @@ class PaymentViewSet(AccountModelViewSet): # Serialize payment data results = [] - for payment in page: + for payment in (page if page is not None else []): results.append({ 'id': payment.id, 'invoice_id': payment.invoice_id, @@ -306,8 +308,10 @@ class PaymentViewSet(AccountModelViewSet): 'manual_notes': payment.manual_notes, }) - paginated_data = paginator.get_paginated_response({'results': results}).data - return paginated_response(paginated_data, request=request) + return paginated_response( + {'count': paginator.page.paginator.count, 'next': paginator.get_next_link(), 'previous': paginator.get_previous_link(), 'results': results}, + request=request + ) @action(detail=False, methods=['post']) def manual(self, request): @@ -361,7 +365,7 @@ class CreditPackageViewSet(viewsets.ReadOnlyModelViewSet): page = paginator.paginate_queryset(queryset, request) results = [] - for package in page: + for package in (page if page is not None else []): results.append({ 'id': package.id, 'name': package.name, @@ -374,8 +378,10 @@ class CreditPackageViewSet(viewsets.ReadOnlyModelViewSet): 'display_order': package.sort_order, }) - paginated_data = paginator.get_paginated_response({'results': results}).data - return paginated_response(paginated_data, request=request) + return paginated_response( + {'count': paginator.page.paginator.count, 'next': paginator.get_next_link(), 'previous': paginator.get_previous_link(), 'results': results}, + request=request + ) class AccountPaymentMethodViewSet(AccountModelViewSet): @@ -398,7 +404,7 @@ class AccountPaymentMethodViewSet(AccountModelViewSet): page = paginator.paginate_queryset(queryset, request) results = [] - for method in page: + for method in (page if page is not None else []): results.append({ 'id': str(method.id), 'type': method.type, @@ -408,5 +414,7 @@ class AccountPaymentMethodViewSet(AccountModelViewSet): 'instructions': method.instructions, }) - paginated_data = paginator.get_paginated_response({'results': results}).data - return paginated_response(paginated_data, request=request) + return paginated_response( + {'count': paginator.page.paginator.count, 'next': paginator.get_next_link(), 'previous': paginator.get_previous_link(), 'results': results}, + request=request + ) diff --git a/backend/igny8_core/modules/billing/views.py b/backend/igny8_core/modules/billing/views.py index b3fcb0f2..a19f60c7 100644 --- a/backend/igny8_core/modules/billing/views.py +++ b/backend/igny8_core/modules/billing/views.py @@ -578,4 +578,176 @@ class AdminBillingViewSet(viewsets.ViewSet): continue return Response({'success': True}) + + def invoices(self, request): + """List all invoices (admin view)""" + from igny8_core.business.billing.models import Invoice + invoices = Invoice.objects.all().select_related('account').order_by('-created_at')[:100] + data = [{ + 'id': inv.id, + 'invoice_number': inv.invoice_number, + 'account_name': inv.account.name if inv.account else 'N/A', + 'status': inv.status, + 'total_amount': str(inv.total), + 'created_at': inv.created_at.isoformat() + } for inv in invoices] + return Response({'results': data}) + + def payments(self, request): + """List all payments (admin view)""" + from igny8_core.business.billing.models import Payment + payments = Payment.objects.all().select_related('account', 'invoice').order_by('-created_at')[:100] + data = [{ + 'id': pay.id, + 'account_name': pay.account.name if pay.account else 'N/A', + 'invoice_number': pay.invoice.invoice_number if pay.invoice else 'N/A', + 'amount': str(pay.amount), + 'status': pay.status, + 'payment_method': pay.payment_method, + 'created_at': pay.created_at.isoformat() + } for pay in payments] + return Response({'results': data}) + + def pending_payments(self, request): + """List pending payments awaiting approval""" + from igny8_core.business.billing.models import Payment + payments = Payment.objects.filter(status='pending_approval').select_related('account', 'invoice').order_by('-created_at') + data = [{ + 'id': pay.id, + 'account_name': pay.account.name if pay.account else 'N/A', + 'invoice_number': pay.invoice.invoice_number if pay.invoice else 'N/A', + 'amount': str(pay.amount), + 'payment_method': pay.payment_method, + 'manual_reference': pay.manual_reference, + 'manual_notes': pay.manual_notes, + 'created_at': pay.created_at.isoformat() + } for pay in payments] + return Response({'results': data}) + + def approve_payment(self, request, pk): + """Approve a pending payment""" + from igny8_core.business.billing.models import Payment + try: + payment = Payment.objects.get(pk=pk, status='pending_approval') + payment.status = 'completed' + payment.processed_at = timezone.now() + payment.save() + + # If payment has an invoice, mark it as paid + if payment.invoice: + payment.invoice.status = 'paid' + payment.invoice.paid_at = timezone.now() + payment.invoice.save() + + return Response({'success': True, 'message': 'Payment approved'}) + except Payment.DoesNotExist: + return Response({'error': 'Payment not found or not pending'}, status=404) + + def reject_payment(self, request, pk): + """Reject a pending payment""" + from igny8_core.business.billing.models import Payment + try: + payment = Payment.objects.get(pk=pk, status='pending_approval') + payment.status = 'failed' + payment.failed_at = timezone.now() + payment.failure_reason = request.data.get('reason', 'Rejected by admin') + payment.save() + + return Response({'success': True, 'message': 'Payment rejected'}) + except Payment.DoesNotExist: + return Response({'error': 'Payment not found or not pending'}, status=404) + + def payment_method_configs(self, request): + """List or create payment method configurations""" + from igny8_core.business.billing.models import PaymentMethodConfig + if request.method == 'GET': + configs = PaymentMethodConfig.objects.all() + data = [{ + 'id': c.id, + 'type': c.type, + 'name': c.name, + 'description': c.description, + 'is_enabled': c.is_enabled, + 'sort_order': c.sort_order + } for c in configs] + return Response({'results': data}) + else: + # Handle POST for creating new config + return Response({'error': 'Not implemented'}, status=501) + + def payment_method_config(self, request, pk): + """Get, update, or delete a payment method config""" + from igny8_core.business.billing.models import PaymentMethodConfig + try: + config = PaymentMethodConfig.objects.get(pk=pk) + if request.method == 'GET': + return Response({ + 'id': config.id, + 'type': config.type, + 'name': config.name, + 'description': config.description, + 'is_enabled': config.is_enabled, + 'sort_order': config.sort_order + }) + elif request.method in ['PATCH', 'PUT']: + # Update config + return Response({'error': 'Not implemented'}, status=501) + elif request.method == 'DELETE': + # Delete config + config.delete() + return Response({'success': True}) + except PaymentMethodConfig.DoesNotExist: + return Response({'error': 'Config not found'}, status=404) + + def account_payment_methods(self, request): + """List or create account payment methods""" + from igny8_core.business.billing.models import AccountPaymentMethod + if request.method == 'GET': + methods = AccountPaymentMethod.objects.all().select_related('account')[:100] + data = [{ + 'id': str(m.id), + 'account_name': m.account.name if m.account else 'N/A', + 'type': m.type, + 'display_name': m.display_name, + 'is_default': m.is_default + } for m in methods] + return Response({'results': data}) + else: + return Response({'error': 'Not implemented'}, status=501) + + def account_payment_method(self, request, pk): + """Get, update, or delete an account payment method""" + from igny8_core.business.billing.models import AccountPaymentMethod + try: + method = AccountPaymentMethod.objects.get(pk=pk) + if request.method == 'GET': + return Response({ + 'id': str(method.id), + 'account_name': method.account.name if method.account else 'N/A', + 'type': method.type, + 'display_name': method.display_name, + 'is_default': method.is_default + }) + elif request.method in ['PATCH', 'PUT']: + return Response({'error': 'Not implemented'}, status=501) + elif request.method == 'DELETE': + method.delete() + return Response({'success': True}) + except AccountPaymentMethod.DoesNotExist: + return Response({'error': 'Method not found'}, status=404) + + def set_default_account_payment_method(self, request, pk): + """Set an account payment method as default""" + from igny8_core.business.billing.models import AccountPaymentMethod + try: + method = AccountPaymentMethod.objects.get(pk=pk) + # Unset other defaults for this account + AccountPaymentMethod.objects.filter(account=method.account, is_default=True).update(is_default=False) + # Set this as default + method.is_default = True + method.save() + return Response({'success': True, 'message': 'Payment method set as default'}) + except AccountPaymentMethod.DoesNotExist: + return Response({'error': 'Method not found'}, status=404) + diff --git a/backend/igny8_core/settings.py b/backend/igny8_core/settings.py index 22ecaf77..56a7c964 100644 --- a/backend/igny8_core/settings.py +++ b/backend/igny8_core/settings.py @@ -80,6 +80,15 @@ USE_SECURE_COOKIES = os.getenv('USE_SECURE_COOKIES', 'False').lower() == 'true' SESSION_COOKIE_SECURE = USE_SECURE_COOKIES CSRF_COOKIE_SECURE = USE_SECURE_COOKIES +# CRITICAL: Session isolation to prevent contamination +SESSION_COOKIE_NAME = 'igny8_sessionid' # Custom name to avoid conflicts +SESSION_COOKIE_HTTPONLY = True # Prevent JavaScript access +SESSION_COOKIE_SAMESITE = 'Strict' # Prevent cross-site cookie sharing +SESSION_COOKIE_AGE = 86400 # 24 hours +SESSION_SAVE_EVERY_REQUEST = False # Don't update session on every request (reduces DB load) +SESSION_COOKIE_PATH = '/' # Explicit path +# Don't set SESSION_COOKIE_DOMAIN - let it default to current domain for strict isolation + MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'whitenoise.middleware.WhiteNoiseMiddleware', @@ -228,39 +237,9 @@ REST_FRAMEWORK = { # Unified API Standard v1.0: Exception handler enabled by default # Set IGNY8_USE_UNIFIED_EXCEPTION_HANDLER=False to disable 'EXCEPTION_HANDLER': 'rest_framework.views.exception_handler' if os.getenv('IGNY8_USE_UNIFIED_EXCEPTION_HANDLER', 'True').lower() == 'false' else 'igny8_core.api.exception_handlers.custom_exception_handler', - # Rate limiting - configured but bypassed in DEBUG mode - 'DEFAULT_THROTTLE_CLASSES': [ - 'igny8_core.api.throttles.DebugScopedRateThrottle', - ], - 'DEFAULT_THROTTLE_RATES': { - # AI Functions - Expensive operations (kept modest but higher to reduce false 429s) - 'ai_function': '60/min', - 'image_gen': '90/min', - # Content Operations - 'content_write': '180/min', - 'content_read': '600/min', - # Authentication - 'auth': '300/min', # Login, register, password reset - 'auth_strict': '120/min', # Sensitive auth operations - 'auth_read': '600/min', # Read-only auth-adjacent endpoints (e.g., subscriptions, industries) - # Planner Operations - 'planner': '300/min', - 'planner_ai': '60/min', - # Writer Operations - 'writer': '300/min', - 'writer_ai': '60/min', - # System Operations - 'system': '600/min', - 'system_admin': '120/min', - # Billing Operations - 'billing': '180/min', - 'billing_admin': '60/min', - 'linker': '180/min', - 'optimizer': '60/min', - 'integration': '600/min', - # Default fallback - 'default': '600/min', - }, + # Rate limiting - DISABLED + 'DEFAULT_THROTTLE_CLASSES': [], + 'DEFAULT_THROTTLE_RATES': {}, # OpenAPI Schema Generation (drf-spectacular) 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', } diff --git a/backend/migrations/0013_add_plan_is_internal.py b/backend/migrations/0013_add_plan_is_internal.py new file mode 100644 index 00000000..597986f1 --- /dev/null +++ b/backend/migrations/0013_add_plan_is_internal.py @@ -0,0 +1,18 @@ +# Generated manually for adding is_internal flag to Plan model + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('igny8_core', '0012_creditpackage_alter_paymentmethodconfig_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='plan', + name='is_internal', + field=models.BooleanField(default=False, help_text='Internal-only plan (Free/Internal) - hidden from public plan listings'), + ), + ] diff --git a/backend/test_session_contamination.py b/backend/test_session_contamination.py new file mode 100644 index 00000000..30c26f1a --- /dev/null +++ b/backend/test_session_contamination.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python +""" +Test script to detect and reproduce session contamination bugs +Usage: docker exec igny8_backend python test_session_contamination.py +""" +import os +import django +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'igny8_core.settings') +django.setup() + +from django.contrib.sessions.models import Session +from django.contrib.auth import get_user_model +from django.test import RequestFactory +from django.contrib.sessions.middleware import SessionMiddleware +from igny8_core.auth.middleware import AccountContextMiddleware +from datetime import datetime, timedelta + +User = get_user_model() + +def test_session_isolation(): + """Test that sessions are properly isolated between users""" + print("\n=== SESSION CONTAMINATION TEST ===\n") + + # Get test users + try: + developer = User.objects.get(username='developer') + scale_user = User.objects.filter(account__slug='scale-account').first() + + if not scale_user: + print("⚠️ No scale account user found, creating one...") + from igny8_core.auth.models import Account + scale_account = Account.objects.filter(slug='scale-account').first() + if scale_account: + scale_user = User.objects.create_user( + username='scale_test', + email='scale@test.com', + password='testpass123', + account=scale_account, + role='owner' + ) + else: + print("❌ No scale account found") + return False + + print(f"✓ Developer user: {developer.username} (account: {developer.account.slug})") + print(f"✓ Scale user: {scale_user.username} (account: {scale_user.account.slug if scale_user.account else 'None'})") + + except Exception as e: + print(f"❌ Failed to get test users: {e}") + return False + + # Check active sessions + active_sessions = Session.objects.filter(expire_date__gte=datetime.now()) + print(f"\n📊 Total active sessions: {active_sessions.count()}") + + # Count sessions by user + user_sessions = {} + for session in active_sessions: + try: + data = session.get_decoded() + user_id = data.get('_auth_user_id') + if user_id: + user = User.objects.get(id=user_id) + key = f"{user.username} ({user.account.slug if user.account else 'no-account'})" + user_sessions[key] = user_sessions.get(key, 0) + 1 + except: + pass + + print("\n📈 Sessions by user:") + for user_key, count in sorted(user_sessions.items(), key=lambda x: x[1], reverse=True): + print(f" {user_key}: {count} sessions") + + # Check for session contamination patterns + contamination_found = False + + # Pattern 1: Too many sessions for one user + for user_key, count in user_sessions.items(): + if count > 20: + print(f"\n⚠️ WARNING: {user_key} has {count} sessions (possible proliferation)") + contamination_found = True + + # Pattern 2: Check session cookie settings + from django.conf import settings + print(f"\n🔧 Session Configuration:") + print(f" SESSION_COOKIE_NAME: {settings.SESSION_COOKIE_NAME}") + print(f" SESSION_COOKIE_DOMAIN: {getattr(settings, 'SESSION_COOKIE_DOMAIN', 'Not set (good)')}") + print(f" SESSION_COOKIE_SAMESITE: {getattr(settings, 'SESSION_COOKIE_SAMESITE', 'Not set')}") + print(f" SESSION_COOKIE_HTTPONLY: {settings.SESSION_COOKIE_HTTPONLY}") + print(f" SESSION_ENGINE: {settings.SESSION_ENGINE}") + + if getattr(settings, 'SESSION_COOKIE_SAMESITE', None) != 'Strict': + print(f"\n⚠️ WARNING: SESSION_COOKIE_SAMESITE should be 'Strict' (currently: {getattr(settings, 'SESSION_COOKIE_SAMESITE', 'Not set')})") + contamination_found = True + + # Test middleware isolation + print(f"\n🧪 Testing Middleware Isolation...") + factory = RequestFactory() + + # Simulate two requests from different users + request1 = factory.get('/api/v1/test/') + request1.user = developer + request1.session = {} + + request2 = factory.get('/api/v1/test/') + request2.user = scale_user + request2.session = {} + + middleware = AccountContextMiddleware(lambda x: None) + + # Process requests + middleware.process_request(request1) + middleware.process_request(request2) + + # Check isolation + account1 = getattr(request1, 'account', None) + account2 = getattr(request2, 'account', None) + + print(f" Request 1 account: {account1.slug if account1 else 'None'}") + print(f" Request 2 account: {account2.slug if account2 else 'None'}") + + if account1 and account2 and account1.id == account2.id: + print(f"\n❌ CONTAMINATION DETECTED: Both requests have same account!") + contamination_found = True + else: + print(f"\n✓ Middleware isolation working correctly") + + # Final result + if contamination_found: + print(f"\n❌ SESSION CONTAMINATION DETECTED") + print(f"\nRecommended fixes:") + print(f"1. Set SESSION_COOKIE_SAMESITE='Strict' in settings.py") + print(f"2. Clear all existing sessions: Session.objects.all().delete()") + print(f"3. Ensure users logout and re-login with fresh cookies") + return False + else: + print(f"\n✅ No contamination detected - sessions appear isolated") + return True + +if __name__ == '__main__': + result = test_session_isolation() + exit(0 if result else 1) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e9319c02..b943a3f1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -63,7 +63,6 @@ const Transactions = lazy(() => import("./pages/Billing/Transactions")); const Usage = lazy(() => import("./pages/Billing/Usage")); const CreditsAndBilling = lazy(() => import("./pages/Settings/CreditsAndBilling")); const PurchaseCreditsPage = lazy(() => import("./pages/account/PurchaseCreditsPage")); -const AccountBillingPage = lazy(() => import("./pages/account/AccountBillingPage")); const PlansAndBillingPage = lazy(() => import("./pages/account/PlansAndBillingPage")); const AccountSettingsPage = lazy(() => import("./pages/account/AccountSettingsPage")); const TeamManagementPage = lazy(() => import("./pages/account/TeamManagementPage")); @@ -385,11 +384,6 @@ export default function App() { } /> - - - - } /> diff --git a/frontend/src/components/auth/ProtectedRoute.tsx b/frontend/src/components/auth/ProtectedRoute.tsx index 4d0759a5..f669370d 100644 --- a/frontend/src/components/auth/ProtectedRoute.tsx +++ b/frontend/src/components/auth/ProtectedRoute.tsx @@ -20,7 +20,6 @@ export default function ProtectedRoute({ children }: ProtectedRouteProps) { const [errorMessage, setErrorMessage] = useState(''); const PLAN_ALLOWED_PATHS = [ '/account/plans', - '/account/billing', '/account/purchase-credits', '/account/settings', '/account/team', @@ -126,7 +125,7 @@ export default function ProtectedRoute({ children }: ProtectedRouteProps) { if (!isPrivileged) { if (pendingPayment && !isPlanAllowedPath) { - return ; + return ; } if (accountInactive && !isPlanAllowedPath) { return ; diff --git a/frontend/src/components/auth/SignUpForm.tsx b/frontend/src/components/auth/SignUpForm.tsx index e3e16b41..259b85a9 100644 --- a/frontend/src/components/auth/SignUpForm.tsx +++ b/frontend/src/components/auth/SignUpForm.tsx @@ -100,7 +100,7 @@ export default function SignUpForm({ planDetails: planDetailsProp, planLoading: const status = user?.account?.status; if (status === "pending_payment") { - navigate("/account/billing", { replace: true }); + navigate("/account/plans", { replace: true }); } else { navigate("/sites", { replace: true }); } diff --git a/frontend/src/components/ui/pricing-table/PricingTable.tsx b/frontend/src/components/ui/pricing-table/PricingTable.tsx index c5b56058..69b08fdd 100644 --- a/frontend/src/components/ui/pricing-table/PricingTable.tsx +++ b/frontend/src/components/ui/pricing-table/PricingTable.tsx @@ -5,6 +5,7 @@ export interface PricingPlan { name: string; price: string | number; // Current displayed price (will be calculated based on period) monthlyPrice?: string | number; // Base monthly price (used for annual discount calculation) + annualDiscountPercent?: number; // Annual discount percentage from backend (default 15%) originalPrice?: string | number; period?: string; // "/month", "/year", "/Lifetime" description?: string; @@ -63,7 +64,7 @@ export default function PricingTable({ return price; }; - // Calculate price based on billing period with 20% annual discount + // Calculate price based on billing period with discount from backend const getDisplayPrice = (plan: PricingPlan): { price: number; originalPrice?: number } => { const monthlyPrice = typeof plan.monthlyPrice === 'number' ? plan.monthlyPrice @@ -72,8 +73,12 @@ export default function PricingTable({ : parseFloat(String(plan.price || 0)); if (billingPeriod === 'annually' && showToggle) { - // Annual price: monthly * 12 * 0.8 (20% discount) - const annualPrice = monthlyPrice * 12 * 0.8; + // Get discount percentage from plan (default 15%) + const discountPercent = plan.annualDiscountPercent || 15; + const discountMultiplier = (100 - discountPercent) / 100; + + // Annual price: monthly * 12 * discount multiplier + const annualPrice = monthlyPrice * 12 * discountMultiplier; const originalAnnualPrice = monthlyPrice * 12; return { price: annualPrice, originalPrice: originalAnnualPrice }; } diff --git a/frontend/src/layout/AppSidebar.tsx b/frontend/src/layout/AppSidebar.tsx index ea2e4a08..17344fb5 100644 --- a/frontend/src/layout/AppSidebar.tsx +++ b/frontend/src/layout/AppSidebar.tsx @@ -201,11 +201,6 @@ const AppSidebar: React.FC = () => { { icon: , name: "Plans & Billing", - path: "/account/billing", - }, - { - icon: , - name: "Plans", path: "/account/plans", }, { @@ -323,7 +318,35 @@ const AppSidebar: React.FC = () => { subItems: [ { name: "Function Testing", path: "/admin/function-testing" }, { name: "System Testing", path: "/admin/system-testing" }, - { name: "UI Elements", path: "/admin/ui-elements" }, + ], + }, + { + icon: , + name: "UI Elements", + subItems: [ + { name: "Alerts", path: "/ui-elements/alerts" }, + { name: "Avatars", path: "/ui-elements/avatars" }, + { name: "Badges", path: "/ui-elements/badges" }, + { name: "Breadcrumb", path: "/ui-elements/breadcrumb" }, + { name: "Buttons", path: "/ui-elements/buttons" }, + { name: "Buttons Group", path: "/ui-elements/buttons-group" }, + { name: "Cards", path: "/ui-elements/cards" }, + { name: "Carousel", path: "/ui-elements/carousel" }, + { name: "Dropdowns", path: "/ui-elements/dropdowns" }, + { name: "Images", path: "/ui-elements/images" }, + { name: "Links", path: "/ui-elements/links" }, + { name: "List", path: "/ui-elements/list" }, + { name: "Modals", path: "/ui-elements/modals" }, + { name: "Notifications", path: "/ui-elements/notifications" }, + { name: "Pagination", path: "/ui-elements/pagination" }, + { name: "Popovers", path: "/ui-elements/popovers" }, + { name: "Pricing Table", path: "/ui-elements/pricing-table" }, + { name: "Progressbar", path: "/ui-elements/progressbar" }, + { name: "Ribbons", path: "/ui-elements/ribbons" }, + { name: "Spinners", path: "/ui-elements/spinners" }, + { name: "Tabs", path: "/ui-elements/tabs" }, + { name: "Tooltips", path: "/ui-elements/tooltips" }, + { name: "Videos", path: "/ui-elements/videos" }, ], }, ], diff --git a/frontend/src/pages/account/AccountBillingPage.tsx b/frontend/src/pages/account/AccountBillingPage.tsx deleted file mode 100644 index 12fc4b24..00000000 --- a/frontend/src/pages/account/AccountBillingPage.tsx +++ /dev/null @@ -1,625 +0,0 @@ -/** - * Account Billing Page - * Consolidated billing dashboard with invoices, payments, and credit balance - */ - -import { useState, useEffect } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; -import { - CreditCard, - Download, - AlertCircle, - Loader2, - FileText, - CheckCircle, - XCircle, - Clock, - DollarSign, - TrendingUp, -} from 'lucide-react'; -import { - getInvoices, - getPayments, - getCreditBalance, - getCreditPackages, - getAvailablePaymentMethods, - downloadInvoicePDF, - getPlans, - getSubscriptions, - type Invoice, - type Payment, - type CreditBalance, - type CreditPackage, - type PaymentMethod, - type Plan, - type Subscription, -} from '../../services/billing.api'; -import { useAuthStore } from '../../store/authStore'; -import { Card } from '../../components/ui/card'; -import BillingRecentTransactions from '../../components/billing/BillingRecentTransactions'; -import PricingTable, { type PricingPlan } from '../../components/ui/pricing-table/PricingTable'; - -type TabType = 'overview' | 'plans' | 'invoices' | 'payments' | 'methods'; - -export default function AccountBillingPage() { - const navigate = useNavigate(); - const [activeTab, setActiveTab] = useState('overview'); - const [creditBalance, setCreditBalance] = useState(null); - const [invoices, setInvoices] = useState([]); - const [payments, setPayments] = useState([]); - const [creditPackages, setCreditPackages] = useState([]); - const [paymentMethods, setPaymentMethods] = useState([]); - const [plans, setPlans] = useState([]); - const [subscriptions, setSubscriptions] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); - const { user } = useAuthStore(); - - const planCatalog: PricingPlan[] = [ - { - id: 1, - name: 'Starter', - price: 89, - period: '/month', - description: 'Good for small teams getting started', - features: ['1,000 credits included', '1 site', '2 users'], - }, - { - id: 2, - name: 'Growth', - price: 139, - period: '/month', - description: 'For growing teams that need more volume', - features: ['2,000 credits included', '3 sites', '3 users'], - highlighted: true, - }, - { - id: 3, - name: 'Scale', - price: 229, - period: '/month', - description: 'Larger teams with higher usage', - features: ['4,000 credits included', '5 sites', '5 users'], - }, - ]; - - useEffect(() => { - loadData(); - }, []); - - const loadData = async () => { - try { - setLoading(true); - const [balanceRes, invoicesRes, paymentsRes, packagesRes, methodsRes, plansRes, subsRes] = await Promise.all([ - getCreditBalance(), - getInvoices(), - getPayments(), - getCreditPackages(), - getAvailablePaymentMethods(), - getPlans(), - getSubscriptions(), - ]); - - setCreditBalance(balanceRes); - setInvoices(invoicesRes.results); - setPayments(paymentsRes.results); - setCreditPackages(packagesRes.results || []); - setPaymentMethods(methodsRes.results || []); - setPlans((plansRes.results || []).filter((p) => p.is_active !== false)); - setSubscriptions(subsRes.results || []); - } catch (err: any) { - setError(err.message || 'Failed to load billing data'); - console.error('Billing data load error:', err); - } finally { - setLoading(false); - } - }; - - const handleDownloadInvoice = async (invoiceId: number, invoiceNumber: string) => { - try { - const blob = await downloadInvoicePDF(invoiceId); - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `invoice-${invoiceNumber}.pdf`; - document.body.appendChild(a); - a.click(); - window.URL.revokeObjectURL(url); - document.body.removeChild(a); - } catch (err) { - alert('Failed to download invoice'); - } - }; - - const getStatusBadge = (status: string) => { - const styles: Record = { - paid: { bg: 'bg-green-100', text: 'text-green-800', icon: CheckCircle }, - succeeded: { bg: 'bg-green-100', text: 'text-green-800', icon: CheckCircle }, - completed: { bg: 'bg-green-100', text: 'text-green-800', icon: CheckCircle }, - pending: { bg: 'bg-yellow-100', text: 'text-yellow-800', icon: Clock }, - pending_approval: { bg: 'bg-blue-100', text: 'text-blue-800', icon: Clock }, - processing: { bg: 'bg-blue-100', text: 'text-blue-800', icon: Clock }, - failed: { bg: 'bg-red-100', text: 'text-red-800', icon: XCircle }, - refunded: { bg: 'bg-gray-100', text: 'text-gray-800', icon: XCircle }, - cancelled: { bg: 'bg-gray-100', text: 'text-gray-800', icon: XCircle }, - void: { bg: 'bg-gray-100', text: 'text-gray-800', icon: XCircle }, - uncollectible: { bg: 'bg-gray-100', text: 'text-gray-800', icon: XCircle }, - }; - - const style = styles[status] || styles.pending; - const Icon = style.icon; - - return ( - - - {status.replace('_', ' ').toUpperCase()} - - ); - }; - - if (loading) { - return ( -
- -
- ); - } - - return ( -
-
-
-
-

Plans & Billing

-

Manage your subscription, credits, and billing

-
- - - Purchase Credits - -
- - {error && ( -
- -

{error}

-
- )} - - {/* Tabs */} -
- -
- - {/* Overview Tab */} - {activeTab === 'overview' && creditBalance && ( -
- {/* Stats Cards */} -
- -
-

Current Balance

- -
-
- {creditBalance?.credits?.toLocaleString() || '0'} -
-

Available credits

-
- - -
-

Monthly Allocation

- -
-
- {creditBalance?.plan_credits_per_month?.toLocaleString() || '0'} -
-

Credits per month

-
- - -
-

Used This Month

- -
-
- {creditBalance?.credits_used_this_month?.toLocaleString() || '0'} -
-

Credits consumed

-
- - -
-

Plan Status

- -
-
- {creditBalance?.plan_credits_per_month ? 'Active Plan' : 'Pay as you go'} -
-

- {creditBalance?.plan_credits_per_month - ? `${creditBalance.plan_credits_per_month.toLocaleString()} credits per month` - : 'Upgrade to a plan for predictable billing'} -

-
- -
-
-
- - {/* Quick Actions */} -
- -

Quick Actions

-
- - Purchase Credits - - - View Usage Analytics - -
-
- - -

Account Summary

-
-
- Remaining Credits: - {creditBalance?.credits_remaining?.toLocaleString() || '0'} -
-
- Total Invoices: - {invoices.length} -
-
- Paid Invoices: - - {invoices.filter(inv => inv.status === 'paid').length} - -
-
-
-
- - {/* Recent Transactions */} - -
-

Recent Transactions

- - View usage details - -
- -
-
- )} - - {/* Plans Tab */} - {activeTab === 'plans' && ( -
- -
-
-

Active plan

-

Your current subscription allocation

-
-
-
Monthly allocation
-
- {creditBalance?.plan_credits_per_month?.toLocaleString() || '—'} -
-
- Remaining: {creditBalance?.credits_remaining?.toLocaleString() ?? '—'} credits -
-
-
-
- - -
-
-

Available plans

-

Choose the plan that fits your team (excluding free).

-
-
- { - const name = (plan.name || '').toLowerCase(); - const slug = (plan as any).slug ? (plan as any).slug.toLowerCase() : ''; - const isEnterprise = name.includes('enterprise') || slug === 'enterprise'; - return !isEnterprise && plan.name !== 'Free'; - })} - onPlanSelect={() => {}} - /> -
- - -
-
-

Credit add-ons

-

One-time credit bundles to top up your balance.

-
-
- {creditPackages.length === 0 ? ( -
- - No packages available yet. Please check back soon. -
- ) : ( - { - const plan: PricingPlan = { - id: pkg.id, - name: pkg.name, - price: Number(pkg.price), - period: '/one-time', - description: pkg.description, - features: [ - `${pkg.credits.toLocaleString()} credits`, - pkg.discount_percentage > 0 ? `${pkg.discount_percentage}% discount applied` : 'Standard pricing', - 'Manual & online payments supported', - ], - highlighted: pkg.is_featured, - buttonText: 'Select', - }; - return plan; - })} - onPlanSelect={() => navigate('/account/purchase-credits')} - /> - )} -
-
- )} - - {/* Invoices Tab */} - {activeTab === 'invoices' && ( -
-
- - - - - - - - - - - - {invoices.length === 0 ? ( - - - - ) : ( - invoices.map((invoice) => ( - - - - - - - - )) - )} - -
- Invoice - - Date - - Amount - - Status - - Actions -
- - No invoices yet -
-
{invoice.invoice_number}
- {invoice.line_items[0] && ( -
- {invoice.line_items[0].description} -
- )} -
- {new Date(invoice.created_at).toLocaleDateString()} - - ${invoice.total_amount} - {getStatusBadge(invoice.status)} - -
-
-
- )} - - {/* Payments Tab */} - {activeTab === 'payments' && ( -
-
- - - - - - - - - - - - {payments.length === 0 ? ( - - - - ) : ( - payments.map((payment) => ( - - - - - - - - )) - )} - -
- Date - - Method - - Reference - - Amount - - Status -
- - No payments yet -
- {new Date(payment.created_at).toLocaleDateString()} - -
- {payment.payment_method.replace('_', ' ')} -
-
- {payment.transaction_reference || '-'} - - ${payment.amount} - {getStatusBadge(payment.status)}
-
-
- )} - - {/* Payment Methods Tab */} - {activeTab === 'methods' && ( -
- -
-
-

Available payment methods

-

Use these options when purchasing credits.

-
- - Go to purchase - -
- - {paymentMethods.length === 0 ? ( -
- - No payment methods available yet. -
- ) : ( -
- {paymentMethods.map((method) => ( -
-
-
-
{method.type}
-
{method.display_name || method.name}
-
- - {method.is_enabled ? 'Enabled' : 'Disabled'} - -
- {method.instructions && ( -

{method.instructions}

- )} - {method.bank_details && ( -
-
- Bank - {method.bank_details.bank_name} -
-
- Account - {method.bank_details.account_number} -
- {method.bank_details.routing_number && ( -
- Routing - {method.bank_details.routing_number} -
- )} -
- )} - {method.wallet_details && ( -
-
- Wallet - {method.wallet_details.wallet_type} -
-
- ID - {method.wallet_details.wallet_id} -
-
- )} -
- ))} -
- )} -
-
- )} -
-
- ); -} diff --git a/frontend/src/pages/account/PlansAndBillingPage.tsx b/frontend/src/pages/account/PlansAndBillingPage.tsx index e5973b2c..7d86e213 100644 --- a/frontend/src/pages/account/PlansAndBillingPage.tsx +++ b/frontend/src/pages/account/PlansAndBillingPage.tsx @@ -1,6 +1,6 @@ /** * Plans & Billing Page - Consolidated - * Tabs: Current Plan, Upgrade/Downgrade, Credits Overview, Purchase Credits, Billing History, Payment Methods + * Tabs: Current Plan, Credits Overview, Billing History */ import { useState, useEffect, useRef } from 'react'; @@ -12,6 +12,7 @@ import { Card } from '../../components/ui/card'; import Badge from '../../components/ui/badge/Badge'; import Button from '../../components/ui/button/Button'; import { useToast } from '../../components/ui/toast/ToastContainer'; +import { PricingTable, PricingPlan } from '../../components/ui/pricing-table'; import { getCreditBalance, getCreditPackages, @@ -38,7 +39,7 @@ import { } from '../../services/billing.api'; import { useAuthStore } from '../../store/authStore'; -type TabType = 'plan' | 'upgrade' | 'credits' | 'purchase' | 'invoices' | 'payments' | 'payment-methods'; +type TabType = 'plan' | 'credits' | 'invoices'; export default function PlansAndBillingPage() { const [activeTab, setActiveTab] = useState('plan'); @@ -339,12 +340,8 @@ export default function PlansAndBillingPage() { const tabs = [ { id: 'plan' as TabType, label: 'Current Plan', icon: }, - { id: 'upgrade' as TabType, label: 'Upgrade/Downgrade', icon: }, { id: 'credits' as TabType, label: 'Credits Overview', icon: }, - { id: 'purchase' as TabType, label: 'Purchase Credits', icon: }, { id: 'invoices' as TabType, label: 'Billing History', icon: }, - { id: 'payments' as TabType, label: 'Payments', icon: }, - { id: 'payment-methods' as TabType, label: 'Payment Methods', icon: }, ]; return ( @@ -403,177 +400,125 @@ export default function PlansAndBillingPage() { {/* Current Plan Tab */} {activeTab === 'plan' && (
- -

Your Current Plan

- {!hasActivePlan && ( -
- No active plan found. Please choose a plan to activate your account. -
- )} -
-
-
-
- {currentPlan?.name || 'No Plan Selected'} + {/* 2/3 Current Plan + 1/3 Plan Features Layout */} +
+ {/* Current Plan Card - 2/3 width */} + +

Your Current Plan

+ {!hasActivePlan && ( +
+ No active plan found. Please choose a plan to activate your account. +
+ )} +
+
+
+
+ {currentPlan?.name || 'No Plan Selected'} +
+
+ {currentPlan?.description || 'Select a plan to unlock full access.'} +
-
- {currentPlan?.description || 'Select a plan to unlock full access.'} + + {hasActivePlan ? subscriptionStatus : 'plan required'} + +
+
+
+
Monthly Credits
+
+ {creditBalance?.plan_credits_per_month?.toLocaleString?.() || 0} +
+
+
+
Current Balance
+
+ {creditBalance?.credits?.toLocaleString?.() || 0} +
+
+
+
Period Ends
+
+ {currentSubscription?.current_period_end + ? new Date(currentSubscription.current_period_end).toLocaleDateString() + : '—'} +
- - {hasActivePlan ? subscriptionStatus : 'plan required'} - -
-
-
-
Monthly Credits
-
- {creditBalance?.plan_credits_per_month?.toLocaleString?.() || 0} -
-
-
-
Current Balance
-
- {creditBalance?.credits?.toLocaleString?.() || 0} -
-
-
-
Period Ends
-
- {currentSubscription?.current_period_end - ? new Date(currentSubscription.current_period_end).toLocaleDateString() - : '—'} -
-
-
-
- - - {hasActivePlan && ( - - )} + {hasActivePlan && ( + + )} +
-
- + - -

Plan Features

-
    - {(currentPlan?.features && currentPlan.features.length > 0 - ? currentPlan.features - : ['Credits included each month', 'Module access per plan limits', 'Email support']) - .map((feature) => ( -
  • - - {feature} -
  • - ))} -
-
-
- )} - - {/* Upgrade/Downgrade Tab */} - {activeTab === 'upgrade' && ( -
-
-

Available Plans

-

Choose the plan that best fits your needs

+ {/* Plan Features Card - 1/3 width with 2-column layout */} + +

Plan Features

+
+ {(currentPlan?.features && currentPlan.features.length > 0 + ? currentPlan.features + : ['ai_writer', 'image_gen', 'auto_publish', 'custom_prompts', 'email_support', 'api_access']) + .map((feature) => ( +
+ + {feature} +
+ ))} +
+
- {hasPaymentMethods ? ( -
-
Select payment method
-
- {paymentMethods.map((method) => ( - - ))} -
+ {/* Upgrade/Downgrade Section with Pricing Table */} +
+
+ { + const discount = plan.annual_discount_percent || 15; + return { + id: plan.id, + name: plan.name, + monthlyPrice: plan.price || 0, + price: plan.price || 0, + annualDiscountPercent: discount, + period: `/${plan.interval || 'month'}`, + description: plan.description || 'Standard plan', + features: plan.features && plan.features.length > 0 + ? plan.features + : ['Monthly credits included', 'Module access', 'Email support'], + buttonText: plan.id === currentPlanId ? 'Current Plan' : 'Select Plan', + highlighted: plan.is_featured || false, + disabled: plan.id === currentPlanId || planLoadingId === plan.id, + }; + })} + showToggle={true} + onPlanSelect={(plan) => plan.id && handleSelectPlan(plan.id)} + />
- ) : ( -
- No payment methods available. Please contact support or add one from the Payment Methods tab. -
- )} - -
- {plans.map((plan) => { - const isCurrent = plan.id === currentPlanId; - const price = plan.price ? `$${plan.price}/${plan.interval || 'month'}` : 'Custom'; - return ( - -
-

{plan.name}

-
{price}
-
{plan.description || 'Standard plan'}
-
-
- {(plan.features && plan.features.length > 0 ? plan.features : ['Monthly credits included', 'Module access per plan', 'Email support']).map((feature) => ( -
- - {feature} -
- ))} -
- -
- ); - })} - {plans.length === 0 && ( -
- No plans available. Please contact support. -
- )} -
- -

Plan Change Policy

-
    -
  • • Upgrades take effect immediately and you'll be charged a prorated amount
  • -
  • • Downgrades take effect at the end of your current billing period
  • -
  • • Unused credits from your current plan will carry over
  • -
  • • You can cancel your subscription at any time
  • -
-
+ +

Plan Change Policy

+
    +
  • • Upgrades take effect immediately and you'll be charged a prorated amount
  • +
  • • Downgrades take effect at the end of your current billing period
  • +
  • • Unused credits from your current plan will carry over
  • +
  • • You can cancel your subscription at any time
  • +
+
+
)} @@ -623,69 +568,53 @@ export default function PlansAndBillingPage() {
-
- )} - {/* Purchase Credits Tab */} - {activeTab === 'purchase' && ( -
- {hasPaymentMethods ? ( -
-
Select payment method
-
- {paymentMethods.map((method) => ( - - ))} -
+ {/* Purchase Credits Section - Single Row */} +
+
+

Purchase Additional Credits

+

Top up your credit balance with our packages

- ) : ( -
- No payment methods available. Please contact support or add one from the Payment Methods tab. -
- )} - -

Credit Packages

-
- {packages.map((pkg) => ( -
-
{pkg.name}
-
- {pkg.credits.toLocaleString()} credits +
+
+ {packages.map((pkg) => ( +
+
+
+ + + +
+

+ {pkg.name} +

+
+ {pkg.credits.toLocaleString()} + credits +
+
+ ${pkg.price} +
+ {pkg.description && ( +

+ {pkg.description} +

+ )} +
+
+
-
- ${pkg.price} -
- {pkg.description && ( -
{pkg.description}
- )} - -
+ ))} {packages.length === 0 && (
@@ -693,88 +622,88 @@ export default function PlansAndBillingPage() {
)}
- +
)} {/* Billing History Tab */} {activeTab === 'invoices' && ( - -
- - - - - - - - - - - - {invoices.length === 0 ? ( +
+ {/* Invoices Section */} + +
+

Invoices

+
+
+
- Invoice - - Date - - Amount - - Status - - Actions -
+ - + + + + + - ) : ( - invoices.map((invoice) => ( - - - - - - + + {invoices.length === 0 ? ( + + - )) - )} - -
- - No invoices yet - + Invoice + + Date + + Amount + + Status + + Actions +
{invoice.invoice_number} - {new Date(invoice.created_at).toLocaleDateString()} - ${invoice.total_amount} - - {invoice.status} - - - +
+ + No invoices yet
-
-
- )} + ) : ( + invoices.map((invoice) => ( + + {invoice.invoice_number} + + {new Date(invoice.created_at).toLocaleDateString()} + + ${invoice.total_amount} + + + {invoice.status} + + + + + + + )) + )} + + +
+
- {/* Payments Tab */} - {activeTab === 'payments' && ( -
- -
-
-

Payments

-

Recent payments and manual submissions

-
+ {/* Payments Section */} + +
+

Payments

+

Recent payments and manual submissions

@@ -831,113 +760,13 @@ export default function PlansAndBillingPage() { - -

Submit Manual Payment

-
-
- - setManualPayment((p) => ({ ...p, invoice_id: e.target.value }))} - className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white" - placeholder="Invoice ID" - /> -
-
- - setManualPayment((p) => ({ ...p, amount: e.target.value }))} - className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white" - placeholder="e.g., 99.00" - /> -
-
- - setManualPayment((p) => ({ ...p, payment_method: e.target.value }))} - className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white" - placeholder="bank_transfer / local_wallet / manual" - /> -
-
- - setManualPayment((p) => ({ ...p, reference: e.target.value }))} - className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white" - placeholder="Reference or transaction id" - /> -
-
- -