55 KiB
Multi-Tenancy Workflow Documentation
Complete Lifecycle: Signup → Site Creation
Last Updated: December 8, 2025
Table of Contents
- Overview
- Core Models & Relationships
- Complete Workflow: Signup to Site Creation
- Data Flow Diagram with Sample Data
- Model Details & Fields
- Architectural Gaps & Issues
- 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 astenant_idin 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
authapp + new model inbillingapp
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__()- Returnsname
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()- Returnsincluded_creditsorcredits_per_monthclean()- 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 rolesis_owner_or_admin()- Returnsrole in ['owner', 'admin']is_developer()- Returnsrole == 'developer'
Indexes:
account+roleemail(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 sectorsget_max_sectors_limit()- Returnsaccount.plan.max_industriesor 5can_add_sector()- Check if under sector limit
Unique Together: account + slug
Indexes:
account+is_activeaccount+statusindustrysite_typehosting_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) - Returnsindustry_sector.industryif set
Key Methods:
save()- Auto-setsaccountfromsite, validates industry match, enforces sector limit
Unique Together: site + slug
Indexes:
site+is_activeaccount+siteindustry_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 JSONcalculate_totals()- Recompute subtotal/total from line_items- Properties:
subtotal_amount,tax_amount,total_amount(legacy aliases)
Indexes:
account+statusaccount+invoice_dateinvoice_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_typeaccount+created_at
Architectural Gaps & Issues
🔴 CRITICAL: Duplicate Subscription Models
Problem:
Subscriptionmodel exists in TWO locations:auth.models.Subscription- LEGACY model (currently in use)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(fromauth.Subscription) - Code references: Mix of
auth.Subscriptionandbilling.Subscription - Invoice FK: Points to
igny8_core_auth.Subscription
Impact:
- Registration fails for paid plans
- Invoice creation broken
- Subscription queries inconsistent
Solution:
-
Option A (Recommended): Migrate to single
billing.Subscriptionmodel- Create
billing/models.py::Subscriptionwith all fields fromauth.Subscription - Add migration to point Invoice FK to new model
- Update all imports to use
billing.Subscription - Deprecate
auth.Subscription
- Create
-
Option B: Stick with
auth.Subscription- Update all imports in billing code to use
auth.Subscription - Remove references to non-existent
billing.Subscription
- Update all imports in billing code to use
🔴 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.ownernullable withSET_NULLon delete (already done ✓) - OR: Remove
Account.ownerfield entirely, derive fromUser.objects.filter(account=account, role='owner').first()
🟡 MEDIUM: Duplicate tenant_id Column Name
Problem:
- All
AccountBaseModelchildren usedb_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_industriesfield 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_industries→Plan.max_sectors_per_site(migration required) - Use
max_sectors_per_site=0to mean unlimited - Update validation logic to handle 0 = unlimited
🟡 MEDIUM: Billing Fields Duplication
Problem:
- Billing address fields exist in TWO places:
Accountmodel (billing_email, billing_address_line1, etc.)Invoicemodel (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
Accountas primary source - Invoice snapshots should store full billing data in
metadataJSON:{ "billing_snapshot": { "email": "john@example.com", "address": {...} } } - Remove
Invoice.billing_emailfield
🟡 MEDIUM: No Subscription Model in billing App
Problem:
billingapp has Invoice, Payment, CreditTransaction models- Missing central
Subscriptionmodel (exists inauthinstead) - Subscription should be in billing domain
Impact:
- Billing logic split across two apps
Invoice.subscriptionFK points to different app- Harder to maintain billing as cohesive module
Solution:
- Create
billing.models.Subscriptionwith fields:account(OneToOne)plan(FK to auth.Plan)status(active/past_due/canceled)current_period_start/endpayment_methodstripe_subscription_id
- Migrate data from
auth.Subscription→billing.Subscription - Update Invoice FK
🟡 MEDIUM: Site Industry Should Not Be Nullable
Problem:
Site.industryis 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.industryrequired (removenull=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.creditsis 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
CreditServicewith 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
SiteIntegrationmodel 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
SiteIntegrationrecords - Update code to use
SiteIntegrationmodel - Remove legacy fields in future migration
🟢 LOW: Plan.credits_per_month Deprecated Field
Problem:
- Plan has both
credits_per_month(old) andincluded_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_month→included_creditswhere needed - Remove
credits_per_monthfield - Update method to just return
included_credits
🟢 LOW: SiteUserAccess Not Used
Problem:
SiteUserAccessmodel 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
SiteUserAccessduring site creation for owner - Create during user invitation for specific sites
- Update ViewSets to filter by
SiteUserAccessfor 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):
- Duplicate Subscription models - registration broken
- 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