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