Files
igny8/multi-tenancy/TENANCY-WORKFLOW-DOCUMENTATION.md
IGNY8 VPS (Salman) c54db6c2d9 reorg
2025-12-08 20:15:09 +00:00

55 KiB

Multi-Tenancy Workflow Documentation

Complete Lifecycle: Signup → Site Creation
Last Updated: December 8, 2025


Table of Contents

  1. Overview
  2. Core Models & Relationships
  3. Complete Workflow: Signup to Site Creation
  4. Data Flow Diagram with Sample Data
  5. Model Details & Fields
  6. Architectural Gaps & Issues
  7. 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:

{
  "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()):

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

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

{
  "name": "Tech Blog",
  "domain": "https://techblog.com",
  "industry": 2,
  "description": "A technology blog"
}

Backend Flow (SiteViewSet.create()):

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

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

{
  "site": 1,
  "name": "AI & Machine Learning",
  "industry_sector": 5,
  "description": "Articles about AI and ML"
}

Backend Flow (SectorViewSet.create()):

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

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

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

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

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:

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_industriesPlan.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:

# 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:
    {
      "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.Subscriptionbilling.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:

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

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

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

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

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

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

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

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

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

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

migrations.RenameField(
    model_name='plan',
    old_name='max_industries',
    new_name='max_sectors_per_site',
)

Update Validation:

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:

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

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

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

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

# 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

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

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:

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

# 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

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

# 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