Files
igny8/docs/multi-tenancy/TENANCY-IMPLEMENTATION-GUIDE.md
IGNY8 VPS (Salman) 4dd129b863 asda
2025-12-09 13:27:29 +00:00

1585 lines
50 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Multi-Tenancy Implementation Guide
**IGNY8 Platform - Complete Technical Documentation**
**Last Updated:** December 9, 2024
**Status:** ✅ Production Ready
---
## Table of Contents
1. [Architecture Overview](#1-architecture-overview)
2. [Core Models & Relationships](#2-core-models--relationships)
3. [Authentication System](#3-authentication-system)
4. [Payment Processing](#4-payment-processing)
5. [Multi-Currency System](#5-multi-currency-system)
6. [Site & Sector Management](#6-site--sector-management)
7. [Credit System](#7-credit-system)
8. [Admin Workflows](#8-admin-workflows)
9. [API Endpoints](#9-api-endpoints)
10. [Security & Permissions](#10-security--permissions)
---
## 1. Architecture Overview
### 1.1 Multi-Tenancy Model
**Hierarchy:**
```
Account (Tenant)
├─ Owner (User with role='owner')
├─ Plan (Subscription Tier)
├─ Payment Methods
├─ Billing Information
├─ Credit Balance
├─ Sites (1 to max_sites)
│ └─ Sectors (up to 5 per site)
├─ Users (1 to max_users)
└─ Subscription (1:1 relationship)
├─ Current Period
└─ Invoices (many)
└─ Payments (many)
```
### 1.2 Data Isolation Strategy
**Database Level:**
- All tenant-scoped models inherit from `AccountBaseModel`
- Database column: `tenant_id` (foreign key to accounts table)
- Automatic filtering via Django ORM queryset methods
- No cross-tenant data access possible
**Middleware Level:**
- `TenantMiddleware` extracts account from request
- Attaches `request.account` to every request
- Uses JWT token claim `account_id` for identification
- Validates user belongs to account
**Permission Level:**
- `HasTenantAccess` permission class validates account ownership
- `IsAuthenticatedAndActive` ensures user and account are active
- Role-based permissions: developer > owner > admin > editor > viewer
- Superusers and developers bypass tenant isolation
### 1.3 Key Design Principles
**Single Source of Truth:**
- Payment Method: `AccountPaymentMethod` model (not duplicated in Account/Subscription)
- Period Dates: `Subscription` model only (Invoice references via FK)
- Plan Information: `Plan` model with historical tracking in Subscription
- Billing Information: `Account` model with snapshot in Invoice metadata
**Immutability Where Needed:**
- Invoices are immutable once created (status changes only)
- Subscription plan locked for period (new subscription on plan change)
- Credit transactions are append-only (no updates/deletes)
- Payment history preserved (soft delete for data retention)
**Atomic Operations:**
- Account creation wraps user + account in transaction
- Payment approval atomically updates invoice + subscription + account + credits
- Subscription creation creates account + subscription + invoice + payment method
- All database modifications use `transaction.atomic()`
---
## 2. Core Models & Relationships
### 2.1 Account Model
**Location:** `backend/igny8_core/auth/models.py`
**Database Table:** `igny8_tenants`
**Inheritance:** `SoftDeletableModel`
**Primary Fields:**
| Field Category | Fields | Purpose |
|---------------|--------|---------|
| **Identity** | id, name, slug | Unique identification |
| **Relationships** | owner (FK User), plan (FK Plan) | Account ownership and tier |
| **Financial** | credits (Integer), status (CharField) | Available credits and account state |
| **Billing Profile** | billing_email, billing_address_line1/2, billing_city, billing_state, billing_postal_code, billing_country, tax_id | Complete billing information |
| **Payment** | stripe_customer_id | External payment gateway reference |
| **Soft Delete** | is_deleted, deleted_at, deleted_by, delete_reason, restore_until, deletion_retention_days | Soft delete with retention period |
| **Audit** | created_at, updated_at | Timestamp tracking |
**Status Values:**
- `trial` - Free trial period (default for free plan)
- `active` - Paid and operational
- `pending_payment` - Awaiting payment confirmation
- `suspended` - Temporarily disabled
- `cancelled` - Permanently closed
**Business Logic Methods:**
`is_system_account()` → Boolean
- Checks if slug in system accounts: 'aws-admin', 'default-account'
- Used to prevent deletion of critical accounts
- System accounts bypass certain restrictions
`soft_delete(user, reason, retention_days)` → None
- Marks account as deleted without removing from database
- Sets deletion timestamp and restore deadline
- Cascades soft delete to related entities
- Allows restoration within retention period
`restore()` → None
- Reverses soft delete if within retention period
- Clears deletion flags and timestamps
- Restores related entities
**Property Methods:**
`payment_method` → String (Read-only property)
- Returns default payment method from `AccountPaymentMethod`
- Fallback logic: default method → first enabled method → 'stripe'
- Replaces deprecated payment_method field
- Single source of truth pattern
---
### 2.2 Plan Model
**Location:** `backend/igny8_core/auth/models.py`
**Database Table:** `igny8_plans`
**Purpose:** Defines subscription tiers and limits
**Field Categories:**
| Category | Fields | Purpose |
|----------|--------|---------|
| **Identity** | id, name, slug | Plan identification (e.g., "Free Trial", "free") |
| **Pricing** | price, billing_cycle, annual_discount_percent | Monthly/annual pricing structure |
| **Display** | is_featured, features (JSON), is_active, is_internal | UI presentation and visibility |
| **Limits** | max_users, max_sites, max_industries, max_author_profiles | Hard limits per account |
| **Credits** | included_credits, extra_credit_price, allow_credit_topup, auto_credit_topup_threshold, auto_credit_topup_amount | Credit allocation and top-up rules |
| **Gateway** | stripe_product_id, stripe_price_id | Payment gateway integration |
| **Legacy** | credits_per_month (deprecated) | Backward compatibility |
**Business Logic Methods:**
`get_effective_credits_per_month()` → Integer
- Returns `included_credits` (preferred) or falls back to `credits_per_month`
- Used during account creation to allocate initial credits
- Ensures backward compatibility with old plans
`clean()` → None
- Django model validation
- Ensures `max_sites >= 1`
- Ensures `included_credits >= 0`
- Called before save operations
**Standard Plan Configuration:**
| Plan | Slug | Price (USD) | Credits | Sites | Users | Featured |
|------|------|-------------|---------|-------|-------|----------|
| Free Trial | free | 0.00 | 1,000 | 1 | 1 | No |
| Starter | starter | 29.00 | 5,000 | 3 | 3 | No |
| Growth | growth | 79.00 | 15,000 | 10 | 10 | Yes |
| Scale | scale | 199.00 | 50,000 | 30 | 30 | No |
---
### 2.3 User Model
**Location:** `backend/igny8_core/auth/models.py`
**Database Table:** `igny8_users`
**Inheritance:** Django `AbstractUser`
**Core Fields:**
| Field | Type | Purpose | Notes |
|-------|------|---------|-------|
| id | AutoField | Primary key | Auto-increment |
| email | EmailField | Login identifier | Unique, required |
| username | CharField(150) | Display name | Auto-generated from email |
| password | CharField(128) | Hashed password | Django PBKDF2 SHA256 |
| first_name, last_name | CharField(150) | Full name | Optional |
| account | FK(Account) | Tenant association | Nullable, CASCADE, db_column='tenant_id' |
| role | CharField(20) | Permission level | Choices below |
| is_active | BooleanField | Account enabled | Default: True |
| is_staff | BooleanField | Django admin access | Default: False |
| is_superuser | BooleanField | Full system access | Default: False |
**Role Hierarchy:**
| Role | Code | Capabilities | Tenant Isolation |
|------|------|--------------|------------------|
| Developer | developer | Full system access, all accounts | Bypassed |
| Owner | owner | Full account access, billing, automation | Enforced |
| Admin | admin | Content management, view billing | Enforced |
| Editor | editor | AI content, manage clusters/tasks | Enforced |
| Viewer | viewer | Read-only dashboards | Enforced |
| System Bot | system_bot | Automation tasks only | Special |
**Business Logic Methods:**
`is_developer()` → Boolean
- Returns True if role == 'developer'
- Used to bypass tenant filtering
- Grants system-wide access
`is_system_account_user()` → Boolean
- Returns True if account is system account
- System users can perform automated tasks
- Used by background jobs and API integrations
`get_accessible_sites()` → QuerySet[Site]
- Returns sites user can access based on role
- Owner/Admin: All sites in account
- Editor/Viewer: Sites with explicit SiteUserAccess grants
- Developer: All sites across all accounts
**Authentication Methods:**
`set_password(raw_password)` → None
- Django's password hashing (PBKDF2 SHA256)
- Salted and stretched for security
`check_password(raw_password)` → Boolean
- Validates password against hash
- Used in login flow
---
### 2.4 Subscription Model
**Location:** `backend/igny8_core/auth/models.py`
**Database Table:** `igny8_subscriptions`
**Relationship:** 1:1 with Account
**Core Fields:**
| Field | Type | Purpose | Notes |
|-------|------|---------|-------|
| id | AutoField | Primary key | |
| account | OneToOneField(Account) | Tenant link | CASCADE delete |
| plan | FK(Plan) | Subscription tier | **NEW: Added for historical tracking** |
| status | CharField(20) | Subscription state | pending_payment/active/cancelled/expired |
| stripe_subscription_id | CharField(255) | Stripe reference | For automated billing |
| external_payment_id | CharField(255) | Manual payment ref | For bank transfer/wallet |
| current_period_start | DateTimeField | Billing period start | **Single source of truth** |
| current_period_end | DateTimeField | Billing period end | **Single source of truth** |
| cancel_at_period_end | BooleanField | Auto-cancel flag | Default: False |
| created_at, updated_at | DateTimeField | Audit timestamps | |
**Status Flow:**
```
[Registration] → pending_payment (for paid plans)
[Payment Approved] → active
[Cancellation Requested] → active (cancel_at_period_end=True)
[Period Ends] → cancelled OR expired
```
**Key Changes from Original Design:**
1. **Added `plan` Field:**
- Previously relied on `account.plan` only
- Problem: Plan changes lost historical context
- Solution: Lock plan in subscription for billing period
- New subscription created on plan changes
2. **Removed `payment_method` Field:**
- Moved to property that queries `AccountPaymentMethod`
- Eliminates data duplication
- Single source of truth pattern
**Property Methods:**
`payment_method` → String (Read-only)
- Returns `self.account.payment_method`
- Delegates to AccountPaymentMethod query
- No data duplication
---
### 2.5 Invoice Model
**Location:** `backend/igny8_core/business/billing/models.py`
**Database Table:** `igny8_invoices`
**Inheritance:** `AccountBaseModel`
**Core Fields:**
| Field | Type | Purpose | Notes |
|-------|------|---------|-------|
| id | AutoField | Primary key | |
| account | FK(Account) | Tenant | Via AccountBaseModel |
| subscription | FK(Subscription) | Billing source | CASCADE, nullable |
| invoice_number | CharField(50) | Unique identifier | Format: INV-{account_id}-{YYYYMM}-{seq} |
| status | CharField(20) | Payment state | draft/pending/paid/void/uncollectible |
| subtotal | DecimalField(10,2) | Pre-tax amount | Calculated from line_items |
| tax | DecimalField(10,2) | Tax amount | Default: 0 |
| total | DecimalField(10,2) | Final amount | subtotal + tax |
| currency | CharField(3) | ISO currency code | USD/PKR/INR/GBP/EUR/CAD/AUD |
| invoice_date | DateField | Issue date | Indexed |
| due_date | DateField | Payment deadline | Calculated: invoice_date + grace period |
| paid_at | DateTimeField | Payment timestamp | Nullable |
| line_items | JSONField | Invoice items | Array of {description, amount, quantity} |
| stripe_invoice_id | CharField(255) | Stripe reference | Nullable |
| payment_method | CharField(50) | How paid | Historical record |
| notes | TextField | Additional info | |
| metadata | JSONField | Structured data | Billing snapshot, period dates, exchange rate |
**Status Flow:**
```
[Created] → pending
[Payment Confirmed] → paid
[Non-Payment] → uncollectible OR void (if cancelled)
```
**Key Changes from Original Design:**
1. **Removed Duplicate Fields:**
- `billing_period_start` - Now in metadata, retrieved via `subscription.current_period_start`
- `billing_period_end` - Now in metadata, retrieved via `subscription.current_period_end`
- `billing_email` - Now in metadata snapshot, not separate field
2. **Added Both Field Names:**
- API returns both `total` and `total_amount` for backward compatibility
- Frontend can use either field name
- Migration path for existing integrations
**Business Logic Methods:**
`calculate_totals()` → None
- Recalculates subtotal from line_items array
- Sets total = subtotal + tax
- Called before save() operations
`add_line_item(description, quantity, unit_price, amount)` → None
- Appends item to line_items JSON array
- Does NOT recalculate totals (call calculate_totals after)
- Used by InvoiceService during creation
**Metadata Structure:**
```python
{
'billing_snapshot': {
'email': 'user@example.com',
'address_line1': '123 Main St',
'city': 'Karachi',
'country': 'PK',
'snapshot_date': '2024-12-09T10:30:00Z'
},
'billing_period_start': '2024-12-01T00:00:00Z',
'billing_period_end': '2024-12-31T23:59:59Z',
'subscription_id': 42,
'usd_price': '29.00',
'exchange_rate': '278.00'
}
```
---
### 2.6 Payment Model
**Location:** `backend/igny8_core/business/billing/models.py`
**Database Table:** `igny8_payments`
**Inheritance:** `AccountBaseModel`
**Core Fields:**
| Field | Type | Purpose | Notes |
|-------|------|---------|-------|
| id | AutoField | Primary key | |
| account | FK(Account) | Tenant | Via AccountBaseModel |
| invoice | FK(Invoice) | Invoice paid | CASCADE |
| amount | DecimalField(10,2) | Payment amount | Must match invoice total |
| currency | CharField(3) | ISO code | Must match invoice currency |
| status | CharField(20) | Payment state | See status table below |
| payment_method | CharField(50) | How paid | stripe/paypal/bank_transfer/local_wallet |
| stripe_payment_intent_id | CharField(255) | Stripe reference | For automated payments |
| stripe_charge_id | CharField(255) | Stripe charge | |
| paypal_order_id | CharField(255) | PayPal reference | For automated payments |
| paypal_capture_id | CharField(255) | PayPal capture | |
| manual_reference | CharField(255) | Manual payment ref | **Required for manual payments** |
| manual_notes | TextField | User notes | Optional |
| admin_notes | TextField | Admin comments | For approval/rejection |
| approved_by | FK(User) | Admin who approved | SET_NULL |
| approved_at | DateTimeField | Approval timestamp | |
| processed_at | DateTimeField | Processing timestamp | |
| failed_at | DateTimeField | Failure timestamp | |
| refunded_at | DateTimeField | Refund timestamp | |
| failure_reason | TextField | Why failed | |
| metadata | JSONField | Additional data | proof_url, submitted_by |
**Payment Status Values:**
| Status | Description | Used For | Next States |
|--------|-------------|----------|-------------|
| pending_approval | Awaiting admin review | Manual payments (bank transfer, mobile wallet) | succeeded, failed |
| succeeded | Completed successfully | All successful payments | refunded |
| failed | Payment rejected | Declined cards, rejected manual payments | - |
| refunded | Money returned | Customer refund requests | - |
**Simplified Status Flow:**
```
[User Confirms Manual Payment] → pending_approval
[Admin Approves] → succeeded
[Refund Requested] → refunded
[Admin Rejects] → failed
```
**Key Changes from Original Design:**
1. **Removed Duplicate Reference Field:**
- Had both `manual_reference` and `transaction_reference`
- Kept `manual_reference` only
- Single field for manual payment tracking
2. **Streamlined Status Values:**
- Removed: pending, processing, completed, cancelled
- Kept: pending_approval, succeeded, failed, refunded
- Clearer workflow for admins
**Validation Logic:**
`clean()` → None
- Validates manual payments have `manual_reference`
- Checks amount matches invoice total
- Ensures currency matches invoice currency
- Called before save operations
---
### 2.7 AccountPaymentMethod Model
**Location:** `backend/igny8_core/business/billing/models.py`
**Database Table:** `igny8_account_payment_methods`
**Purpose:** Single source of truth for account's payment preferences
**Core Fields:**
| Field | Type | Purpose | Notes |
|-------|------|---------|-------|
| id | AutoField | Primary key | |
| account | FK(Account) | Tenant | CASCADE |
| type | CharField(50) | Payment method code | stripe/paypal/bank_transfer/local_wallet |
| display_name | CharField(255) | User-friendly name | "Credit Card (Stripe)" |
| is_default | BooleanField | Primary method | Only one per account |
| is_enabled | BooleanField | Currently active | Can disable temporarily |
| is_verified | BooleanField | Verified by admin | For manual methods |
| country_code | CharField(2) | ISO country | For country-specific methods |
| instructions | TextField | Payment details | Bank account info, wallet number |
| metadata | JSONField | Additional config | Provider-specific settings |
**Business Logic:**
`save()` → None
- If `is_default=True`, sets all other methods to `is_default=False`
- Ensures only one default method per account
- Uses `update()` to avoid recursion
---
### 2.8 PaymentMethodConfig Model
**Location:** `backend/igny8_core/business/billing/models.py`
**Database Table:** `igny8_payment_method_configs`
**Purpose:** Global configuration for available payment methods
**Core Fields:**
| Field | Type | Purpose | Notes |
|-------|------|---------|-------|
| id | AutoField | Primary key | |
| country_code | CharField(2) | ISO country or '*' | '*' = available globally |
| payment_method | CharField(50) | Method code | stripe/paypal/bank_transfer/local_wallet |
| display_name | CharField(255) | UI label | "Bank Transfer (Manual)" |
| is_enabled | BooleanField | Currently available | Can disable globally |
| instructions | TextField | Payment details | Shown to users during signup |
| provider | CharField(100) | Gateway provider | Stripe, PayPal, Bank, JazzCash |
| wallet_type | CharField(50) | Wallet provider | For local_wallet: JazzCash, Easypaisa |
| sort_order | IntegerField | Display order | Lower = shown first |
| metadata | JSONField | Config data | API keys, webhooks (encrypted) |
| webhook_url | URLField | Webhook endpoint | For automated payments |
| webhook_secret | CharField(255) | Webhook signature | For verification |
| webhook_events | JSONField | Event subscriptions | Array of event types |
**Standard Configurations:**
| Method | Country | Enabled | Instructions |
|--------|---------|---------|--------------|
| stripe | * (global) | No | Will be enabled when API keys configured |
| paypal | * (global) | No | Will be enabled when API keys configured |
| bank_transfer | * (global) | Yes | See account details in payment confirmation |
| local_wallet | PK | Yes | JazzCash: 0300-1234567 / Easypaisa: 0300-7654321 |
**Query Methods:**
`get_available_for_country(country_code)` → QuerySet
- Returns enabled methods for country
- Includes global methods (country_code='*')
- Ordered by sort_order
- Used in signup flow
---
### 2.9 CreditTransaction Model
**Location:** `backend/igny8_core/business/billing/models.py`
**Database Table:** `igny8_credit_transactions`
**Inheritance:** `AccountBaseModel`
**Purpose:** Immutable audit trail of credit changes
**Core Fields:**
| Field | Type | Purpose | Notes |
|-------|------|---------|-------|
| id | AutoField | Primary key | |
| account | FK(Account) | Tenant | Via AccountBaseModel |
| payment | FK(Payment) | Source payment | **NEW: Added for tracking**, nullable |
| transaction_type | CharField(50) | Category | subscription/topup/refund/adjustment/usage |
| amount | IntegerField | Credit change | Positive = add, Negative = deduct |
| balance_after | IntegerField | Resulting balance | Snapshot after this transaction |
| description | TextField | Human readable | "Free plan credits from Free Trial" |
| metadata | JSONField | Structured data | payment_id, subscription_id, approved_by |
| created_at | DateTimeField | Transaction time | Immutable |
**Transaction Types:**
| Type | Amount Sign | Description | Example |
|------|-------------|-------------|---------|
| subscription | Positive | Plan credits allocated | +1000 credits from Free Trial plan |
| topup | Positive | Manual credit purchase | +5000 credits purchased for $50 |
| refund | Positive | Credits returned | +2000 credits from refunded payment |
| adjustment | Either | Manual correction | +/-500 credits - admin adjustment |
| usage | Negative | Credits consumed | -10 credits for content generation |
**Key Changes:**
Added `payment` foreign key:
- Links credit allocation to payment that triggered it
- Enables preventing duplicate credit grants
- Used in payment approval workflow
- Query: "Has this payment already allocated credits?"
**Business Logic:**
Immutable Design:
- No update() or delete() operations
- Append-only transaction log
- Balance recalculated by summing all transactions
- Maintains complete audit trail
---
## 3. Authentication System
### 3.1 Registration Flow
**Endpoint:** `POST /v1/auth/register/`
**Handler:** `RegisterView.post()` in `backend/igny8_core/auth/urls.py`
**Permission:** `AllowAny` (public endpoint)
**Request Payload Structure:**
```python
{
# Required Fields
'email': String,
'password': String,
'password_confirm': String,
# Optional User Fields
'username': String, # Auto-generated from email if not provided
'first_name': String,
'last_name': String,
# Optional Account Fields
'account_name': String, # Auto-generated from name or email if not provided
# Plan Selection
'plan_slug': String, # 'free' (default) | 'starter' | 'growth' | 'scale'
# Billing Information (required for paid plans)
'billing_email': String,
'billing_address_line1': String,
'billing_address_line2': String,
'billing_city': String,
'billing_state': String,
'billing_postal_code': String,
'billing_country': String (2-letter ISO), # Required for paid plans
'tax_id': String,
# Payment Selection (required for paid plans)
'payment_method': String # 'bank_transfer' | 'local_wallet' | 'stripe' | 'paypal'
}
```
**Registration Process Logic:**
Step 1: Validate Input
- Serializer: `RegisterSerializer.validate()`
- Check password == password_confirm
- For paid plans: Require billing_country and payment_method
- Convert empty strings to None for optional fields
Step 2: Resolve Plan
- If `plan_slug='free'` or not provided:
- Plan = Free Trial
- Status = 'trial'
- Credits = plan.included_credits (e.g., 1000)
- No subscription/invoice created
- If `plan_slug` in ['starter', 'growth', 'scale']:
- Fetch plan by slug
- Status = 'pending_payment'
- Credits = 0 (allocated after payment)
- Subscription and invoice will be created
Step 3: Generate Username (if not provided)
- Extract local part from email: 'john@example.com' → 'john'
- Check uniqueness: If exists, append counter (john1, john2, etc.)
- Ensures no duplicate usernames
Step 4: Create User (without account first)
- Django's `User.objects.create_user()`
- Hash password using PBKDF2 SHA256
- Set account=None temporarily
- Allows rolling back account creation if fails
Step 5: Generate Account Slug
- Source: account_name if provided, else first_name + last_name, else email
- Slugify: Convert to lowercase, replace spaces with hyphens
- Ensure uniqueness: Append counter if duplicate exists
- Example: "John's Business" → "johns-business"
Step 6: Create Account (Atomic Transaction)
- Set owner=user (from step 4)
- Set plan from step 2
- Set credits (1000 for free, 0 for paid)
- Set status ('trial' for free, 'pending_payment' for paid)
- Save all billing information to account fields
- Generate billing_email = billing_email OR email
Step 7: Link User to Account
- Update user.account = account
- Save user record
- Completes circular reference
Step 8: Log Initial Credits (Free Trial Only)
- Create CreditTransaction record
- Type = 'subscription'
- Amount = plan.included_credits
- Description = "Free plan credits from {plan.name}"
- Metadata includes plan_slug, registration=True, trial=True
Step 9: Create Subscription (Paid Plans Only)
- Create Subscription record
- Status = 'pending_payment'
- Plan = selected plan (locked for this subscription)
- Current period = now to now + 30 days
- Payment method = from request
Step 10: Create Invoice (Paid Plans Only)
- Function: `InvoiceService.create_subscription_invoice()`
- Convert USD price to local currency
- Create line item with converted price
- Status = 'pending'
- Due date = invoice_date + grace period (7 days)
Step 11: Create AccountPaymentMethod (Paid Plans Only)
- Type = selected payment method
- Display name from lookup table
- Is_default = True
- Is_enabled = True
- Instructions = from PaymentMethodConfig
Step 12: Generate JWT Tokens
- Access token: `generate_access_token(user, account)`
- Refresh token: `generate_refresh_token(user, account)`
- Include claims: user_id, account_id, role
- Expiry: access=1 hour, refresh=7 days
Step 13: Return Response
- Success response with user data and tokens
- Frontend stores tokens in localStorage
- User automatically logged in
**Key Fix Applied (Dec 9, 2024):**
Problem: RegisterView wasn't generating tokens
- Original code only returned user data
- No tokens in response
- Frontend couldn't authenticate subsequent requests
- User immediately logged out
Solution: Added token generation to RegisterView
- Generate access and refresh tokens
- Return in response: `data.tokens.access` and `data.tokens.refresh`
- Matches LoginView response structure
- Users stay logged in after registration
---
### 3.2 Login Flow
**Endpoint:** `POST /v1/auth/login/`
**Handler:** `LoginView.post()` in `backend/igny8_core/auth/urls.py`
**Permission:** `AllowAny`
**Request Payload:**
```python
{
'email': String, # User's email address
'password': String # Plain text password (hashed by backend)
}
```
**Login Process Logic:**
Step 1: Validate Credentials
- Serializer: `LoginSerializer.is_valid()`
- Validates email format
- Password required (not validated yet)
Step 2: Fetch User
- Query: `User.objects.select_related('account', 'account__plan').get(email=email)`
- Optimized query: Pre-loads account and plan data
- Raises DoesNotExist if email not found
Step 3: Check Password
- Django's `user.check_password(password)`
- Compares hashed password
- Returns False if incorrect
Step 4: Validate User Status
- Check `user.is_active == True`
- Inactive users cannot login
- Returns error: "User account is disabled"
Step 5: Validate Account Status
- Check account exists: `user.account is not None`
- Check account status in ['trial', 'active', 'pending_payment']
- Suspended/cancelled accounts cannot login
- Returns error: "Account is {status}"
Step 6: Generate Tokens
- Access token with user_id, account_id, role claims
- Refresh token with same claims
- Calculate expiry timestamps
Step 7: Return Response
- User data (via UserSerializer)
- Access and refresh tokens
- Token expiry timestamps
---
### 3.3 Token Structure
**JWT Claims:**
Access Token:
```python
{
'user_id': Integer,
'account_id': Integer,
'email': String,
'role': String,
'exp': Timestamp, # Now + 1 hour
'iat': Timestamp, # Issued at
'type': 'access'
}
```
Refresh Token:
```python
{
'user_id': Integer,
'account_id': Integer,
'exp': Timestamp, # Now + 7 days
'iat': Timestamp,
'type': 'refresh'
}
```
**Token Refresh Flow:**
Endpoint: `POST /v1/auth/refresh/`
Process:
1. Validate refresh token signature
2. Check expiry (must not be expired)
3. Extract user_id and account_id
4. Fetch user and account from database
5. Validate user.is_active and account status
6. Generate new access token
7. Return new access token (refresh token stays same)
---
### 3.4 Frontend Token Handling
**Storage Strategy:**
Dual Storage Approach:
- Zustand persist middleware: Stores in `localStorage['auth-storage']`
- Direct localStorage keys: `access_token` and `refresh_token`
Reason for Dual Storage:
- Zustand persist is asynchronous
- API calls happen immediately after login/register
- Direct keys available synchronously
- No race condition between persist and first API call
**Token Extraction Logic (Fixed Dec 9, 2024):**
Frontend: `authStore.ts`
```typescript
Function: register() and login()
// Extract from response
const responseData = data.data || data
const tokens = responseData.tokens || {}
const newToken = tokens.access || responseData.access ||
data.data?.tokens?.access || data.tokens?.access
// Store in Zustand (async)
set({ token: newToken, refreshToken: newRefreshToken, ... })
// CRITICAL FIX: Store synchronously for immediate API calls
localStorage.setItem('access_token', newToken)
localStorage.setItem('refresh_token', newRefreshToken)
```
**API Interceptor:**
Function: `fetchAPI()` in `services/api.ts`
Authorization Header:
```typescript
const token = localStorage.getItem('access_token') ||
useAuthStore.getState().token
headers['Authorization'] = `Bearer ${token}`
```
401 Handling:
1. Attempt token refresh with refresh_token
2. If refresh succeeds: Retry original request with new token
3. If refresh fails: Clear auth state and redirect to /signin
403 Handling:
1. Check error message for authentication keywords
2. If auth error: Force logout and redirect
3. If permission error: Show error to user
---
## 4. Payment Processing
### 4.1 Payment Workflow Overview
**Manual Payment Flow (Bank Transfer / Mobile Wallet):**
```
User Signs Up (Paid Plan)
Account Created (status='pending_payment')
Subscription Created (status='pending_payment')
Invoice Created (status='pending', with local currency amount)
User Shown Payment Instructions
User Makes Payment Externally (bank or wallet)
User Submits Payment Confirmation (with transaction reference)
Payment Record Created (status='pending_approval')
Admin Reviews Payment in Django Admin
Admin Approves Payment (changes status to 'succeeded')
AUTOMATIC WORKFLOW TRIGGERED:
- Invoice status → 'paid'
- Subscription status → 'active'
- Account status → 'active'
- Credits allocated based on plan
User Can Now Use Platform
```
**Automated Payment Flow (Stripe / PayPal - Future):**
```
User Signs Up (Paid Plan)
Account Created (status='pending_payment')
Subscription Created (status='pending_payment')
Invoice Created (status='pending')
User Enters Card/PayPal Details
Payment Gateway Processes Payment
Webhook Received: payment.succeeded
Payment Record Created (status='succeeded')
SAME AUTOMATIC WORKFLOW AS MANUAL
User Can Use Platform Immediately
```
---
### 4.2 Invoice Creation Service
**Service:** `InvoiceService` in `backend/igny8_core/business/billing/services/invoice_service.py`
**Method:** `create_subscription_invoice(subscription, billing_period_start, billing_period_end)`
**Purpose:** Creates invoice with currency conversion for subscription billing
**Process Logic:**
Step 1: Extract Account and Plan
- Get account from subscription.account
- Get plan from subscription.plan (new field added Dec 9)
- Validate plan exists
Step 2: Snapshot Billing Information
- Create billing_snapshot dictionary from account fields:
- email (from billing_email or owner.email)
- address_line1, address_line2
- city, state, postal_code, country
- tax_id
- snapshot_date (current timestamp)
- Stored in invoice.metadata for historical record
- Account billing info can change later without affecting invoice
Step 3: Calculate Invoice and Due Dates
- invoice_date = today's date
- due_date = invoice_date + INVOICE_DUE_DATE_OFFSET
- Default offset: 7 days
- Configurable in billing config
Step 4: Determine Currency
- Function: `get_currency_for_country(account.billing_country)`
- Maps country codes to currencies:
- PK → PKR, IN → INR
- GB → GBP, EU countries → EUR
- CA → CAD, AU → AUD
- US and others → USD
- Returns 3-letter ISO currency code
Step 5: Convert Price to Local Currency
- Function: `convert_usd_to_local(usd_amount, country_code)`
- Applies currency multiplier from CURRENCY_MULTIPLIERS table:
- PKR: ×278.0 (1 USD = 278 PKR)
- INR: ×83.0
- GBP: ×0.79
- EUR: ×0.92
- CAD: ×1.36
- AUD: ×1.52
- Formula: local_price = usd_price × multiplier
- Example: $29 × 278 = PKR 8,062
Step 6: Create Invoice Record
- Set account, subscription
- Generate invoice_number: `INV-{account.id}-{YYYYMM}-{sequence}`
- Set status='pending'
- Set currency from step 4
- Set invoice_date and due_date from step 3
- Store metadata with billing_snapshot and period dates
Step 7: Add Line Item
- Description: "{plan.name} Plan - {month_year}"
- Example: "Starter Plan - Dec 2024"
- Quantity: 1
- Unit price: converted local price
- Amount: same as unit price
- Appended to invoice.line_items JSON array
Step 8: Calculate Totals
- Function: `invoice.calculate_totals()`
- Sums all line_items amounts → subtotal
- Adds tax (currently 0) → total
- Saves calculated amounts to invoice
Step 9: Store Exchange Rate Metadata
- Original USD price stored in metadata
- Exchange rate stored for reference
- Enables audit trail of currency conversion
- Example metadata:
```python
{
'usd_price': '29.00',
'exchange_rate': '278.00',
'subscription_id': 42,
'billing_period_start': '2024-12-01T00:00:00Z',
'billing_period_end': '2024-12-31T23:59:59Z'
}
```
Step 10: Return Created Invoice
- Invoice saved to database
- Returns invoice object
- Used in subscription creation flow
---
### 4.3 Payment Confirmation Flow
**Endpoint:** `POST /v1/billing/admin/payments/confirm/`
**Handler:** `AdminPaymentViewSet.confirm_payment()` in `backend/igny8_core/business/billing/views.py`
**Permission:** `IsAuthenticatedAndActive`
**Request Payload:**
```python
{
'invoice_id': Integer, # Required
'payment_method': String, # Required: bank_transfer | local_wallet
'amount': Decimal, # Required: Must match invoice.total
'manual_reference': String, # Required: Transaction ID from bank/wallet
'manual_notes': String, # Optional: Additional info from user
'proof_url': String (URL) # Optional: Receipt/screenshot URL
}
```
**Validation Process:**
Step 1: Validate Payload
- Serializer: `PaymentConfirmationSerializer`
- Check all required fields present
- Validate payment_method in ['bank_transfer', 'local_wallet']
- Validate amount is positive decimal with 2 decimal places
- Validate manual_reference not empty
- Validate proof_url is valid URL format (if provided)
Step 2: Fetch and Validate Invoice
- Query invoice by id
- Ensure invoice belongs to request.account (tenant isolation)
- If not found or wrong account: Return 404
Step 3: Check for Existing Payment
- Query Payment table for invoice
- Filter: status in ['pending_approval', 'succeeded']
- If exists with status='succeeded':
- Return error: "Invoice already paid"
- If exists with status='pending_approval':
- Return error: "Payment confirmation already pending approval (Payment ID: {id})"
Step 4: Validate Amount Matches Invoice
- Compare payment amount to invoice.total
- Must match exactly (including decimals)
- If mismatch: Return error with expected amount and currency
Step 5: Create Payment Record
- Set account from request.account
- Set invoice from step 2
- Set amount and currency from invoice
- Set status='pending_approval'
- Set payment_method from request
- Set manual_reference and manual_notes from request
- Set metadata with proof_url and submitted_by email
- Save to database
Step 6: Log Payment Submission
- Log to application logger
- Include: Payment ID, Invoice number, Account ID, Reference
- Used for audit trail and debugging
Step 7: Send Email Notification (Optional)
- Service: `BillingEmailService.send_payment_confirmation_email()`
- Send to user's billing_email
- Subject: "Payment Confirmation Received"
- Body: Payment details, next steps, estimated review time
Step 8: Return Success Response
- Include payment_id for tracking
- Include invoice_id and invoice_number
- Include status='pending_approval'
- Include formatted amount and currency
---
### 4.4 Payment Approval Workflow
**Location:** Django Admin → Billing → Payments
**Handler:** `PaymentAdmin.save_model()` in `backend/igny8_core/modules/billing/admin.py`
**Trigger:** Admin changes payment status from 'pending_approval' to 'succeeded'
**Automatic Workflow (Atomic Transaction):**
Step 1: Detect Status Change
- Compare current status to previous status
- If changed to 'succeeded' and was not 'succeeded' before:
- Trigger approval workflow
- If no change: Just save, no workflow
Step 2: Set Approval Metadata
- approved_by = current admin user
- approved_at = current timestamp
- processed_at = current timestamp
- Save payment record first
Step 3: Update Invoice Status
- Fetch invoice from payment.invoice
- If invoice.status != 'paid':
- Set invoice.status = 'paid'
- Set invoice.paid_at = current timestamp
- Save invoice
Step 4: Update Subscription Status
- Get subscription from invoice.subscription OR account.subscription
- If subscription.status != 'active':
- Set subscription.status = 'active'
- Set subscription.external_payment_id = payment.manual_reference
- Save subscription
Step 5: Update Account Status
- Get account from payment.account
- If account.status != 'active':
- Set account.status = 'active'
- Save account
Step 6: Allocate Credits (Check for Duplicates First)
- Query CreditTransaction table
- Filter: account=account, metadata__payment_id=payment.id
- If exists: Skip credit allocation (prevents duplicates)
- If not exists:
- Get credits_to_add from subscription.plan.included_credits OR account.plan.included_credits
- If credits_to_add > 0:
- Call `CreditService.add_credits()`
Step 7: Add Credits via CreditService
- Function: `CreditService.add_credits(account, amount, transaction_type, description, metadata)`
- Create CreditTransaction record
- Calculate new balance: current_balance + amount
- Update account.credits = new_balance
- Metadata includes:
- subscription_id
- invoice_id
- payment_id (for duplicate detection)
- approved_by (admin email)
Step 8: Show Admin Success Message
- Django admin message framework
- Success message: "✓ Payment approved: Account activated, {credits} credits added"
- Shows plan name, invoice number, and credit amount
Step 9: Error Handling
- If any step fails: Rollback entire transaction (atomic)
- Show error message in admin
- Payment stays in 'pending_approval' status
- Admin can retry or investigate issue
**Key Fix Applied (Dec 9, 2024):**
Problem: Manual status change didn't trigger workflow
- Original code only set approved_by field
- No invoice/subscription/account updates
- No credit allocation
- Admin had to manually activate everything
Solution: Enhanced save_model() with full workflow
- Detects status changes
- Runs complete approval process
- All updates in atomic transaction
- Prevents duplicate credit allocation
---
## 5. Multi-Currency System
### 5.1 Currency Configuration
**Location:** `backend/igny8_core/business/billing/utils/currency.py`
**Currency Multipliers Table:**
| Country Code | Currency | Multiplier | Example Conversion |
|--------------|----------|------------|-------------------|
| PK | PKR | 278.0 | $29 → PKR 8,062 |
| IN | INR | 83.0 | $29 → INR 2,407 |
| GB | GBP | 0.79 | $29 → £22.91 |
| EU (multiple) | EUR | 0.92 | $29 → €26.68 |
| CA | CAD | 1.36 | $29 → CAD 39.44 |
| AU | AUD | 1.52 | $29 → AUD 44.08 |
| US, others | USD | 1.0 | $29 → $29.00 |
**Function:** `convert_usd_to_local(usd_amount, country_code)`
Logic:
1. Get multiplier from CURRENCY_MULTIPLIERS dict
2. Default to 1.0 if country not in table (uses USD)
3. Calculate: local_amount = usd_amount × multiplier
4. Return as Decimal with 2 decimal places
**Function:** `get_currency_for_country(country_code)`
Returns 3-letter ISO currency code based on country:
- Maps country codes to currencies
- Default: USD for unmapped countries
**Function:** `format_currency(amount, currency_code)`
Returns formatted string for display:
- PKR 8,062.00
- $29.00
- €26.68
---
### 5.2 Invoice Currency Handling
**Invoice Creation:**
- Detects user's billing_country from account
- Converts plan price from USD to local currency
- Stores both amounts:
- invoice.total (in local currency)
- metadata.usd_price (original USD price)
- metadata.exchange_rate (multiplier used)
**API Response:**
- Returns both `total` and `total_amount` fields
- Frontend can use either field name
- Currency code included in `currency` field
**Payment Validation:**
- Payment amount must match invoice.total exactly
- Currency must match invoice.currency
- No currency conversion during payment confirmation
---
## 6. Site & Sector Management
### 6.1 Site Creation
**Endpoint:** `POST /v1/auth/sites/`
**Handler:** `SiteViewSet.create()` in `backend/igny8_core/auth/views.py`
**Permission:** `IsAuthenticated` (account status not required)
**Request Payload:**
```python
{
'name': String, # Required: Site display name
'domain': String, # Optional: Website URL
'description': String, # Optional
'industry': Integer, # Required: FK to Industry
'is_active': Boolean, # Default: True
'hosting_type': String, # wordpress/custom/static
'site_type': String # blog/ecommerce/corporate
}
```
**Validation Process:**
Step 1: Check Plan Limits
- Get account from request.account (middleware)
- Get max_sites from account.plan.max_sites
- Count existing active sites for account
- If count >= max_sites: Return error "Site limit reached"
Step 2: Auto-Generate Slug
- If slug not provided: Generate from name
- Slugify: Convert to lowercase, replace spaces with hyphens
- Ensure uniqueness within account
- Example: "Tech Blog" → "tech-blog"
Step 3: Validate and Format Domain
- If domain provided:
- Strip whitespace
- If empty string: Set to None
- If starts with http://: Replace with https://
- If no protocol: Add https://
- Validate URL format
- If domain not provided or empty: Set to None
Step 4: Create Site Record
- Set account from request.account (automatic)
- Set all fields from validated data
- Save to database
Step 5: Grant Creator Access (Owner/Admin Only)
- If user role in ['owner', 'admin']:
- Create SiteUserAccess record
- Set user=request.user
- Set site=newly created site
- Set granted_by=request.user
Step 6: Return Created Site
- Serializer includes related counts
- Returns site with industry name and slug
- Includes sectors_count=0 (new site has no sectors)
**Key Fix Applied (Dec 9, 2024):**
Problem: Site creation failed with empty domain
- Domain field validation rejected empty strings
- Frontend sent empty string when field left blank
- Users couldn't create sites without domain
Solution: Allow empty domain values
- Modified validation to convert empty string to None
- Accepts None, empty string, or valid URL
- Allows sites without domains
---
### 6.2 Sector Management
**Sector Creation Endpoint:** `POST /v1/auth/sites/{site_id}/select_sectors/`
**Handler:** `SiteViewSet.select_sectors()` in `backend/igny8_core/auth/views.py`
**Request Payload:**
```python
{
'industry_slug': String, # Must match site.industry.slug
'sector_slugs': [String] # Array of IndustrySector slugs
}
```
**Process Logic:**
Step 1: Validate Site Ownership
- Ensure site belongs to request.account
- Check user has access to site
Step 2: Validate Industry Match
- Fetch industry by slug
- Ensure industry matches site.industry
- Prevent cross-industry sector assignment
Step 3: Check Sector Limit
- Max sectors per site: 5 (hard-coded)
- Count existing active sectors for site
- Calculate available slots
- If sector_slugs length > available: Return error
Step 4: Fetch Industry Sectors
- Query IndustrySector by slugs and industry
- Validate all requested sectors exist
- Ensure sectors belong to correct industry
Step 5: Create or Update Sectors
- For each industry_sector:
- Check if Sector already exists for site
- If exists: Update is_active=True
- If not exists: Create new Sector
- Auto-generate sector name and slug
- Set account=site.account (automatic)
Step 6: Return Results
- Count of created sectors
- Count of updated sectors
- Array of sector objects with details
---
## 7. Credit System
### 7.1 Credit Allocation
**Service:** `CreditService` in `backend/igny8_core/business/billing/services/credit_service.py`
**Method:** `add_credits(account, amount, transaction_type, description, metadata)`
**Process:**
Step 1: Validate Amount
- Must be positive integer
- No fractional credits allowed
Step 2: Calculate New Balance
- Fetch current balance from account.credits
- New balance = current + amount
Step 3: Create Transaction Record
- Type = transaction_type ('subscription' for plan credits)
- Amount = credit amount being added
- Balance_after = new calculated balance
- Description = human-readable text
- Metadata = structured data (payment_id, etc.)
Step 4: Update Account Balance
- Set account.credits = new balance
- Save account record
Step 5: Return Transaction
- Returns created CreditTransaction object
---
### 7.2 Credit Deduction
**Method:** `deduct_credits(account, amount, transaction_type, description, metadata)`
**Process:**
Similar to add_credits but:
- Amount is positive (converted to negative internally)
- Checks sufficient balance before deduction
- Raises InsufficientCreditsError if balance < amount
- Transaction record has negative amount
- Balance_after = current - amount
---
## 8. Admin Workflows
### 8.1 Payment Approval (Django Admin)
**Location:** Admin → Billing → Payments
**Process:**
Option 1: Bulk Action
1. Select payment checkboxes
2. Choose "Approve selected manual payments" from Actions dropdown
3. Click "Go"
4. Automatic workflow runs for each payment
5. Success message shows details
Option 2: Individual Edit
1. Click payment to open edit page
2. Change Status dropdown from "Pending Approval" to "Succeeded"
3. Add admin notes (optional)
4. Click "Save"
5. Automatic workflow runs on save
6. Success message confirms activation
**Admin Interface Shows:**
- Invoice number
- Account name
- Amount and currency
- Payment method
- Manual reference (transaction ID from user)
- Manual notes (user's comments)
- Status with color coding
- Approved by and timestamp (after approval)
---
### 8.2 Account Management
**Admin Can:**
View Account Details:
- Plan and status
- Credit balance
- Billing information
- Owner and users
- Sites and sectors
- Payment history
Modify Account:
- Change plan (creates new subscription)
- Adjust credit balance (creates adjustment transaction)
- Update billing information
- Change account status
- Add/remove users
Soft Delete Account:
- Mark as deleted with retention period
- All data preserved during retention
- Can restore within period
- Hard delete after retention expires
---
## 9. API Endpoints Summary
### 9.1 Authentication Endpoints
| Method | Endpoint | Permission | Purpose |
|--------|----------|------------|---------|
| POST | /v1/auth/register/ | AllowAny | User registration |
| POST | /v1/auth/login/ | AllowAny | User login |
| POST | /v1/auth/refresh/ | AllowAny | Refresh access token |
| GET | /v1/auth/me/ | IsAuthenticated | Get current user |
| POST | /v1/auth/change-password/ | IsAuthenticated | Change password |
### 9.2 Billing Endpoints
| Method | Endpoint | Permission | Purpose |
|--------|----------|------------|---------|
| GET | /v1/billing/admin/payment-methods/ | AllowAny | Get payment methods by country |
| GET | /v1/billing/plans/ | AllowAny | List subscription plans |
| GET | /v1/billing/invoices/ | IsAuthenticatedAndActive | List user's invoices |
| GET | /v1/billing/payments/ | IsAuthenticatedAndActive | List user's payments |
| POST | /v1/billing/admin/payments/confirm/ | IsAuthenticatedAndActive | Confirm manual payment |
### 9.3 Site & Sector Endpoints
| Method | Endpoint | Permission | Purpose |
|--------|----------|------------|---------|
| GET | /v1/auth/sites/ | IsAuthenticated | List user's sites |
| POST | /v1/auth/sites/ | IsAuthenticated | Create site |
| GET | /v1/auth/sites/{id}/ | IsAuthenticatedAndActive | Get site details |
| POST | /v1/auth/sites/{id}/select_sectors/ | IsAuthenticatedAndActive | Add sectors to site |
| GET | /v1/auth/industries/ | AllowAny | List available industries |
---
## 10. Security & Permissions
### 10.1 Permission Classes
**IsAuthenticatedAndActive:**
- Requires valid JWT token
- Checks user.is_active == True
- Used for most authenticated endpoints
**HasTenantAccess:**
- Validates request.account matches user.account
- Ensures tenant isolation
- Bypassed for developers and superusers
**IsEditorOrAbove:**
- Requires role in ['developer', 'owner', 'admin', 'editor']
- Blocks 'viewer' and 'system_bot' roles
- Used for content modification
**IsOwnerOrAdmin:**
- Requires role in ['developer', 'owner', 'admin']
- Used for account management and billing
### 10.2 Data Isolation
**Query Filtering:**
- All models inherit from AccountBaseModel
- Automatic account filtering in querysets
- Middleware sets request.account
- Impossible to query cross-tenant data
**API Security:**
- JWT tokens include account_id claim
- Tokens validated on every request
- Expired tokens rejected
- Refresh tokens for long sessions
---
**End of Implementation Guide**
This document provides complete technical details of the IGNY8 multi-tenancy system as implemented on December 9, 2024.