sadasd
This commit is contained in:
@@ -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!'
|
||||||
|
))
|
||||||
@@ -275,19 +275,24 @@ class RegisterSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
from igny8_core.business.billing.models import CreditTransaction
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
# Get or assign free plan
|
# ALWAYS assign Free Trial plan for simple signup
|
||||||
plan = validated_data.get('plan_id')
|
# Ignore plan_id parameter - this is for free trial signups only
|
||||||
if not plan:
|
try:
|
||||||
# Auto-assign free plan
|
plan = Plan.objects.get(slug='free-trial', is_active=True)
|
||||||
|
except Plan.DoesNotExist:
|
||||||
|
# Fallback to 'free' if free-trial doesn't exist
|
||||||
try:
|
try:
|
||||||
plan = Plan.objects.get(slug='free', is_active=True)
|
plan = Plan.objects.get(slug='free', is_active=True)
|
||||||
except Plan.DoesNotExist:
|
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()
|
plan = Plan.objects.filter(is_active=True).order_by('price').first()
|
||||||
if not plan:
|
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
|
# Generate account name if not provided
|
||||||
account_name = validated_data.get('account_name')
|
account_name = validated_data.get('account_name')
|
||||||
@@ -295,7 +300,8 @@ class RegisterSerializer(serializers.Serializer):
|
|||||||
first_name = validated_data.get('first_name', '')
|
first_name = validated_data.get('first_name', '')
|
||||||
last_name = validated_data.get('last_name', '')
|
last_name = validated_data.get('last_name', '')
|
||||||
if first_name or 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:
|
else:
|
||||||
account_name = validated_data['email'].split('@')[0]
|
account_name = validated_data['email'].split('@')[0]
|
||||||
|
|
||||||
@@ -321,19 +327,39 @@ class RegisterSerializer(serializers.Serializer):
|
|||||||
role='owner'
|
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'
|
base_slug = account_name.lower().replace(' ', '-').replace('_', '-')[:50] or 'account'
|
||||||
slug = base_slug
|
slug = base_slug
|
||||||
counter = 1
|
counter = 1
|
||||||
while Account.objects.filter(slug=slug).exists():
|
while Account.objects.filter(slug=slug).exists():
|
||||||
slug = f"{base_slug}-{counter}"
|
slug = f"{base_slug}-{counter}"
|
||||||
counter += 1
|
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(
|
account = Account.objects.create(
|
||||||
name=account_name,
|
name=account_name,
|
||||||
slug=slug,
|
slug=slug,
|
||||||
owner=user,
|
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
|
# Update user to reference the new account
|
||||||
|
|||||||
@@ -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 <commit_hash>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.**
|
||||||
708
final-tenancy-accounts-payments/FREE-TRIAL-SIGNUP-FIX.md
Normal file
708
final-tenancy-accounts-payments/FREE-TRIAL-SIGNUP-FIX.md
Normal file
@@ -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')
|
||||||
|
<Plan: 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<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`](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<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
|
||||||
|
```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.**
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { ChevronLeftIcon, EyeCloseIcon, EyeIcon } from "../../icons";
|
import { ChevronLeftIcon, EyeCloseIcon, EyeIcon } from "../../icons";
|
||||||
import Label from "../form/Label";
|
import Label from "../form/Label";
|
||||||
@@ -6,15 +6,6 @@ import Input from "../form/input/InputField";
|
|||||||
import Checkbox from "../form/input/Checkbox";
|
import Checkbox from "../form/input/Checkbox";
|
||||||
import { useAuthStore } from "../../store/authStore";
|
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() {
|
export default function SignUpForm() {
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [isChecked, setIsChecked] = useState(false);
|
const [isChecked, setIsChecked] = useState(false);
|
||||||
@@ -26,43 +17,10 @@ export default function SignUpForm() {
|
|||||||
username: "",
|
username: "",
|
||||||
accountName: "",
|
accountName: "",
|
||||||
});
|
});
|
||||||
const [plans, setPlans] = useState<Plan[]>([]);
|
|
||||||
const [selectedPlanId, setSelectedPlanId] = useState<number | null>(null);
|
|
||||||
const [plansLoading, setPlansLoading] = useState(true);
|
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { register, loading } = useAuthStore();
|
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<HTMLInputElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||||
@@ -82,15 +40,11 @@ export default function SignUpForm() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selectedPlanId) {
|
|
||||||
setError("Please select a plan to continue");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Generate username from email if not provided
|
// Generate username from email if not provided
|
||||||
const username = formData.username || formData.email.split("@")[0];
|
const username = formData.username || formData.email.split("@")[0];
|
||||||
|
|
||||||
|
// No plan_id needed - backend auto-assigns free trial
|
||||||
await register({
|
await register({
|
||||||
email: formData.email,
|
email: formData.email,
|
||||||
password: formData.password,
|
password: formData.password,
|
||||||
@@ -98,21 +52,15 @@ export default function SignUpForm() {
|
|||||||
first_name: formData.firstName,
|
first_name: formData.firstName,
|
||||||
last_name: formData.lastName,
|
last_name: formData.lastName,
|
||||||
account_name: formData.accountName,
|
account_name: formData.accountName,
|
||||||
plan_id: selectedPlanId,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Redirect to plan selection after successful registration
|
// Redirect to dashboard/sites instead of payment page
|
||||||
navigate("/account/plans", { replace: true });
|
navigate("/sites", { 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);
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || "Registration failed. Please try again.");
|
setError(err.message || "Registration failed. Please try again.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 w-full overflow-y-auto lg:w-1/2 no-scrollbar">
|
<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">
|
<div className="w-full max-w-md mx-auto mb-5 sm:pt-10">
|
||||||
@@ -128,10 +76,10 @@ export default function SignUpForm() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="mb-5 sm:mb-8">
|
<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">
|
<h1 className="mb-2 font-semibold text-gray-800 text-title-sm dark:text-white/90 sm:text-title-md">
|
||||||
Sign Up
|
Start Your Free Trial
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
Enter your email and password to sign up!
|
No credit card required. 2,000 AI credits to get started.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -195,7 +143,7 @@ export default function SignUpForm() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||||
{/* <!-- First Name --> */}
|
{/* First Name */}
|
||||||
<div className="sm:col-span-1">
|
<div className="sm:col-span-1">
|
||||||
<Label>
|
<Label>
|
||||||
First Name<span className="text-error-500">*</span>
|
First Name<span className="text-error-500">*</span>
|
||||||
@@ -207,10 +155,9 @@ export default function SignUpForm() {
|
|||||||
value={formData.firstName}
|
value={formData.firstName}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder="Enter your first name"
|
placeholder="Enter your first name"
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* <!-- Last Name --> */}
|
{/* Last Name */}
|
||||||
<div className="sm:col-span-1">
|
<div className="sm:col-span-1">
|
||||||
<Label>
|
<Label>
|
||||||
Last Name<span className="text-error-500">*</span>
|
Last Name<span className="text-error-500">*</span>
|
||||||
@@ -222,11 +169,10 @@ export default function SignUpForm() {
|
|||||||
value={formData.lastName}
|
value={formData.lastName}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder="Enter your last name"
|
placeholder="Enter your last name"
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* <!-- Email --> */}
|
{/* Email */}
|
||||||
<div>
|
<div>
|
||||||
<Label>
|
<Label>
|
||||||
Email<span className="text-error-500">*</span>
|
Email<span className="text-error-500">*</span>
|
||||||
@@ -238,10 +184,9 @@ export default function SignUpForm() {
|
|||||||
value={formData.email}
|
value={formData.email}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder="Enter your email"
|
placeholder="Enter your email"
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* <!-- Account Name --> */}
|
{/* Account Name */}
|
||||||
<div>
|
<div>
|
||||||
<Label>Account Name (optional)</Label>
|
<Label>Account Name (optional)</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -253,32 +198,7 @@ export default function SignUpForm() {
|
|||||||
placeholder="Workspace / Company name"
|
placeholder="Workspace / Company name"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Password */}
|
||||||
{/* <!-- Plan Selection --> */}
|
|
||||||
<div>
|
|
||||||
<Label>
|
|
||||||
Select Plan<span className="text-error-500">*</span>
|
|
||||||
</Label>
|
|
||||||
<select
|
|
||||||
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900 text-sm"
|
|
||||||
value={selectedPlanId ?? ""}
|
|
||||||
onChange={(e) => setSelectedPlanId(Number(e.target.value))}
|
|
||||||
disabled={plansLoading || plans.length === 0}
|
|
||||||
>
|
|
||||||
{plansLoading && <option>Loading plans...</option>}
|
|
||||||
{!plansLoading && plans.length === 0 && (
|
|
||||||
<option value="">No plans available</option>
|
|
||||||
)}
|
|
||||||
{plans.map((plan) => (
|
|
||||||
<option key={plan.id} value={plan.id}>
|
|
||||||
{plan.name}
|
|
||||||
{plan.price ? ` - $${plan.price}/${plan.billing_cycle || "month"}` : ""}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* <!-- Password --> */}
|
|
||||||
<div>
|
<div>
|
||||||
<Label>
|
<Label>
|
||||||
Password<span className="text-error-500">*</span>
|
Password<span className="text-error-500">*</span>
|
||||||
@@ -291,7 +211,6 @@ export default function SignUpForm() {
|
|||||||
name="password"
|
name="password"
|
||||||
value={formData.password}
|
value={formData.password}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
@@ -305,7 +224,7 @@ export default function SignUpForm() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* <!-- Checkbox --> */}
|
{/* Terms Checkbox */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
className="w-5 h-5"
|
className="w-5 h-5"
|
||||||
@@ -323,14 +242,14 @@ export default function SignUpForm() {
|
|||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/* <!-- Button --> */}
|
{/* Submit Button */}
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
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"
|
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 ? "Signing up..." : "Sign Up"}
|
{loading ? "Creating your account..." : "Start Free Trial"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -338,7 +257,7 @@ export default function SignUpForm() {
|
|||||||
|
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
<p className="text-sm font-normal text-center text-gray-700 dark:text-gray-400 sm:text-start">
|
<p className="text-sm font-normal text-center text-gray-700 dark:text-gray-400 sm:text-start">
|
||||||
Already have an account? {""}
|
Already have an account?{" "}
|
||||||
<Link
|
<Link
|
||||||
to="/signin"
|
to="/signin"
|
||||||
className="text-brand-500 hover:text-brand-600 dark:text-brand-400"
|
className="text-brand-500 hover:text-brand-600 dark:text-brand-400"
|
||||||
|
|||||||
Reference in New Issue
Block a user