50 KiB
Multi-Tenancy Implementation Guide
IGNY8 Platform - Complete Technical Documentation
Last Updated: December 9, 2024
Status: ✅ Production Ready
Table of Contents
- Architecture Overview
- Core Models & Relationships
- Authentication System
- Payment Processing
- Multi-Currency System
- Site & Sector Management
- Credit System
- Admin Workflows
- API Endpoints
- 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:
TenantMiddlewareextracts account from request- Attaches
request.accountto every request - Uses JWT token claim
account_idfor identification - Validates user belongs to account
Permission Level:
HasTenantAccesspermission class validates account ownershipIsAuthenticatedAndActiveensures 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:
AccountPaymentMethodmodel (not duplicated in Account/Subscription) - Period Dates:
Subscriptionmodel only (Invoice references via FK) - Plan Information:
Planmodel with historical tracking in Subscription - Billing Information:
Accountmodel 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 operationalpending_payment- Awaiting payment confirmationsuspended- Temporarily disabledcancelled- 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 tocredits_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 |
| 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:
-
Added
planField:- Previously relied on
account.planonly - Problem: Plan changes lost historical context
- Solution: Lock plan in subscription for billing period
- New subscription created on plan changes
- Previously relied on
-
Removed
payment_methodField:- Moved to property that queries
AccountPaymentMethod - Eliminates data duplication
- Single source of truth pattern
- Moved to property that queries
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:
-
Removed Duplicate Fields:
billing_period_start- Now in metadata, retrieved viasubscription.current_period_startbilling_period_end- Now in metadata, retrieved viasubscription.current_period_endbilling_email- Now in metadata snapshot, not separate field
-
Added Both Field Names:
- API returns both
totalandtotal_amountfor backward compatibility - Frontend can use either field name
- Migration path for existing integrations
- API returns both
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:
{
'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:
-
Removed Duplicate Reference Field:
- Had both
manual_referenceandtransaction_reference - Kept
manual_referenceonly - Single field for manual payment tracking
- Had both
-
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 tois_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:
{
# 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_slugin ['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.accessanddata.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:
{
'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:
{
'user_id': Integer,
'account_id': Integer,
'email': String,
'role': String,
'exp': Timestamp, # Now + 1 hour
'iat': Timestamp, # Issued at
'type': 'access'
}
Refresh Token:
{
'user_id': Integer,
'account_id': Integer,
'exp': Timestamp, # Now + 7 days
'iat': Timestamp,
'type': 'refresh'
}
Token Refresh Flow:
Endpoint: POST /v1/auth/refresh/
Process:
- Validate refresh token signature
- Check expiry (must not be expired)
- Extract user_id and account_id
- Fetch user and account from database
- Validate user.is_active and account status
- Generate new access token
- 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_tokenandrefresh_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
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:
const token = localStorage.getItem('access_token') ||
useAuthStore.getState().token
headers['Authorization'] = `Bearer ${token}`
401 Handling:
- Attempt token refresh with refresh_token
- If refresh succeeds: Retry original request with new token
- If refresh fails: Clear auth state and redirect to /signin
403 Handling:
- Check error message for authentication keywords
- If auth error: Force logout and redirect
- 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:
{ '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:
{
'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()
- Call
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:
- Get multiplier from CURRENCY_MULTIPLIERS dict
- Default to 1.0 if country not in table (uses USD)
- Calculate: local_amount = usd_amount × multiplier
- 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
totalandtotal_amountfields - Frontend can use either field name
- Currency code included in
currencyfield
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:
{
'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:
{
'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
- Select payment checkboxes
- Choose "Approve selected manual payments" from Actions dropdown
- Click "Go"
- Automatic workflow runs for each payment
- Success message shows details
Option 2: Individual Edit
- Click payment to open edit page
- Change Status dropdown from "Pending Approval" to "Succeeded"
- Add admin notes (optional)
- Click "Save"
- Automatic workflow runs on save
- 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.