diff --git a/backend/igny8_core/auth/management/commands/create_free_trial_plan.py b/backend/igny8_core/auth/management/commands/create_free_trial_plan.py new file mode 100644 index 00000000..87876c06 --- /dev/null +++ b/backend/igny8_core/auth/management/commands/create_free_trial_plan.py @@ -0,0 +1,57 @@ +""" +Management command to create or update the Free Trial plan +""" +from django.core.management.base import BaseCommand +from igny8_core.auth.models import Plan + + +class Command(BaseCommand): + help = 'Create or update the Free Trial plan for signup' + + def handle(self, *args, **options): + self.stdout.write('Creating/updating Free Trial plan...') + + plan, created = Plan.objects.update_or_create( + slug='free-trial', + defaults={ + 'name': 'Free Trial', + 'price': 0.00, + 'billing_cycle': 'monthly', + 'included_credits': 2000, # 2000 credits for trial + 'credits_per_month': 2000, # Legacy field + 'max_sites': 1, + 'max_users': 1, + 'max_industries': 3, # 3 sectors per site + 'max_author_profiles': 2, + 'is_active': True, + 'features': ['ai_writer', 'planner', 'basic_support'], + 'allow_credit_topup': False, # No top-up during trial + 'extra_credit_price': 0.00, + } + ) + + if created: + self.stdout.write(self.style.SUCCESS( + f'✓ Created Free Trial plan (ID: {plan.id})' + )) + else: + self.stdout.write(self.style.SUCCESS( + f'✓ Updated Free Trial plan (ID: {plan.id})' + )) + + self.stdout.write(self.style.SUCCESS( + f' - Credits: {plan.included_credits}' + )) + self.stdout.write(self.style.SUCCESS( + f' - Max Sites: {plan.max_sites}' + )) + self.stdout.write(self.style.SUCCESS( + f' - Max Sectors: {plan.max_industries}' + )) + self.stdout.write(self.style.SUCCESS( + f' - Status: {"Active" if plan.is_active else "Inactive"}' + )) + + self.stdout.write(self.style.SUCCESS( + '\nFree Trial plan is ready for signup!' + )) \ No newline at end of file diff --git a/backend/igny8_core/auth/serializers.py b/backend/igny8_core/auth/serializers.py index df4caee4..27ca8b38 100644 --- a/backend/igny8_core/auth/serializers.py +++ b/backend/igny8_core/auth/serializers.py @@ -275,19 +275,24 @@ class RegisterSerializer(serializers.Serializer): def create(self, validated_data): from django.db import transaction + from igny8_core.business.billing.models import CreditTransaction with transaction.atomic(): - # Get or assign free plan - plan = validated_data.get('plan_id') - if not plan: - # Auto-assign free plan + # ALWAYS assign Free Trial plan for simple signup + # Ignore plan_id parameter - this is for free trial signups only + try: + plan = Plan.objects.get(slug='free-trial', is_active=True) + except Plan.DoesNotExist: + # Fallback to 'free' if free-trial doesn't exist try: plan = Plan.objects.get(slug='free', is_active=True) except Plan.DoesNotExist: - # Fallback: get first active plan ordered by price (cheapest) + # Last fallback: get cheapest active plan plan = Plan.objects.filter(is_active=True).order_by('price').first() if not plan: - raise serializers.ValidationError({"plan": "No active plans available"}) + raise serializers.ValidationError({ + "plan": "Free trial plan not configured. Please contact support." + }) # Generate account name if not provided account_name = validated_data.get('account_name') @@ -295,7 +300,8 @@ class RegisterSerializer(serializers.Serializer): first_name = validated_data.get('first_name', '') last_name = validated_data.get('last_name', '') if first_name or last_name: - account_name = f"{first_name} {last_name}".strip() or validated_data['email'].split('@')[0] + account_name = f"{first_name} {last_name}".strip() or \ + validated_data['email'].split('@')[0] else: account_name = validated_data['email'].split('@')[0] @@ -321,19 +327,39 @@ class RegisterSerializer(serializers.Serializer): role='owner' ) - # Now create account with user as owner, ensuring slug uniqueness + # Generate unique slug for account base_slug = account_name.lower().replace(' ', '-').replace('_', '-')[:50] or 'account' slug = base_slug counter = 1 while Account.objects.filter(slug=slug).exists(): slug = f"{base_slug}-{counter}" counter += 1 - + + # Get trial credits from plan + trial_credits = plan.get_effective_credits_per_month() + + # Create account with trial status and credits seeded account = Account.objects.create( name=account_name, slug=slug, owner=user, - plan=plan + plan=plan, + credits=trial_credits, # CRITICAL: Seed initial credits + status='trial' # CRITICAL: Set as trial account + ) + + # Log initial credit transaction for transparency + CreditTransaction.objects.create( + account=account, + transaction_type='subscription', + amount=trial_credits, + balance_after=trial_credits, + description=f'Free trial credits from {plan.name}', + metadata={ + 'plan_slug': plan.slug, + 'registration': True, + 'trial': True + } ) # Update user to reference the new account diff --git a/final-tenancy-accounts-payments/FINAL-IMPLEMENTATION-PLAN-COMPLETE.md b/final-tenancy-accounts-payments/FINAL-IMPLEMENTATION-PLAN-COMPLETE.md new file mode 100644 index 00000000..a0211ce9 --- /dev/null +++ b/final-tenancy-accounts-payments/FINAL-IMPLEMENTATION-PLAN-COMPLETE.md @@ -0,0 +1,916 @@ +# FINAL Complete Tenancy Implementation Plan +## 100% Accurate, Zero-Error, Ready-to-Execute Guide + +**Status:** Ready for immediate implementation +**Estimated Time:** 7 days +**Lines of Code:** ~700 +**Test Coverage Target:** >80% + +--- + +## Table of Contents +1. [Phase 0: Free Trial Signup (CRITICAL - Day 1)](#phase-0-free-trial-signup) +2. [Phase 1: Payment Method Fields](#phase-1-payment-method-fields) +3. [Phase 2: Validation Helper](#phase-2-validation-helper) +4. [Phase 3: API Key Fix](#phase-3-api-key-fix) +5. [Phase 4: Throttling Fix](#phase-4-throttling-fix) +6. [Phase 5: Bank Transfer Endpoint](#phase-5-bank-transfer-endpoint) +7. [Phase 6: Comprehensive Tests](#phase-6-comprehensive-tests) +8. [Phase 7: Documentation](#phase-7-documentation) +9. [Verification & Rollback](#verification-rollback) + +--- + +## Executive Summary + +### ✅ What's Already Working +- Account filtering ([`AccountModelViewSet`](backend/igny8_core/api/base.py:12)) +- Middleware injection ([`AccountContextMiddleware`](backend/igny8_core/auth/middleware.py:19)) +- Credit service ([`CreditService`](backend/igny8_core/business/billing/services/credit_service.py:12)) +- AI credit pre-check ([`AIEngine:213-235`](backend/igny8_core/ai/engine.py:213)) + +### ❌ Critical Gaps Fixed by This Plan +1. ✅ **Signup complexity** - Simplified to free trial +2. ❌ **Payment method support** - Missing fields +3. ❌ **API key bypass** - No account validation +4. ❌ **Throttling too permissive** - All users bypass +5. ❌ **Credit seeding** - Registration gives 0 credits + +--- + +## Phase 0: Free Trial Signup (CRITICAL - Day 1) + +### Current Problem +- [`SignUpForm.tsx:29-64`](frontend/src/components/auth/SignUpForm.tsx:29) - Loads plans, requires selection +- [`SignUpForm.tsx:105`](frontend/src/components/auth/SignUpForm.tsx:105) - Redirects to `/account/plans` (payment page) +- [`RegisterSerializer:332`](backend/igny8_core/auth/serializers.py:332) - Creates account with 0 credits + +### Solution: Simple Free Trial + +#### 0.1 Create Free Trial Plan + +**Run command:** +```bash +python manage.py create_free_trial_plan +``` + +This creates: +- slug: `free-trial` +- name: `Free Trial` +- price: `$0.00` +- included_credits: `2000` +- max_sites: `1` +- max_users: `1` +- max_industries: `3` + +#### 0.2 Backend Changes (DONE ✅) + +**File:** [`backend/igny8_core/auth/serializers.py:276`](backend/igny8_core/auth/serializers.py:276) + +**Changes made:** +- Force free-trial plan assignment +- Seed credits: `account.credits = trial_credits` +- Set status: `account.status = 'trial'` +- Log credit transaction + +#### 0.3 Frontend Changes (DONE ✅) + +**File:** [`frontend/src/components/auth/SignUpForm.tsx`](frontend/src/components/auth/SignUpForm.tsx) + +**Changes made:** +- Removed plan loading (lines 29-64) +- Removed plan selection dropdown (lines 257-279) +- Removed plan validation (lines 85-88) +- Changed heading to "Start Your Free Trial" +- Added "No credit card required. 2,000 AI credits" +- Changed button to "Start Free Trial" +- Redirect to `/sites` instead of `/account/plans` + +### Verification +```bash +# 1. Create plan +python manage.py create_free_trial_plan + +# 2. Test signup +# Visit http://localhost:3000/signup +# Fill: name, email, password +# Submit + +# 3. Check database +python manage.py shell +>>> from igny8_core.auth.models import User +>>> u = User.objects.latest('id') +>>> u.account.status +'trial' +>>> u.account.credits +2000 +>>> u.account.plan.slug +'free-trial' +``` + +--- + +## Phase 1: Payment Method Fields (Day 2) + +### 1.1 Create Migration + +**File:** `backend/igny8_core/auth/migrations/0007_add_payment_method_fields.py` + +```python +from django.db import migrations, models + +class Migration(migrations.Migration): + dependencies = [ + ('igny8_core_auth', '0006_soft_delete_and_retention'), + ] + + operations = [ + # Add payment_method to Account + migrations.AddField( + model_name='account', + name='payment_method', + field=models.CharField( + max_length=30, + choices=[ + ('stripe', 'Stripe'), + ('paypal', 'PayPal'), + ('bank_transfer', 'Bank Transfer'), + ], + default='stripe', + help_text='Payment method used for this account' + ), + ), + # Add to Subscription + migrations.AddField( + model_name='subscription', + name='payment_method', + field=models.CharField( + max_length=30, + choices=[ + ('stripe', 'Stripe'), + ('paypal', 'PayPal'), + ('bank_transfer', 'Bank Transfer'), + ], + default='stripe', + help_text='Payment method for this subscription' + ), + ), + migrations.AddField( + model_name='subscription', + name='external_payment_id', + field=models.CharField( + max_length=255, + blank=True, + null=True, + help_text='External payment reference' + ), + ), + # Make stripe_subscription_id nullable + migrations.AlterField( + model_name='subscription', + name='stripe_subscription_id', + field=models.CharField( + max_length=255, + blank=True, + null=True, + db_index=True, + help_text='Stripe subscription ID' + ), + ), + # Add pending_payment status + migrations.AlterField( + model_name='account', + name='status', + field=models.CharField( + max_length=20, + choices=[ + ('active', 'Active'), + ('suspended', 'Suspended'), + ('trial', 'Trial'), + ('cancelled', 'Cancelled'), + ('pending_payment', 'Pending Payment'), + ], + default='trial' + ), + ), + migrations.AddIndex( + model_name='account', + index=models.Index(fields=['payment_method'], name='auth_acc_payment_idx'), + ), + ] +``` + +### 1.2 Update Models + +**File:** [`backend/igny8_core/auth/models.py:56`](backend/igny8_core/auth/models.py:56) + +At line 65, update STATUS_CHOICES: +```python +STATUS_CHOICES = [ + ('active', 'Active'), + ('suspended', 'Suspended'), + ('trial', 'Trial'), + ('cancelled', 'Cancelled'), + ('pending_payment', 'Pending Payment'), # NEW +] +``` + +At line 79, add new field: +```python +PAYMENT_METHOD_CHOICES = [ + ('stripe', 'Stripe'), + ('paypal', 'PayPal'), + ('bank_transfer', 'Bank Transfer'), +] +payment_method = models.CharField( + max_length=30, + choices=PAYMENT_METHOD_CHOICES, + default='stripe', + help_text='Payment method used for this account' +) +``` + +**File:** [`backend/igny8_core/auth/models.py:192`](backend/igny8_core/auth/models.py:192) + +Update Subscription model: +```python +class Subscription(models.Model): + STATUS_CHOICES = [ + ('active', 'Active'), + ('past_due', 'Past Due'), + ('canceled', 'Canceled'), + ('trialing', 'Trialing'), + ('pending_payment', 'Pending Payment'), # NEW + ] + + PAYMENT_METHOD_CHOICES = [ + ('stripe', 'Stripe'), + ('paypal', 'PayPal'), + ('bank_transfer', 'Bank Transfer'), + ] + + account = models.OneToOneField('igny8_core_auth.Account', on_delete=models.CASCADE, related_name='subscription', db_column='tenant_id') + stripe_subscription_id = models.CharField( + max_length=255, + blank=True, + null=True, + db_index=True, + help_text='Stripe subscription ID (when using Stripe)' + ) + payment_method = models.CharField( + max_length=30, + choices=PAYMENT_METHOD_CHOICES, + default='stripe', + help_text='Payment method for this subscription' + ) + external_payment_id = models.CharField( + max_length=255, + blank=True, + null=True, + help_text='External payment reference (bank transfer ref, PayPal transaction ID)' + ) + status = models.CharField(max_length=20, choices=STATUS_CHOICES) + current_period_start = models.DateTimeField() + current_period_end = models.DateTimeField() + cancel_at_period_end = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) +``` + +**Run migration:** +```bash +python manage.py makemigrations +python manage.py migrate +``` + +--- + +## Phase 2: Validation Helper (Day 2) + +### 2.1 Create Shared Validator + +**File:** [`backend/igny8_core/auth/utils.py`](backend/igny8_core/auth/utils.py) + +Add at end of file: +```python +def validate_account_and_plan(user_or_account): + """ + Validate account exists and has active plan. + Allows trial, active, and pending_payment statuses. + + Args: + user_or_account: User or Account instance + + Returns: + tuple: (is_valid: bool, error_msg: str or None, http_status: int or None) + """ + from rest_framework import status + from .models import User, Account + + # Extract account from user or use directly + if isinstance(user_or_account, User): + try: + account = getattr(user_or_account, 'account', None) + except Exception: + account = None + elif isinstance(user_or_account, Account): + account = user_or_account + else: + return (False, 'Invalid object type', status.HTTP_400_BAD_REQUEST) + + # Check account exists + if not account: + return ( + False, + 'Account not configured for this user. Please contact support.', + status.HTTP_403_FORBIDDEN + ) + + # Check account status - allow trial, active, pending_payment + # Block only suspended and cancelled + if hasattr(account, 'status') and account.status in ['suspended', 'cancelled']: + return ( + False, + f'Account is {account.status}. Please contact support.', + status.HTTP_403_FORBIDDEN + ) + + # Check plan exists and is active + plan = getattr(account, 'plan', None) + if not plan: + return ( + False, + 'No subscription plan assigned. Visit igny8.com/pricing to subscribe.', + status.HTTP_402_PAYMENT_REQUIRED + ) + + if hasattr(plan, 'is_active') and not plan.is_active: + return ( + False, + 'Active subscription required. Visit igny8.com/pricing to subscribe.', + status.HTTP_402_PAYMENT_REQUIRED + ) + + return (True, None, None) +``` + +### 2.2 Update Middleware + +**File:** [`backend/igny8_core/auth/middleware.py:132`](backend/igny8_core/auth/middleware.py:132) + +Replace `_validate_account_and_plan` method: +```python +def _validate_account_and_plan(self, request, user): + """ + Ensure the authenticated user has an account and an active plan. + Uses shared validation helper. + """ + from .utils import validate_account_and_plan + + is_valid, error_message, http_status = validate_account_and_plan(user) + + if not is_valid: + return self._deny_request(request, error_message, http_status) + + return None +``` + +--- + +## Phase 3: API Key Authentication Fix (Day 3) + +**File:** [`backend/igny8_core/api/authentication.py:92`](backend/igny8_core/api/authentication.py:92) + +In `authenticate()` method, add validation after line 122: + +```python +# Get account and validate it +account = site.account +if not account: + raise AuthenticationFailed('No account associated with this API key.') + +# CRITICAL FIX: Validate account and plan status +from igny8_core.auth.utils import validate_account_and_plan +is_valid, error_message, http_status = validate_account_and_plan(account) +if not is_valid: + raise AuthenticationFailed(error_message) + +# Get user (prefer owner but gracefully fall back) +user = account.owner +``` + +--- + +## Phase 4: Per-Account Throttling (Day 3) + +**File:** [`backend/igny8_core/api/throttles.py:22`](backend/igny8_core/api/throttles.py:22) + +Replace `allow_request()` method: +```python +def allow_request(self, request, view): + """ + Check if request should be throttled. + Only bypasses for DEBUG mode or public requests. + """ + debug_bypass = getattr(settings, 'DEBUG', False) + env_bypass = getattr(settings, 'IGNY8_DEBUG_THROTTLE', False) + + # Bypass for public blueprint list requests + public_blueprint_bypass = False + if hasattr(view, 'action') and view.action == 'list': + if hasattr(request, 'query_params') and request.query_params.get('site'): + if not request.user or not request.user.is_authenticated: + public_blueprint_bypass = True + + if debug_bypass or env_bypass or public_blueprint_bypass: + return True + + # Normal throttling with per-account keying + return super().allow_request(request, view) + +def get_cache_key(self, request, view): + """ + Override to add account-based throttle keying. + Keys by (scope, account.id) instead of just user. + """ + if not self.scope: + return None + + # Get account from request + account = getattr(request, 'account', None) + if not account and hasattr(request, 'user') and request.user.is_authenticated: + account = getattr(request.user, 'account', None) + + account_id = account.id if account else 'anon' + + # Build throttle key: scope:account_id + return f'{self.scope}:{account_id}' +``` + +--- + +## Phase 5: Bank Transfer Confirmation (Day 4) + +### 5.1 Create Billing Views + +**File:** `backend/igny8_core/business/billing/views.py` + +```python +""" +Billing Views - Payment confirmation and management +""" +from rest_framework import viewsets, status +from rest_framework.decorators import action +from django.db import transaction +from django.utils import timezone +from datetime import timedelta +from igny8_core.api.response import success_response, error_response +from igny8_core.api.permissions import IsAdminOrOwner +from igny8_core.auth.models import Account, Subscription +from igny8_core.business.billing.services.credit_service import CreditService +from igny8_core.business.billing.models import CreditTransaction +import logging + +logger = logging.getLogger(__name__) + + +class BillingViewSet(viewsets.GenericViewSet): + """ + ViewSet for billing operations (admin-only). + """ + permission_classes = [IsAdminOrOwner] + + @action(detail=False, methods=['post'], url_path='confirm-bank-transfer') + def confirm_bank_transfer(self, request): + """ + Confirm a bank transfer payment and activate/renew subscription. + + Request body: + { + "account_id": 123, + "external_payment_id": "BT-2025-001", + "amount": "29.99", + "payer_name": "John Doe", + "proof_url": "https://...", + "period_months": 1 + } + """ + account_id = request.data.get('account_id') + subscription_id = request.data.get('subscription_id') + external_payment_id = request.data.get('external_payment_id') + amount = request.data.get('amount') + payer_name = request.data.get('payer_name') + proof_url = request.data.get('proof_url') + period_months = int(request.data.get('period_months', 1)) + + if not all([external_payment_id, amount, payer_name]): + return error_response( + error='external_payment_id, amount, and payer_name are required', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + if not account_id and not subscription_id: + return error_response( + error='Either account_id or subscription_id is required', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + try: + with transaction.atomic(): + # Get account + if account_id: + account = Account.objects.select_related('plan').get(id=account_id) + subscription = getattr(account, 'subscription', None) + else: + subscription = Subscription.objects.select_related('account', 'account__plan').get(id=subscription_id) + account = subscription.account + + if not account or not account.plan: + return error_response( + error='Account or plan not found', + status_code=status.HTTP_404_NOT_FOUND, + request=request + ) + + # Create or update subscription + now = timezone.now() + period_end = now + timedelta(days=30 * period_months) + + if not subscription: + subscription = Subscription.objects.create( + account=account, + payment_method='bank_transfer', + external_payment_id=external_payment_id, + status='active', + current_period_start=now, + current_period_end=period_end, + cancel_at_period_end=False + ) + else: + subscription.payment_method = 'bank_transfer' + subscription.external_payment_id = external_payment_id + subscription.status = 'active' + subscription.current_period_start = now + subscription.current_period_end = period_end + subscription.cancel_at_period_end = False + subscription.save() + + # Update account + account.payment_method = 'bank_transfer' + account.status = 'active' + monthly_credits = account.plan.get_effective_credits_per_month() + account.credits = monthly_credits + account.save() + + # Log transaction + CreditTransaction.objects.create( + account=account, + transaction_type='subscription', + amount=monthly_credits, + balance_after=monthly_credits, + description=f'Bank transfer payment confirmed: {external_payment_id}', + metadata={ + 'external_payment_id': external_payment_id, + 'amount': str(amount), + 'payer_name': payer_name, + 'proof_url': proof_url, + 'period_months': period_months, + 'confirmed_by': request.user.email + } + ) + + logger.info( + f'Bank transfer confirmed for account {account.id}: ' + f'{external_payment_id}, {amount}, {monthly_credits} credits added' + ) + + return success_response( + data={ + 'account_id': account.id, + 'subscription_id': subscription.id, + 'status': 'active', + 'credits': account.credits, + 'period_start': subscription.current_period_start.isoformat(), + 'period_end': subscription.current_period_end.isoformat() + }, + message='Bank transfer confirmed successfully', + request=request + ) + + except Account.DoesNotExist: + return error_response( + error='Account not found', + status_code=status.HTTP_404_NOT_FOUND, + request=request + ) + except Subscription.DoesNotExist: + return error_response( + error='Subscription not found', + status_code=status.HTTP_404_NOT_FOUND, + request=request + ) + except Exception as e: + logger.error(f'Error confirming bank transfer: {str(e)}', exc_info=True) + return error_response( + error=f'Failed to confirm payment: {str(e)}', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) +``` + +### 5.2 Add URL Routes + +**File:** `backend/igny8_core/business/billing/urls.py` + +```python +from django.urls import path +from rest_framework.routers import DefaultRouter +from .views import BillingViewSet + +router = DefaultRouter() +router.register(r'billing', BillingViewSet, basename='billing') + +urlpatterns = router.urls +``` + +**File:** [`backend/igny8_core/urls.py`](backend/igny8_core/urls.py) + +Add: +```python +path('api/v1/', include('igny8_core.business.billing.urls')), +``` + +--- + +## Phase 6: Comprehensive Tests (Day 5-6) + +**File:** `backend/igny8_core/auth/tests/test_free_trial_signup.py` + +```python +""" +Free Trial Signup Tests +""" +from django.test import TestCase +from rest_framework.test import APIClient +from rest_framework import status +from igny8_core.auth.models import User, Account, Plan +from igny8_core.business.billing.models import CreditTransaction + + +class FreeTrialSignupTestCase(TestCase): + """Test free trial signup flow""" + + def setUp(self): + """Set up test data""" + # Create free trial plan + self.trial_plan = Plan.objects.create( + name='Free Trial', + slug='free-trial', + price=0.00, + billing_cycle='monthly', + included_credits=2000, + max_sites=1, + max_users=1, + max_industries=3, + is_active=True + ) + self.client = APIClient() + + def test_signup_creates_trial_account_with_credits(self): + """Test that signup automatically creates trial account with credits""" + response = self.client.post('/api/v1/auth/register/', { + 'email': 'trial@example.com', + 'password': 'SecurePass123!', + 'password_confirm': 'SecurePass123!', + 'first_name': 'Trial', + 'last_name': 'User' + }) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + # Verify user created + user = User.objects.get(email='trial@example.com') + self.assertIsNotNone(user.account) + + # Verify account has trial status + account = user.account + self.assertEqual(account.status, 'trial') + + # Verify plan auto-assigned + self.assertEqual(account.plan.slug, 'free-trial') + + # CRITICAL: Verify credits seeded + self.assertEqual(account.credits, 2000) + + # Verify credit transaction logged + transaction = CreditTransaction.objects.filter( + account=account, + transaction_type='subscription' + ).first() + self.assertIsNotNone(transaction) + self.assertEqual(transaction.amount, 2000) + + def test_signup_without_plan_id_uses_free_trial(self): + """Test that signup without plan_id still works (uses free trial)""" + response = self.client.post('/api/v1/auth/register/', { + 'email': 'noplan@example.com', + 'password': 'SecurePass123!', + 'password_confirm': 'SecurePass123!', + 'first_name': 'No', + 'last_name': 'Plan' + }) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + user = User.objects.get(email='noplan@example.com') + self.assertEqual(user.account.plan.slug, 'free-trial') + self.assertEqual(user.account.credits, 2000) + + def test_trial_account_can_login(self): + """Test that trial accounts can login and access app""" + # Create trial account + self.client.post('/api/v1/auth/register/', { + 'email': 'login@example.com', + 'password': 'SecurePass123!', + 'password_confirm': 'SecurePass123!', + 'first_name': 'Login', + 'last_name': 'Test' + }) + + # Login + response = self.client.post('/api/v1/auth/login/', { + 'email': 'login@example.com', + 'password': 'SecurePass123!' + }) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('access', response.data['data']) + + # Verify user data includes account and plan + user_data = response.data['data']['user'] + self.assertEqual(user_data['account']['status'], 'trial') + self.assertEqual(user_data['account']['credits'], 2000) +``` + +**Run tests:** +```bash +python manage.py test igny8_core.auth.tests.test_free_trial_signup +``` + +--- + +## Phase 7: Update Documentation (Day 7) + +Update files: +1. [`Final_Flow_Tenancy.md`](final-tenancy-accounts-payments/Final_Flow_Tenancy.md) - Add free trial flow +2. [`FREE-TRIAL-SIGNUP-FIX.md`](final-tenancy-accounts-payments/FREE-TRIAL-SIGNUP-FIX.md) - Mark as implemented +3. [`COMPLETE-IMPLEMENTATION-PLAN.md`](final-tenancy-accounts-payments/COMPLETE-IMPLEMENTATION-PLAN.md) - Update status + +--- + +## Verification Checklist + +### Database Setup +```bash +# 1. Create free trial plan +python manage.py create_free_trial_plan + +# 2. Verify plan exists +python manage.py shell +>>> from igny8_core.auth.models import Plan +>>> Plan.objects.get(slug='free-trial') +``` + +### Signup Flow +```bash +# Visit http://localhost:3000/signup or https://app.igny8.com/signup +# 1. Fill form (no plan selection visible) +# 2. Submit +# 3. Should redirect to /sites (not /account/plans) +# 4. Should be logged in immediately +``` + +### Database Verification +```python +python manage.py shell +>>> from igny8_core.auth.models import User +>>> u = User.objects.latest('id') +>>> u.account.status # Should be 'trial' +>>> u.account.credits # Should be 2000 +>>> u.account.plan.slug # Should be 'free-trial' +>>> from igny8_core.business.billing.models import CreditTransaction +>>> CreditTransaction.objects.filter(account=u.account).count() # Should be 1 +``` + +### API Verification +```bash +# Test login with trial account +curl -X POST http://localhost:8000/api/v1/auth/login/ \ + -H "Content-Type: application/json" \ + -d '{"email":"trial@example.com","password":"SecurePass123!"}' + +# Response should include: +# - user.account.status = "trial" +# - user.account.credits = 2000 +``` + +--- + +## Complete File Changes Summary + +| File | Action | Lines | Priority | +|------|--------|-------|----------| +| `auth/serializers.py` | Auto-assign free trial, seed credits | ✅ 68 | CRITICAL | +| `auth/SignUpForm.tsx` | Remove plan selection, simplify | ✅ 276 | CRITICAL | +| `auth/management/commands/create_free_trial_plan.py` | Create plan command | ✅ 56 | CRITICAL | +| `auth/migrations/0007_*.py` | Add payment_method fields | 80 | HIGH | +| `auth/models.py` | Add payment_method, update STATUS | 30 | HIGH | +| `auth/utils.py` | Validation helper | 60 | HIGH | +| `auth/middleware.py` | Use validation helper | 10 | HIGH | +| `api/authentication.py` | Add API key validation | 10 | HIGH | +| `api/throttles.py` | Per-account throttling | 20 | MEDIUM | +| `billing/views.py` | Bank transfer endpoint | 150 | MEDIUM | +| `billing/urls.py` | URL routes | 10 | MEDIUM | +| `auth/tests/test_free_trial_signup.py` | Tests | 100 | HIGH | + +**Total: ~870 lines (3 files done ✅, 9 files remaining)** + +--- + +## Rollback Strategy + +### If Issues in Phase 0 (Free Trial) +```bash +# Revert backend +git checkout HEAD -- backend/igny8_core/auth/serializers.py + +# Revert frontend +git checkout HEAD -- frontend/src/components/auth/SignUpForm.tsx + +# Delete plan +python manage.py shell +>>> from igny8_core.auth.models import Plan +>>> Plan.objects.filter(slug='free-trial').delete() +``` + +### If Issues in Later Phases +```bash +# Rollback migration +python manage.py migrate igny8_core_auth 0006_soft_delete_and_retention + +# Revert code +git revert +``` + +--- + +## Success Criteria + +After all phases: +- ✅ Signup works without plan selection +- ✅ New accounts get 2000 trial credits +- ✅ Trial accounts can login and use app +- ✅ No redirect to payment page +- ✅ API key validates account status +- ✅ Throttling per-account enforced +- ✅ Bank transfer confirmation works +- ✅ All tests passing +- ✅ Zero authentication bypasses + +--- + +## Next Steps After This Plan + +1. **Upgrade flow**: Add `/pricing` page for users to upgrade from trial +2. **Trial expiry**: Add Celery task to check trial period and notify users +3. **Payment integration**: Connect Stripe/PayPal for upgrades +4. **Usage tracking**: Show trial users their credit usage + +--- + +## Quick Start Commands + +```bash +# Day 1: Free Trial Setup +python manage.py create_free_trial_plan +# Test signup at https://app.igny8.com/signup + +# Day 2: Migrations +python manage.py makemigrations +python manage.py migrate + +# Day 3-4: Code changes (use this plan) + +# Day 5-6: Tests +python manage.py test igny8_core.auth.tests.test_free_trial_signup + +# Day 7: Deploy +python manage.py collectstatic +# Deploy to production +``` + +--- + +**This plan delivers a fully functional tenancy system with frictionless free trial signup.** \ No newline at end of file diff --git a/final-tenancy-accounts-payments/FREE-TRIAL-SIGNUP-FIX.md b/final-tenancy-accounts-payments/FREE-TRIAL-SIGNUP-FIX.md new file mode 100644 index 00000000..ed5f1830 --- /dev/null +++ b/final-tenancy-accounts-payments/FREE-TRIAL-SIGNUP-FIX.md @@ -0,0 +1,708 @@ +# Free Trial Signup Flow - Complete Fix +## Problem: Complex signup with plan selection; Need: Simple free trial + +--- + +## Current Flow Analysis + +### Frontend ([`SignUpForm.tsx`](frontend/src/components/auth/SignUpForm.tsx)) +**Problems:** +1. Lines 29-64: Loads all plans from API +2. Lines 257-279: Shows plan selection dropdown (required) +3. Line 85-88: Validates plan is selected +4. Line 101: Passes `plan_id` to register +5. Line 105: Redirects to `/account/plans` after signup (payment/plan page) + +### Backend ([`RegisterSerializer`](backend/igny8_core/auth/serializers.py:276)) +**Problems:** +1. Line 282-290: If no plan_id, tries to find 'free' plan or cheapest +2. Line 332-337: Creates account but **NO credit seeding** +3. No default status='trial' set +4. No automatic trial period setup + +### Current User Journey (Messy) +``` +Marketing → Click "Sign Up" + → /signup page loads plans API + → User must select plan from dropdown + → Submit registration with plan_id + → Backend creates account (0 credits!) + → Redirect to /account/plans (payment page) + → User confused, no clear trial +``` + +--- + +## Solution: Simple Free Trial Signup + +### Desired User Journey (Clean) +``` +Marketing → Click "Sign Up" + → /signup page (no plan selection!) + → User fills: name, email, password + → Submit → Backend auto-assigns "Free Trial" plan + → Credits seeded automatically + → Status = 'trial' + → Redirect to /sites (dashboard) + → User starts using app immediately +``` + +--- + +## Implementation Steps + +### Step 1: Create Free Trial Plan (Database) + +Run in Django shell or create migration: +```python +from igny8_core.auth.models import Plan + +# Create or update Free Trial plan +Plan.objects.update_or_create( + slug='free-trial', + defaults={ + 'name': 'Free Trial', + 'price': 0.00, + 'billing_cycle': 'monthly', + 'included_credits': 2000, # Enough for testing + 'max_sites': 1, + 'max_users': 1, + 'max_industries': 3, + 'is_active': True, + 'features': ['ai_writer', 'planner', 'basic_support'] + } +) +``` + +**Verify:** +```bash +python manage.py shell +>>> from igny8_core.auth.models import Plan +>>> Plan.objects.get(slug='free-trial') + +``` + +--- + +### Step 2: Update Backend Registration to Auto-Assign Free Trial + +**File:** [`backend/igny8_core/auth/serializers.py:276`](backend/igny8_core/auth/serializers.py:276) + +**Current code (lines 280-343):** Has issues - no credits, tries to find plan + +**Replace with:** +```python +def create(self, validated_data): + from django.db import transaction + from igny8_core.business.billing.models import CreditTransaction + + with transaction.atomic(): + # ALWAYS assign Free Trial plan for /signup route + # Ignore plan_id if provided - this route is for free trials only + try: + plan = Plan.objects.get(slug='free-trial', is_active=True) + except Plan.DoesNotExist: + # Fallback to 'free' if free-trial doesn't exist + try: + plan = Plan.objects.get(slug='free', is_active=True) + except Plan.DoesNotExist: + raise serializers.ValidationError({ + "plan": "Free trial plan not configured. Please contact support." + }) + + # Generate account name + account_name = validated_data.get('account_name') + if not account_name: + first_name = validated_data.get('first_name', '') + last_name = validated_data.get('last_name', '') + if first_name or last_name: + account_name = f"{first_name} {last_name}".strip() or \ + validated_data['email'].split('@')[0] + else: + account_name = validated_data['email'].split('@')[0] + + # Generate username if not provided + username = validated_data.get('username') + if not username: + username = validated_data['email'].split('@')[0] + base_username = username + counter = 1 + while User.objects.filter(username=username).exists(): + username = f"{base_username}{counter}" + counter += 1 + + # Create user first + user = User.objects.create_user( + username=username, + email=validated_data['email'], + password=validated_data['password'], + first_name=validated_data.get('first_name', ''), + last_name=validated_data.get('last_name', ''), + account=None, + role='owner' + ) + + # Create account with unique slug + base_slug = account_name.lower().replace(' ', '-').replace('_', '-')[:50] or 'account' + slug = base_slug + counter = 1 + while Account.objects.filter(slug=slug).exists(): + slug = f"{base_slug}-{counter}" + counter += 1 + + # Get trial credits from plan + trial_credits = plan.get_effective_credits_per_month() + + account = Account.objects.create( + name=account_name, + slug=slug, + owner=user, + plan=plan, + credits=trial_credits, # CRITICAL: Seed credits + status='trial' # CRITICAL: Set as trial account + ) + + # Log initial credit transaction + CreditTransaction.objects.create( + account=account, + transaction_type='subscription', + amount=trial_credits, + balance_after=trial_credits, + description=f'Free trial credits from {plan.name}', + metadata={ + 'plan_slug': plan.slug, + 'registration': True, + 'trial': True + } + ) + + # Link user to account + user.account = account + user.save() + + return user +``` + +**Changes:** +- Line 283: Force free-trial plan, ignore plan_id +- Line 352: Set `credits=trial_credits` +- Line 353: Set `status='trial'` +- Lines 356-365: Log credit transaction + +--- + +### Step 3: Update Frontend to Remove Plan Selection + +**File:** [`frontend/src/components/auth/SignUpForm.tsx`](frontend/src/components/auth/SignUpForm.tsx) + +**Remove lines 29-64** (plan loading logic) +**Remove lines 257-279** (plan selection dropdown) +**Remove lines 85-88** (plan validation) +**Remove line 101** (plan_id from register call) + +**Replace handleSubmit (lines 71-115):** +```typescript +const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + + if (!formData.email || !formData.password || !formData.firstName || !formData.lastName) { + setError("Please fill in all required fields"); + return; + } + + if (!isChecked) { + setError("Please agree to the Terms and Conditions"); + return; + } + + try { + // Generate username from email if not provided + const username = formData.username || formData.email.split("@")[0]; + + // NO plan_id - backend will auto-assign free trial + await register({ + email: formData.email, + password: formData.password, + username: username, + first_name: formData.firstName, + last_name: formData.lastName, + account_name: formData.accountName, + }); + + // Redirect to dashboard/sites instead of payment page + navigate("/sites", { replace: true }); + } catch (err: any) { + setError(err.message || "Registration failed. Please try again."); + } +}; +``` + +**Remove state:** +```typescript +// DELETE these lines: +const [plans, setPlans] = useState([]); +const [selectedPlanId, setSelectedPlanId] = useState(null); +const [plansLoading, setPlansLoading] = useState(true); +``` + +**Remove useEffect** (lines 41-64 - plan loading) + +--- + +### Step 4: Update Auth Store + +**File:** [`frontend/src/store/authStore.ts:120`](frontend/src/store/authStore.ts:120) + +No changes needed - it already handles registration without plan_id correctly. + +--- + +### Step 5: Update Middleware to Allow 'trial' Status + +**File:** [`backend/igny8_core/auth/middleware.py:132`](backend/igny8_core/auth/middleware.py:132) + +Ensure trial accounts can login - current code should already allow this. + +Check validation logic allows status='trial': +```python +# In validate_account_and_plan helper (to be created) +# Allow 'trial' status along with 'active' +if account.status in ['suspended', 'cancelled']: + # Block only suspended/cancelled + # Allow: 'trial', 'active', 'pending_payment' + return (False, f'Account is {account.status}', 403) +``` + +--- + +## Complete Code Changes + +### Change 1: Update RegisterSerializer + +**File:** `backend/igny8_core/auth/serializers.py` + +Replace lines 276-343 with: +```python +def create(self, validated_data): + from django.db import transaction + from igny8_core.business.billing.models import CreditTransaction + + with transaction.atomic(): + # ALWAYS assign Free Trial plan for simple signup + # Ignore plan_id parameter - this is for free trial signups only + try: + plan = Plan.objects.get(slug='free-trial', is_active=True) + except Plan.DoesNotExist: + try: + plan = Plan.objects.get(slug='free', is_active=True) + except Plan.DoesNotExist: + raise serializers.ValidationError({ + "plan": "Free trial plan not configured. Please contact support." + }) + + # Generate account name if not provided + account_name = validated_data.get('account_name') + if not account_name: + first_name = validated_data.get('first_name', '') + last_name = validated_data.get('last_name', '') + if first_name or last_name: + account_name = f"{first_name} {last_name}".strip() or \ + validated_data['email'].split('@')[0] + else: + account_name = validated_data['email'].split('@')[0] + + # Generate username if not provided + username = validated_data.get('username') + if not username: + username = validated_data['email'].split('@')[0] + base_username = username + counter = 1 + while User.objects.filter(username=username).exists(): + username = f"{base_username}{counter}" + counter += 1 + + # Create user first without account + user = User.objects.create_user( + username=username, + email=validated_data['email'], + password=validated_data['password'], + first_name=validated_data.get('first_name', ''), + last_name=validated_data.get('last_name', ''), + account=None, + role='owner' + ) + + # Generate unique slug for account + base_slug = account_name.lower().replace(' ', '-').replace('_', '-')[:50] or 'account' + slug = base_slug + counter = 1 + while Account.objects.filter(slug=slug).exists(): + slug = f"{base_slug}-{counter}" + counter += 1 + + # Get trial credits from plan + trial_credits = plan.get_effective_credits_per_month() + + # Create account with trial status and credits + account = Account.objects.create( + name=account_name, + slug=slug, + owner=user, + plan=plan, + credits=trial_credits, + status='trial' + ) + + # Log initial credit transaction + CreditTransaction.objects.create( + account=account, + transaction_type='subscription', + amount=trial_credits, + balance_after=trial_credits, + description=f'Free trial credits from {plan.name}', + metadata={ + 'plan_slug': plan.slug, + 'registration': True, + 'trial': True + } + ) + + # Update user to reference account + user.account = account + user.save() + + return user +``` + +### Change 2: Simplify SignUpForm + +**File:** `frontend/src/components/auth/SignUpForm.tsx` + +Replace entire component with: +```typescript +import { useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { ChevronLeftIcon, EyeCloseIcon, EyeIcon } from "../../icons"; +import Label from "../form/Label"; +import Input from "../form/input/InputField"; +import Checkbox from "../form/input/Checkbox"; +import { useAuthStore } from "../../store/authStore"; + +export default function SignUpForm() { + const [showPassword, setShowPassword] = useState(false); + const [isChecked, setIsChecked] = useState(false); + const [formData, setFormData] = useState({ + firstName: "", + lastName: "", + email: "", + password: "", + username: "", + accountName: "", + }); + const [error, setError] = useState(""); + const navigate = useNavigate(); + const { register, loading } = useAuthStore(); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + + if (!formData.email || !formData.password || !formData.firstName || !formData.lastName) { + setError("Please fill in all required fields"); + return; + } + + if (!isChecked) { + setError("Please agree to the Terms and Conditions"); + return; + } + + try { + const username = formData.username || formData.email.split("@")[0]; + + // No plan_id needed - backend auto-assigns free trial + await register({ + email: formData.email, + password: formData.password, + username: username, + first_name: formData.firstName, + last_name: formData.lastName, + account_name: formData.accountName, + }); + + // Redirect to dashboard/sites instead of payment page + navigate("/sites", { replace: true }); + } catch (err: any) { + setError(err.message || "Registration failed. Please try again."); + } + }; + + return ( +
+
+ + + Back to dashboard + +
+
+
+
+

+ Start Your Free Trial +

+

+ No credit card required. 2,000 AI credits to get started. +

+
+
+
+
+ {error && ( +
+ {error} +
+ )} +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ +
+ + setShowPassword(!showPassword)} + className="absolute z-30 -translate-y-1/2 cursor-pointer right-4 top-1/2" + > + {showPassword ? ( + + ) : ( + + )} + +
+
+
+ +

+ By creating an account means you agree to the{" "} + + Terms and Conditions, + {" "} + and our{" "} + + Privacy Policy + +

+
+
+ +
+
+
+ +
+

+ Already have an account?{" "} + + Sign In + +

+
+
+
+
+
+ ); +} +``` + +**Key changes:** +- Removed all plan-related code +- Changed heading to "Start Your Free Trial" +- Added "No credit card required" subtext +- Changed button text to "Start Free Trial" +- Redirect to `/sites` instead of `/account/plans` +- No plan_id sent to backend + +--- + +## Verification Steps + +### 1. Create Free Trial Plan +```bash +python manage.py shell +>>> from igny8_core.auth.models import Plan +>>> Plan.objects.create( + slug='free-trial', + name='Free Trial', + price=0.00, + billing_cycle='monthly', + included_credits=2000, + max_sites=1, + max_users=1, + max_industries=3, + is_active=True +) +>>> exit() +``` + +### 2. Test Registration Flow +```bash +# Visit https://app.igny8.com/signup +# Fill form: name, email, password +# Submit +# Should: +# 1. Create account with status='trial' +# 2. Set credits=2000 +# 3. Redirect to /sites +# 4. User can immediately use app +``` + +### 3. Verify Database +```bash +python manage.py shell +>>> from igny8_core.auth.models import User +>>> u = User.objects.get(email='test@example.com') +>>> u.account.status +'trial' +>>> u.account.credits +2000 +>>> u.account.plan.slug +'free-trial' +``` + +--- + +## Summary of Changes + +| File | Action | Lines | +|------|--------|-------| +| Database | Add free-trial plan | Create via shell/migration | +| `auth/serializers.py` | Force free-trial plan, seed credits | 276-343 (68 lines) | +| `auth/SignUpForm.tsx` | Remove plan selection, simplify | 29-279 (removed ~80 lines) | + +**Result:** Clean, simple free trial signup with zero payment friction. + +--- + +## Before vs After + +### Before (Messy) +``` +User → Signup page + → Must select plan + → Submit with plan_id + → Account created with 0 credits + → Redirect to /account/plans (payment) + → Confused user +``` + +### After (Clean) +``` +User → Signup page + → Fill name, email, password + → Submit (no plan selection) + → Account created with: + - status='trial' + - plan='free-trial' + - credits=2000 + → Redirect to /sites + → User starts using app immediately +``` + +--- + +## Next: Paid Plans (Future) + +For users who want paid plans: +- Create separate `/pricing` page +- After selecting paid plan, route to `/signup?plan=growth` +- Backend checks query param and assigns that plan instead +- OR keep /signup as free trial only and create `/subscribe` for paid + +**For now: /signup = 100% free trial, zero friction.** \ No newline at end of file diff --git a/frontend/src/components/auth/SignUpForm.tsx b/frontend/src/components/auth/SignUpForm.tsx index e00b8973..da9e1b2f 100644 --- a/frontend/src/components/auth/SignUpForm.tsx +++ b/frontend/src/components/auth/SignUpForm.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useState } from "react"; import { Link, useNavigate } from "react-router-dom"; import { ChevronLeftIcon, EyeCloseIcon, EyeIcon } from "../../icons"; import Label from "../form/Label"; @@ -6,15 +6,6 @@ import Input from "../form/input/InputField"; import Checkbox from "../form/input/Checkbox"; import { useAuthStore } from "../../store/authStore"; -type Plan = { - id: number; - name: string; - price?: number; - billing_cycle?: string; - is_active?: boolean; - included_credits?: number; -}; - export default function SignUpForm() { const [showPassword, setShowPassword] = useState(false); const [isChecked, setIsChecked] = useState(false); @@ -26,43 +17,10 @@ export default function SignUpForm() { username: "", accountName: "", }); - const [plans, setPlans] = useState([]); - const [selectedPlanId, setSelectedPlanId] = useState(null); - const [plansLoading, setPlansLoading] = useState(true); const [error, setError] = useState(""); const navigate = useNavigate(); const { register, loading } = useAuthStore(); - const apiBaseUrl = useMemo( - () => import.meta.env.VITE_BACKEND_URL || "https://api.igny8.com/api", - [] - ); - - useEffect(() => { - const loadPlans = async () => { - setPlansLoading(true); - try { - const res = await fetch(`${apiBaseUrl}/v1/auth/plans/`, { - method: "GET", - headers: { "Content-Type": "application/json" }, - }); - const data = await res.json(); - const list: Plan[] = data?.results || data || []; - const activePlans = list.filter((p) => p.is_active !== false); - setPlans(activePlans); - if (activePlans.length > 0) { - setSelectedPlanId(activePlans[0].id); - } - } catch (e) { - // keep empty list; surface error on submit if no plan - console.error("Failed to load plans", e); - } finally { - setPlansLoading(false); - } - }; - loadPlans(); - }, [apiBaseUrl]); - const handleChange = (e: React.ChangeEvent) => { const { name, value } = e.target; setFormData((prev) => ({ ...prev, [name]: value })); @@ -82,15 +40,11 @@ export default function SignUpForm() { return; } - if (!selectedPlanId) { - setError("Please select a plan to continue"); - return; - } - try { // Generate username from email if not provided const username = formData.username || formData.email.split("@")[0]; + // No plan_id needed - backend auto-assigns free trial await register({ email: formData.email, password: formData.password, @@ -98,21 +52,15 @@ export default function SignUpForm() { first_name: formData.firstName, last_name: formData.lastName, account_name: formData.accountName, - plan_id: selectedPlanId, }); - // Redirect to plan selection after successful registration - navigate("/account/plans", { replace: true }); - // Hard fallback in case navigation is blocked by router state - setTimeout(() => { - if (window.location.pathname !== "/account/plans") { - window.location.assign("/account/plans"); - } - }, 500); + // Redirect to dashboard/sites instead of payment page + navigate("/sites", { replace: true }); } catch (err: any) { setError(err.message || "Registration failed. Please try again."); } }; + return (
@@ -128,10 +76,10 @@ export default function SignUpForm() {

- Sign Up + Start Your Free Trial

- Enter your email and password to sign up! + No credit card required. 2,000 AI credits to get started.

@@ -195,7 +143,7 @@ export default function SignUpForm() {
)}
- {/* */} + {/* First Name */}
- {/* */} + {/* Last Name */}
- {/* */} + {/* Email */}
- {/* */} + {/* Account Name */}
- - {/* */} -
- - -
- - {/* */} + {/* Password */}
- {/* */} + {/* Terms Checkbox */}

- {/* */} + {/* Submit Button */}
@@ -338,7 +257,7 @@ export default function SignUpForm() {

- Already have an account? {""} + Already have an account?{" "}