# Multi-Tenancy Workflow Documentation **Complete Lifecycle: Signup → Site Creation** **Last Updated:** December 8, 2025 --- ## Table of Contents 1. [Overview](#overview) 2. [Core Models & Relationships](#core-models--relationships) 3. [Complete Workflow: Signup to Site Creation](#complete-workflow-signup-to-site-creation) 4. [Data Flow Diagram with Sample Data](#data-flow-diagram-with-sample-data) 5. [Model Details & Fields](#model-details--fields) 6. [Architectural Gaps & Issues](#architectural-gaps--issues) 7. [Recommendations](#recommendations) --- ## Overview IGNY8 uses a **hierarchical multi-tenancy architecture** where each client account is completely isolated: ``` Account (Tenant) └── Plan (subscription tier) └── Users (team members) └── Sites (websites/projects) └── Sectors (content categories) └── Keywords → Clusters → Ideas → Content ``` **Key Principles:** - **Account Isolation:** All data scoped by `account` (stored as `tenant_id` in DB) - **Plan-Based Limits:** Accounts limited by plan (users, sites, sectors, credits) - **Credit-Based System:** All operations consume credits (no per-feature limits) - **Dual Subscription Models:** Legacy model in `auth` app + new model in `billing` app --- ## Core Models & Relationships ### Primary Models | Model | Location | Purpose | Key Fields | |-------|----------|---------|------------| | **Account** | `auth.models` | Tenant/Organization | `plan`, `credits`, `status`, `owner` | | **Plan** | `auth.models` | Subscription tier | `included_credits`, `max_sites`, `max_users` | | **User** | `auth.models` | Account member | `account`, `role`, `email` | | **Site** | `auth.models` | Website/Project | `account`, `industry`, `domain` | | **Sector** | `auth.models` | Content category | `site`, `account`, `industry_sector` | | **Subscription** | `auth.models` | Subscription record (LEGACY) | `account`, `stripe_subscription_id` | | **Subscription** | `billing.models` | NEW - Not defined yet | N/A - MISSING | | **Invoice** | `billing.models` | Billing invoices | `account`, `subscription`, `status` | | **Payment** | `billing.models` | Payment records | `invoice`, `payment_method`, `status` | | **CreditTransaction** | `billing.models` | Credit history | `account`, `amount`, `balance_after` | ### Relationship Map ``` ┌─────────────┐ │ Plan │ (Global, shared across all accounts) │ (1:many) │ └──────┬──────┘ │ ▼ ┌─────────────────────────────────────────────────────┐ │ Account │ │ - plan: FK(Plan) │ │ - credits: IntegerField │ │ - status: CharField (trial/active/suspended) │ │ - owner: FK(User) [nullable] │ │ - payment_method: CharField │ │ - billing_email, billing_address, tax_id │ └──────┬──────────────────────────────────────────────┘ │ ├─────────────────┬──────────────────┬─────────────────┐ │ │ │ │ ▼ ▼ ▼ ▼ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ User │ │ Site │ │Subscription │ │ Invoice │ │ (1:many) │ │ (1:many) │ │ (1:1) │ │ (1:many) │ └──────┬──────┘ └──────┬──────┘ └─────────────┘ └─────────────┘ │ │ │ ├──────────────────┐ │ │ │ │ ▼ ▼ │ ┌─────────────┐ ┌─────────────┐ │ │ Sector │ │ Industry │ │ │ (1:many) │ │ (FK) │ │ └─────────────┘ └─────────────┘ │ └──────────────────┐ ▼ ┌─────────────┐ │SiteUserAccess│ (many:many bridge) └─────────────┘ ``` --- ## Complete Workflow: Signup to Site Creation ### Phase 1: User Registration (Free Trial) **Endpoint:** `POST /api/v1/auth/register/` **Request Payload:** ```json { "email": "john@example.com", "password": "SecurePass123!", "password_confirm": "SecurePass123!", "first_name": "John", "last_name": "Doe", "account_name": "John's Business", "plan_slug": "free" } ``` **Backend Flow (RegisterSerializer.create()):** ```python # Step 1: Resolve Plan if plan_slug == 'free': plan = Plan.objects.get(slug='free') # Free plan account_status = 'trial' initial_credits = plan.included_credits # e.g., 1000 credits else: # Paid plans (starter/growth/scale) plan = Plan.objects.get(slug=plan_slug) account_status = 'pending_payment' initial_credits = 0 # No credits until payment # Step 2: Create User (without account first) user = User.objects.create_user( username='john-doe', # Auto-generated from email email='john@example.com', password='hashed_password', role='owner', account=None # Set later ) # Step 3: Generate unique slug for Account slug = 'johns-business' # From account_name, ensure unique # Step 4: Create Account account = Account.objects.create( name="John's Business", slug='johns-business', owner=user, # FK to User plan=plan, credits=initial_credits, status=account_status, payment_method='bank_transfer' ) # Step 5: Link User to Account user.account = account user.save() # Step 6: Log Initial Credits (if free trial) if initial_credits > 0: CreditTransaction.objects.create( account=account, transaction_type='subscription', amount=initial_credits, balance_after=initial_credits, description='Free plan credits from Free Trial' ) # Step 7: For Paid Plans - Create Subscription & Invoice if plan_slug in ['starter', 'growth', 'scale']: subscription = Subscription.objects.create( account=account, plan=plan, status='pending_payment', current_period_start=now(), current_period_end=now() + 30 days ) invoice = Invoice.objects.create( account=account, subscription=subscription, status='pending', total=plan.price ) # Create default payment method AccountPaymentMethod.objects.create( account=account, type='bank_transfer', display_name='Bank Transfer (Manual)', is_default=True ) ``` **Database State After Registration (Free Trial):** ```sql -- Accounts Table INSERT INTO igny8_tenants (name, slug, plan_id, credits, status, owner_id) VALUES ('Johns Business', 'johns-business', 1, 1000, 'trial', 1); -- Users Table INSERT INTO igny8_users (email, username, role, tenant_id) VALUES ('john@example.com', 'john-doe', 'owner', 1); -- Credit Transactions Table INSERT INTO igny8_credit_transactions (tenant_id, transaction_type, amount, balance_after) VALUES (1, 'subscription', 1000, 1000); ``` --- ### Phase 2: Site Creation **Endpoint:** `POST /api/v1/auth/sites/` **Request Payload:** ```json { "name": "Tech Blog", "domain": "https://techblog.com", "industry": 2, "description": "A technology blog" } ``` **Backend Flow (SiteViewSet.create()):** ```python # Step 1: Validate Account Plan Limits account = request.account # From middleware max_sites = account.plan.max_sites # e.g., Free plan: 1 site current_sites = Site.objects.filter(account=account, is_active=True).count() if current_sites >= max_sites: raise ValidationError("Site limit reached for your plan") # Step 2: Auto-generate slug from name slug = 'tech-blog' # From name, ensure unique per account # Step 3: Validate domain format domain = 'https://techblog.com' # Ensure https:// prefix # Step 4: Create Site site = Site.objects.create( account=account, # Automatically set from middleware name='Tech Blog', slug='tech-blog', domain='https://techblog.com', industry_id=2, # FK to Industry status='active', is_active=True ) ``` **Database State After Site Creation:** ```sql -- Sites Table INSERT INTO igny8_sites (tenant_id, name, slug, domain, industry_id, status) VALUES (1, 'Tech Blog', 'tech-blog', 'https://techblog.com', 2, 'active'); ``` --- ### Phase 3: Sector Creation **Endpoint:** `POST /api/v1/auth/sectors/` **Request Payload:** ```json { "site": 1, "name": "AI & Machine Learning", "industry_sector": 5, "description": "Articles about AI and ML" } ``` **Backend Flow (SectorViewSet.create()):** ```python # Step 1: Validate Site belongs to Account site = Site.objects.get(id=1, account=request.account) # Step 2: Validate Sector Limit max_sectors = site.get_max_sectors_limit() # Default: 5 sectors per site current_sectors = site.sectors.filter(is_active=True).count() if current_sectors >= max_sectors: raise ValidationError("Sector limit reached for this site") # Step 3: Validate Industry Match industry_sector = IndustrySector.objects.get(id=5) if industry_sector.industry != site.industry: raise ValidationError("Sector must belong to site's industry") # Step 4: Auto-generate slug slug = 'ai-machine-learning' # Step 5: Create Sector sector = Sector.objects.create( account=request.account, # Auto-set from site site=site, industry_sector=industry_sector, name='AI & Machine Learning', slug='ai-machine-learning', status='active', is_active=True ) ``` **Database State After Sector Creation:** ```sql -- Sectors Table INSERT INTO igny8_sectors (tenant_id, site_id, industry_sector_id, name, slug, status) VALUES (1, 1, 5, 'AI & Machine Learning', 'ai-machine-learning', 'active'); ``` --- ## Data Flow Diagram with Sample Data ### Sample Data Setup **Plans:** ``` ID | Name | Slug | Price | Credits | Max Sites | Max Users 1 | Free Trial | free | 0.00 | 1000 | 1 | 1 2 | Starter | starter | 29.00 | 5000 | 3 | 3 3 | Growth | growth | 79.00 | 15000 | 10 | 10 4 | Scale | scale | 199.00| 50000 | 30 | 30 ``` **Industries:** ``` ID | Name | Slug 1 | Healthcare | healthcare 2 | Technology | technology 3 | Finance | finance ``` **Industry Sectors (for Technology):** ``` ID | Industry ID | Name | Slug 4 | 2 | Web Development | web-development 5 | 2 | AI & Machine Learning | ai-machine-learning 6 | 2 | Cybersecurity | cybersecurity ``` ### Complete Data Flow Example **Scenario:** New user "John Doe" signs up for free trial, creates a tech blog, and adds 2 sectors. ``` ┌─────────────────────────────────────────────────────────────────┐ │ STEP 1: User Registration (Free Trial) │ └─────────────────────────────────────────────────────────────────┘ Request: POST /api/v1/auth/register/ { "email": "john@techblog.com", "password": "SecurePass123!", "account_name": "Tech Blog LLC", "plan_slug": "free" } Database Changes: ┌──────────────────────────────────────────────────────────────┐ │ Table: igny8_tenants (Accounts) │ ├────┬────────────────┬──────────────┬─────┬────────┬─────────┤ │ ID │ Name │ Slug │ Plan│ Credits│ Status │ ├────┼────────────────┼──────────────┼─────┼────────┼─────────┤ │ 1 │ Tech Blog LLC │ tech-blog-llc│ 1 │ 1000 │ trial │ └────┴────────────────┴──────────────┴─────┴────────┴─────────┘ ┌──────────────────────────────────────────────────────────────┐ │ Table: igny8_users │ ├────┬────────────────────┬───────────┬────────┬──────────────┤ │ ID │ Email │ Username │ Role │ Account (FK) │ ├────┼────────────────────┼───────────┼────────┼──────────────┤ │ 1 │ john@techblog.com │ john-doe │ owner │ 1 │ └────┴────────────────────┴───────────┴────────┴──────────────┘ ┌──────────────────────────────────────────────────────────────┐ │ Table: igny8_credit_transactions │ ├────┬────────┬──────────────┬────────┬──────────────────────┤ │ ID │Account │ Type │ Amount │ Balance After │ ├────┼────────┼──────────────┼────────┼──────────────────────┤ │ 1 │ 1 │ subscription │ 1000 │ 1000 │ └────┴────────┴──────────────┴────────┴──────────────────────┘ ┌─────────────────────────────────────────────────────────────────┐ │ STEP 2: Site Creation │ └─────────────────────────────────────────────────────────────────┘ Request: POST /api/v1/auth/sites/ { "name": "Tech Insights", "domain": "https://techinsights.com", "industry": 2, "description": "Technology news and tutorials" } Validation: ✓ Account has 0 sites < max_sites (1) → ALLOWED ✓ Industry ID 2 (Technology) exists ✓ Domain formatted with https:// Database Changes: ┌──────────────────────────────────────────────────────────────────────┐ │ Table: igny8_sites │ ├────┬────────┬───────────────┬──────────────────────────┬─────────────┤ │ ID │Account │ Name │ Domain │ Industry(FK)│ ├────┼────────┼───────────────┼──────────────────────────┼─────────────┤ │ 1 │ 1 │ Tech Insights │ https://techinsights.com │ 2 │ └────┴────────┴───────────────┴──────────────────────────┴─────────────┘ ┌─────────────────────────────────────────────────────────────────┐ │ STEP 3: Sector Creation (Sector 1) │ └─────────────────────────────────────────────────────────────────┘ Request: POST /api/v1/auth/sectors/ { "site": 1, "name": "Web Development", "industry_sector": 4 } Validation: ✓ Site ID 1 belongs to Account 1 ✓ Site has 0 sectors < max_sectors (5) → ALLOWED ✓ IndustrySector ID 4 belongs to Industry 2 (Technology) ✓ Database Changes: ┌──────────────────────────────────────────────────────────────────┐ │ Table: igny8_sectors │ ├────┬────────┬──────┬──────────────────┬───────────────────────┤ │ ID │Account │ Site │ Name │ IndustrySector (FK) │ ├────┼────────┼──────┼──────────────────┼───────────────────────┤ │ 1 │ 1 │ 1 │ Web Development │ 4 │ └────┴────────┴──────┴──────────────────┴───────────────────────┘ ┌─────────────────────────────────────────────────────────────────┐ │ STEP 4: Sector Creation (Sector 2) │ └─────────────────────────────────────────────────────────────────┘ Request: POST /api/v1/auth/sectors/ { "site": 1, "name": "Artificial Intelligence", "industry_sector": 5 } Validation: ✓ Site ID 1 belongs to Account 1 ✓ Site has 1 sector < max_sectors (5) → ALLOWED ✓ IndustrySector ID 5 belongs to Industry 2 (Technology) ✓ Database Changes: ┌──────────────────────────────────────────────────────────────────┐ │ Table: igny8_sectors │ ├────┬────────┬──────┬────────────────────────┬──────────────────┤ │ ID │Account │ Site │ Name │ IndustrySector(FK)│ ├────┼────────┼──────┼────────────────────────┼──────────────────┤ │ 1 │ 1 │ 1 │ Web Development │ 4 │ │ 2 │ 1 │ 1 │ Artificial Intelligence│ 5 │ └────┴────────┴──────┴────────────────────────┴──────────────────┘ ┌─────────────────────────────────────────────────────────────────┐ │ FINAL STATE: Complete Tenancy Structure │ └─────────────────────────────────────────────────────────────────┘ Account: Tech Blog LLC (ID: 1) ├── Plan: Free Trial ├── Credits: 1000 ├── Status: trial │ ├── Users (1/1 max): │ └── john@techblog.com (owner) │ └── Sites (1/1 max): └── Tech Insights (ID: 1) ├── Industry: Technology ├── Domain: https://techinsights.com │ └── Sectors (2/5 max): ├── Web Development └── Artificial Intelligence ``` --- ## Model Details & Fields ### Account Model **Location:** `backend/igny8_core/auth/models.py` **Table:** `igny8_tenants` **Inheritance:** `SoftDeletableModel` | Field | Type | Purpose | Notes | |-------|------|---------|-------| | `id` | AutoField | Primary key | Auto-increment | | `name` | CharField(255) | Display name | "Tech Blog LLC" | | `slug` | SlugField(255) | URL-safe identifier | Unique, auto-generated | | `owner` | FK(User) | Account owner | Nullable, SET_NULL on delete | | `plan` | FK(Plan) | Subscription tier | PROTECT on delete | | `credits` | IntegerField | Available credits | Default: 0, Min: 0 | | `status` | CharField(20) | Account state | trial/active/suspended/cancelled | | `payment_method` | CharField(30) | Default payment | stripe/paypal/bank_transfer | | `stripe_customer_id` | CharField(255) | Stripe reference | Nullable | | `billing_email` | EmailField | Billing contact | Nullable | | `billing_address_line1` | CharField(255) | Street address | Blank allowed | | `billing_address_line2` | CharField(255) | Apt/Suite | Blank allowed | | `billing_city` | CharField(100) | City | Blank allowed | | `billing_state` | CharField(100) | State/Province | Blank allowed | | `billing_postal_code` | CharField(20) | ZIP/Postal code | Blank allowed | | `billing_country` | CharField(2) | ISO country code | Blank allowed | | `tax_id` | CharField(100) | VAT/Tax ID | Blank allowed | | `deletion_retention_days` | PositiveIntegerField | Soft delete retention | Default: 14, Range: 1-365 | | `is_deleted` | BooleanField | Soft delete flag | Default: False, indexed | | `deleted_at` | DateTimeField | Deletion timestamp | Nullable, indexed | | `deleted_by` | FK(User) | Who deleted | Nullable, SET_NULL | | `delete_reason` | CharField(255) | Why deleted | Nullable | | `restore_until` | DateTimeField | Restore deadline | Nullable, indexed | | `created_at` | DateTimeField | Creation time | Auto-set | | `updated_at` | DateTimeField | Last update | Auto-updated | **Key Methods:** - `is_system_account()` - Check if slug in ['aws-admin', 'default-account'] - `soft_delete(user, reason, retention_days)` - Soft delete with retention - `__str__()` - Returns `name` **Indexes:** - `slug` (unique) - `status` --- ### Plan Model **Location:** `backend/igny8_core/auth/models.py` **Table:** `igny8_plans` | Field | Type | Purpose | Notes | |-------|------|---------|-------| | `id` | AutoField | Primary key | | | `name` | CharField(255) | Display name | "Free Trial" | | `slug` | SlugField(255) | URL identifier | Unique | | `price` | DecimalField(10,2) | Monthly price | USD | | `billing_cycle` | CharField(20) | Cycle type | monthly/annual | | `annual_discount_percent` | DecimalField(5,2) | Annual discount | Default: 15.00, Range: 0-100 | | `is_featured` | BooleanField | Highlight plan | Default: False | | `features` | JSONField | Feature list | Array of strings | | `is_active` | BooleanField | Available | Default: True | | `is_internal` | BooleanField | Hidden plan | Default: False | | `max_users` | IntegerField | User limit | Default: 1, Min: 1 | | `max_sites` | IntegerField | Site limit | Default: 1, Min: 1 | | `max_industries` | IntegerField | Sector limit | Nullable, Min: 1 | | `max_author_profiles` | IntegerField | Author profiles | Default: 5, Min: 0 | | `included_credits` | IntegerField | Monthly credits | Default: 0, Min: 0 | | `extra_credit_price` | DecimalField(10,2) | Per-credit cost | Default: 0.01 | | `allow_credit_topup` | BooleanField | Can buy credits | Default: True | | `auto_credit_topup_threshold` | IntegerField | Auto-buy trigger | Nullable, Min: 0 | | `auto_credit_topup_amount` | IntegerField | Auto-buy amount | Nullable, Min: 1 | | `stripe_product_id` | CharField(255) | Stripe product | Nullable | | `stripe_price_id` | CharField(255) | Stripe price | Nullable | | `credits_per_month` | IntegerField | DEPRECATED | Use included_credits | | `created_at` | DateTimeField | Creation time | Auto-set | **Key Methods:** - `get_effective_credits_per_month()` - Returns `included_credits` or `credits_per_month` - `clean()` - Validates max_sites >= 1, included_credits >= 0 --- ### User Model **Location:** `backend/igny8_core/auth/models.py` **Table:** `igny8_users` **Inheritance:** `AbstractUser` | Field | Type | Purpose | Notes | |-------|------|---------|-------| | `id` | AutoField | Primary key | | | `email` | EmailField | Login email | Unique | | `username` | CharField(150) | Username | Auto-generated from email | | `password` | CharField(128) | Hashed password | Django auth | | `first_name` | CharField(150) | First name | Optional | | `last_name` | CharField(150) | Last name | Optional | | `account` | FK(Account) | Tenant | Nullable, CASCADE, db_column='tenant_id' | | `role` | CharField(20) | Permission level | developer/owner/admin/editor/viewer | | `is_active` | BooleanField | Account enabled | Default: True | | `is_staff` | BooleanField | Django admin | Default: False | | `is_superuser` | BooleanField | All permissions | Default: False | | `created_at` | DateTimeField | Join date | Auto-set | | `updated_at` | DateTimeField | Last update | Auto-updated | **USERNAME_FIELD:** `email` **REQUIRED_FIELDS:** `username` **Key Methods:** - `has_role(*roles)` - Check if user has any of specified roles - `is_owner_or_admin()` - Returns `role in ['owner', 'admin']` - `is_developer()` - Returns `role == 'developer'` **Indexes:** - `account` + `role` - `email` (unique) --- ### Site Model **Location:** `backend/igny8_core/auth/models.py` **Table:** `igny8_sites` **Inheritance:** `SoftDeletableModel`, `AccountBaseModel` | Field | Type | Purpose | Notes | |-------|------|---------|-------| | `id` | AutoField | Primary key | | | `account` | FK(Account) | Owner tenant | CASCADE, db_column='tenant_id', indexed | | `name` | CharField(255) | Site name | "Tech Insights" | | `slug` | SlugField(255) | URL identifier | Unique per account | | `domain` | URLField | Site URL | Nullable, https:// enforced | | `description` | TextField | Site purpose | Nullable | | `industry` | FK(Industry) | Industry category | PROTECT, nullable | | `is_active` | BooleanField | Enabled | Default: True, indexed | | `status` | CharField(20) | Site state | active/inactive/suspended | | `wp_url` | URLField | WordPress URL | LEGACY - use SiteIntegration | | `wp_username` | CharField(255) | WP user | LEGACY | | `wp_app_password` | CharField(255) | WP password | LEGACY | | `wp_api_key` | CharField(255) | WP Bridge API key | LEGACY | | `site_type` | CharField(50) | Site category | marketing/ecommerce/blog/portfolio/corporate | | `hosting_type` | CharField(50) | Platform | igny8_sites/wordpress/shopify/multi | | `seo_metadata` | JSONField | SEO data | Default: {} | | `is_deleted` | BooleanField | Soft delete | Default: False, indexed | | `deleted_at` | DateTimeField | Delete time | Nullable, indexed | | `deleted_by` | FK(User) | Deleter | Nullable, SET_NULL | | `delete_reason` | CharField(255) | Delete reason | Nullable | | `restore_until` | DateTimeField | Restore deadline | Nullable, indexed | | `created_at` | DateTimeField | Creation time | Auto-set | | `updated_at` | DateTimeField | Last update | Auto-updated | **Key Methods:** - `get_active_sectors_count()` - Count active sectors - `get_max_sectors_limit()` - Returns `account.plan.max_industries` or 5 - `can_add_sector()` - Check if under sector limit **Unique Together:** `account` + `slug` **Indexes:** - `account` + `is_active` - `account` + `status` - `industry` - `site_type` - `hosting_type` --- ### Sector Model **Location:** `backend/igny8_core/auth/models.py` **Table:** `igny8_sectors` **Inheritance:** `SoftDeletableModel`, `AccountBaseModel` | Field | Type | Purpose | Notes | |-------|------|---------|-------| | `id` | AutoField | Primary key | | | `account` | FK(Account) | Owner tenant | CASCADE, db_column='tenant_id', auto-set from site | | `site` | FK(Site) | Parent site | CASCADE, indexed | | `industry_sector` | FK(IndustrySector) | Sector template | PROTECT, nullable | | `name` | CharField(255) | Sector name | "Web Development" | | `slug` | SlugField(255) | URL identifier | Unique per site | | `description` | TextField | Purpose | Nullable | | `is_active` | BooleanField | Enabled | Default: True, indexed | | `status` | CharField(20) | State | active/inactive | | `is_deleted` | BooleanField | Soft delete | Default: False, indexed | | `deleted_at` | DateTimeField | Delete time | Nullable, indexed | | `deleted_by` | FK(User) | Deleter | Nullable, SET_NULL | | `restore_until` | DateTimeField | Restore deadline | Nullable, indexed | | `created_at` | DateTimeField | Creation time | Auto-set | | `updated_at` | DateTimeField | Last update | Auto-updated | **Key Properties:** - `industry` (property) - Returns `industry_sector.industry` if set **Key Methods:** - `save()` - Auto-sets `account` from `site`, validates industry match, enforces sector limit **Unique Together:** `site` + `slug` **Indexes:** - `site` + `is_active` - `account` + `site` - `industry_sector` --- ### Subscription Model (auth.models - LEGACY) **Location:** `backend/igny8_core/auth/models.py` **Table:** `igny8_subscriptions` | Field | Type | Purpose | Notes | |-------|------|---------|-------| | `id` | AutoField | Primary key | | | `account` | OneToOneFK(Account) | Owner tenant | CASCADE, db_column='tenant_id' | | `stripe_subscription_id` | CharField(255) | Stripe sub ID | Nullable, indexed | | `payment_method` | CharField(30) | Payment type | stripe/paypal/bank_transfer | | `external_payment_id` | CharField(255) | External ref | Nullable | | `status` | CharField(20) | Sub state | active/past_due/canceled/trialing/pending_payment | | `current_period_start` | DateTimeField | Billing start | Required | | `current_period_end` | DateTimeField | Billing end | Required | | `cancel_at_period_end` | BooleanField | Cancel scheduled | Default: False | | `created_at` | DateTimeField | Creation time | Auto-set | | `updated_at` | DateTimeField | Last update | Auto-updated | **Indexes:** - `status` **⚠️ ISSUE:** This model exists in `auth` app but new billing flow references `billing.Subscription` which doesn't exist! --- ### Invoice Model **Location:** `backend/igny8_core/business/billing/models.py` **Table:** `igny8_invoices` **Inheritance:** `AccountBaseModel` | Field | Type | Purpose | Notes | |-------|------|---------|-------| | `id` | AutoField | Primary key | | | `account` | FK(Account) | Owner tenant | CASCADE, db_column='tenant_id' | | `invoice_number` | CharField(50) | Invoice ID | Unique, indexed | | `subscription` | FK(Subscription) | Related sub | SET_NULL, nullable | | `subtotal` | DecimalField(10,2) | Pre-tax amount | Default: 0 | | `tax` | DecimalField(10,2) | Tax amount | Default: 0 | | `total` | DecimalField(10,2) | Total amount | Default: 0 | | `currency` | CharField(3) | Currency code | Default: USD | | `status` | CharField(20) | Invoice state | draft/pending/paid/void/uncollectible | | `invoice_date` | DateField | Issue date | Indexed | | `due_date` | DateField | Payment due | Required | | `paid_at` | DateTimeField | Payment time | Nullable | | `line_items` | JSONField | Item details | Array of {description, amount, quantity} | | `stripe_invoice_id` | CharField(255) | Stripe ref | Nullable | | `payment_method` | CharField(50) | Payment type | Nullable | | `billing_email` | EmailField | Contact email | Nullable | | `billing_period_start` | DateTimeField | Period start | Nullable | | `billing_period_end` | DateTimeField | Period end | Nullable | | `notes` | TextField | Admin notes | Blank allowed | | `metadata` | JSONField | Extra data | Default: {} | | `created_at` | DateTimeField | Creation time | Auto-set | | `updated_at` | DateTimeField | Last update | Auto-updated | **Key Methods:** - `add_line_item(description, quantity, unit_price, amount)` - Append item to JSON - `calculate_totals()` - Recompute subtotal/total from line_items - Properties: `subtotal_amount`, `tax_amount`, `total_amount` (legacy aliases) **Indexes:** - `account` + `status` - `account` + `invoice_date` - `invoice_number` (unique) **⚠️ ISSUE:** References `Subscription` FK but points to `auth.Subscription`, not `billing.Subscription`! --- ### CreditTransaction Model **Location:** `backend/igny8_core/business/billing/models.py` **Table:** `igny8_credit_transactions` **Inheritance:** `AccountBaseModel` | Field | Type | Purpose | Notes | |-------|------|---------|-------| | `id` | AutoField | Primary key | | | `account` | FK(Account) | Owner tenant | CASCADE, db_column='tenant_id' | | `transaction_type` | CharField(20) | Transaction category | purchase/subscription/refund/deduction/adjustment | | `amount` | IntegerField | Credit change | Positive=add, Negative=subtract | | `balance_after` | IntegerField | New balance | After transaction | | `description` | CharField(255) | Human description | "Free plan credits" | | `metadata` | JSONField | Extra context | Default: {} | | `reference_id` | CharField(255) | External ref | Blank allowed | | `created_at` | DateTimeField | Transaction time | Auto-set | **Indexes:** - `account` + `transaction_type` - `account` + `created_at` --- ## Architectural Gaps & Issues ### 🔴 CRITICAL: Duplicate Subscription Models **Problem:** - `Subscription` model exists in **TWO locations**: 1. `auth.models.Subscription` - LEGACY model (currently in use) 2. `billing.Subscription` - Referenced in code but **DOESN'T EXIST** **Evidence:** ```python # In auth/serializers.py RegisterSerializer.create() from igny8_core.business.billing.models import Subscription # ❌ DOESN'T EXIST subscription = Subscription.objects.create(...) # FAILS ``` **Current State:** - Database table: `igny8_subscriptions` (from `auth.Subscription`) - Code references: Mix of `auth.Subscription` and `billing.Subscription` - Invoice FK: Points to `igny8_core_auth.Subscription` **Impact:** - Registration fails for paid plans - Invoice creation broken - Subscription queries inconsistent **Solution:** 1. **Option A (Recommended):** Migrate to single `billing.Subscription` model - Create `billing/models.py::Subscription` with all fields from `auth.Subscription` - Add migration to point Invoice FK to new model - Update all imports to use `billing.Subscription` - Deprecate `auth.Subscription` 2. **Option B:** Stick with `auth.Subscription` - Update all imports in billing code to use `auth.Subscription` - Remove references to non-existent `billing.Subscription` --- ### 🔴 CRITICAL: Account.owner Circular Dependency **Problem:** - `Account.owner` → FK(User) - `User.account` → FK(Account) - **Chicken-and-egg during registration** **Current Workaround:** ```python # Step 1: Create User WITHOUT account user = User.objects.create_user(account=None) # Step 2: Create Account WITH user as owner account = Account.objects.create(owner=user) # Step 3: Update User to link back to Account user.account = account user.save() ``` **Issues:** - User exists briefly without account (breaks middleware assumptions) - Three database writes for one logical operation - Race condition if another request hits between steps 1-3 **Solution:** - Make `Account.owner` nullable with `SET_NULL` on delete (already done ✓) - OR: Remove `Account.owner` field entirely, derive from `User.objects.filter(account=account, role='owner').first()` --- ### 🟡 MEDIUM: Duplicate tenant_id Column Name **Problem:** - All `AccountBaseModel` children use `db_column='tenant_id'` - Field name in Django: `account` - Field name in DB: `tenant_id` - **Confusing when writing raw SQL or debugging** **Evidence:** ```python class AccountBaseModel(models.Model): account = models.ForeignKey( 'igny8_core_auth.Account', db_column='tenant_id' # ← DB column name ) ``` **Impact:** - `SELECT * FROM igny8_sites WHERE account=1` ❌ (SQL fails) - Must use: `SELECT * FROM igny8_sites WHERE tenant_id=1` ✓ - ORM queries work fine but raw SQL doesn't **Solution:** - **Option A:** Rename DB column to `account_id` (requires migration, breaks existing data) - **Option B:** Keep as-is, document clearly (current approach) --- ### 🟡 MEDIUM: Sector Limit Inconsistency **Problem:** - Plan has `max_industries` field for sector limits - Field name implies "number of industries" not "number of sectors per site" - Default sector limit hardcoded as 5 in `Site.get_max_sectors_limit()` **Evidence:** ```python def get_max_sectors_limit(self): if self.account.plan.max_industries is not None: return self.account.plan.max_industries return 5 # Hardcoded fallback ``` **Impact:** - Field name misleading (sectors ≠ industries) - Free plan has `max_industries=NULL` → defaults to 5 sectors - No way to configure "unlimited sectors" **Solution:** - Rename `Plan.max_industries` → `Plan.max_sectors_per_site` (migration required) - Use `max_sectors_per_site=0` to mean unlimited - Update validation logic to handle 0 = unlimited --- ### 🟡 MEDIUM: Billing Fields Duplication **Problem:** - Billing address fields exist in **TWO places**: 1. `Account` model (billing_email, billing_address_line1, etc.) 2. `Invoice` model (billing_email) **Current State:** ```python # Account model billing_email = models.EmailField(...) billing_address_line1 = models.CharField(...) billing_city = models.CharField(...) # ... 7 billing fields # Invoice model billing_email = models.EmailField(...) # DUPLICATE ``` **Impact:** - Data can become inconsistent - Which is source of truth? - Invoice should snapshot billing info at time of invoice creation **Solution:** - Keep billing fields on `Account` as primary source - Invoice snapshots should store full billing data in `metadata` JSON: ```json { "billing_snapshot": { "email": "john@example.com", "address": {...} } } ``` - Remove `Invoice.billing_email` field --- ### 🟡 MEDIUM: No Subscription Model in billing App **Problem:** - `billing` app has Invoice, Payment, CreditTransaction models - Missing central `Subscription` model (exists in `auth` instead) - Subscription should be in billing domain **Impact:** - Billing logic split across two apps - `Invoice.subscription` FK points to different app - Harder to maintain billing as cohesive module **Solution:** - Create `billing.models.Subscription` with fields: - `account` (OneToOne) - `plan` (FK to auth.Plan) - `status` (active/past_due/canceled) - `current_period_start/end` - `payment_method` - `stripe_subscription_id` - Migrate data from `auth.Subscription` → `billing.Subscription` - Update Invoice FK --- ### 🟡 MEDIUM: Site Industry Should Not Be Nullable **Problem:** - `Site.industry` is nullable but required for sector creation - Sectors validate `industry_sector.industry == site.industry` - **If site.industry is NULL, sector creation always fails** **Evidence:** ```python # In Sector.save() if self.industry_sector.industry != self.site.industry: raise ValidationError("Sector must belong to site's industry") ``` **Impact:** - Sites created without industry can never have sectors - Confusing UX: "Why can't I add sectors?" **Solution:** - Make `Site.industry` required (remove `null=True, blank=True`) - Require industry selection during site creation - Migration: Set default industry for existing sites with NULL --- ### 🟡 MEDIUM: Credits Auto-Update Missing **Problem:** - `Account.credits` is manually updated - No automatic credit refresh on subscription renewal - No automatic credit deduction tracking **Current State:** ```python # Manual credit update account.credits += 1000 account.save() # CreditTransaction created separately CreditTransaction.objects.create( account=account, amount=1000, balance_after=account.credits ) ``` **Impact:** - Easy to forget credit transaction logging - Balance can become inconsistent - No single source of truth **Solution:** - Create `CreditService` with methods: - `add_credits(account, amount, description, metadata)` - `deduct_credits(account, amount, description, metadata)` - Service handles BOTH account update AND transaction logging atomically - All credit changes must go through service --- ### 🟢 LOW: Legacy WordPress Fields on Site **Problem:** - Site model has 4 WordPress-specific fields: - `wp_url`, `wp_username`, `wp_app_password`, `wp_api_key` - New `SiteIntegration` model exists for integrations - **Duplication of WordPress config** **Impact:** - Two places to store same data - Code uses legacy fields, SiteIntegration ignored - Can't support multiple integrations per site **Solution:** - Mark legacy WP fields as deprecated - Migrate existing WP configs to `SiteIntegration` records - Update code to use `SiteIntegration` model - Remove legacy fields in future migration --- ### 🟢 LOW: Plan.credits_per_month Deprecated Field **Problem:** - Plan has both `credits_per_month` (old) and `included_credits` (new) - `get_effective_credits_per_month()` checks both - **Confusing which field to use** **Current Code:** ```python def get_effective_credits_per_month(self): return self.included_credits if self.included_credits > 0 else self.credits_per_month ``` **Impact:** - Admin UI shows both fields - Risk of setting wrong field - Database stores redundant data **Solution:** - Data migration: Copy `credits_per_month` → `included_credits` where needed - Remove `credits_per_month` field - Update method to just return `included_credits` --- ### 🟢 LOW: SiteUserAccess Not Used **Problem:** - `SiteUserAccess` model exists for granular site permissions - **Never created during registration or site setup** - Code assumes owners/admins have access to all sites **Current State:** ```python # Registration creates: - User (role='owner') - Account - Site # MISSING: SiteUserAccess record linking User ↔ Site ``` **Impact:** - Granular permissions not enforced - Role-based access only (no site-level restrictions) - Model exists but unused **Solution:** - Create `SiteUserAccess` during site creation for owner - Create during user invitation for specific sites - Update ViewSets to filter by `SiteUserAccess` for non-owner roles --- ### 🟢 LOW: No Automatic Slug Generation **Problem:** - Slugs manually generated from names with counters - Logic duplicated in multiple serializers - No utility function **Evidence:** ```python # In RegisterSerializer base_slug = account_name.lower().replace(' ', '-')[:50] slug = base_slug counter = 1 while Account.objects.filter(slug=slug).exists(): slug = f"{base_slug}-{counter}" counter += 1 ``` **Impact:** - Duplicate code in Account, Site, Sector serializers - Inconsistent slug generation logic - Hard to maintain **Solution:** - Create utility: `generate_unique_slug(model, name_field, base_name)` - Use in all serializers - Handle edge cases (special chars, max length) consistently --- ## Summary of Issues ### By Severity **🔴 CRITICAL (Fix Immediately):** 1. Duplicate Subscription models - registration broken 2. Account.owner circular dependency - race conditions **🟡 MEDIUM (Fix Soon):** 3. tenant_id vs account field naming confusion 4. Sector limit field misnamed (max_industries) 5. Billing fields duplicated (Account + Invoice) 6. No Subscription in billing app 7. Site.industry should be required 8. No automatic credit updates **🟢 LOW (Technical Debt):** 9. Legacy WordPress fields unused 10. Plan.credits_per_month deprecated 11. SiteUserAccess never created 12. No slug generation utility --- ## Recommendations ### Immediate Actions (Critical Fixes) #### 1. Fix Subscription Model Duplication **Steps:** ```python # backend/igny8_core/business/billing/models.py class Subscription(AccountBaseModel): """Account subscription - moved from auth app""" plan = models.ForeignKey('igny8_core_auth.Plan', on_delete=models.PROTECT) status = models.CharField(max_length=20, choices=STATUS_CHOICES) payment_method = models.CharField(max_length=30) stripe_subscription_id = models.CharField(max_length=255, null=True, blank=True) external_payment_id = models.CharField(max_length=255, null=True, blank=True) current_period_start = models.DateTimeField() current_period_end = models.DateTimeField() cancel_at_period_end = models.BooleanField(default=False) class Meta: app_label = 'billing' db_table = 'igny8_subscriptions' # Reuse existing table ``` **Migration:** ```python # Create migration to change Invoice.subscription FK # Point to billing.Subscription instead of auth.Subscription # No data migration needed - table name stays same ``` **Update Imports:** ```python # Update everywhere: from igny8_core.business.billing.models import Subscription # Instead of: from igny8_core.auth.models import Subscription ``` --- #### 2. Resolve Account.owner Circular Dependency **Recommended Approach:** Make `Account.owner` derivable from User role instead of storing FK. **Changes:** ```python # Account model class Account(SoftDeletableModel): # REMOVE: owner = models.ForeignKey(...) @property def owner(self): """Get account owner from users""" return self.users.filter(role='owner').first() def set_owner(self, user): """Make user the account owner""" # Remove owner role from previous owner self.users.filter(role='owner').update(role='admin') # Set new owner user.role = 'owner' user.save() ``` **Registration Flow (Simplified):** ```python # Single transaction, no circular dependency with transaction.atomic(): account = Account.objects.create( name=account_name, slug=slug, plan=plan, credits=initial_credits ) user = User.objects.create_user( account=account, role='owner', # This makes them the owner ... ) ``` --- ### Short-Term Improvements (Medium Priority) #### 3. Rename Plan.max_industries → max_sectors_per_site **Migration:** ```python migrations.RenameField( model_name='plan', old_name='max_industries', new_name='max_sectors_per_site', ) ``` **Update Validation:** ```python def get_max_sectors_limit(self): limit = self.account.plan.max_sectors_per_site if limit is None or limit == 0: return float('inf') # Unlimited return limit ``` --- #### 4. Make Site.industry Required **Migration:** ```python # Set default industry for existing sites migrations.RunPython(set_default_industry) # Make field required migrations.AlterField( model_name='site', name='industry', field=models.ForeignKey( 'igny8_core_auth.Industry', on_delete=models.PROTECT, related_name='sites' # Remove: null=True, blank=True ), ) ``` --- #### 5. Create CreditService for Automatic Updates **Implementation:** ```python # backend/igny8_core/business/billing/services/credit_service.py class CreditService: @staticmethod @transaction.atomic def add_credits(account, amount, description, metadata=None): """Add credits and log transaction atomically""" # Update account account.credits += amount account.save() # Log transaction CreditTransaction.objects.create( account=account, transaction_type='purchase', amount=amount, balance_after=account.credits, description=description, metadata=metadata or {} ) return account.credits @staticmethod @transaction.atomic def deduct_credits(account, amount, description, metadata=None): """Deduct credits and log transaction atomically""" if account.credits < amount: raise InsufficientCreditsError( f"Need {amount} credits, have {account.credits}" ) account.credits -= amount account.save() CreditTransaction.objects.create( account=account, transaction_type='deduction', amount=-amount, balance_after=account.credits, description=description, metadata=metadata or {} ) return account.credits ``` **Usage:** ```python # Instead of: account.credits += 1000 account.save() CreditTransaction.objects.create(...) # Use: CreditService.add_credits( account=account, amount=1000, description='Monthly subscription credits', metadata={'plan_slug': 'starter'} ) ``` --- ### Long-Term Refactoring (Technical Debt) #### 6. Create Slug Generation Utility **Implementation:** ```python # backend/igny8_core/common/utils.py def generate_unique_slug(model_class, base_name, filters=None, max_length=50): """ Generate unique slug from base name Args: model_class: Django model to check uniqueness against base_name: Source string for slug filters: Additional Q filters for uniqueness check max_length: Maximum slug length Returns: Unique slug string """ import re from django.utils.text import slugify # Clean and slugify base_slug = slugify(base_name)[:max_length] # Remove special characters base_slug = re.sub(r'[^a-z0-9-]', '', base_slug) # Check uniqueness slug = base_slug counter = 1 while True: query = model_class.objects.filter(slug=slug) if filters: query = query.filter(filters) if not query.exists(): return slug # Append counter suffix = f"-{counter}" available_length = max_length - len(suffix) slug = f"{base_slug[:available_length]}{suffix}" counter += 1 ``` **Usage:** ```python # In serializers from igny8_core.common.utils import generate_unique_slug slug = generate_unique_slug( model_class=Account, base_name=account_name, max_length=50 ) ``` --- #### 7. Migrate WordPress Fields to SiteIntegration **Step 1: Data Migration** ```python def migrate_wp_fields(apps, schema_editor): Site = apps.get_model('igny8_core_auth', 'Site') SiteIntegration = apps.get_model('igny8_core_business_integration', 'SiteIntegration') for site in Site.objects.exclude(wp_url__isnull=True): SiteIntegration.objects.create( account=site.account, site=site, platform='wordpress', api_url=site.wp_url, credentials={ 'username': site.wp_username, 'app_password': site.wp_app_password, 'api_key': site.wp_api_key }, is_active=True ) ``` **Step 2: Remove Legacy Fields** ```python migrations.RemoveField(model_name='site', name='wp_url') migrations.RemoveField(model_name='site', name='wp_username') migrations.RemoveField(model_name='site', name='wp_app_password') migrations.RemoveField(model_name='site', name='wp_api_key') ``` --- #### 8. Auto-Create SiteUserAccess Records **Update Site Creation:** ```python # In SiteViewSet.perform_create() def perform_create(self, serializer): site = serializer.save(account=self.request.account) # Auto-grant access to owner if self.request.user.role in ['owner', 'admin']: SiteUserAccess.objects.create( user=self.request.user, site=site, granted_by=self.request.user ) ``` **Update Registration:** ```python # After site creation in RegisterSerializer if default_site_created: SiteUserAccess.objects.create( user=user, site=default_site, granted_by=user ) ``` --- ### Database Optimization #### Add Missing Indexes ```python # Account model class Meta: indexes = [ models.Index(fields=['slug']), # Already exists models.Index(fields=['status']), # Already exists models.Index(fields=['plan', 'status']), # NEW - for plan filtering models.Index(fields=['created_at']), # NEW - for sorting ] # Site model class Meta: indexes = [ # Existing indexes... models.Index(fields=['account', 'industry']), # NEW - for filtering models.Index(fields=['created_at']), # NEW - for sorting ] # Sector model class Meta: indexes = [ # Existing indexes... models.Index(fields=['created_at']), # NEW - for sorting ] ``` --- ### API Consistency #### Standardize Response Formats **Current Issues:** - Some endpoints return `{data: {...}}`, others return `{...}` directly - Error formats inconsistent - Pagination formats vary **Recommendations:** ```python # Standard success response { "success": true, "data": {...}, "message": "Operation completed successfully" } # Standard error response { "success": false, "error": { "code": "VALIDATION_ERROR", "message": "Site limit reached", "details": {...} } } # Standard pagination { "success": true, "data": [...], "pagination": { "count": 100, "page": 1, "pages": 10, "page_size": 10 } } ``` --- ## Conclusion ### Current State Summary **✅ Working Well:** - Account isolation via `AccountBaseModel` - Plan-based limits enforcement - Credit system with transaction logging - Soft delete functionality - Multi-level hierarchy (Account → Site → Sector) **⚠️ Needs Attention:** - Subscription model duplication (breaks paid signups) - Circular dependency in Account/User relationship - Field naming inconsistencies (tenant_id, max_industries) - Missing automatic credit management - Unused models (SiteUserAccess) **📈 Future Improvements:** - Centralized credit service - Unified billing domain - Better slug generation - Migration to SiteIntegration for all platforms - Comprehensive API response standardization ### Migration Priority **Phase 1 (Week 1):** Fix critical issues - Consolidate Subscription model - Fix Account.owner circular dependency **Phase 2 (Week 2-3):** Medium priority improvements - Rename fields for clarity - Make Site.industry required - Implement CreditService **Phase 3 (Week 4+):** Technical debt cleanup - Migrate WordPress fields - Create slug utility - Implement SiteUserAccess properly - Add missing indexes --- **Document Version:** 1.0 **Last Updated:** December 8, 2025 **Maintainer:** Development Team