Files
igny8/final-tenancy-accounts-payments/FINAL-IMPLEMENTATION-PLAN-COMPLETE.md
IGNY8 VPS (Salman) 4e9d8af768 sadasd
2025-12-08 06:15:35 +00:00

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

  1. Phase 0: Free Trial Signup (CRITICAL - Day 1)
  2. Phase 1: Payment Method Fields
  3. Phase 2: Validation Helper
  4. Phase 3: API Key Fix
  5. Phase 4: Throttling Fix
  6. Phase 5: Bank Transfer Endpoint
  7. Phase 6: Comprehensive Tests
  8. Phase 7: Documentation
  9. Verification & Rollback

Executive Summary

What's Already Working

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

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 /sites instead 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:

  1. Final_Flow_Tenancy.md - Add free trial flow
  2. FREE-TRIAL-SIGNUP-FIX.md - Mark as implemented
  3. COMPLETE-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

  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

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