Files
igny8/multi-tenancy/TENANCY-IMPLEMENTATION-GUIDE.md
IGNY8 VPS (Salman) 6a4f95c35a docs re-org
2025-12-09 13:26:35 +00:00

50 KiB
Raw Blame History

Multi-Tenancy Implementation Guide

IGNY8 Platform - Complete Technical Documentation
Last Updated: December 9, 2024
Status: Production Ready


Table of Contents

  1. Architecture Overview
  2. Core Models & Relationships
  3. Authentication System
  4. Payment Processing
  5. Multi-Currency System
  6. Site & Sector Management
  7. Credit System
  8. Admin Workflows
  9. API Endpoints
  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:

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

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

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

  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

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:

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

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:

{
    '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

  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.