Files
igny8/multi-tenancy/faulty-docs-with issues/FREE-TRIAL-SIGNUP-FIX.md
IGNY8 VPS (Salman) c54db6c2d9 reorg
2025-12-08 20:15:09 +00:00

22 KiB

Free Trial Signup Flow - Complete Fix

Problem: Complex signup with plan selection; Need: Simple free trial


Current Flow Analysis

Frontend (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)

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:

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:

python manage.py shell
>>> from igny8_core.auth.models import Plan
>>> Plan.objects.get(slug='free-trial')
<Plan: Free Trial>

Step 2: Update Backend Registration to Auto-Assign Free Trial

File: backend/igny8_core/auth/serializers.py:276

Current code (lines 280-343): Has issues - no credits, tries to find plan

Replace with:

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

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):

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:

// DELETE these lines:
const [plans, setPlans] = useState<Plan[]>([]);
const [selectedPlanId, setSelectedPlanId] = useState<number | null>(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

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

Ensure trial accounts can login - current code should already allow this.

Check validation logic allows status='trial':

# 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:

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:

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<HTMLInputElement>) => {
    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 (
    <div className="flex flex-col flex-1 w-full overflow-y-auto lg:w-1/2 no-scrollbar">
      <div className="w-full max-w-md mx-auto mb-5 sm:pt-10">
        <Link
          to="/"
          className="inline-flex items-center text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
        >
          <ChevronLeftIcon className="size-5" />
          Back to dashboard
        </Link>
      </div>
      <div className="flex flex-col justify-center flex-1 w-full max-w-md mx-auto">
        <div>
          <div className="mb-5 sm:mb-8">
            <h1 className="mb-2 font-semibold text-gray-800 text-title-sm dark:text-white/90 sm:text-title-md">
              Start Your Free Trial
            </h1>
            <p className="text-sm text-gray-500 dark:text-gray-400">
              No credit card required. 2,000 AI credits to get started.
            </p>
          </div>
          <div>
            <form onSubmit={handleSubmit}>
              <div className="space-y-5">
                {error && (
                  <div className="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg dark:bg-red-900/20 dark:text-red-400 dark:border-red-800">
                    {error}
                  </div>
                )}
                <div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
                  <div className="sm:col-span-1">
                    <Label>
                      First Name<span className="text-error-500">*</span>
                    </Label>
                    <Input
                      type="text"
                      id="firstName"
                      name="firstName"
                      value={formData.firstName}
                      onChange={handleChange}
                      placeholder="Enter your first name"
                      required
                    />
                  </div>
                  <div className="sm:col-span-1">
                    <Label>
                      Last Name<span className="text-error-500">*</span>
                    </Label>
                    <Input
                      type="text"
                      id="lastName"
                      name="lastName"
                      value={formData.lastName}
                      onChange={handleChange}
                      placeholder="Enter your last name"
                      required
                    />
                  </div>
                </div>
                <div>
                  <Label>
                    Email<span className="text-error-500">*</span>
                  </Label>
                  <Input
                    type="email"
                    id="email"
                    name="email"
                    value={formData.email}
                    onChange={handleChange}
                    placeholder="Enter your email"
                    required
                  />
                </div>
                <div>
                  <Label>Account Name (optional)</Label>
                  <Input
                    type="text"
                    id="accountName"
                    name="accountName"
                    value={formData.accountName}
                    onChange={handleChange}
                    placeholder="Workspace / Company name"
                  />
                </div>
                <div>
                  <Label>
                    Password<span className="text-error-500">*</span>
                  </Label>
                  <div className="relative">
                    <Input
                      placeholder="Enter your password"
                      type={showPassword ? "text" : "password"}
                      id="password"
                      name="password"
                      value={formData.password}
                      onChange={handleChange}
                      required
                    />
                    <span
                      onClick={() => setShowPassword(!showPassword)}
                      className="absolute z-30 -translate-y-1/2 cursor-pointer right-4 top-1/2"
                    >
                      {showPassword ? (
                        <EyeIcon className="fill-gray-500 dark:fill-gray-400 size-5" />
                      ) : (
                        <EyeCloseIcon className="fill-gray-500 dark:fill-gray-400 size-5" />
                      )}
                    </span>
                  </div>
                </div>
                <div className="flex items-center gap-3">
                  <Checkbox
                    className="w-5 h-5"
                    checked={isChecked}
                    onChange={setIsChecked}
                  />
                  <p className="inline-block font-normal text-gray-500 dark:text-gray-400">
                    By creating an account means you agree to the{" "}
                    <span className="text-gray-800 dark:text-white/90">
                      Terms and Conditions,
                    </span>{" "}
                    and our{" "}
                    <span className="text-gray-800 dark:text-white">
                      Privacy Policy
                    </span>
                  </p>
                </div>
                <div>
                  <button 
                    type="submit"
                    disabled={loading}
                    className="flex items-center justify-center w-full px-4 py-3 text-sm font-medium text-white transition rounded-lg bg-brand-500 shadow-theme-xs hover:bg-brand-600 disabled:opacity-50 disabled:cursor-not-allowed"
                  >
                    {loading ? "Creating your account..." : "Start Free Trial"}
                  </button>
                </div>
              </div>
            </form>

            <div className="mt-5">
              <p className="text-sm font-normal text-center text-gray-700 dark:text-gray-400 sm:text-start">
                Already have an account?{" "}
                <Link
                  to="/signin"
                  className="text-brand-500 hover:text-brand-600 dark:text-brand-400"
                >
                  Sign In
                </Link>
              </p>
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

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

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

# 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

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.