29 KiB
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
- Phase 0: Free Trial Signup (CRITICAL - Day 1)
- Phase 1: Payment Method Fields
- Phase 2: Validation Helper
- Phase 3: API Key Fix
- Phase 4: Throttling Fix
- Phase 5: Bank Transfer Endpoint
- Phase 6: Comprehensive Tests
- Phase 7: Documentation
- Verification & Rollback
Executive Summary
✅ What's Already Working
- Account filtering (
AccountModelViewSet) - Middleware injection (
AccountContextMiddleware) - Credit service (
CreditService) - AI credit pre-check (
AIEngine:213-235)
❌ Critical Gaps Fixed by This Plan
- ✅ Signup complexity - Simplified to free trial
- ❌ Payment method support - Missing fields
- ❌ API key bypass - No account validation
- ❌ Throttling too permissive - All users bypass
- ❌ Credit seeding - Registration gives 0 credits
Phase 0: Free Trial Signup (CRITICAL - Day 1)
Current Problem
SignUpForm.tsx:29-64- Loads plans, requires selectionSignUpForm.tsx:105- Redirects to/account/plans(payment page)RegisterSerializer:332- Creates account with 0 credits
Solution: Simple Free Trial
0.1 Create Free Trial Plan
Run command:
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
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
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
/sitesinstead of/account/plans
Verification
# 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
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
At line 65, update STATUS_CHOICES:
STATUS_CHOICES = [
('active', 'Active'),
('suspended', 'Suspended'),
('trial', 'Trial'),
('cancelled', 'Cancelled'),
('pending_payment', 'Pending Payment'), # NEW
]
At line 79, add new field:
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
Update Subscription model:
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:
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
Add at end of file:
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
Replace _validate_account_and_plan method:
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
In authenticate() method, add validation after line 122:
# 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
Replace allow_request() method:
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
"""
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
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
Add:
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
"""
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:
python manage.py test igny8_core.auth.tests.test_free_trial_signup
Phase 7: Update Documentation (Day 7)
Update files:
Final_Flow_Tenancy.md- Add free trial flowFREE-TRIAL-SIGNUP-FIX.md- Mark as implementedCOMPLETE-IMPLEMENTATION-PLAN.md- Update status
Verification Checklist
Database Setup
# 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
# 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 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
# 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)
# 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
# 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
- Upgrade flow: Add
/pricingpage for users to upgrade from trial - Trial expiry: Add Celery task to check trial period and notify users
- Payment integration: Connect Stripe/PayPal for upgrades
- Usage tracking: Show trial users their credit usage
Quick Start Commands
# 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.