1632 lines
55 KiB
Markdown
1632 lines
55 KiB
Markdown
# 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
|
|
|