Jainish1808 commited on
Commit
4e4664a
·
verified ·
1 Parent(s): 6365281

Upload folder using huggingface_hub

Browse files
.gitignore ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ .venv/
8
+ venv/
9
+ ENV/
10
+
11
+ # Environment
12
+ .env
13
+ .env.local
14
+ .env.*.local
15
+
16
+ # IDE
17
+ .vscode/
18
+ .idea/
19
+ *.swp
20
+ *.swo
21
+
22
+ # Logs
23
+ logs/
24
+ *.log
25
+
26
+ # OS
27
+ .DS_Store
28
+ Thumbs.db
Dockerfile ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hugging Face Spaces Dockerfile for FastAPI Backend
2
+ FROM python:3.10-slim
3
+
4
+ # Set working directory
5
+ WORKDIR /app
6
+
7
+ # Install system dependencies
8
+ RUN apt-get update && apt-get install -y \
9
+ gcc \
10
+ libffi-dev \
11
+ && rm -rf /var/lib/apt/lists/*
12
+
13
+ # Copy requirements first for caching
14
+ COPY requirements.txt .
15
+
16
+ # Install Python dependencies
17
+ RUN pip install --no-cache-dir -r requirements.txt
18
+
19
+ # Copy application code
20
+ COPY . .
21
+
22
+ # Create non-root user for security (required by HF Spaces)
23
+ RUN useradd -m -u 1000 user
24
+ USER user
25
+
26
+ # Set environment variables
27
+ ENV HOME=/home/user \
28
+ PATH=/home/user/.local/bin:$PATH \
29
+ PYTHONUNBUFFERED=1
30
+
31
+ # Expose port 7860 (Hugging Face Spaces default)
32
+ EXPOSE 7860
33
+
34
+ # Run FastAPI with uvicorn
35
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,10 +1,44 @@
1
  ---
2
- title: Atriumchain Api
3
- emoji: 👀
4
- colorFrom: yellow
5
- colorTo: blue
6
  sdk: docker
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: AtriumChain API
3
+ emoji: 🏠
4
+ colorFrom: blue
5
+ colorTo: purple
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
  ---
10
 
11
+ # AtriumChain Backend API
12
+
13
+ Real Estate Tokenization Platform API built with FastAPI and XRP Ledger.
14
+
15
+ ## Features
16
+
17
+ - User authentication with JWT
18
+ - Property tokenization on XRP Ledger
19
+ - Portfolio management
20
+ - KYC verification
21
+ - Admin dashboard APIs
22
+
23
+ ## API Documentation
24
+
25
+ Once deployed, access the API docs at:
26
+ - Swagger UI: `/docs`
27
+ - ReDoc: `/redoc`
28
+
29
+ ## Environment Variables
30
+
31
+ Configure these secrets in your Hugging Face Space settings:
32
+
33
+ | Variable | Description |
34
+ |----------|-------------|
35
+ | `SECRET_KEY` | JWT secret key (min 32 chars) |
36
+ | `MONGODB_URI` | MongoDB Atlas connection string |
37
+ | `MONGODB_DB_NAME` | Database name |
38
+ | `ENVIRONMENT` | `production` |
39
+ | `DEBUG_MODE` | `false` |
40
+
41
+ ## Health Check
42
+
43
+ - `GET /` - API status
44
+ - `GET /health` - Detailed health check
config.py ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic_settings import BaseSettings
2
+ from typing import List, Optional
3
+ from pathlib import Path
4
+
5
+ class Settings(BaseSettings):
6
+ # SECURITY: Must be set via .env file. No default for production safety.
7
+ SECRET_KEY: str # Required - generate with: openssl rand -base64 32
8
+ ALGORITHM: str = "HS256"
9
+ ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24
10
+
11
+ # MongoDB Atlas configuration
12
+ # Example: mongodb+srv://user:pass@cluster.mongodb.net/
13
+ # NOTE: Credentials present here only for legacy/dev; MUST be overridden in environment (.env)
14
+ # Recommendation: remove secrets before committing publicly.
15
+ # IMPORTANT: Provide via environment (.env). Default points to localhost for safety.
16
+ MONGODB_URI: str = "mongodb://localhost:27017" # Override in .env for Atlas or remote
17
+ MONGODB_DB_NAME: str = "atriumchain"
18
+ # SECURITY: Explicit allowed origins only - no wildcard "*" for production
19
+ # Add production domains here when deploying (e.g., "https://yourdomain.com")
20
+ CORS_ORIGINS: List[str] = [
21
+ "http://localhost:5173",
22
+ "http://127.0.0.1:5173",
23
+ "http://localhost:5174",
24
+ "http://127.0.0.1:5174",
25
+ "http://localhost:5175",
26
+ "http://127.0.0.1:5175"
27
+ ]
28
+ ALLOWED_ORIGINS: Optional[str] = None # comma-separated override list; if set replaces CORS_ORIGINS
29
+
30
+ XRPL_NETWORK: str = "testnet" # testnet only
31
+ ISSUER_SEED: str | None = None # DEPRECATED: Use admin wallet instead (kept for backward compatibility)
32
+
33
+ # TOKEN MODEL: IOU for fractional real estate ownership
34
+ # Uses XRPL issued currencies for fungible property tokens
35
+ XRPL_TOKEN_MODEL: str = "IOU" # Fixed to IOU - the correct model for fractional ownership
36
+
37
+ # Blockchain integration is mandatory for production
38
+ XRPL_LEDGER_PURCHASE_ENABLED: bool = True
39
+
40
+ # Configurable AED to XRP demo conversion rate (used only for displaying estimated costs
41
+ # and in IOU path cost calculation). Previously hard-coded to 1e-6.
42
+ # NOTE: This should be replaced with real-time oracle in production
43
+ XRPL_RATE_AED_TO_XRP: float = 0.000001
44
+
45
+ # Unified explorer base (avoid hard-coded duplicates elsewhere)
46
+ XRPL_EXPLORER_BASE: str = "https://testnet.xrpl.org/transactions/"
47
+ # Unified RPC URL (replace ad-hoc constants in services)
48
+ XRPL_RPC_URL: str = "https://s.altnet.rippletest.net:51234/"
49
+
50
+ # Optional encryption key (32 byte raw, hex, or Fernet base64). If absent seeds remain legacy/base64.
51
+ ENCRYPTION_KEY: str | None = None
52
+
53
+ # Security Configuration
54
+ RATE_LIMIT_ENABLED: bool = True
55
+ MAX_LOGIN_ATTEMPTS: int = 5
56
+ LOGIN_LOCKOUT_MINUTES: int = 15
57
+ REQUIRE_STRONG_PASSWORDS: bool = True
58
+ MIN_PASSWORD_LENGTH: int = 8
59
+
60
+ # Session Configuration
61
+ SESSION_TIMEOUT_MINUTES: int = 60
62
+ REFRESH_TOKEN_EXPIRE_DAYS: int = 7
63
+
64
+ # IPFS Configuration for Decentralized Document Storage
65
+ WEB3_STORAGE_API_TOKEN: str | None = None # Get free token from https://web3.storage
66
+ PINATA_API_KEY: str | None = None # Get free API key from https://pinata.cloud
67
+ PINATA_SECRET_KEY: str | None = None # Secret key from Pinata dashboard
68
+ IPFS_GATEWAY: str = "ipfs.io" # Default public IPFS gateway
69
+ IPFS_BACKUP_GATEWAY: str = "cloudflare-ipfs.com" # Backup gateway
70
+ IPFS_PROVIDER: str = "local_simulation" # Options: "pinata", "web3storage", "local_simulation"
71
+
72
+ # Monitoring and Logging Configuration
73
+ LOG_LEVEL: str = "INFO" # DEBUG, INFO, WARNING, ERROR, CRITICAL
74
+ LOG_FILE_ENABLED: bool = True
75
+ LOG_FILE_PATH: str = "logs/app.log"
76
+ LOG_FILE_MAX_SIZE: int = 10485760 # 10MB in bytes
77
+ LOG_FILE_BACKUP_COUNT: int = 5
78
+
79
+ # Performance Monitoring
80
+ ENABLE_PERFORMANCE_MONITORING: bool = True
81
+ SLOW_QUERY_THRESHOLD: float = 1.0 # Log queries taking longer than 1 second
82
+
83
+ # Transaction Monitoring
84
+ ENABLE_TRANSACTION_MONITORING: bool = True
85
+ BLOCKCHAIN_TIMEOUT_SECONDS: int = 30
86
+
87
+ # Environment-specific settings
88
+ ENVIRONMENT: str = "development" # development, staging, production
89
+ DEBUG_MODE: bool = True # Set to False in production
90
+
91
+ def encryption_enabled(self) -> bool:
92
+ return bool(self.ENCRYPTION_KEY)
93
+
94
+ def is_production(self) -> bool:
95
+ return self.ENVIRONMENT.lower() == "production"
96
+
97
+ def is_development(self) -> bool:
98
+ return self.ENVIRONMENT.lower() == "development"
99
+
100
+ def validate_configuration(self): # renamed from validate_issuer_seed for clarity
101
+ """Validate configuration settings and log important information"""
102
+
103
+ # Validate IOU token configuration
104
+ if not self.ISSUER_SEED:
105
+ print("[CONFIG][INFO] ISSUER_SEED not set – using admin wallet for token issuance (recommended).")
106
+
107
+ # Configure CORS origins
108
+ if self.ALLOWED_ORIGINS:
109
+ self.CORS_ORIGINS = [o.strip() for o in self.ALLOWED_ORIGINS.split(',') if o.strip()]
110
+
111
+ # Validate environment-specific settings
112
+ if self.is_production():
113
+ # CRITICAL: Validate production security requirements
114
+ if self.DEBUG_MODE:
115
+ print("[CONFIG][ERROR] DEBUG_MODE is enabled in production environment!")
116
+ print("[CONFIG][ERROR] Set DEBUG_MODE=false in .env file immediately!")
117
+ raise ValueError("DEBUG_MODE must be disabled in production environment")
118
+
119
+ # H1/H3: Enforce encryption key in production for wallet seed security
120
+ if not self.ENCRYPTION_KEY:
121
+ print("[CONFIG][ERROR] ENCRYPTION_KEY not set in production environment!")
122
+ print("[CONFIG][ERROR] Wallet seeds will not be encrypted. This is a security risk.")
123
+ print("[CONFIG][INFO] Generate key: python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\"")
124
+ raise ValueError("ENCRYPTION_KEY is required in production environment")
125
+ else:
126
+ print("[CONFIG][INFO] ✅ Encryption enabled for wallet seeds.")
127
+ else:
128
+ # Development environment warnings
129
+ if not self.ENCRYPTION_KEY:
130
+ print("[CONFIG][WARNING] ENCRYPTION_KEY not set – wallet seeds stored with legacy obfuscation.")
131
+ print("[CONFIG][WARNING] For production, generate with: python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\"")
132
+ else:
133
+ print("[CONFIG][INFO] ✅ Encryption enabled for wallet seeds.")
134
+
135
+ # Log monitoring configuration
136
+ print(f"[CONFIG][INFO] Logging level: {self.LOG_LEVEL}")
137
+ print(f"[CONFIG][INFO] Environment: {self.ENVIRONMENT}")
138
+ print(f"[CONFIG][INFO] Performance monitoring: {'Enabled' if self.ENABLE_PERFORMANCE_MONITORING else 'Disabled'}")
139
+ print(f"[CONFIG][INFO] Transaction monitoring: {'Enabled' if self.ENABLE_TRANSACTION_MONITORING else 'Disabled'}")
140
+
141
+ class Config:
142
+ env_file = Path(__file__).parent / ".env"
143
+
144
+ settings = Settings()
145
+ settings.validate_configuration()
create_super_admin.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Script to create the first Super Admin user
3
+ Run this once to initialize the platform with a Super Admin account
4
+ Usage: python create_super_admin.py
5
+ """
6
+ import asyncio
7
+ from datetime import datetime
8
+ from pymongo import MongoClient
9
+ from passlib.context import CryptContext
10
+ from config import settings
11
+
12
+ # Password hashing
13
+ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
14
+
15
+
16
+ async def create_first_super_admin():
17
+ """Create the first Super Admin user in the database"""
18
+
19
+ print("\n" + "="*80)
20
+ print("SUPER ADMIN INITIALIZATION SCRIPT")
21
+ print("="*80 + "\n")
22
+
23
+ # Connect to MongoDB
24
+ try:
25
+ client = MongoClient(settings.MONGODB_URI)
26
+ db = client[settings.MONGODB_DB_NAME]
27
+ users_collection = db["users"]
28
+ print("[[SUCCESS]] Connected to MongoDB successfully")
29
+ except Exception as e:
30
+ print(f"[✗] Failed to connect to MongoDB: {str(e)}")
31
+ return
32
+
33
+ # Check if super admin already exists
34
+ existing_super_admin = users_collection.find_one({"role": "super_admin"})
35
+ if existing_super_admin:
36
+ print(f"\n[!] Super Admin already exists: {existing_super_admin['email']}")
37
+ print("[!] Aborting to prevent duplicate Super Admin accounts")
38
+ client.close()
39
+ return
40
+
41
+ # Get Super Admin credentials
42
+ print("\nEnter Super Admin credentials:")
43
+ print("-" * 40)
44
+
45
+ email = input("Email: ").strip()
46
+ if not email or "@" not in email:
47
+ print("[✗] Invalid email address")
48
+ client.close()
49
+ return
50
+
51
+ # Check if email already exists
52
+ existing_user = users_collection.find_one({"email": email})
53
+ if existing_user:
54
+ print(f"[!] User with email '{email}' already exists")
55
+
56
+ # Ask if want to promote existing user to super_admin
57
+ promote = input("\nPromote this user to Super Admin? (yes/no): ").strip().lower()
58
+ if promote == "yes":
59
+ result = users_collection.update_one(
60
+ {"email": email},
61
+ {"$set": {"role": "super_admin", "updated_at": datetime.utcnow()}}
62
+ )
63
+
64
+ if result.modified_count > 0:
65
+ print(f"\n[[SUCCESS]] User '{email}' promoted to Super Admin successfully!")
66
+ else:
67
+ print("[✗] Failed to promote user")
68
+
69
+ client.close()
70
+ return
71
+ else:
72
+ print("[!] Operation cancelled")
73
+ client.close()
74
+ return
75
+
76
+ password = input("Password: ").strip()
77
+ if len(password) < 8:
78
+ print("[✗] Password must be at least 8 characters long")
79
+ client.close()
80
+ return
81
+
82
+ name = input("Full Name: ").strip()
83
+ if not name:
84
+ print("[✗] Full name is required")
85
+ client.close()
86
+ return
87
+
88
+ phone = input("Phone (10 digits): ").strip()
89
+ if not phone or len(phone) != 10 or not phone.isdigit():
90
+ print("[✗] Phone must be exactly 10 digits")
91
+ client.close()
92
+ return
93
+
94
+ # Hash password
95
+ hashed_password = pwd_context.hash(password)
96
+
97
+ # Create Super Admin user document
98
+ super_admin_user = {
99
+ "email": email,
100
+ "password_hash": hashed_password,
101
+ "name": name,
102
+ "phone": phone,
103
+ "role": "super_admin",
104
+ "is_active": True,
105
+ "wallet_id": None,
106
+ "deleted": False,
107
+ "kyc_status": "approved", # Pre-approved
108
+ "created_at": datetime.utcnow(),
109
+ "updated_at": datetime.utcnow()
110
+ }
111
+
112
+ try:
113
+ # Insert Super Admin
114
+ result = users_collection.insert_one(super_admin_user)
115
+
116
+ if result.inserted_id:
117
+ print("\n" + "="*80)
118
+ print("[SUCCESS] SUPER ADMIN CREATED SUCCESSFULLY!")
119
+ print("="*80)
120
+ print(f"\nEmail: {email}")
121
+ print(f"Name: {name}")
122
+ print(f"Phone: {phone}")
123
+ print(f"Role: super_admin")
124
+ print(f"User ID: {str(result.inserted_id)}")
125
+ print("\n[!] Please save these credentials securely!")
126
+ print("="*80 + "\n")
127
+ else:
128
+ print("\n[✗] Failed to create Super Admin")
129
+
130
+ except Exception as e:
131
+ print(f"\n[✗] Error creating Super Admin: {str(e)}")
132
+
133
+ finally:
134
+ client.close()
135
+
136
+
137
+ if __name__ == "__main__":
138
+ asyncio.run(create_first_super_admin())
db.py ADDED
@@ -0,0 +1,429 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Production Database Initialization
3
+ Sets up MongoDB with proper indexes and collections
4
+ """
5
+ from typing import Optional
6
+ from pymongo import MongoClient, ASCENDING, DESCENDING, TEXT
7
+ from pymongo.errors import CollectionInvalid
8
+ from config import settings
9
+ from utils.crypto_utils import encrypt_secret, is_encrypted
10
+ import base64
11
+
12
+
13
+ _client: Optional[MongoClient] = None
14
+
15
+
16
+ def get_client() -> MongoClient:
17
+ """Get MongoDB client (singleton) with connection pooling"""
18
+ global _client
19
+ if _client is None:
20
+ # Mask credentials in URI for logging
21
+ uri = settings.MONGODB_URI
22
+ masked = uri
23
+ try:
24
+ if "@" in uri and "://" in uri:
25
+ scheme_sep = uri.split("://", 1)
26
+ scheme, rest = scheme_sep[0], scheme_sep[1]
27
+ if "@" in rest:
28
+ creds, host = rest.split("@", 1)
29
+ masked_creds = "***:***" if ":" in creds else "***"
30
+ masked = f"{scheme}://{masked_creds}@{host}"
31
+ except Exception:
32
+ masked = "<redacted>"
33
+ print(f"[DB] Connecting to MongoDB: {masked}")
34
+
35
+ # Connection pool configuration for production workloads
36
+ _client = MongoClient(
37
+ settings.MONGODB_URI,
38
+ maxPoolSize=50, # Maximum connections in pool
39
+ minPoolSize=5, # Minimum connections to maintain
40
+ maxIdleTimeMS=30000, # Close idle connections after 30s
41
+ serverSelectionTimeoutMS=5000, # Fast fail on connection issues
42
+ connectTimeoutMS=10000, # Connection timeout
43
+ retryWrites=True, # Auto-retry on transient errors
44
+ )
45
+ # Test connection
46
+ _client.admin.command('ping')
47
+ print("[DB] [SUCCESS] MongoDB connection successful (pool: 5-50)")
48
+ return _client
49
+
50
+
51
+ def get_db():
52
+ """Get database instance"""
53
+ client = get_client()
54
+ return client[settings.MONGODB_DB_NAME]
55
+
56
+
57
+ def get_mongo():
58
+ """Generator for FastAPI dependency injection"""
59
+ db = get_db()
60
+ try:
61
+ yield db
62
+ finally:
63
+ pass # Connection pooling handles cleanup
64
+
65
+
66
+ def get_next_sequence(db, name: str) -> int:
67
+ """
68
+ Get the next sequence number for auto-incrementing IDs.
69
+ Used for generating unique sequential identifiers.
70
+
71
+ Args:
72
+ db: Database connection
73
+ name: Name of the sequence (e.g., 'property_id', 'user_id')
74
+
75
+ Returns:
76
+ Next sequence number
77
+ """
78
+ from pymongo import ReturnDocument
79
+ doc = db.counters.find_one_and_update(
80
+ {"_id": name},
81
+ {"$inc": {"seq": 1}},
82
+ upsert=True,
83
+ return_document=ReturnDocument.AFTER,
84
+ )
85
+ return int(doc.get("seq", 1))
86
+
87
+
88
+ def create_collections(db):
89
+ """Create all required collections"""
90
+ print("[DB] Creating collections...")
91
+
92
+ collections = [
93
+ 'users', 'wallets', 'properties', 'property_specifications',
94
+ 'amenities', 'property_images', 'investments', 'transactions',
95
+ 'portfolios', 'documents', 'secondary_market_transactions',
96
+ 'otps', 'sessions', 'certificates', 'rent_distributions',
97
+ 'rent_payments', 'counters', 'tokens', 'funded_properties'
98
+ ]
99
+
100
+ existing = db.list_collection_names()
101
+
102
+ for coll_name in collections:
103
+ if coll_name not in existing:
104
+ try:
105
+ db.create_collection(coll_name)
106
+ print(f"[DB] [SUCCESS] Created collection: {coll_name}")
107
+ except CollectionInvalid:
108
+ pass # Collection already exists
109
+
110
+
111
+ def ensure_indexes(db):
112
+ """Create all database indexes for performance and uniqueness"""
113
+ print("[DB] Creating indexes...")
114
+
115
+ # ========================================================================
116
+ # USERS COLLECTION
117
+ # ========================================================================
118
+ db.users.create_index([("email", ASCENDING)], unique=True, name="idx_email_unique")
119
+ db.users.create_index([("role", ASCENDING)], name="idx_role")
120
+ db.users.create_index([("is_active", ASCENDING)], name="idx_is_active")
121
+ db.users.create_index([("deleted", ASCENDING)], name="idx_deleted")
122
+ db.users.create_index([("created_at", DESCENDING)], name="idx_created_at")
123
+ print("[DB] [SUCCESS] Users indexes created")
124
+
125
+ # ========================================================================
126
+ # OTPs COLLECTION (for email verification)
127
+ # ========================================================================
128
+ db.otps.create_index([("email", ASCENDING), ("purpose", ASCENDING)], name="idx_email_purpose")
129
+ # TTL index: automatically deletes OTPs after they expire
130
+ db.otps.create_index([("expires_at", ASCENDING)], name="idx_expires_at_ttl", expireAfterSeconds=0)
131
+ db.otps.create_index([("verified", ASCENDING)], name="idx_verified")
132
+ db.otps.create_index([("created_at", DESCENDING)], name="idx_created_at")
133
+ print("[DB] [SUCCESS] OTPs indexes created (with TTL)")
134
+
135
+ # ========================================================================
136
+ # WALLETS COLLECTION
137
+ # ========================================================================
138
+ db.wallets.create_index([("user_id", ASCENDING)], unique=True, name="idx_user_id_unique")
139
+ db.wallets.create_index([("is_active", ASCENDING)], name="idx_is_active")
140
+ print("[DB] [SUCCESS] Wallets indexes created")
141
+
142
+ # ========================================================================
143
+ # PROPERTIES COLLECTION
144
+ # ========================================================================
145
+ db.properties.create_index([("title", TEXT)], name="idx_title_text")
146
+ db.properties.create_index([("location", ASCENDING)], name="idx_location")
147
+ db.properties.create_index([("property_type", ASCENDING)], name="idx_property_type")
148
+ db.properties.create_index([("is_active", ASCENDING)], name="idx_is_active")
149
+ db.properties.create_index([("deleted", ASCENDING)], name="idx_deleted")
150
+ db.properties.create_index([("created_by", ASCENDING)], name="idx_created_by")
151
+ db.properties.create_index([("created_at", DESCENDING)], name="idx_created_at")
152
+ db.properties.create_index([("funded_date", DESCENDING)], name="idx_funded_date")
153
+ db.properties.create_index([("available_tokens", ASCENDING)], name="idx_available_tokens")
154
+ print("[DB] [SUCCESS] Properties indexes created")
155
+
156
+ # ========================================================================
157
+ # PROPERTY SPECIFICATIONS COLLECTION
158
+ # ========================================================================
159
+ db.property_specifications.create_index(
160
+ [("property_id", ASCENDING)],
161
+ unique=True,
162
+ name="idx_property_id_unique"
163
+ )
164
+ print("[DB] [SUCCESS] Property specifications indexes created")
165
+
166
+ # ========================================================================
167
+ # AMENITIES COLLECTION
168
+ # ========================================================================
169
+ db.amenities.create_index([("property_id", ASCENDING)], name="idx_property_id")
170
+ db.amenities.create_index([("is_active", ASCENDING)], name="idx_is_active")
171
+ print("[DB] [SUCCESS] Amenities indexes created")
172
+
173
+ # ========================================================================
174
+ # PROPERTY IMAGES COLLECTION
175
+ # ========================================================================
176
+ db.property_images.create_index([("property_id", ASCENDING)], name="idx_property_id")
177
+ db.property_images.create_index([("is_main", ASCENDING)], name="idx_is_main")
178
+ db.property_images.create_index([("is_active", ASCENDING)], name="idx_is_active")
179
+ print("[DB] [SUCCESS] Property images indexes created")
180
+
181
+ # ========================================================================
182
+ # INVESTMENTS COLLECTION
183
+ # ========================================================================
184
+ db.investments.create_index([("user_id", ASCENDING)], name="idx_user_id")
185
+ db.investments.create_index([("property_id", ASCENDING)], name="idx_property_id")
186
+ db.investments.create_index(
187
+ [("user_id", ASCENDING), ("property_id", ASCENDING)],
188
+ name="idx_user_property"
189
+ )
190
+ db.investments.create_index([("status", ASCENDING)], name="idx_status")
191
+ db.investments.create_index([("created_at", DESCENDING)], name="idx_created_at")
192
+ print("[DB] [SUCCESS] Investments indexes created")
193
+
194
+ # ========================================================================
195
+ # TRANSACTIONS COLLECTION
196
+ # ========================================================================
197
+ db.transactions.create_index([("user_id", ASCENDING)], name="idx_user_id")
198
+ db.transactions.create_index([("wallet_id", ASCENDING)], name="idx_wallet_id")
199
+ db.transactions.create_index([("property_id", ASCENDING)], name="idx_property_id")
200
+ db.transactions.create_index([("type", ASCENDING)], name="idx_type")
201
+ db.transactions.create_index([("status", ASCENDING)], name="idx_status")
202
+ db.transactions.create_index([("created_at", DESCENDING)], name="idx_created_at")
203
+ db.transactions.create_index(
204
+ [("user_id", ASCENDING), ("type", ASCENDING)],
205
+ name="idx_user_type"
206
+ )
207
+ print("[DB] [SUCCESS] Transactions indexes created")
208
+
209
+ # ========================================================================
210
+ # PORTFOLIOS COLLECTION
211
+ # ========================================================================
212
+ db.portfolios.create_index([("user_id", ASCENDING)], unique=True, name="idx_user_id_unique")
213
+ db.portfolios.create_index([("updated_at", DESCENDING)], name="idx_updated_at")
214
+ print("[DB] [SUCCESS] Portfolios indexes created")
215
+
216
+ # ========================================================================
217
+ # DOCUMENTS COLLECTION
218
+ # ========================================================================
219
+ db.documents.create_index([("property_id", ASCENDING)], name="idx_property_id")
220
+ db.documents.create_index([("file_type", ASCENDING)], name="idx_file_type")
221
+ db.documents.create_index([("uploaded_by", ASCENDING)], name="idx_uploaded_by")
222
+ print("[DB] [SUCCESS] Documents indexes created")
223
+
224
+ # ========================================================================
225
+ # SECONDARY MARKET TRANSACTIONS COLLECTION
226
+ # ========================================================================
227
+ db.secondary_market_transactions.create_index(
228
+ [("transaction_id", ASCENDING)],
229
+ unique=True,
230
+ name="idx_transaction_id_unique"
231
+ )
232
+ db.secondary_market_transactions.create_index(
233
+ [("seller_id", ASCENDING)],
234
+ name="idx_seller_id"
235
+ )
236
+ db.secondary_market_transactions.create_index(
237
+ [("buyer_id", ASCENDING)],
238
+ name="idx_buyer_id"
239
+ )
240
+ db.secondary_market_transactions.create_index(
241
+ [("property_id", ASCENDING)],
242
+ name="idx_property_id"
243
+ )
244
+ db.secondary_market_transactions.create_index(
245
+ [("status", ASCENDING)],
246
+ name="idx_status"
247
+ )
248
+ db.secondary_market_transactions.create_index(
249
+ [("transaction_type", ASCENDING)],
250
+ name="idx_transaction_type"
251
+ )
252
+ db.secondary_market_transactions.create_index(
253
+ [("initiated_at", DESCENDING)],
254
+ name="idx_initiated_at"
255
+ )
256
+ db.secondary_market_transactions.create_index(
257
+ [("seller_id", ASCENDING), ("status", ASCENDING)],
258
+ name="idx_seller_status"
259
+ )
260
+ db.secondary_market_transactions.create_index(
261
+ [("property_id", ASCENDING), ("status", ASCENDING)],
262
+ name="idx_property_status"
263
+ )
264
+ db.secondary_market_transactions.create_index(
265
+ [("blockchain_tx_hash", ASCENDING)],
266
+ name="idx_blockchain_tx"
267
+ )
268
+ print("[DB] [SUCCESS] Secondary market transactions indexes created")
269
+
270
+ # ========================================================================
271
+ # SESSIONS COLLECTION (Device-bound Authentication)
272
+ # ========================================================================
273
+ db.sessions.create_index([("session_id", ASCENDING)], unique=True, name="idx_session_id_unique")
274
+ db.sessions.create_index([("user_id", ASCENDING)], name="idx_user_id")
275
+ db.sessions.create_index([("expires_at", ASCENDING)], name="idx_expires_at_ttl", expireAfterSeconds=0)
276
+ db.sessions.create_index(
277
+ [("user_id", ASCENDING), ("device_fingerprint", ASCENDING)],
278
+ name="idx_user_device"
279
+ )
280
+ db.sessions.create_index([("is_active", ASCENDING)], name="idx_is_active")
281
+ print("[DB] [SUCCESS] Sessions indexes created (with TTL)")
282
+
283
+ # ========================================================================
284
+ # CERTIFICATES COLLECTION (Ownership Certificates)
285
+ # ========================================================================
286
+ db.certificates.create_index([("certificate_id", ASCENDING)], unique=True, name="idx_certificate_id_unique")
287
+ db.certificates.create_index([("user_id", ASCENDING)], name="idx_user_id")
288
+ db.certificates.create_index(
289
+ [("user_id", ASCENDING), ("property_details.property_id", ASCENDING)],
290
+ name="idx_user_property"
291
+ )
292
+ db.certificates.create_index([("issued_date", ASCENDING)], name="idx_issued_date")
293
+ db.certificates.create_index([("is_valid", ASCENDING)], name="idx_is_valid")
294
+ print("[DB] [SUCCESS] Certificates indexes created")
295
+
296
+ # ========================================================================
297
+ # RENT DISTRIBUTIONS COLLECTION
298
+ # ========================================================================
299
+ db.rent_distributions.create_index([("property_id", ASCENDING)], name="idx_property_id")
300
+ db.rent_distributions.create_index([("distribution_date", DESCENDING)], name="idx_distribution_date")
301
+ db.rent_distributions.create_index([("status", ASCENDING)], name="idx_status")
302
+ db.rent_distributions.create_index([("created_at", DESCENDING)], name="idx_created_at")
303
+ db.rent_distributions.create_index(
304
+ [("property_id", ASCENDING), ("status", ASCENDING)],
305
+ name="idx_property_status"
306
+ )
307
+ print("[DB] [SUCCESS] Rent distributions indexes created")
308
+
309
+ # ========================================================================
310
+ # RENT PAYMENTS COLLECTION
311
+ # ========================================================================
312
+ db.rent_payments.create_index([("distribution_id", ASCENDING)], name="idx_distribution_id")
313
+ db.rent_payments.create_index([("user_id", ASCENDING)], name="idx_user_id")
314
+ db.rent_payments.create_index([("property_id", ASCENDING)], name="idx_property_id")
315
+ db.rent_payments.create_index(
316
+ [("user_id", ASCENDING), ("property_id", ASCENDING)],
317
+ name="idx_user_property"
318
+ )
319
+ db.rent_payments.create_index([("payment_date", DESCENDING)], name="idx_payment_date")
320
+ db.rent_payments.create_index([("payment_status", ASCENDING)], name="idx_payment_status")
321
+ print("[DB] [SUCCESS] Rent payments indexes created")
322
+
323
+ # ========================================================================
324
+ # TOKENS COLLECTION (Property Tokens)
325
+ # ========================================================================
326
+ db.tokens.create_index([("property_id", ASCENDING)], unique=True, name="idx_property_id_unique")
327
+ db.tokens.create_index([("is_active", ASCENDING)], name="idx_is_active")
328
+ print("[DB] [SUCCESS] Tokens indexes created")
329
+
330
+ # ========================================================================
331
+ # FUNDED PROPERTIES COLLECTION (Fully Funded Properties Archive)
332
+ # ========================================================================
333
+ db.funded_properties.create_index([("property_id", ASCENDING)], unique=True, name="idx_property_id_unique")
334
+ db.funded_properties.create_index([("funded_date", DESCENDING)], name="idx_funded_date")
335
+ db.funded_properties.create_index([("funding_status", ASCENDING)], name="idx_funding_status")
336
+ db.funded_properties.create_index([("is_active", ASCENDING)], name="idx_is_active")
337
+ db.funded_properties.create_index([("created_at", DESCENDING)], name="idx_created_at")
338
+ print("[DB] [SUCCESS] Funded properties indexes created")
339
+
340
+ print("[DB] ✅ All indexes created successfully")
341
+
342
+
343
+ def init_mongo(app=None):
344
+ """Initialize MongoDB database"""
345
+ print("\n" + "="*80)
346
+ print("INITIALIZING MONGODB DATABASE")
347
+ print("="*80 + "\n")
348
+
349
+ try:
350
+ db = get_db()
351
+
352
+ # Create collections
353
+ create_collections(db)
354
+
355
+ # Create indexes
356
+ ensure_indexes(db)
357
+
358
+ # Optional: migrate legacy wallet seeds to encrypted form if encryption enabled
359
+ try:
360
+ if settings.encryption_enabled():
361
+ wallets = db.wallets.find({"xrp_seed": {"$exists": True, "$ne": None}})
362
+ migrated = 0
363
+ for w in wallets:
364
+ seed_val = w.get("xrp_seed")
365
+ if not seed_val or is_encrypted(seed_val):
366
+ continue
367
+ # Detect base64 vs plain: attempt decode
368
+ try:
369
+ decoded = base64.b64decode(seed_val).decode()
370
+ candidate = decoded if decoded.startswith("s") else seed_val
371
+ except Exception:
372
+ candidate = seed_val
373
+ new_enc = encrypt_secret(candidate, settings.ENCRYPTION_KEY)
374
+ db.wallets.update_one({"_id": w["_id"]}, {"$set": {"xrp_seed": new_enc}})
375
+ migrated += 1
376
+ if migrated:
377
+ print(f"[DB] [SUCCESS] Migrated {migrated} wallet seeds to encrypted format")
378
+ except Exception as mig_err:
379
+ print(f"[DB][WARNING] Seed encryption migration skipped: {mig_err}")
380
+
381
+ # Attach to FastAPI app if provided
382
+ if app:
383
+ app.state.mongo = db
384
+ print("[DB] [SUCCESS] Database attached to FastAPI app")
385
+
386
+ print("\n[DB] ✅ Database initialization complete!")
387
+ print("="*80 + "\n")
388
+
389
+ return db
390
+
391
+ except Exception as e:
392
+ print(f"\n[DB] [ERROR] ERROR during initialization: {str(e)}")
393
+ import traceback
394
+ traceback.print_exc()
395
+ raise
396
+
397
+
398
+ def close_mongo():
399
+ """Close MongoDB connection"""
400
+ global _client
401
+ if _client:
402
+ _client.close()
403
+ _client = None
404
+ print("[DB] Connection closed")
405
+
406
+
407
+ # Optional: Database health check
408
+ def check_db_health():
409
+ """Check database connectivity and health"""
410
+ try:
411
+ client = get_client()
412
+ # Ping the database
413
+ client.admin.command('ping')
414
+
415
+ # Check database exists
416
+ db = get_db()
417
+ collections = db.list_collection_names()
418
+
419
+ return {
420
+ "status": "healthy",
421
+ "database": settings.MONGODB_DB_NAME,
422
+ "collections": len(collections),
423
+ "collection_names": collections
424
+ }
425
+ except Exception as e:
426
+ return {
427
+ "status": "unhealthy",
428
+ "error": str(e)
429
+ }
main.py ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Production-Ready FastAPI Application
3
+ Real Estate Tokenization Platform - AtriumChain Platform
4
+ """
5
+ from fastapi import FastAPI, status, Request
6
+ from fastapi.middleware.cors import CORSMiddleware
7
+ from fastapi.middleware.httpsredirect import HTTPSRedirectMiddleware
8
+ from fastapi.responses import JSONResponse
9
+ from contextlib import asynccontextmanager
10
+ from slowapi import _rate_limit_exceeded_handler
11
+ from slowapi.errors import RateLimitExceeded
12
+
13
+ from config import settings
14
+ from db import init_mongo, close_mongo, check_db_health
15
+ from utils.logger import setup_logging
16
+ from middleware.security import SecurityHeadersMiddleware, RequestIDMiddleware, limiter
17
+
18
+ # Import routes
19
+ from routes import (
20
+ auth,
21
+ otp,
22
+ properties,
23
+ market,
24
+ wallet,
25
+ portfolio,
26
+ admin,
27
+ super_admin,
28
+ secondary_market,
29
+ profile
30
+ )
31
+
32
+
33
+ @asynccontextmanager
34
+ async def lifespan(app: FastAPI):
35
+ """Application lifespan events"""
36
+ print("\n" + "="*80)
37
+ print("STARTING REAL ESTATE TOKENIZATION PLATFORM")
38
+ print("="*80 + "\n")
39
+
40
+ # Startup
41
+ try:
42
+ # Initialize logging system
43
+ setup_logging()
44
+ print("[APP] ✅ Logging system initialized")
45
+
46
+ db = init_mongo(app)
47
+ print("[APP] ✅ Application startup complete\n")
48
+ except Exception as e:
49
+ print(f"[APP] [ERROR] Startup failed: {str(e)}\n")
50
+ raise
51
+
52
+ yield
53
+
54
+ # Shutdown
55
+ print("\n[APP] Shutting down...")
56
+ close_mongo()
57
+ print("[APP] ✅ Shutdown complete\n")
58
+
59
+
60
+ # Create FastAPI app
61
+ app = FastAPI(
62
+ title="Real Estate Tokenization Platform",
63
+ description="Production-ready platform for fractional real estate investment using blockchain",
64
+ version="2.0.0",
65
+ lifespan=lifespan
66
+ )
67
+
68
+ # Add HTTPS redirect middleware in production
69
+ if settings.is_production():
70
+ app.add_middleware(HTTPSRedirectMiddleware)
71
+ print("[APP] ✅ HTTPS enforcement enabled (production mode)")
72
+
73
+ # Add security middleware
74
+ app.add_middleware(SecurityHeadersMiddleware)
75
+ app.add_middleware(RequestIDMiddleware)
76
+
77
+ # Add rate limiter
78
+ app.state.limiter = limiter
79
+ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
80
+
81
+ # CORS middleware
82
+ app.add_middleware(
83
+ CORSMiddleware,
84
+ allow_origins=settings.CORS_ORIGINS,
85
+ allow_credentials=True,
86
+ allow_methods=["*"],
87
+ allow_headers=["*"],
88
+ )
89
+
90
+
91
+ # Health check endpoint
92
+ @app.get("/", tags=["Health"])
93
+ async def root():
94
+ """Root endpoint - API health check"""
95
+ return {
96
+ "status": "online",
97
+ "service": "Real Estate Tokenization Platform",
98
+ "version": "2.0.0",
99
+ "environment": settings.XRPL_NETWORK
100
+ }
101
+
102
+
103
+ @app.get("/health", tags=["Health"])
104
+ async def health_check():
105
+ """Detailed health check including database connectivity"""
106
+ db_health = check_db_health()
107
+
108
+ return {
109
+ "status": "healthy" if db_health["status"] == "healthy" else "degraded",
110
+ "api": "online",
111
+ "database": db_health
112
+ }
113
+
114
+
115
+ # Include routers
116
+ app.include_router(
117
+ auth.router,
118
+ prefix="/api/v1/auth",
119
+ tags=["Authentication"]
120
+ )
121
+
122
+ app.include_router(
123
+ otp.router,
124
+ prefix="/api/v1/otp",
125
+ tags=["OTP Verification"]
126
+ )
127
+
128
+ app.include_router(
129
+ properties.router,
130
+ prefix="/api/v1",
131
+ tags=["Properties"]
132
+ )
133
+
134
+ app.include_router(
135
+ market.router,
136
+ prefix="/api/v1",
137
+ tags=["Market"]
138
+ )
139
+
140
+ app.include_router(
141
+ secondary_market.router,
142
+ prefix="/api/v1",
143
+ tags=["Secondary Market"]
144
+ )
145
+
146
+ app.include_router(
147
+ wallet.router,
148
+ prefix="/api/v1",
149
+ tags=["Wallet"]
150
+ )
151
+
152
+ app.include_router(
153
+ portfolio.router,
154
+ prefix="/api/v1",
155
+ tags=["Portfolio"]
156
+ )
157
+
158
+ app.include_router(
159
+ admin.router,
160
+ prefix="/api/v1",
161
+ tags=["Admin"]
162
+ )
163
+
164
+ app.include_router(
165
+ super_admin.router,
166
+ prefix="/api/v1",
167
+ tags=["Super Admin"]
168
+ )
169
+
170
+ app.include_router(
171
+ profile.router,
172
+ prefix="/api/v1/profile",
173
+ tags=["Profile"]
174
+ )
175
+
176
+
177
+ # Global exception handler (sanitized)
178
+ @app.exception_handler(Exception)
179
+ async def global_exception_handler(request, exc):
180
+ """Handle all unhandled exceptions (hide internals in production)."""
181
+ # Use structured logging instead of print
182
+ import logging
183
+ logger = logging.getLogger(__name__)
184
+ logger.error(f"Unhandled exception: {exc.__class__.__name__}: {str(exc)}", exc_info=settings.is_development())
185
+
186
+ return JSONResponse(
187
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
188
+ content={
189
+ "detail": "An unexpected error occurred. Please try again later.",
190
+ "error": str(exc) if settings.is_development() else "Internal server error"
191
+ }
192
+ )
193
+
194
+
195
+ # Run with: uvicorn main:app --reload --port 8000
196
+ if __name__ == "__main__":
197
+ import uvicorn
198
+
199
+ print("\n" + "="*80)
200
+ print("DEVELOPMENT SERVER (LOCAL ONLY)")
201
+ print("="*80)
202
+ print("Starting server on http://127.0.0.1:8000")
203
+ print("API Documentation: http://127.0.0.1:8000/docs")
204
+ print("NOTE: For production, use: uvicorn main:app --host 0.0.0.0 --port 8000")
205
+ print("="*80 + "\n")
206
+
207
+ uvicorn.run(
208
+ "main:app",
209
+ host="0.0.0.0", # Bind to localhost only for development security
210
+ port=8000,
211
+ reload=True,
212
+ log_level="info"
213
+ )
214
+
middleware/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ """
2
+ Middleware Package
3
+ Security and request handling middleware
4
+ """
middleware/security.py ADDED
@@ -0,0 +1,488 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Security Middleware
3
+ Implements rate limiting, input sanitization, CSRF protection, and security headers
4
+
5
+ SECURITY REQUIREMENTS FOR NEW FEATURES:
6
+ ========================================
7
+ 1. INPUT VALIDATION:
8
+ - Always use sanitize_input() for text fields (titles, descriptions, comments)
9
+ - Use sanitize_dict() for JSON payloads
10
+ - Validate file uploads: check file type, size, and scan content
11
+ - Validate ObjectIds before database queries with validate_object_id()
12
+
13
+ 2. AUTHENTICATION & AUTHORIZATION:
14
+ - Use Depends(get_current_user) for user-only endpoints
15
+ - Use Depends(get_current_admin) for admin-only endpoints
16
+ - Never expose user data without authentication
17
+ - Check ownership before allowing updates/deletes
18
+
19
+ 3. RATE LIMITING:
20
+ - Apply @limiter.limit() to all write endpoints
21
+ - Use stricter limits for sensitive operations (login, registration, money transfers)
22
+ - Example: @limiter.limit("5/minute") for login
23
+
24
+ 4. DATA MASKING:
25
+ - Use mask_email(), mask_phone(), mask_sensitive_data() for PII
26
+ - Default to masked view, require explicit permission for full data
27
+ - Log access to unmasked sensitive data
28
+
29
+ 5. ERROR HANDLING:
30
+ - Never expose stack traces or internal details to users
31
+ - Use HTTPException with sanitized messages
32
+ - Log full errors server-side with request ID
33
+ - Return consistent error format
34
+
35
+ 6. LOGGING:
36
+ - Log all security events (failed logins, access denials, suspicious activity)
37
+ - Include request ID in all logs for tracing
38
+ - Never log passwords, tokens, or sensitive PII
39
+
40
+ 7. NEW FEATURE CHECKLIST:
41
+ ☐ Input sanitization implemented
42
+ ☐ Authentication/authorization configured
43
+ ☐ Rate limiting applied
44
+ ☐ PII data masked
45
+ ☐ Error handling prevents information leakage
46
+ ☐ Security logging added
47
+ ☐ Unit tests for security scenarios written
48
+ ☐ Penetration testing performed
49
+
50
+ EXAMPLES:
51
+ ---------
52
+ Chat/Comments Feature:
53
+ - Sanitize message content: sanitize_input(message.content)
54
+ - Rate limit: @limiter.limit("10/minute") for sending messages
55
+ - Authenticate: current_user = Depends(get_current_user)
56
+ - Validate: max message length, blocked words list
57
+ - Mask: user email/phone in chat metadata
58
+
59
+ File Upload Feature:
60
+ - Validate file type: allowed_types = ['pdf', 'jpg', 'png']
61
+ - Validate file size: max_size = 10 * 1024 * 1024 # 10MB
62
+ - Scan content: virus scan, malicious code detection
63
+ - Sanitize filename: remove path traversal characters
64
+ - Store securely: use IPFS or encrypted storage
65
+ """
66
+ from fastapi import Request, HTTPException, status
67
+ from fastapi.responses import JSONResponse
68
+ from slowapi import Limiter, _rate_limit_exceeded_handler
69
+ from slowapi.util import get_remote_address
70
+ from slowapi.errors import RateLimitExceeded
71
+ from starlette.middleware.base import BaseHTTPMiddleware
72
+ from starlette.datastructures import Headers
73
+ import bleach
74
+ import re
75
+ from typing import Dict, Any
76
+ import secrets
77
+ from datetime import datetime, timedelta
78
+ import uuid
79
+ import logging
80
+
81
+ # Initialize rate limiter
82
+ limiter = Limiter(key_func=get_remote_address)
83
+
84
+ # Configure logger
85
+ logger = logging.getLogger(__name__)
86
+
87
+ # Failed login attempts tracking (in-memory - replace with Redis in production)
88
+ failed_login_attempts: Dict[str, Dict[str, Any]] = {}
89
+
90
+ # CSRF token storage (in-memory - replace with Redis in production)
91
+ csrf_tokens: Dict[str, datetime] = {}
92
+
93
+
94
+ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
95
+ """Add security headers to all responses"""
96
+
97
+ async def dispatch(self, request: Request, call_next):
98
+ response = await call_next(request)
99
+
100
+ # Security Headers
101
+ response.headers["X-Content-Type-Options"] = "nosniff"
102
+ response.headers["X-Frame-Options"] = "DENY"
103
+ response.headers["X-XSS-Protection"] = "1; mode=block"
104
+ response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
105
+ response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
106
+
107
+ # Content Security Policy
108
+ csp = (
109
+ "default-src 'self'; "
110
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
111
+ "style-src 'self' 'unsafe-inline'; "
112
+ "img-src 'self' data: https:; "
113
+ "font-src 'self' data:; "
114
+ "connect-src 'self' https://s.altnet.rippletest.net:51234 https://testnet.xrpl.org; "
115
+ "frame-ancestors 'none';"
116
+ )
117
+ response.headers["Content-Security-Policy"] = csp
118
+
119
+ return response
120
+
121
+
122
+ class RequestIDMiddleware(BaseHTTPMiddleware):
123
+ """Add unique request ID to each request for tracking and debugging"""
124
+
125
+ async def dispatch(self, request: Request, call_next):
126
+ # Generate unique request ID
127
+ request_id = str(uuid.uuid4())
128
+
129
+ # Store request ID in request state for access in route handlers
130
+ request.state.request_id = request_id
131
+
132
+ # Log request details
133
+ logger.info(f"[{request_id}] {request.method} {request.url.path} - Client: {request.client.host if request.client else 'unknown'}")
134
+
135
+ try:
136
+ response = await call_next(request)
137
+ # Add request ID to response headers for client tracking
138
+ response.headers["X-Request-ID"] = request_id
139
+ logger.info(f"[{request_id}] Response status: {response.status_code}")
140
+ return response
141
+ except Exception as e:
142
+ # Log error with request ID
143
+ logger.error(f"[{request_id}] Error: {str(e)}", exc_info=True)
144
+ # Return error response without exposing internal details
145
+ return JSONResponse(
146
+ status_code=500,
147
+ content={
148
+ "detail": "Internal server error",
149
+ "request_id": request_id
150
+ },
151
+ headers={"X-Request-ID": request_id}
152
+ )
153
+
154
+
155
+ def sanitize_input(text: str) -> str:
156
+ """Sanitize user input to prevent XSS attacks"""
157
+ if not isinstance(text, str):
158
+ return text
159
+
160
+ # Remove HTML tags and attributes
161
+ cleaned = bleach.clean(
162
+ text,
163
+ tags=[], # No HTML tags allowed
164
+ attributes={},
165
+ strip=True
166
+ )
167
+
168
+ return cleaned.strip()
169
+
170
+
171
+ def sanitize_dict(data: dict) -> dict:
172
+ """Recursively sanitize all string values in a dictionary"""
173
+ if not isinstance(data, dict):
174
+ return data
175
+
176
+ sanitized = {}
177
+ for key, value in data.items():
178
+ if isinstance(value, str):
179
+ sanitized[key] = sanitize_input(value)
180
+ elif isinstance(value, dict):
181
+ sanitized[key] = sanitize_dict(value)
182
+ elif isinstance(value, list):
183
+ sanitized[key] = [
184
+ sanitize_input(item) if isinstance(item, str) else item
185
+ for item in value
186
+ ]
187
+ else:
188
+ sanitized[key] = value
189
+
190
+ return sanitized
191
+
192
+
193
+ def validate_object_id(id_string: str) -> bool:
194
+ """Validate MongoDB ObjectId format"""
195
+ if not isinstance(id_string, str):
196
+ return False
197
+
198
+ # ObjectId is 24 character hexadecimal string
199
+ pattern = re.compile(r'^[0-9a-fA-F]{24}$')
200
+ return bool(pattern.match(id_string))
201
+
202
+
203
+ def validate_name(name: str) -> tuple[bool, str]:
204
+ """
205
+ Validate and sanitize user name
206
+ Returns: (is_valid, error_message or sanitized_name)
207
+ """
208
+ if not name:
209
+ return False, "Name is required"
210
+
211
+ # Sanitize first
212
+ name = sanitize_input(name).strip()
213
+
214
+ # Check length
215
+ if len(name) < 2:
216
+ return False, "Name must be at least 2 characters"
217
+ if len(name) > 100:
218
+ return False, "Name must not exceed 100 characters"
219
+
220
+ # Only allow letters, spaces, hyphens, and apostrophes (strict)
221
+ pattern = re.compile(r"^[a-zA-Z\s\-']+$")
222
+ if not pattern.match(name):
223
+ return False, "Name can only contain letters, spaces, hyphens, and apostrophes"
224
+
225
+ # Check for excessive spaces
226
+ if ' ' in name:
227
+ return False, "Name cannot contain multiple consecutive spaces"
228
+
229
+ return True, name
230
+
231
+
232
+ def validate_phone(phone: str) -> tuple[bool, str]:
233
+ """
234
+ Validate and sanitize phone number (REQUIRED, exactly 10 digits)
235
+ Returns: (is_valid, error_message or sanitized_phone)
236
+ """
237
+ if not phone:
238
+ return False, "Phone number is required"
239
+
240
+ # Sanitize first
241
+ phone = sanitize_input(phone).strip()
242
+
243
+ # Remove any non-digit characters for validation
244
+ digits_only = re.sub(r'\D', '', phone)
245
+
246
+ # Must be exactly 10 digits
247
+ if len(digits_only) != 10:
248
+ return False, "Phone number must be exactly 10 digits"
249
+
250
+ # Only allow pure digits (no formatting characters)
251
+ pattern = re.compile(r'^[0-9]{10}$')
252
+ if not pattern.match(phone):
253
+ return False, "Phone number must contain only 10 digits (no spaces or special characters)"
254
+
255
+ return True, phone
256
+
257
+
258
+ def validate_date(date_str: str) -> tuple[bool, str]:
259
+ """
260
+ Validate date string (YYYY-MM-DD format)
261
+ Returns: (is_valid, error_message or sanitized_date)
262
+ """
263
+ if not date_str:
264
+ return True, None # Date is optional
265
+
266
+ # Sanitize first
267
+ date_str = sanitize_input(date_str).strip()
268
+
269
+ # Check format
270
+ pattern = re.compile(r'^\d{4}-\d{2}-\d{2}$')
271
+ if not pattern.match(date_str):
272
+ return False, "Date must be in YYYY-MM-DD format"
273
+
274
+ # Try to parse date
275
+ try:
276
+ date_obj = datetime.strptime(date_str, '%Y-%m-%d')
277
+
278
+ # Check if date is not in future
279
+ if date_obj > datetime.now():
280
+ return False, "Date cannot be in the future"
281
+
282
+ return True, date_str
283
+ except ValueError:
284
+ return False, "Invalid date"
285
+
286
+
287
+ def validate_gender(gender: str) -> tuple[bool, str]:
288
+ """
289
+ Validate gender selection
290
+ Returns: (is_valid, error_message or sanitized_gender)
291
+ """
292
+ if not gender:
293
+ return True, None # Gender is optional
294
+
295
+ # Sanitize first
296
+ gender = sanitize_input(gender).strip()
297
+
298
+ # Must be from predefined list
299
+ valid_genders = ['Male', 'Female', 'Other']
300
+ if gender not in valid_genders:
301
+ return False, f"Gender must be one of: {', '.join(valid_genders)}"
302
+
303
+ return True, gender
304
+
305
+
306
+ def validate_address(address: str) -> tuple[bool, str]:
307
+ """
308
+ Validate and sanitize address
309
+ Returns: (is_valid, error_message or sanitized_address)
310
+ """
311
+ if not address:
312
+ return True, "" # Address is optional
313
+
314
+ # Sanitize first
315
+ address = sanitize_input(address).strip()
316
+
317
+ # Check length
318
+ if len(address) > 500:
319
+ return False, "Address must not exceed 500 characters"
320
+
321
+ # Only allow letters, numbers, spaces, and common address characters
322
+ pattern = re.compile(r"^[a-zA-Z0-9\s,.\-'#/()\n]+$")
323
+ if not pattern.match(address):
324
+ return False, "Address contains invalid characters"
325
+
326
+ return True, address
327
+
328
+
329
+ def validate_redirect_url(url: str, allowed_domains: list = None) -> bool:
330
+ """
331
+ Validate redirect URL to prevent open redirect attacks
332
+ Only allows relative URLs or URLs from whitelisted domains
333
+ Blocks javascript:, data:, and other dangerous schemes
334
+ """
335
+ if not url:
336
+ return False
337
+
338
+ # Block dangerous schemes
339
+ dangerous_schemes = ['javascript:', 'data:', 'vbscript:', 'file:', 'about:']
340
+ url_lower = url.lower().strip()
341
+ for scheme in dangerous_schemes:
342
+ if url_lower.startswith(scheme):
343
+ return False
344
+
345
+ # Default allowed domains (localhost and local dev)
346
+ if allowed_domains is None:
347
+ allowed_domains = [
348
+ 'localhost',
349
+ '127.0.0.1',
350
+ 'localhost:5173',
351
+ 'localhost:5174',
352
+ 'localhost:5175',
353
+ '127.0.0.1:5173',
354
+ '127.0.0.1:5174',
355
+ '127.0.0.1:5175'
356
+ ]
357
+
358
+ # Check if URL is relative (starts with /)
359
+ if url.startswith('/') and not url.startswith('//'):
360
+ return True
361
+
362
+ # Check if URL starts with allowed domain
363
+ for domain in allowed_domains:
364
+ if url.startswith(f'http://{domain}') or url.startswith(f'https://{domain}'):
365
+ return True
366
+
367
+ # Reject all other URLs (external domains)
368
+ return False
369
+
370
+
371
+ def generate_csrf_token() -> str:
372
+ """Generate a CSRF token"""
373
+ token = secrets.token_urlsafe(32)
374
+ csrf_tokens[token] = datetime.utcnow() + timedelta(hours=1)
375
+ return token
376
+
377
+
378
+ def validate_csrf_token(token: str) -> bool:
379
+ """Validate CSRF token"""
380
+ if not token or token not in csrf_tokens:
381
+ return False
382
+
383
+ # Check if token is expired
384
+ if csrf_tokens[token] < datetime.utcnow():
385
+ del csrf_tokens[token]
386
+ return False
387
+
388
+ return True
389
+
390
+
391
+ def check_rate_limit(ip: str, endpoint: str, max_attempts: int = 5, window_minutes: int = 15) -> bool:
392
+ """
393
+ Check if IP has exceeded rate limit for failed login attempts
394
+ Returns True if allowed, False if blocked
395
+ """
396
+ key = f"{ip}:{endpoint}"
397
+ now = datetime.utcnow()
398
+
399
+ if key not in failed_login_attempts:
400
+ failed_login_attempts[key] = {
401
+ 'count': 0,
402
+ 'first_attempt': now,
403
+ 'locked_until': None
404
+ }
405
+
406
+ attempt_data = failed_login_attempts[key]
407
+
408
+ # Check if currently locked
409
+ if attempt_data['locked_until'] and attempt_data['locked_until'] > now:
410
+ return False
411
+
412
+ # Reset if window has passed
413
+ if now - attempt_data['first_attempt'] > timedelta(minutes=window_minutes):
414
+ failed_login_attempts[key] = {
415
+ 'count': 0,
416
+ 'first_attempt': now,
417
+ 'locked_until': None
418
+ }
419
+ return True
420
+
421
+ # Check if exceeded max attempts
422
+ if attempt_data['count'] >= max_attempts:
423
+ # Lock for 15 minutes
424
+ attempt_data['locked_until'] = now + timedelta(minutes=window_minutes)
425
+ return False
426
+
427
+ return True
428
+
429
+
430
+ def record_failed_attempt(ip: str, endpoint: str):
431
+ """Record a failed login attempt"""
432
+ key = f"{ip}:{endpoint}"
433
+ now = datetime.utcnow()
434
+
435
+ if key not in failed_login_attempts:
436
+ failed_login_attempts[key] = {
437
+ 'count': 1,
438
+ 'first_attempt': now,
439
+ 'locked_until': None
440
+ }
441
+ else:
442
+ failed_login_attempts[key]['count'] += 1
443
+
444
+
445
+ def reset_failed_attempts(ip: str, endpoint: str):
446
+ """Reset failed attempts after successful login"""
447
+ key = f"{ip}:{endpoint}"
448
+ if key in failed_login_attempts:
449
+ del failed_login_attempts[key]
450
+
451
+
452
+ def mask_sensitive_data(data: str, mask_char: str = "*", visible_chars: int = 4) -> str:
453
+ """
454
+ Mask sensitive data showing only last N characters
455
+ Shows fixed-length mask (12 chars) for consistent display
456
+ """
457
+ if not data or len(data) <= visible_chars:
458
+ return data
459
+
460
+ # Use fixed mask length for consistent display (prevents length-based attacks)
461
+ fixed_mask_length = 12
462
+ return mask_char * fixed_mask_length + data[-visible_chars:]
463
+
464
+
465
+ def mask_email(email: str) -> str:
466
+ """Mask email address preserving domain"""
467
+ if not email or '@' not in email:
468
+ return email
469
+
470
+ local, domain = email.split('@', 1)
471
+ if len(local) <= 2:
472
+ return f"{local}@{domain}"
473
+
474
+ masked_local = local[0] + '*' * (len(local) - 2) + local[-1]
475
+ return f"{masked_local}@{domain}"
476
+
477
+
478
+ def mask_phone(phone: str) -> str:
479
+ """Mask phone number showing only last 4 digits"""
480
+ if not phone:
481
+ return phone
482
+
483
+ # Remove non-numeric characters
484
+ digits_only = re.sub(r'\D', '', phone)
485
+ if len(digits_only) <= 4:
486
+ return phone
487
+
488
+ return '*' * (len(digits_only) - 4) + digits_only[-4:]
repo.py ADDED
@@ -0,0 +1,2316 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Production-Ready Repository Layer for Real Estate Tokenization Platform
3
+ Implements the complete database schema with MongoDB
4
+ """
5
+ from datetime import datetime
6
+ from typing import Optional, List, Dict, Any
7
+ import logging
8
+ from pymongo.collection import Collection
9
+ from pymongo import ReturnDocument, ASCENDING, DESCENDING
10
+ from bson import ObjectId
11
+ from db import get_db, get_next_sequence
12
+ from config import settings
13
+ from utils.logger import setup_logger
14
+
15
+ logger = setup_logger(__name__)
16
+
17
+
18
+ def _now():
19
+ """Get current UTC timestamp"""
20
+ return datetime.utcnow()
21
+
22
+
23
+ def _clean_object(data):
24
+ """Clean MongoDB object by converting ObjectId to string"""
25
+ if isinstance(data, dict):
26
+ cleaned = {}
27
+ for key, value in data.items():
28
+ if key == "_id":
29
+ cleaned["id"] = str(value) if isinstance(value, ObjectId) else value
30
+ else:
31
+ cleaned[key] = _clean_object(value)
32
+ return cleaned
33
+ if isinstance(data, list):
34
+ return [_clean_object(item) for item in data]
35
+ if isinstance(data, ObjectId):
36
+ return str(data)
37
+ return data
38
+
39
+
40
+ def _to_object_id(id_str: str) -> ObjectId:
41
+ """Convert string ID to ObjectId"""
42
+ try:
43
+ return ObjectId(id_str)
44
+ except:
45
+ raise ValueError(f"Invalid ObjectId: {id_str}")
46
+
47
+
48
+ def _coerce_object_id(id_value):
49
+ """Return ObjectId when possible, otherwise fall back to original value."""
50
+ if id_value is None or isinstance(id_value, ObjectId):
51
+ return id_value
52
+ try:
53
+ return ObjectId(id_value)
54
+ except Exception:
55
+ logger.warning("Invalid ObjectId %r; storing plain value", id_value)
56
+ return id_value
57
+
58
+
59
+ # ============================================================================
60
+ # USER OPERATIONS
61
+ # ============================================================================
62
+
63
+ def users_col(db) -> Collection:
64
+ return db.users
65
+
66
+
67
+ def create_user(db, name: str, email: str, password_hash: str, country_code: str, phone: str, role: str = "user") -> Dict[str, Any]:
68
+ """Create a new user"""
69
+ logger.info(f"Creating user: {email} with role: {role}")
70
+
71
+ now = _now()
72
+ doc = {
73
+ "name": name,
74
+ "email": email,
75
+ "password_hash": password_hash,
76
+ "role": role,
77
+ "country_code": country_code,
78
+ "phone": phone,
79
+ "wallet_id": None,
80
+ "is_active": True,
81
+ "deleted": False,
82
+ "created_at": now,
83
+ "updated_at": now,
84
+ }
85
+
86
+ result = users_col(db).insert_one(doc)
87
+ doc["_id"] = result.inserted_id
88
+
89
+ logger.info(f"User created successfully with ID: {result.inserted_id}")
90
+ return _clean_object(doc)
91
+
92
+
93
+ def get_user_by_email(db, email: str) -> Optional[Dict[str, Any]]:
94
+ """Get user by email"""
95
+ doc = users_col(db).find_one({"email": email, "deleted": False})
96
+ return _clean_object(doc) if doc else None
97
+
98
+
99
+ def get_user_by_id(db, user_id: str) -> Optional[Dict[str, Any]]:
100
+ """Get user by ID"""
101
+ doc = users_col(db).find_one({"_id": _to_object_id(user_id), "deleted": False})
102
+ return _clean_object(doc) if doc else None
103
+
104
+
105
+ def get_user_by_role(db, role: str) -> Optional[Dict[str, Any]]:
106
+ """Get a user by role (e.g., 'admin', 'super_admin')"""
107
+ doc = users_col(db).find_one({"role": role, "deleted": False, "is_active": True})
108
+ return _clean_object(doc) if doc else None
109
+
110
+
111
+ def update_user(db, user_id: str, fields: Dict[str, Any]) -> Optional[Dict[str, Any]]:
112
+ """Update user fields"""
113
+ print(f"[REPO] Updating user {user_id} with fields: {list(fields.keys())}")
114
+
115
+ fields["updated_at"] = _now()
116
+ doc = users_col(db).find_one_and_update(
117
+ {"_id": _to_object_id(user_id)},
118
+ {"$set": fields},
119
+ return_document=ReturnDocument.AFTER
120
+ )
121
+
122
+ if doc:
123
+ print(f"[REPO] User updated successfully")
124
+ return _clean_object(doc) if doc else None
125
+
126
+
127
+ def list_all_users(db, skip: int = 0, limit: int = 100, fetch_live_xrp: bool = True) -> List[Dict[str, Any]]:
128
+ """List all users with pagination and wallet information
129
+
130
+ Excludes admin users - only returns regular users with role='user'
131
+
132
+ Args:
133
+ db: Database connection
134
+ skip: Number of records to skip
135
+ limit: Maximum number of records to return
136
+ fetch_live_xrp: If True, fetch live XRP balance from blockchain (slower but accurate)
137
+ """
138
+
139
+ # Filter to only show regular users, exclude admins
140
+ # user_docs = list(users_col(db).find({"deleted": False}).skip(skip).limit(limit).sort("created_at", DESCENDING))
141
+ user_docs = list(users_col(db).find({"deleted": False, "role": "user"}).skip(skip).limit(limit).sort("created_at", DESCENDING))
142
+
143
+ users_with_wallets = []
144
+ for user_doc in user_docs:
145
+ user = _clean_object(user_doc)
146
+
147
+ # Get wallet information for balances
148
+ wallet_doc = wallets_col(db).find_one({"user_id": _to_object_id(user["id"])})
149
+ if wallet_doc:
150
+ # Always use wallet.balance as the source of truth for AED balance
151
+ user["aed_balance"] = wallet_doc.get("balance", 0.0)
152
+ user["xrp_address"] = wallet_doc.get("xrp_address")
153
+
154
+ # Use cached XRP balance instead of live fetching (much faster!)
155
+ if wallet_doc.get("xrp_address"):
156
+ # Try to get cached balance from Redis first
157
+ cached_balance = None
158
+ try:
159
+ from utils.cache import get_cached_xrp_balance
160
+ cached_balance = get_cached_xrp_balance(wallet_doc.get("xrp_address"))
161
+ except ImportError:
162
+ pass
163
+
164
+ if cached_balance is not None:
165
+ # Use cached balance (fast)
166
+ user["xrp_balance"] = cached_balance
167
+ elif wallet_doc.get("xrp_balance_cached"):
168
+ # Use database cached balance (fallback)
169
+ user["xrp_balance"] = wallet_doc.get("xrp_balance_cached", 0.0)
170
+ elif fetch_live_xrp:
171
+ # Only fetch live if explicitly requested and no cache available
172
+ try:
173
+ from services.xrp_service import XRPLService
174
+ from utils.cache import cache_xrp_balance
175
+ xrpl_service = XRPLService()
176
+ live_xrp_balance = xrpl_service.get_xrp_balance(wallet_doc.get("xrp_address"))
177
+ user["xrp_balance"] = live_xrp_balance
178
+ # Cache the result for next time
179
+ cache_xrp_balance(wallet_doc.get("xrp_address"), live_xrp_balance, ttl=300)
180
+ # Also update database cache
181
+ wallets_col(db).update_one(
182
+ {"_id": wallet_doc["_id"]},
183
+ {"$set": {"xrp_balance_cached": live_xrp_balance, "balance_updated_at": _now()}}
184
+ )
185
+ except Exception as e:
186
+ logger.warning(f"Failed to fetch live XRP balance for {wallet_doc.get('xrp_address')}: {e}")
187
+ user["xrp_balance"] = 0.0
188
+ else:
189
+ user["xrp_balance"] = 0.0
190
+ else:
191
+ user["xrp_balance"] = 0.0
192
+ else:
193
+ # No wallet exists yet
194
+ user["aed_balance"] = 0.0
195
+ user["xrp_address"] = None
196
+ user["xrp_balance"] = 0.0
197
+
198
+ users_with_wallets.append(user)
199
+
200
+ return users_with_wallets
201
+
202
+ # ============================================================================
203
+ # ADMIN USER HELPERS
204
+ # ============================================================================
205
+
206
+ def get_primary_admin(db, session=None) -> Optional[Dict[str, Any]]:
207
+ """Return first user with role == 'admin'."""
208
+ base_query = {"role": "admin", "deleted": False}
209
+ if session:
210
+ doc = users_col(db).find_one(base_query, session=session)
211
+ else:
212
+ doc = users_col(db).find_one(base_query)
213
+ return _clean_object(doc) if doc else None
214
+
215
+
216
+ # ============================================================================
217
+ # WALLET OPERATIONS
218
+ # ============================================================================
219
+
220
+ def wallets_col(db) -> Collection:
221
+ return db.wallets
222
+
223
+
224
+ def create_wallet(db, user_id: str, balance: float = 0.0, currency: str = "AED", xrp_address: str = None, xrp_seed: str = None) -> Dict[str, Any]:
225
+ """Create a wallet for a user"""
226
+ print(f"[REPO] Creating wallet for user {user_id}, balance: {balance} {currency}")
227
+
228
+ now = _now()
229
+ doc = {
230
+ "user_id": _to_object_id(user_id),
231
+ "balance": balance,
232
+ "currency": currency,
233
+ "xrp_address": xrp_address,
234
+ "xrp_seed": xrp_seed, # Encrypted in production!
235
+ "is_active": True,
236
+ "created_at": now,
237
+ "updated_at": now,
238
+ }
239
+
240
+ result = wallets_col(db).insert_one(doc)
241
+ doc["_id"] = result.inserted_id
242
+
243
+ # Update user with wallet reference
244
+ users_col(db).update_one(
245
+ {"_id": _to_object_id(user_id)},
246
+ {"$set": {"wallet_id": result.inserted_id, "updated_at": now}}
247
+ )
248
+
249
+ print(f"[REPO] Wallet created successfully with ID: {result.inserted_id}")
250
+ return _clean_object(doc)
251
+
252
+
253
+ def get_wallet_by_user(db, user_id: str) -> Optional[Dict[str, Any]]:
254
+ """Get wallet by user ID"""
255
+ doc = wallets_col(db).find_one({"user_id": _to_object_id(user_id)})
256
+ return _clean_object(doc) if doc else None
257
+
258
+
259
+ def update_wallet_balance(db, wallet_id: str, amount: float, operation: str = "add", session=None) -> Optional[Dict[str, Any]]:
260
+ """Update wallet balance (add or subtract)"""
261
+ print(f"[REPO] {operation.capitalize()}ing {amount} to wallet {wallet_id}")
262
+
263
+ increment = amount if operation == "add" else -amount
264
+
265
+ doc = wallets_col(db).find_one_and_update(
266
+ {"_id": _to_object_id(wallet_id)},
267
+ {
268
+ "$inc": {"balance": increment},
269
+ "$set": {"updated_at": _now()}
270
+ },
271
+ return_document=ReturnDocument.AFTER,
272
+ session=session
273
+ )
274
+
275
+ if doc:
276
+ print(f"[REPO] Wallet balance updated. New balance: {doc.get('balance')}")
277
+ return _clean_object(doc) if doc else None
278
+
279
+
280
+ def update_wallet_xrp(db, wallet_id: str, xrp_address: str, xrp_seed: str) -> Optional[Dict[str, Any]]:
281
+ """Update wallet with XRP address and seed"""
282
+ print(f"[REPO] Adding XRP wallet to wallet {wallet_id}: {xrp_address}")
283
+
284
+ doc = wallets_col(db).find_one_and_update(
285
+ {"_id": _to_object_id(wallet_id)},
286
+ {
287
+ "$set": {
288
+ "xrp_address": xrp_address,
289
+ "xrp_seed": xrp_seed, # Should be encrypted in production
290
+ "updated_at": _now()
291
+ }
292
+ },
293
+ return_document=ReturnDocument.AFTER
294
+ )
295
+
296
+ if doc:
297
+ print(f"[REPO] XRP wallet added successfully")
298
+ return _clean_object(doc) if doc else None
299
+
300
+
301
+ # ============================================================================
302
+ # PROPERTY OPERATIONS
303
+ # ============================================================================
304
+
305
+ def properties_col(db) -> Collection:
306
+ return db.properties
307
+
308
+
309
+ def create_property(db, property_data: Dict[str, Any], created_by: str) -> Dict[str, Any]:
310
+ """Create a new property"""
311
+ print(f"[REPO] Creating property: {property_data.get('title')}")
312
+
313
+ now = _now()
314
+ doc = {
315
+ **property_data,
316
+ "available_tokens": property_data.get("total_tokens", 0),
317
+ "created_by": _coerce_object_id(created_by),
318
+ "is_active": True,
319
+ "deleted": False,
320
+ "created_at": now,
321
+ "updated_at": now,
322
+ }
323
+
324
+ result = properties_col(db).insert_one(doc)
325
+ doc["_id"] = result.inserted_id
326
+
327
+ print(f"[REPO] Property created successfully with ID: {result.inserted_id}")
328
+ return _clean_object(doc)
329
+
330
+
331
+ def get_property_by_id(db, property_id: str) -> Optional[Dict[str, Any]]:
332
+ """Get property by ID"""
333
+ doc = properties_col(db).find_one({"_id": _to_object_id(property_id), "deleted": False})
334
+ return _clean_object(doc) if doc else None
335
+
336
+
337
+ def get_property_by_id_optimized(db, property_id: str) -> Optional[Dict[str, Any]]:
338
+ """
339
+ Get property by ID with all related data using aggregation (optimized)
340
+ Returns property with specifications, amenities, images, and documents in a single query
341
+ """
342
+ pipeline = [
343
+ {"$match": {"_id": _to_object_id(property_id), "deleted": False}},
344
+
345
+ # Join creator information
346
+ {"$lookup": {
347
+ "from": "users",
348
+ "localField": "created_by",
349
+ "foreignField": "_id",
350
+ "as": "creator"
351
+ }},
352
+ {"$unwind": {
353
+ "path": "$creator",
354
+ "preserveNullAndEmptyArrays": True
355
+ }},
356
+
357
+ # Join with property_specifications
358
+ {"$lookup": {
359
+ "from": "property_specifications",
360
+ "localField": "_id",
361
+ "foreignField": "property_id",
362
+ "as": "specifications"
363
+ }},
364
+ {"$unwind": {
365
+ "path": "$specifications",
366
+ "preserveNullAndEmptyArrays": True
367
+ }},
368
+
369
+ # Join with amenities
370
+ {"$lookup": {
371
+ "from": "amenities",
372
+ "localField": "_id",
373
+ "foreignField": "property_id",
374
+ "as": "amenities",
375
+ "pipeline": [
376
+ {"$match": {"is_active": True}}
377
+ ]
378
+ }},
379
+
380
+ # Join with property_images
381
+ {"$lookup": {
382
+ "from": "property_images",
383
+ "localField": "_id",
384
+ "foreignField": "property_id",
385
+ "as": "images",
386
+ "pipeline": [
387
+ {"$match": {"is_active": True}}
388
+ ]
389
+ }},
390
+
391
+ # Join with documents
392
+ {"$lookup": {
393
+ "from": "documents",
394
+ "localField": "_id",
395
+ "foreignField": "property_id",
396
+ "as": "documents"
397
+ }}
398
+ ]
399
+
400
+ docs = list(properties_col(db).aggregate(pipeline))
401
+
402
+ if docs:
403
+ return _clean_object(docs[0])
404
+ return None
405
+
406
+
407
+ def list_properties(db, is_active: Optional[bool] = None, skip: int = 0, limit: int = 100) -> List[Dict[str, Any]]:
408
+ """List properties with optional filtering"""
409
+ query = {"deleted": False}
410
+ if is_active is not None:
411
+ query["is_active"] = is_active
412
+
413
+ docs = list(properties_col(db).find(query).skip(skip).limit(limit).sort("created_at", DESCENDING))
414
+ return [_clean_object(doc) for doc in docs]
415
+
416
+
417
+ def list_properties_optimized(db, is_active: Optional[bool] = None, skip: int = 0, limit: int = 100) -> List[Dict[str, Any]]:
418
+ """
419
+ List properties with related data using MongoDB aggregation (optimized - no N+1 queries)
420
+ Returns properties with specifications, amenities, images, and documents in a single query
421
+ """
422
+ # Build match query
423
+ match_query = {"deleted": False}
424
+ if is_active is not None:
425
+ match_query["is_active"] = is_active
426
+
427
+ # Aggregation pipeline
428
+ pipeline = [
429
+ {"$match": match_query},
430
+ {"$sort": {"created_at": DESCENDING}},
431
+ {"$skip": skip},
432
+ {"$limit": limit},
433
+
434
+ # Join creator information
435
+ {"$lookup": {
436
+ "from": "users",
437
+ "localField": "created_by",
438
+ "foreignField": "_id",
439
+ "as": "creator"
440
+ }},
441
+ {"$unwind": {
442
+ "path": "$creator",
443
+ "preserveNullAndEmptyArrays": True
444
+ }},
445
+
446
+ # Join with property_specifications (1-to-1)
447
+ {"$lookup": {
448
+ "from": "property_specifications",
449
+ "localField": "_id",
450
+ "foreignField": "property_id",
451
+ "as": "specifications"
452
+ }},
453
+ {"$unwind": {
454
+ "path": "$specifications",
455
+ "preserveNullAndEmptyArrays": True
456
+ }},
457
+
458
+ # Join with amenities (1-to-many)
459
+ {"$lookup": {
460
+ "from": "amenities",
461
+ "localField": "_id",
462
+ "foreignField": "property_id",
463
+ "as": "amenities",
464
+ "pipeline": [
465
+ {"$match": {"is_active": True}}
466
+ ]
467
+ }},
468
+
469
+ # Join with property_images (1-to-many)
470
+ {"$lookup": {
471
+ "from": "property_images",
472
+ "localField": "_id",
473
+ "foreignField": "property_id",
474
+ "as": "images",
475
+ "pipeline": [
476
+ {"$match": {"is_active": True}}
477
+ ]
478
+ }},
479
+
480
+ # Join with documents (1-to-many)
481
+ {"$lookup": {
482
+ "from": "documents",
483
+ "localField": "_id",
484
+ "foreignField": "property_id",
485
+ "as": "documents"
486
+ }}
487
+ ]
488
+
489
+ # Execute aggregation
490
+ docs = list(properties_col(db).aggregate(pipeline))
491
+
492
+ # Clean and return
493
+ return [_clean_object(doc) for doc in docs]
494
+
495
+
496
+ def update_property(db, property_id: str, fields: Dict[str, Any], session=None) -> Optional[Dict[str, Any]]:
497
+ """Update property fields"""
498
+ print(f"[REPO] Updating property {property_id} with fields: {list(fields.keys())}")
499
+
500
+ fields["updated_at"] = _now()
501
+ doc = properties_col(db).find_one_and_update(
502
+ {"_id": _to_object_id(property_id)},
503
+ {"$set": fields},
504
+ return_document=ReturnDocument.AFTER,
505
+ session=session
506
+ )
507
+
508
+ if doc:
509
+ print(f"[REPO] Property updated successfully")
510
+ return _clean_object(doc) if doc else None
511
+
512
+
513
+ def delete_property(db, property_id: str) -> bool:
514
+ """Soft delete a property"""
515
+ print(f"[REPO] Soft deleting property {property_id}")
516
+
517
+ result = properties_col(db).update_one(
518
+ {"_id": _to_object_id(property_id)},
519
+ {"$set": {"deleted": True, "is_active": False, "updated_at": _now()}}
520
+ )
521
+
522
+ success = result.matched_count > 0
523
+ if success:
524
+ print(f"[REPO] Property soft deleted successfully")
525
+ else:
526
+ print(f"[REPO] Property not found for deletion")
527
+
528
+ return success
529
+
530
+
531
+ def decrement_available_tokens(db, property_id: str, amount: int, session=None) -> bool:
532
+ """Decrement available tokens atomically"""
533
+ print(f"[REPO] Decrementing {amount} tokens from property {property_id}")
534
+
535
+ result = properties_col(db).update_one(
536
+ {
537
+ "_id": _to_object_id(property_id),
538
+ "available_tokens": {"$gte": amount}
539
+ },
540
+ {
541
+ "$inc": {"available_tokens": -amount},
542
+ "$set": {"updated_at": _now()}
543
+ },
544
+ session=session
545
+ )
546
+
547
+ success = result.matched_count > 0
548
+ if success:
549
+ print(f"[REPO] Successfully decremented tokens")
550
+ else:
551
+ print(f"[REPO] ERROR: Failed to decrement tokens - insufficient available")
552
+
553
+ return success
554
+
555
+
556
+ # ============================================================================
557
+ # PROPERTY SPECIFICATIONS
558
+ # ============================================================================
559
+
560
+ def property_specifications_col(db) -> Collection:
561
+ return db.property_specifications
562
+
563
+
564
+ def create_property_specification(db, property_id: str, spec_data: Dict[str, Any]) -> Dict[str, Any]:
565
+ """Create property specifications"""
566
+ print(f"[REPO] Creating specifications for property {property_id}")
567
+
568
+ now = _now()
569
+ doc = {
570
+ **spec_data,
571
+ "property_id": _to_object_id(property_id),
572
+ "created_at": now,
573
+ "updated_at": now,
574
+ }
575
+
576
+ result = property_specifications_col(db).insert_one(doc)
577
+ doc["_id"] = result.inserted_id
578
+
579
+ return _clean_object(doc)
580
+
581
+
582
+ def get_property_specification(db, property_id: str) -> Optional[Dict[str, Any]]:
583
+ """Get specifications for a property"""
584
+ doc = property_specifications_col(db).find_one({"property_id": _to_object_id(property_id)})
585
+ return _clean_object(doc) if doc else None
586
+
587
+
588
+ # ============================================================================
589
+ # AMENITIES
590
+ # ============================================================================
591
+
592
+ def amenities_col(db) -> Collection:
593
+ return db.amenities
594
+
595
+
596
+ def create_amenities(db, property_id: str, amenity_names: List[str]) -> List[Dict[str, Any]]:
597
+ """Create multiple amenities for a property"""
598
+ print(f"[REPO] Creating {len(amenity_names)} amenities for property {property_id}")
599
+
600
+ now = _now()
601
+ docs = []
602
+ for name in amenity_names:
603
+ doc = {
604
+ "property_id": _to_object_id(property_id),
605
+ "name": name,
606
+ "is_active": True,
607
+ "created_at": now,
608
+ "updated_at": now,
609
+ }
610
+ docs.append(doc)
611
+
612
+ if docs:
613
+ result = amenities_col(db).insert_many(docs)
614
+ for i, inserted_id in enumerate(result.inserted_ids):
615
+ docs[i]["_id"] = inserted_id
616
+
617
+ return [_clean_object(doc) for doc in docs]
618
+
619
+
620
+ def get_property_amenities(db, property_id: str) -> List[Dict[str, Any]]:
621
+ """Get all amenities for a property"""
622
+ docs = list(amenities_col(db).find({"property_id": _to_object_id(property_id), "is_active": True}))
623
+ return [_clean_object(doc) for doc in docs]
624
+
625
+
626
+ # ============================================================================
627
+ # PROPERTY IMAGES
628
+ # ============================================================================
629
+
630
+ def property_images_col(db) -> Collection:
631
+ return db.property_images
632
+
633
+
634
+ def create_property_images(db, property_id: str, images_data: List[Dict[str, Any]], uploaded_by: str) -> List[Dict[str, Any]]:
635
+ """Create multiple images for a property"""
636
+ print(f"[REPO] Creating {len(images_data)} images for property {property_id}")
637
+
638
+ now = _now()
639
+ docs = []
640
+ for img_data in images_data:
641
+ doc = {
642
+ **img_data,
643
+ "property_id": _to_object_id(property_id),
644
+ "uploaded_by": _coerce_object_id(uploaded_by),
645
+ "is_active": True,
646
+ "created_at": now,
647
+ "updated_at": now,
648
+ }
649
+ docs.append(doc)
650
+
651
+ if docs:
652
+ result = property_images_col(db).insert_many(docs)
653
+ for i, inserted_id in enumerate(result.inserted_ids):
654
+ docs[i]["_id"] = inserted_id
655
+
656
+ return [_clean_object(doc) for doc in docs]
657
+
658
+
659
+ def get_property_images(db, property_id: str) -> List[Dict[str, Any]]:
660
+ """Get all images for a property"""
661
+ docs = list(property_images_col(db).find({"property_id": _to_object_id(property_id), "is_active": True}))
662
+ return [_clean_object(doc) for doc in docs]
663
+
664
+
665
+ # ============================================================================
666
+ # INVESTMENTS
667
+ # ============================================================================
668
+
669
+ def investments_col(db) -> Collection:
670
+ return db.investments
671
+
672
+
673
+ def create_investment(db, user_id: str, property_id: str, tokens_purchased: int, amount: float, status: str = "confirmed", profit_share: float = 0.0, session=None) -> Dict[str, Any]:
674
+ """Create a new investment record"""
675
+ print(f"[REPO] Creating investment: user={user_id}, property={property_id}, tokens={tokens_purchased}, amount={amount}")
676
+
677
+ now = _now()
678
+ doc = {
679
+ "user_id": _to_object_id(user_id),
680
+ "property_id": _to_object_id(property_id),
681
+ "tokens_purchased": tokens_purchased,
682
+ "amount": amount,
683
+ "status": status,
684
+ "profit_share": profit_share,
685
+ "created_at": now,
686
+ "updated_at": now,
687
+ }
688
+
689
+ result = investments_col(db).insert_one(doc, session=session)
690
+ doc["_id"] = result.inserted_id
691
+
692
+ print(f"[REPO] Investment created with ID: {result.inserted_id}")
693
+ return _clean_object(doc)
694
+
695
+
696
+ def get_user_investments(db, user_id: str) -> List[Dict[str, Any]]:
697
+ """Get all investments for a user"""
698
+ docs = list(investments_col(db).find({"user_id": _to_object_id(user_id)}).sort("created_at", DESCENDING))
699
+ return [_clean_object(doc) for doc in docs]
700
+
701
+
702
+ def get_investment_by_user_and_property(db, user_id: str, property_id: str) -> Optional[Dict[str, Any]]:
703
+ """Get investment by user and property"""
704
+ doc = investments_col(db).find_one({
705
+ "user_id": _to_object_id(user_id),
706
+ "property_id": _to_object_id(property_id)
707
+ })
708
+ return _clean_object(doc) if doc else None
709
+
710
+
711
+ def update_investment(db, investment_id: str, fields: Dict[str, Any], session=None) -> Optional[Dict[str, Any]]:
712
+ """Update investment"""
713
+ fields["updated_at"] = _now()
714
+ doc = investments_col(db).find_one_and_update(
715
+ {"_id": _to_object_id(investment_id)},
716
+ {"$set": fields},
717
+ return_document=ReturnDocument.AFTER,
718
+ session=session
719
+ )
720
+ return _clean_object(doc) if doc else None
721
+
722
+
723
+ def reduce_investment(db, user_id: str, property_id: str, tokens_to_reduce: int, session=None) -> Optional[Dict[str, Any]]:
724
+ """Reduce investment tokens when user sells back tokens"""
725
+ print(f"[REPO] Reducing investment: user={user_id}, property={property_id}, tokens={tokens_to_reduce}")
726
+
727
+ investment = get_investment_by_user_and_property(db, user_id, property_id)
728
+ if not investment:
729
+ print(f"[REPO] No investment found")
730
+ return None
731
+
732
+ current_tokens = investment.get('tokens_purchased', 0)
733
+ if tokens_to_reduce > current_tokens:
734
+ print(f"[REPO] Cannot reduce {tokens_to_reduce} tokens, user only has {current_tokens}")
735
+ return None
736
+
737
+ new_token_count = current_tokens - tokens_to_reduce
738
+
739
+ if new_token_count == 0:
740
+ # Delete investment if no tokens left
741
+ print(f"[REPO] Removing investment (all tokens sold)")
742
+ investments_col(db).delete_one({"_id": _to_object_id(investment['id'])}, session=session)
743
+ return {**investment, 'tokens_purchased': 0}
744
+ else:
745
+ # Update investment with reduced tokens
746
+ print(f"[REPO] Updating investment tokens: {current_tokens} -> {new_token_count}")
747
+ updated = update_investment(db, investment['id'], {'tokens_purchased': new_token_count}, session=session)
748
+ return updated
749
+
750
+
751
+ def upsert_investment(db, user_id: str, property_id: str, tokens_purchased: int, amount: float, session=None) -> Dict[str, Any]:
752
+ """Update existing investment or create new one"""
753
+ print(f"[REPO] Upserting investment for user {user_id}, property {property_id}")
754
+
755
+ now = _now()
756
+ doc = investments_col(db).find_one_and_update(
757
+ {
758
+ "user_id": _to_object_id(user_id),
759
+ "property_id": _to_object_id(property_id)
760
+ },
761
+ {
762
+ "$inc": {
763
+ "tokens_purchased": tokens_purchased,
764
+ "amount": amount
765
+ },
766
+ "$set": {
767
+ "status": "confirmed",
768
+ "updated_at": now
769
+ },
770
+ "$setOnInsert": {
771
+ "profit_share": 0.0,
772
+ "created_at": now
773
+ }
774
+ },
775
+ upsert=True,
776
+ return_document=ReturnDocument.AFTER,
777
+ session=session
778
+ )
779
+
780
+ return _clean_object(doc)
781
+
782
+
783
+ def get_user_portfolio_optimized(db, user_id: str) -> List[Dict[str, Any]]:
784
+ """
785
+ Get user's complete portfolio with all related data in a single aggregation query
786
+ Eliminates N+1 query problem by using MongoDB aggregation pipeline
787
+ Returns investments with property, specifications, amenities, images, and tokens in one query
788
+ """
789
+ pipeline = [
790
+ # Match investments for this user
791
+ {"$match": {"user_id": _to_object_id(user_id)}},
792
+ {"$sort": {"created_at": DESCENDING}},
793
+
794
+ # Join with properties collection
795
+ {
796
+ "$lookup": {
797
+ "from": "properties",
798
+ "localField": "property_id",
799
+ "foreignField": "_id",
800
+ "as": "property"
801
+ }
802
+ },
803
+ {"$unwind": {"path": "$property", "preserveNullAndEmptyArrays": False}},
804
+
805
+ # Filter out deleted properties
806
+ {"$match": {"property.deleted": False}},
807
+
808
+ # Join with property_specifications
809
+ {
810
+ "$lookup": {
811
+ "from": "property_specifications",
812
+ "localField": "property_id",
813
+ "foreignField": "property_id",
814
+ "as": "specifications"
815
+ }
816
+ },
817
+ {"$unwind": {"path": "$specifications", "preserveNullAndEmptyArrays": True}},
818
+
819
+ # Join with amenities
820
+ {
821
+ "$lookup": {
822
+ "from": "amenities",
823
+ "let": {"prop_id": "$property_id"},
824
+ "pipeline": [
825
+ {"$match": {
826
+ "$expr": {"$eq": ["$property_id", "$$prop_id"]},
827
+ "is_active": True
828
+ }}
829
+ ],
830
+ "as": "amenities"
831
+ }
832
+ },
833
+
834
+ # Join with property_images
835
+ {
836
+ "$lookup": {
837
+ "from": "property_images",
838
+ "let": {"prop_id": "$property_id"},
839
+ "pipeline": [
840
+ {"$match": {
841
+ "$expr": {"$eq": ["$property_id", "$$prop_id"]},
842
+ "is_active": True
843
+ }}
844
+ ],
845
+ "as": "images"
846
+ }
847
+ },
848
+
849
+ # Join with property_tokens
850
+ {
851
+ "$lookup": {
852
+ "from": "property_tokens",
853
+ "localField": "property_id",
854
+ "foreignField": "property_id",
855
+ "as": "tokens"
856
+ }
857
+ },
858
+ {"$unwind": {"path": "$tokens", "preserveNullAndEmptyArrays": True}},
859
+
860
+ # Project final structure
861
+ {
862
+ "$project": {
863
+ "_id": 1,
864
+ "user_id": 1,
865
+ "property_id": 1,
866
+ "tokens_purchased": 1,
867
+ "amount": 1,
868
+ "status": 1,
869
+ "created_at": 1,
870
+ "updated_at": 1,
871
+ "property": 1,
872
+ "specifications": 1,
873
+ "amenities": 1,
874
+ "images": 1,
875
+ "tokens": 1
876
+ }
877
+ }
878
+ ]
879
+
880
+ docs = list(investments_col(db).aggregate(pipeline))
881
+ return [_clean_object(doc) for doc in docs]
882
+
883
+
884
+ # ============================================================================
885
+ # TRANSACTIONS
886
+ # ============================================================================
887
+
888
+ def transactions_col(db) -> Collection:
889
+ return db.transactions
890
+
891
+
892
+ def create_transaction(db, user_id: str, wallet_id: Optional[str], tx_type: str, amount: float,
893
+ property_id: Optional[str] = None, status: str = "pending",
894
+ metadata: Optional[Dict[str, Any]] = None, session=None) -> Dict[str, Any]:
895
+ """Create a transaction record"""
896
+ print(f"[REPO] Creating transaction: type={tx_type}, amount={amount}, status={status}")
897
+
898
+ now = _now()
899
+ doc = {
900
+ "user_id": _to_object_id(user_id),
901
+ "wallet_id": _to_object_id(wallet_id) if wallet_id else None,
902
+ "type": tx_type,
903
+ "amount": amount,
904
+ "property_id": _to_object_id(property_id) if property_id else None,
905
+ "status": status,
906
+ "metadata": metadata or {},
907
+ "created_at": now,
908
+ "updated_at": now,
909
+ }
910
+
911
+ result = transactions_col(db).insert_one(doc, session=session)
912
+ doc["_id"] = result.inserted_id
913
+
914
+ print(f"[REPO] Transaction created with ID: {result.inserted_id}")
915
+ return _clean_object(doc)
916
+
917
+
918
+ def insert_transaction(db, transaction_data: Dict[str, Any], session=None) -> Dict[str, Any]:
919
+ """Compatibility layer for legacy code paths expecting integer fils fields.
920
+
921
+ Supports fields:
922
+ - total_amount_aed (int fils)
923
+ - amount (float) (legacy investment amount)
924
+ - direction (credit/debit informational)
925
+ - notes/payment_method/payment_reference
926
+ """
927
+ # Map legacy naming: if total_amount_aed present, convert fils to float amount for storage uniformity
928
+ amount = transaction_data.get("amount")
929
+ total_amount_fils = transaction_data.get("total_amount_aed")
930
+ metadata = transaction_data.get("metadata", {}) or {}
931
+
932
+ if total_amount_fils is not None and amount is None:
933
+ try:
934
+ amount = float(total_amount_fils) / 100.0
935
+ metadata["total_amount_fils"] = int(total_amount_fils)
936
+ except Exception:
937
+ amount = 0.0
938
+
939
+ # Embed ancillary fields into metadata to avoid schema drift
940
+ for k in ("notes", "payment_method", "payment_reference", "price_per_token", "xrp_cost", "blockchain_tx_hash",
941
+ "direction", "counterparty_user_id", "counterparty_username", "counterparty_email", "property_name"):
942
+ if k in transaction_data and transaction_data[k] is not None:
943
+ metadata[k] = transaction_data[k]
944
+
945
+ return create_transaction(
946
+ db,
947
+ user_id=transaction_data.get("user_id"),
948
+ wallet_id=transaction_data.get("wallet_id"),
949
+ tx_type=transaction_data.get("type"),
950
+ amount=amount or 0.0,
951
+ property_id=transaction_data.get("property_id"),
952
+ status=transaction_data.get("status", "pending"),
953
+ metadata=metadata,
954
+ session=session,
955
+ )
956
+
957
+
958
+ def get_user_transactions(db, user_id: str, skip: int = 0, limit: int = 100) -> List[Dict[str, Any]]:
959
+ """Get all transactions for a user"""
960
+ docs = list(transactions_col(db).find({"user_id": _to_object_id(user_id)})
961
+ .skip(skip).limit(limit).sort("created_at", DESCENDING))
962
+ return [_clean_object(doc) for doc in docs]
963
+
964
+
965
+ def update_transaction_status(db, transaction_id: str, status: str, session=None) -> Optional[Dict[str, Any]]:
966
+ """Update transaction status"""
967
+ print(f"[REPO] Updating transaction {transaction_id} status to {status}")
968
+
969
+ doc = transactions_col(db).find_one_and_update(
970
+ {"_id": _to_object_id(transaction_id)},
971
+ {"$set": {"status": status, "updated_at": _now()}},
972
+ return_document=ReturnDocument.AFTER,
973
+ session=session
974
+ )
975
+
976
+ return _clean_object(doc) if doc else None
977
+
978
+
979
+ # ============================================================================
980
+ # PORTFOLIOS
981
+ # ============================================================================
982
+
983
+ def portfolios_col(db) -> Collection:
984
+ return db.portfolios
985
+
986
+
987
+ def create_or_update_portfolio(db, user_id: str, session=None) -> Dict[str, Any]:
988
+ """Create or update user portfolio based on investments"""
989
+ print(f"[REPO] Calculating portfolio for user {user_id}")
990
+
991
+ # Get all user investments
992
+ investments = get_user_investments(db, user_id)
993
+
994
+ total_invested = sum(inv.get("amount", 0) for inv in investments)
995
+ total_current_value = 0
996
+
997
+ # Calculate current value based on current token prices
998
+ for inv in investments:
999
+ property_obj = get_property_by_id(db, inv["property_id"])
1000
+ if property_obj:
1001
+ current_value = inv.get("tokens_purchased", 0) * property_obj.get("token_price", 0)
1002
+ total_current_value += current_value
1003
+
1004
+ total_profit = total_current_value - total_invested
1005
+
1006
+ now = _now()
1007
+ doc = portfolios_col(db).find_one_and_update(
1008
+ {"user_id": _to_object_id(user_id)},
1009
+ {
1010
+ "$set": {
1011
+ "total_invested": total_invested,
1012
+ "total_current_value": total_current_value,
1013
+ "total_profit": total_profit,
1014
+ "updated_at": now
1015
+ },
1016
+ "$setOnInsert": {
1017
+ "created_at": now
1018
+ }
1019
+ },
1020
+ upsert=True,
1021
+ return_document=ReturnDocument.AFTER,
1022
+ session=session
1023
+ )
1024
+
1025
+ print(f"[REPO] Portfolio updated: invested={total_invested}, current={total_current_value}, profit={total_profit}")
1026
+ return _clean_object(doc)
1027
+
1028
+
1029
+ def get_user_portfolio(db, user_id: str) -> Optional[Dict[str, Any]]:
1030
+ """Get user portfolio"""
1031
+ doc = portfolios_col(db).find_one({"user_id": _to_object_id(user_id)})
1032
+ return _clean_object(doc) if doc else None
1033
+
1034
+
1035
+ # ============================================================================
1036
+ # DOCUMENTS
1037
+ # ============================================================================
1038
+
1039
+ def documents_col(db) -> Collection:
1040
+ return db.documents
1041
+
1042
+
1043
+ def create_document(db, property_id: str, file_type: str, file_url: str, uploaded_by: str) -> Dict[str, Any]:
1044
+ """Create a document record"""
1045
+ print(f"[REPO] Creating document for property {property_id}, type: {file_type}")
1046
+
1047
+ now = _now()
1048
+ doc = {
1049
+ "property_id": _to_object_id(property_id),
1050
+ "file_type": file_type,
1051
+ "file_url": file_url,
1052
+ "uploaded_by": _coerce_object_id(uploaded_by),
1053
+ "created_at": now,
1054
+ "updated_at": now,
1055
+ }
1056
+
1057
+ result = documents_col(db).insert_one(doc)
1058
+ doc["_id"] = result.inserted_id
1059
+
1060
+ return _clean_object(doc)
1061
+
1062
+
1063
+ def create_property_documents(db, property_id: str, documents_data: List[Dict[str, str]], uploaded_by: str) -> List[Dict[str, Any]]:
1064
+ """Create multiple documents for a property"""
1065
+ print(f"[REPO] Creating {len(documents_data)} documents for property {property_id}")
1066
+
1067
+ now = _now()
1068
+ docs = []
1069
+ for doc_data in documents_data:
1070
+ doc = {
1071
+ "property_id": _to_object_id(property_id),
1072
+ "file_type": doc_data.get("file_type", "pdf"),
1073
+ "file_url": doc_data.get("file_url", ""),
1074
+ "uploaded_by": _coerce_object_id(uploaded_by),
1075
+ "created_at": now,
1076
+ "updated_at": now,
1077
+ }
1078
+ docs.append(doc)
1079
+
1080
+ if docs:
1081
+ result = documents_col(db).insert_many(docs)
1082
+ for i, inserted_id in enumerate(result.inserted_ids):
1083
+ docs[i]["_id"] = inserted_id
1084
+
1085
+ return [_clean_object(doc) for doc in docs]
1086
+
1087
+
1088
+ def get_property_documents(db, property_id: str) -> List[Dict[str, Any]]:
1089
+ """Get all documents for a property"""
1090
+ docs = list(documents_col(db).find({"property_id": _to_object_id(property_id)}))
1091
+ return [_clean_object(doc) for doc in docs]
1092
+
1093
+
1094
+ # ============================================================================
1095
+ # TRANSACTIONS (MISSING FUNCTIONS)
1096
+ # ============================================================================
1097
+
1098
+ def list_user_transactions(db, user_id: str, skip: int = 0, limit: int = 100) -> List[Dict[str, Any]]:
1099
+ docs = list(transactions_col(db).find({"user_id": _to_object_id(user_id)}).skip(skip).limit(limit).sort("created_at", DESCENDING))
1100
+ return [_clean_object(d) for d in docs]
1101
+
1102
+
1103
+ def list_aed_wallet_transactions(db, user_id: str, limit: int = 50) -> List[Dict[str, Any]]:
1104
+ """List AED wallet add/deduct style transactions stored via insert_transaction wrapper."""
1105
+ # Filter by metadata presence of total_amount_fils OR type in wallet_add/wallet_deduct/investment
1106
+ docs = list(transactions_col(db).find({
1107
+ "user_id": _to_object_id(user_id),
1108
+ "type": {"$in": ["wallet_add", "wallet_deduct", "investment"]}
1109
+ }).sort("created_at", DESCENDING).limit(limit))
1110
+ return [_clean_object(d) for d in docs]
1111
+
1112
+
1113
+ def record_admin_wallet_event(
1114
+ db,
1115
+ delta_fils: int,
1116
+ event_type: str,
1117
+ notes: str,
1118
+ *,
1119
+ counterparty_user: Optional[Dict[str, Any]] = None,
1120
+ property_obj: Optional[Dict[str, Any]] = None,
1121
+ payment_method: Optional[str] = None,
1122
+ payment_reference: Optional[str] = None,
1123
+ metadata: Optional[Dict[str, Any]] = None,
1124
+ session=None,
1125
+ ):
1126
+ """Adjust property creator's (admin) AED balance and mirror a transaction record.
1127
+
1128
+ If property_obj is provided, credits the property creator's wallet.
1129
+ Otherwise, credits the primary admin's wallet.
1130
+ Stores admin AED balance in admin's wallet.balance (float AED).
1131
+ Direction inferred from delta.
1132
+ """
1133
+ try:
1134
+ delta = int(delta_fils or 0)
1135
+ except Exception:
1136
+ delta = 0
1137
+
1138
+ # Determine which admin to credit
1139
+ admin_doc = None
1140
+
1141
+ # If property is provided, credit the property creator
1142
+ if property_obj and property_obj.get('created_by'):
1143
+ admin_doc = get_user_by_id(db, property_obj['created_by'])
1144
+ if admin_doc:
1145
+ logger.info(f"record_admin_wallet_event: crediting property creator {admin_doc.get('email')}")
1146
+
1147
+ # Fallback to primary admin if no property or creator not found
1148
+ if not admin_doc:
1149
+ admin_doc = get_primary_admin(db, session=session)
1150
+ if admin_doc:
1151
+ logger.info(f"record_admin_wallet_event: crediting primary admin {admin_doc.get('email')}")
1152
+
1153
+ if not admin_doc:
1154
+ logger.warning("record_admin_wallet_event: no admin user found")
1155
+ return None
1156
+
1157
+ direction = "credit" if delta >= 0 else "debit"
1158
+ amount_abs = abs(delta)
1159
+
1160
+ # Get admin's wallet
1161
+ admin_wallet = None
1162
+ admin_wallet_id = None
1163
+
1164
+ if delta != 0:
1165
+ # Update the admin's wallet balance instead of user document
1166
+ admin_wallet = wallets_col(db).find_one({"user_id": _to_object_id(admin_doc["id"])})
1167
+ if admin_wallet:
1168
+ admin_wallet_id = str(admin_wallet["_id"])
1169
+ # Convert fils to AED and update wallet balance
1170
+ delta_aed = float(delta) / 100.0
1171
+ wallets_col(db).update_one(
1172
+ {"_id": admin_wallet["_id"]},
1173
+ {"$inc": {"balance": delta_aed}},
1174
+ session=session
1175
+ )
1176
+ else:
1177
+ logger.warning(f"record_admin_wallet_event: admin {admin_doc['id']} has no wallet")
1178
+
1179
+ # Build metadata with all transaction details
1180
+ tx_metadata: Dict[str, Any] = {
1181
+ "direction": direction,
1182
+ "notes": notes,
1183
+ }
1184
+
1185
+ if property_obj:
1186
+ tx_metadata["property_name"] = property_obj.get("title")
1187
+ if counterparty_user:
1188
+ tx_metadata["counterparty_user_id"] = counterparty_user.get("id")
1189
+ if counterparty_user.get("name"):
1190
+ tx_metadata["counterparty_username"] = counterparty_user.get("name")
1191
+ if counterparty_user.get("email"):
1192
+ tx_metadata["counterparty_email"] = counterparty_user.get("email")
1193
+ if payment_method:
1194
+ tx_metadata["payment_method"] = payment_method
1195
+ if payment_reference:
1196
+ tx_metadata["payment_reference"] = payment_reference
1197
+ if metadata:
1198
+ tx_metadata.update(metadata) # Merge additional metadata
1199
+
1200
+ # Convert fils to AED for the amount field
1201
+ amount_aed = float(amount_abs) / 100.0
1202
+
1203
+ tx_data: Dict[str, Any] = {
1204
+ "user_id": admin_doc["id"],
1205
+ "wallet_id": admin_wallet_id, # Include wallet_id
1206
+ "type": event_type,
1207
+ "amount": amount_aed, # Include amount in AED
1208
+ "status": "completed",
1209
+ "metadata": tx_metadata,
1210
+ }
1211
+
1212
+ if property_obj:
1213
+ # property_obj id may be str
1214
+ tx_data["property_id"] = property_obj.get("id")
1215
+
1216
+ return insert_transaction(db, tx_data, session=session)
1217
+
1218
+
1219
+ def upsert_token(db, property_id: str, token_data: Dict[str, Any]) -> Dict[str, Any]:
1220
+ """Create or update token record for a property"""
1221
+ print(f"[REPO] Upserting token record for property {property_id}")
1222
+
1223
+ now = _now()
1224
+
1225
+ # Use tokens collection
1226
+ tokens_col = db.tokens
1227
+
1228
+ doc = tokens_col.find_one_and_update(
1229
+ {"property_id": property_id},
1230
+ {
1231
+ "$set": {
1232
+ **token_data,
1233
+ "property_id": property_id,
1234
+ "updated_at": now
1235
+ },
1236
+ "$setOnInsert": {
1237
+ "created_at": now
1238
+ }
1239
+ },
1240
+ upsert=True,
1241
+ return_document=ReturnDocument.AFTER
1242
+ )
1243
+
1244
+ return _clean_object(doc)
1245
+
1246
+
1247
+ def get_property_token(db, property_id: str) -> Optional[Dict[str, Any]]:
1248
+ """Get token information for a property"""
1249
+ tokens_col = db.tokens
1250
+ doc = tokens_col.find_one({"property_id": property_id})
1251
+ return _clean_object(doc) if doc else None
1252
+
1253
+
1254
+ # ============================================================================
1255
+ # STATISTICS
1256
+ # ============================================================================
1257
+
1258
+ def get_admin_stats(db) -> Dict[str, Any]:
1259
+ """Get platform statistics for admin dashboard"""
1260
+ print("[REPO] Calculating admin statistics...")
1261
+
1262
+ stats = {
1263
+ "total_users": users_col(db).count_documents({"deleted": False}),
1264
+ "total_properties": properties_col(db).count_documents({"deleted": False}),
1265
+ "total_investments": investments_col(db).count_documents({}),
1266
+ "active_users": users_col(db).count_documents({"is_active": True, "deleted": False}),
1267
+ "total_tokens_sold": 0,
1268
+ "total_volume": 0.0,
1269
+ "total_revenue": 0.0,
1270
+ }
1271
+
1272
+ # Calculate total tokens sold and volume
1273
+ pipeline = [
1274
+ {"$group": {
1275
+ "_id": None,
1276
+ "total_tokens": {"$sum": "$tokens_purchased"},
1277
+ "total_amount": {"$sum": "$amount"}
1278
+ }}
1279
+ ]
1280
+
1281
+ result = list(investments_col(db).aggregate(pipeline))
1282
+ if result:
1283
+ stats["total_tokens_sold"] = result[0].get("total_tokens", 0)
1284
+ stats["total_volume"] = result[0].get("total_amount", 0.0)
1285
+ stats["total_revenue"] = stats["total_volume"] * 0.02 # 2% platform fee
1286
+
1287
+ print(f"[REPO] Stats calculated: {stats}")
1288
+ return stats
1289
+
1290
+
1291
+ def get_admin_specific_stats(db, admin_user_id: str) -> Dict[str, Any]:
1292
+ """Get statistics for a specific admin's properties only"""
1293
+ print(f"[REPO] Calculating admin-specific statistics for admin: {admin_user_id}")
1294
+
1295
+ admin_oid = _coerce_object_id(admin_user_id)
1296
+
1297
+ # Get properties created by this admin
1298
+ admin_properties = list(properties_col(db).find({"created_by": admin_oid, "deleted": False}))
1299
+ admin_property_ids = [prop["_id"] for prop in admin_properties]
1300
+
1301
+ print(f"[REPO] Found {len(admin_property_ids)} properties created by admin")
1302
+
1303
+ # Get investments for this admin's properties only
1304
+ admin_investments = list(investments_col(db).find({"property_id": {"$in": admin_property_ids}}))
1305
+
1306
+ # Get unique buyers who invested in this admin's properties
1307
+ unique_buyers = len(set([inv.get("user_id") for inv in admin_investments if inv.get("user_id")]))
1308
+
1309
+ # Calculate totals
1310
+ total_tokens_sold = sum(inv.get("tokens_purchased", 0) for inv in admin_investments)
1311
+ total_volume = sum(inv.get("amount", 0) for inv in admin_investments)
1312
+ total_revenue = total_volume * 0.02 # 2% platform fee
1313
+
1314
+ stats = {
1315
+ "total_users": users_col(db).count_documents({"deleted": False}), # Platform-wide
1316
+ "total_properties": len(admin_property_ids), # Admin's properties only
1317
+ "total_investments": len(admin_investments), # Investments in admin's properties
1318
+ "active_users": unique_buyers, # Buyers who invested in admin's properties
1319
+ "total_tokens_sold": total_tokens_sold,
1320
+ "total_volume": total_volume,
1321
+ "total_revenue": total_revenue,
1322
+ }
1323
+
1324
+ print(f"[REPO] Admin-specific stats calculated: {stats}")
1325
+ return stats
1326
+
1327
+
1328
+ def get_properties_by_creator(db, creator_id: str) -> List[Dict[str, Any]]:
1329
+ """Get all properties created by a specific admin"""
1330
+ creator_oid = _coerce_object_id(creator_id)
1331
+ properties = list(properties_col(db).find({"created_by": creator_oid, "deleted": False}))
1332
+ return [_clean_object(prop) for prop in properties]
1333
+
1334
+
1335
+ # ============================================================================
1336
+ # RENT DISTRIBUTION OPERATIONS
1337
+ # ============================================================================
1338
+
1339
+ def rent_distributions_col(db) -> Collection:
1340
+ return db.rent_distributions
1341
+
1342
+
1343
+ def rent_payments_col(db) -> Collection:
1344
+ return db.rent_payments
1345
+
1346
+
1347
+ def get_investors_by_property(db, property_id: str) -> List[Dict[str, Any]]:
1348
+ """Get all investors (users with investments) for a specific property"""
1349
+ print(f"[REPO] Getting investors for property: {property_id}")
1350
+
1351
+ property_oid = _to_object_id(property_id)
1352
+
1353
+ # Find all unique users who have invested in this property
1354
+ pipeline = [
1355
+ {"$match": {"property_id": property_oid}},
1356
+ {"$group": {
1357
+ "_id": "$user_id",
1358
+ "total_tokens": {"$sum": "$tokens_purchased"},
1359
+ "total_invested": {"$sum": "$investment_amount"}
1360
+ }},
1361
+ {"$lookup": {
1362
+ "from": "users",
1363
+ "localField": "_id",
1364
+ "foreignField": "_id",
1365
+ "as": "user_info"
1366
+ }},
1367
+ {"$unwind": "$user_info"},
1368
+ {"$project": {
1369
+ "user_id": "$_id",
1370
+ "tokens_purchased": "$total_tokens",
1371
+ "total_invested": "$total_invested",
1372
+ "wallet_address": "$user_info.wallet_address",
1373
+ "email": "$user_info.email",
1374
+ "name": "$user_info.name"
1375
+ }}
1376
+ ]
1377
+
1378
+ investors = list(investments_col(db).aggregate(pipeline))
1379
+
1380
+ # Clean the results
1381
+ result = []
1382
+ for inv in investors:
1383
+ result.append({
1384
+ "user_id": str(inv["user_id"]),
1385
+ "tokens_purchased": inv.get("tokens_purchased", 0), # Changed from tokens_owned
1386
+ "total_invested": inv.get("total_invested", 0),
1387
+ "wallet_address": inv.get("wallet_address"),
1388
+ "email": inv.get("email"),
1389
+ "name": inv.get("name")
1390
+ })
1391
+
1392
+ print(f"[REPO] Found {len(result)} investors for property {property_id}")
1393
+ return result
1394
+
1395
+
1396
+ def create_rent_distribution(db, property_id: str, total_rent_amount: float,
1397
+ rent_period_start: str, rent_period_end: str,
1398
+ total_tokens: int, rent_per_token: float,
1399
+ distribution_date: datetime, notes: Optional[str] = None,
1400
+ session=None) -> Dict[str, Any]:
1401
+ """Create a rent distribution record"""
1402
+ print(f"[REPO] Creating rent distribution for property: {property_id}, amount: {total_rent_amount}")
1403
+
1404
+ now = _now()
1405
+ doc = {
1406
+ "property_id": _to_object_id(property_id),
1407
+ "total_rent_amount": total_rent_amount,
1408
+ "total_tokens": total_tokens,
1409
+ "rent_per_token": rent_per_token,
1410
+ "rent_period_start": rent_period_start,
1411
+ "rent_period_end": rent_period_end,
1412
+ "distribution_date": distribution_date,
1413
+ "total_investors": 0,
1414
+ "payments_completed": 0,
1415
+ "status": "pending",
1416
+ "notes": notes,
1417
+ "created_at": now,
1418
+ "updated_at": now,
1419
+ }
1420
+
1421
+ result = rent_distributions_col(db).insert_one(doc, session=session)
1422
+ doc["_id"] = result.inserted_id
1423
+
1424
+ print(f"[REPO] Rent distribution created with ID: {result.inserted_id}")
1425
+ return _clean_object(doc)
1426
+
1427
+
1428
+ def update_rent_distribution_status(db, distribution_id: str, status: str,
1429
+ total_investors: int, payments_completed: int,
1430
+ session=None) -> Dict[str, Any]:
1431
+ """Update rent distribution status and counts"""
1432
+ print(f"[REPO] Updating rent distribution {distribution_id}: status={status}, payments={payments_completed}/{total_investors}")
1433
+
1434
+ updated = rent_distributions_col(db).find_one_and_update(
1435
+ {"_id": _to_object_id(distribution_id)},
1436
+ {
1437
+ "$set": {
1438
+ "status": status,
1439
+ "total_investors": total_investors,
1440
+ "payments_completed": payments_completed,
1441
+ "updated_at": _now(),
1442
+ }
1443
+ },
1444
+ return_document=ReturnDocument.AFTER,
1445
+ session=session
1446
+ )
1447
+
1448
+ return _clean_object(updated) if updated else None
1449
+
1450
+
1451
+ def create_rent_payment(db, distribution_id: str, user_id: str, property_id: str,
1452
+ tokens_owned: int, rent_amount: float, rent_period_start: str,
1453
+ rent_period_end: str, payment_status: str = "pending",
1454
+ wallet_credited: bool = False, transaction_id: Optional[str] = None,
1455
+ session=None) -> Dict[str, Any]:
1456
+ """Create a rent payment record for a user"""
1457
+ print(f"[REPO] Creating rent payment for user: {user_id}, amount: {rent_amount}")
1458
+
1459
+ now = _now()
1460
+ doc = {
1461
+ "distribution_id": _to_object_id(distribution_id),
1462
+ "user_id": _to_object_id(user_id),
1463
+ "property_id": _to_object_id(property_id),
1464
+ "tokens_owned": tokens_owned,
1465
+ "rent_amount": rent_amount,
1466
+ "rent_period_start": rent_period_start,
1467
+ "rent_period_end": rent_period_end,
1468
+ "payment_status": payment_status,
1469
+ "wallet_credited": wallet_credited,
1470
+ "transaction_id": _to_object_id(transaction_id) if transaction_id else None,
1471
+ "payment_date": now if payment_status == "completed" else None,
1472
+ "created_at": now,
1473
+ }
1474
+
1475
+ result = rent_payments_col(db).insert_one(doc, session=session)
1476
+ doc["_id"] = result.inserted_id
1477
+
1478
+ print(f"[REPO] Rent payment created with ID: {result.inserted_id}")
1479
+ return _clean_object(doc)
1480
+
1481
+
1482
+ def get_user_rent_payments(db, user_id: str, property_id: Optional[str] = None) -> List[Dict[str, Any]]:
1483
+ """Get all rent payments for a user, optionally filtered by property"""
1484
+ print(f"[REPO] Getting rent payments for user: {user_id}")
1485
+
1486
+ query = {"user_id": _to_object_id(user_id)}
1487
+ if property_id:
1488
+ query["property_id"] = _to_object_id(property_id)
1489
+
1490
+ payments = list(rent_payments_col(db).find(query).sort("payment_date", DESCENDING))
1491
+ return [_clean_object(payment) for payment in payments]
1492
+
1493
+
1494
+ def get_rent_distribution_by_id(db, distribution_id: str) -> Optional[Dict[str, Any]]:
1495
+ """Get a rent distribution by ID"""
1496
+ doc = rent_distributions_col(db).find_one({"_id": _to_object_id(distribution_id)})
1497
+ return _clean_object(doc) if doc else None
1498
+
1499
+
1500
+ def get_user_rent_summary(db, user_id: str, property_id: str) -> Dict[str, Any]:
1501
+ """Get rent summary for a user's investment in a property"""
1502
+ print(f"[REPO] Getting rent summary for user: {user_id}, property: {property_id}")
1503
+
1504
+ # Get all rent payments for this user and property
1505
+ payments = get_user_rent_payments(db, user_id, property_id)
1506
+
1507
+ if not payments:
1508
+ return {
1509
+ "property_id": property_id,
1510
+ "total_rent_received": 0.0,
1511
+ "total_rent_payments": 0,
1512
+ "last_rent_amount": None,
1513
+ "last_rent_date": None,
1514
+ "average_monthly_rent": None,
1515
+ }
1516
+
1517
+ # Calculate aggregates
1518
+ total_rent = sum(p.get("rent_amount", 0.0) for p in payments if p.get("payment_status") == "completed")
1519
+ completed_payments = [p for p in payments if p.get("payment_status") == "completed"]
1520
+
1521
+ last_payment = completed_payments[0] if completed_payments else None
1522
+
1523
+ return {
1524
+ "property_id": property_id,
1525
+ "total_rent_received": total_rent,
1526
+ "total_rent_payments": len(completed_payments),
1527
+ "last_rent_amount": last_payment.get("rent_amount") if last_payment else None,
1528
+ "last_rent_date": last_payment.get("payment_date") if last_payment else None,
1529
+ "average_monthly_rent": total_rent / len(completed_payments) if completed_payments else None,
1530
+ }
1531
+
1532
+
1533
+ # =====================================================================
1534
+ # SESSION MANAGEMENT
1535
+ # =====================================================================
1536
+
1537
+ def sessions_col(db):
1538
+ """Get sessions collection"""
1539
+ return db.sessions
1540
+
1541
+
1542
+ def create_session(
1543
+ db,
1544
+ session_id: str,
1545
+ user_id: str,
1546
+ device_fingerprint: str,
1547
+ token: str,
1548
+ expires_at: datetime
1549
+ ) -> Dict[str, Any]:
1550
+ """
1551
+ Create a new authentication session
1552
+
1553
+ Args:
1554
+ db: Database connection
1555
+ session_id: Unique session identifier
1556
+ user_id: User's ID
1557
+ device_fingerprint: Device fingerprint hash
1558
+ token: JWT token
1559
+ expires_at: Session expiration timestamp
1560
+
1561
+ Returns:
1562
+ Created session document
1563
+ """
1564
+ print(f"[REPO] Creating session for user: {user_id}")
1565
+
1566
+ session_doc = {
1567
+ "session_id": session_id,
1568
+ "user_id": _to_object_id(user_id),
1569
+ "device_fingerprint": device_fingerprint,
1570
+ "token": token,
1571
+ "created_at": _now(),
1572
+ "last_activity": _now(),
1573
+ "expires_at": expires_at,
1574
+ "is_active": True
1575
+ }
1576
+
1577
+ sessions_col(db).insert_one(session_doc)
1578
+ return _clean_object(session_doc)
1579
+
1580
+
1581
+ def get_session_by_id(db, session_id: str) -> Optional[Dict[str, Any]]:
1582
+ """Get session by session_id"""
1583
+ doc = sessions_col(db).find_one({"session_id": session_id, "is_active": True})
1584
+ return _clean_object(doc) if doc else None
1585
+
1586
+
1587
+ def get_session_by_token(db, token: str) -> Optional[Dict[str, Any]]:
1588
+ """Get session by JWT token"""
1589
+ doc = sessions_col(db).find_one({"token": token, "is_active": True})
1590
+ return _clean_object(doc) if doc else None
1591
+
1592
+
1593
+ def validate_session(
1594
+ db,
1595
+ session_id: str,
1596
+ device_fingerprint: str
1597
+ ) -> bool:
1598
+ """
1599
+ Validate that a session exists and matches the device fingerprint
1600
+
1601
+ Args:
1602
+ db: Database connection
1603
+ session_id: Session identifier
1604
+ device_fingerprint: Current device fingerprint
1605
+
1606
+ Returns:
1607
+ True if session is valid, False otherwise
1608
+ """
1609
+ session = get_session_by_id(db, session_id)
1610
+
1611
+ if not session:
1612
+ print(f"[REPO] Session not found: {session_id}")
1613
+ return False
1614
+
1615
+ # Check if expired
1616
+ if session.get("expires_at") and session["expires_at"] < _now():
1617
+ print(f"[REPO] Session expired: {session_id}")
1618
+ invalidate_session(db, session_id)
1619
+ return False
1620
+
1621
+ # Check device fingerprint match
1622
+ if session.get("device_fingerprint") != device_fingerprint:
1623
+ print(f"[REPO] Device fingerprint mismatch for session: {session_id}")
1624
+ return False
1625
+
1626
+ # Update last activity
1627
+ sessions_col(db).update_one(
1628
+ {"session_id": session_id},
1629
+ {"$set": {"last_activity": _now()}}
1630
+ )
1631
+
1632
+ return True
1633
+
1634
+
1635
+ def invalidate_session(db, session_id: str) -> bool:
1636
+ """Invalidate a session (logout)"""
1637
+ print(f"[REPO] Invalidating session: {session_id}")
1638
+ result = sessions_col(db).update_one(
1639
+ {"session_id": session_id},
1640
+ {"$set": {"is_active": False, "invalidated_at": _now()}}
1641
+ )
1642
+ return result.modified_count > 0
1643
+
1644
+
1645
+ def invalidate_user_sessions(db, user_id: str) -> int:
1646
+ """Invalidate all sessions for a user (logout from all devices)"""
1647
+ print(f"[REPO] Invalidating all sessions for user: {user_id}")
1648
+ result = sessions_col(db).update_many(
1649
+ {"user_id": _to_object_id(user_id), "is_active": True},
1650
+ {"$set": {"is_active": False, "invalidated_at": _now()}}
1651
+ )
1652
+ return result.modified_count
1653
+
1654
+
1655
+ def get_user_active_sessions(db, user_id: str) -> List[Dict[str, Any]]:
1656
+ """Get all active sessions for a user"""
1657
+ sessions = list(sessions_col(db).find({
1658
+ "user_id": _to_object_id(user_id),
1659
+ "is_active": True
1660
+ }).sort("last_activity", DESCENDING))
1661
+ return [_clean_object(s) for s in sessions]
1662
+
1663
+
1664
+ def cleanup_expired_sessions(db) -> int:
1665
+ """Clean up expired sessions (can be run periodically)"""
1666
+ print("[REPO] Cleaning up expired sessions")
1667
+ result = sessions_col(db).update_many(
1668
+ {
1669
+ "expires_at": {"$lt": _now()},
1670
+ "is_active": True
1671
+ },
1672
+ {"$set": {"is_active": False, "invalidated_at": _now()}}
1673
+ )
1674
+ return result.modified_count
1675
+
1676
+
1677
+ # ============================================================================
1678
+ # SECONDARY MARKET TRANSACTIONS OPERATIONS
1679
+ # ============================================================================
1680
+
1681
+ def secondary_market_col(db) -> Collection:
1682
+ """Get secondary_market_transactions collection"""
1683
+ return db["secondary_market_transactions"]
1684
+
1685
+
1686
+ def create_market_transaction(
1687
+ db,
1688
+ transaction_id: str,
1689
+ transaction_type: str,
1690
+ property_id: str,
1691
+ property_title: str,
1692
+ token_currency: str,
1693
+ seller_id: str,
1694
+ seller_email: str,
1695
+ seller_xrp_address: str,
1696
+ buyer_id: str,
1697
+ buyer_email: str,
1698
+ buyer_xrp_address: str,
1699
+ tokens_amount: int,
1700
+ price_per_token: float,
1701
+ total_amount: float,
1702
+ blockchain_tx_hash: Optional[str] = None,
1703
+ notes: Optional[str] = None,
1704
+ session=None
1705
+ ) -> Dict[str, Any]:
1706
+ """
1707
+ Create a new secondary market transaction record
1708
+
1709
+ Args:
1710
+ db: Database instance
1711
+ transaction_id: Unique transaction identifier
1712
+ transaction_type: Type of market transaction (sell_to_admin, etc.)
1713
+ property_id: Property ID
1714
+ property_title: Property title
1715
+ token_currency: Token currency code
1716
+ seller_id: Seller user ID
1717
+ seller_email: Seller email
1718
+ seller_xrp_address: Seller XRP address
1719
+ buyer_id: Buyer user ID (admin for sell_to_admin)
1720
+ buyer_email: Buyer email
1721
+ buyer_xrp_address: Buyer XRP address
1722
+ tokens_amount: Number of tokens traded
1723
+ price_per_token: Price per token
1724
+ total_amount: Total transaction amount
1725
+ blockchain_tx_hash: Blockchain transaction hash
1726
+ notes: Additional notes
1727
+ session: MongoDB session for transactions
1728
+
1729
+ Returns:
1730
+ Created market transaction document
1731
+ """
1732
+ logger.info(f"[REPO] Creating market transaction: {transaction_id}")
1733
+
1734
+ market_tx = {
1735
+ "transaction_id": transaction_id,
1736
+ "transaction_type": transaction_type,
1737
+ "status": "completed" if blockchain_tx_hash else "pending",
1738
+
1739
+ "property_id": _coerce_object_id(property_id),
1740
+ "property_title": property_title,
1741
+ "token_currency": token_currency,
1742
+
1743
+ "seller_id": _coerce_object_id(seller_id),
1744
+ "seller_email": seller_email,
1745
+ "seller_xrp_address": seller_xrp_address,
1746
+
1747
+ "buyer_id": _coerce_object_id(buyer_id),
1748
+ "buyer_email": buyer_email,
1749
+ "buyer_xrp_address": buyer_xrp_address,
1750
+
1751
+ "tokens_amount": tokens_amount,
1752
+ "price_per_token": price_per_token,
1753
+ "total_amount": total_amount,
1754
+ "currency": "AED",
1755
+
1756
+ "blockchain_tx_hash": blockchain_tx_hash,
1757
+ "blockchain_confirmed": bool(blockchain_tx_hash),
1758
+ "blockchain_confirmed_at": _now() if blockchain_tx_hash else None,
1759
+
1760
+ "db_investment_updated": False,
1761
+ "db_wallet_updated": False,
1762
+ "db_property_updated": False,
1763
+ "db_transaction_recorded": False,
1764
+
1765
+ "initiated_at": _now(),
1766
+ "completed_at": _now() if blockchain_tx_hash else None,
1767
+
1768
+ "notes": notes,
1769
+ "error_message": None
1770
+ }
1771
+
1772
+ result = secondary_market_col(db).insert_one(market_tx, session=session)
1773
+ market_tx["_id"] = result.inserted_id
1774
+
1775
+ logger.info(f"[REPO] Market transaction created: {transaction_id}")
1776
+ return _clean_object(market_tx)
1777
+
1778
+
1779
+ def update_market_transaction_status(
1780
+ db,
1781
+ transaction_id: str,
1782
+ status: str,
1783
+ blockchain_tx_hash: Optional[str] = None,
1784
+ error_message: Optional[str] = None,
1785
+ db_updates: Optional[Dict[str, bool]] = None,
1786
+ session=None
1787
+ ) -> Optional[Dict[str, Any]]:
1788
+ """
1789
+ Update market transaction status and details
1790
+
1791
+ Args:
1792
+ db: Database instance
1793
+ transaction_id: Transaction ID to update
1794
+ status: New status (pending, completed, failed, etc.)
1795
+ blockchain_tx_hash: Blockchain transaction hash
1796
+ error_message: Error message if failed
1797
+ db_updates: Dictionary of database update flags (investment_updated, wallet_updated, etc.)
1798
+ session: MongoDB session for transactions
1799
+
1800
+ Returns:
1801
+ Updated market transaction document or None
1802
+ """
1803
+ logger.info(f"[REPO] Updating market transaction {transaction_id} to status: {status}")
1804
+
1805
+ update_fields = {
1806
+ "status": status
1807
+ }
1808
+
1809
+ if blockchain_tx_hash:
1810
+ update_fields["blockchain_tx_hash"] = blockchain_tx_hash
1811
+ update_fields["blockchain_confirmed"] = True
1812
+ update_fields["blockchain_confirmed_at"] = _now()
1813
+
1814
+ if error_message:
1815
+ update_fields["error_message"] = error_message
1816
+
1817
+ if status == "completed":
1818
+ update_fields["completed_at"] = _now()
1819
+
1820
+ if db_updates:
1821
+ if "investment_updated" in db_updates:
1822
+ update_fields["db_investment_updated"] = db_updates["investment_updated"]
1823
+ if "wallet_updated" in db_updates:
1824
+ update_fields["db_wallet_updated"] = db_updates["wallet_updated"]
1825
+ if "property_updated" in db_updates:
1826
+ update_fields["db_property_updated"] = db_updates["property_updated"]
1827
+ if "transaction_recorded" in db_updates:
1828
+ update_fields["db_transaction_recorded"] = db_updates["transaction_recorded"]
1829
+
1830
+ result = secondary_market_col(db).find_one_and_update(
1831
+ {"transaction_id": transaction_id},
1832
+ {"$set": update_fields},
1833
+ return_document=ReturnDocument.AFTER,
1834
+ session=session
1835
+ )
1836
+
1837
+ if result:
1838
+ logger.info(f"[REPO] Market transaction {transaction_id} updated successfully")
1839
+ return _clean_object(result)
1840
+
1841
+ logger.warning(f"[REPO] Market transaction {transaction_id} not found")
1842
+ return None
1843
+
1844
+
1845
+ def get_user_market_history(
1846
+ db,
1847
+ user_id: str,
1848
+ transaction_type: Optional[str] = None,
1849
+ status: Optional[str] = None,
1850
+ limit: int = 100
1851
+ ) -> List[Dict[str, Any]]:
1852
+ """
1853
+ Get user's secondary market transaction history
1854
+
1855
+ Args:
1856
+ db: Database instance
1857
+ user_id: User ID
1858
+ transaction_type: Filter by transaction type (optional)
1859
+ status: Filter by status (optional)
1860
+ limit: Maximum number of records to return
1861
+
1862
+ Returns:
1863
+ List of market transactions
1864
+ """
1865
+ logger.info(f"[REPO] Fetching market history for user: {user_id}")
1866
+
1867
+ query = {
1868
+ "$or": [
1869
+ {"seller_id": _coerce_object_id(user_id)},
1870
+ {"buyer_id": _coerce_object_id(user_id)}
1871
+ ]
1872
+ }
1873
+
1874
+ if transaction_type:
1875
+ query["transaction_type"] = transaction_type
1876
+
1877
+ if status:
1878
+ query["status"] = status
1879
+
1880
+ transactions = list(
1881
+ secondary_market_col(db)
1882
+ .find(query)
1883
+ .sort("initiated_at", DESCENDING)
1884
+ .limit(limit)
1885
+ )
1886
+
1887
+ logger.info(f"[REPO] Found {len(transactions)} market transactions for user {user_id}")
1888
+ return [_clean_object(tx) for tx in transactions]
1889
+
1890
+
1891
+ def get_property_market_history(
1892
+ db,
1893
+ property_id: str,
1894
+ status: Optional[str] = None,
1895
+ limit: int = 100
1896
+ ) -> List[Dict[str, Any]]:
1897
+ """
1898
+ Get property's secondary market transaction history
1899
+
1900
+ Args:
1901
+ db: Database instance
1902
+ property_id: Property ID
1903
+ status: Filter by status (optional)
1904
+ limit: Maximum number of records to return
1905
+
1906
+ Returns:
1907
+ List of market transactions for the property
1908
+ """
1909
+ logger.info(f"[REPO] Fetching market history for property: {property_id}")
1910
+
1911
+ query = {"property_id": _coerce_object_id(property_id)}
1912
+
1913
+ if status:
1914
+ query["status"] = status
1915
+
1916
+ transactions = list(
1917
+ secondary_market_col(db)
1918
+ .find(query)
1919
+ .sort("initiated_at", DESCENDING)
1920
+ .limit(limit)
1921
+ )
1922
+
1923
+ logger.info(f"[REPO] Found {len(transactions)} market transactions for property {property_id}")
1924
+ return [_clean_object(tx) for tx in transactions]
1925
+
1926
+
1927
+ def get_market_transaction(db, transaction_id: str) -> Optional[Dict[str, Any]]:
1928
+ """
1929
+ Get a specific market transaction by ID
1930
+
1931
+ Args:
1932
+ db: Database instance
1933
+ transaction_id: Transaction ID
1934
+
1935
+ Returns:
1936
+ Market transaction document or None
1937
+ """
1938
+ transaction = secondary_market_col(db).find_one({"transaction_id": transaction_id})
1939
+
1940
+ if transaction:
1941
+ return _clean_object(transaction)
1942
+
1943
+ return None
1944
+
1945
+
1946
+ # ============================================================================
1947
+ # CARDS OPERATIONS (Unified for both investor and admin)
1948
+ # ============================================================================
1949
+
1950
+ def cards_col(db) -> Collection:
1951
+ """Get cards collection (shared for investor and admin)"""
1952
+ return db["cards"]
1953
+
1954
+
1955
+ def _detect_card_type(card_number: str) -> str:
1956
+ """Detect card type from card number"""
1957
+ try:
1958
+ if card_number.startswith('4'):
1959
+ return 'visa'
1960
+ elif card_number.startswith(('51', '52', '53', '54', '55')) or (int(card_number[:6]) >= 222100 and int(card_number[:6]) <= 272099):
1961
+ return 'mastercard'
1962
+ elif card_number.startswith(('34', '37')):
1963
+ return 'amex'
1964
+ elif card_number.startswith('6011') or card_number.startswith('65'):
1965
+ return 'discover'
1966
+ except:
1967
+ pass
1968
+ return 'unknown'
1969
+
1970
+
1971
+ def create_card(
1972
+ db,
1973
+ user_id: str,
1974
+ user_role: str, # "investor" or "admin"
1975
+ card_number: str,
1976
+ expiry_month: int,
1977
+ expiry_year: int,
1978
+ cardholder_name: str,
1979
+ bank_name: Optional[str] = None
1980
+ ) -> Dict[str, Any]:
1981
+ """
1982
+ Create a new card for user/admin (stores only last 4 digits for security)
1983
+
1984
+ Args:
1985
+ db: Database instance
1986
+ user_id: User's ID
1987
+ user_role: "investor" or "admin"
1988
+ card_number: Full card number (will only store last 4)
1989
+ expiry_month: Expiry month (1-12)
1990
+ expiry_year: Expiry year (YYYY)
1991
+ cardholder_name: Name on card
1992
+ bank_name: Issuing bank name (optional)
1993
+
1994
+ Returns:
1995
+ Created card document (secure - no full card number)
1996
+ """
1997
+ logger.info(f"[REPO] Creating card for {user_role} user: {user_id}")
1998
+
1999
+ # Detect card type from full number
2000
+ card_type = _detect_card_type(card_number)
2001
+
2002
+ # Store only last 4 digits
2003
+ last_four = card_number[-4:]
2004
+
2005
+ # Check if card already exists for this user+role
2006
+ existing = cards_col(db).find_one({
2007
+ "user_id": _to_object_id(user_id),
2008
+ "user_role": user_role,
2009
+ "last_four": last_four,
2010
+ "expiry_month": expiry_month,
2011
+ "expiry_year": expiry_year,
2012
+ "is_active": True
2013
+ })
2014
+
2015
+ if existing:
2016
+ logger.warning(f"[REPO] Card with last four {last_four} already exists for {user_role} {user_id}")
2017
+ raise ValueError("Card already exists")
2018
+
2019
+ # Check if this should be default (first card for this user+role)
2020
+ existing_count = cards_col(db).count_documents({
2021
+ "user_id": _to_object_id(user_id),
2022
+ "user_role": user_role,
2023
+ "is_active": True
2024
+ })
2025
+ is_default = existing_count == 0
2026
+
2027
+ now = _now()
2028
+ card_doc = {
2029
+ "user_id": _to_object_id(user_id),
2030
+ "user_role": user_role,
2031
+ "card_type": card_type,
2032
+ "last_four": last_four,
2033
+ "cardholder_name": cardholder_name,
2034
+ "bank_name": bank_name if bank_name else None,
2035
+ "expiry_month": expiry_month,
2036
+ "expiry_year": expiry_year,
2037
+ "is_default": is_default,
2038
+ "is_active": True,
2039
+ "created_at": now,
2040
+ "updated_at": now
2041
+ }
2042
+
2043
+ result = cards_col(db).insert_one(card_doc)
2044
+ card_doc["_id"] = result.inserted_id
2045
+
2046
+ logger.info(f"[REPO] Card created with ID: {result.inserted_id}")
2047
+ return _clean_object(card_doc)
2048
+
2049
+
2050
+ def get_cards(db, user_id: str, user_role: str) -> List[Dict[str, Any]]:
2051
+ """Get all active cards for a user/admin"""
2052
+ logger.info(f"[REPO] Getting cards for {user_role} user: {user_id}")
2053
+
2054
+ cards = list(cards_col(db).find({
2055
+ "user_id": _to_object_id(user_id),
2056
+ "user_role": user_role,
2057
+ "is_active": True
2058
+ }).sort("created_at", DESCENDING))
2059
+
2060
+ return [_clean_object(card) for card in cards]
2061
+
2062
+
2063
+ def delete_card(db, user_id: str, user_role: str, card_id: str) -> bool:
2064
+ """Soft delete a user's/admin's card"""
2065
+ logger.info(f"[REPO] Deleting card {card_id} for {user_role} user {user_id}")
2066
+
2067
+ result = cards_col(db).update_one(
2068
+ {
2069
+ "_id": _to_object_id(card_id),
2070
+ "user_id": _to_object_id(user_id),
2071
+ "user_role": user_role
2072
+ },
2073
+ {"$set": {"is_active": False, "updated_at": _now()}}
2074
+ )
2075
+
2076
+ if result.modified_count > 0:
2077
+ # If deleted card was default, set another card as default
2078
+ deleted_card = cards_col(db).find_one({"_id": _to_object_id(card_id)})
2079
+ if deleted_card and deleted_card.get("is_default"):
2080
+ next_card = cards_col(db).find_one({
2081
+ "user_id": _to_object_id(user_id),
2082
+ "user_role": user_role,
2083
+ "is_active": True
2084
+ })
2085
+ if next_card:
2086
+ cards_col(db).update_one(
2087
+ {"_id": next_card["_id"]},
2088
+ {"$set": {"is_default": True}}
2089
+ )
2090
+
2091
+ return result.modified_count > 0
2092
+
2093
+
2094
+ def set_default_card(db, user_id: str, user_role: str, card_id: str) -> bool:
2095
+ """Set a card as the default payment method"""
2096
+ logger.info(f"[REPO] Setting card {card_id} as default for {user_role} user {user_id}")
2097
+
2098
+ # Unset current default for this user+role
2099
+ cards_col(db).update_many(
2100
+ {"user_id": _to_object_id(user_id), "user_role": user_role, "is_default": True},
2101
+ {"$set": {"is_default": False}}
2102
+ )
2103
+
2104
+ # Set new default
2105
+ result = cards_col(db).update_one(
2106
+ {
2107
+ "_id": _to_object_id(card_id),
2108
+ "user_id": _to_object_id(user_id),
2109
+ "user_role": user_role,
2110
+ "is_active": True
2111
+ },
2112
+ {"$set": {"is_default": True}}
2113
+ )
2114
+
2115
+ return result.modified_count > 0
2116
+
2117
+
2118
+ # Legacy aliases for backward compatibility
2119
+ def create_user_card(db, user_id, card_number, expiry_month, expiry_year, cardholder_name, bank_name=None):
2120
+ return create_card(db, user_id, "investor", card_number, expiry_month, expiry_year, cardholder_name, bank_name)
2121
+
2122
+ def get_user_cards(db, user_id):
2123
+ return get_cards(db, user_id, "investor")
2124
+
2125
+ def delete_user_card(db, user_id, card_id):
2126
+ return delete_card(db, user_id, "investor", card_id)
2127
+
2128
+
2129
+ # ============================================================================
2130
+ # BANKS OPERATIONS (Unified for both investor and admin)
2131
+ # ============================================================================
2132
+
2133
+ def banks_col(db) -> Collection:
2134
+ """Get banks collection (shared for investor and admin)"""
2135
+ return db["banks"]
2136
+
2137
+
2138
+ def create_bank(
2139
+ db,
2140
+ user_id: str,
2141
+ user_role: str, # "investor" or "admin"
2142
+ bank_name: str,
2143
+ account_holder_name: str,
2144
+ account_number: str,
2145
+ account_type: str = "savings",
2146
+ iban: Optional[str] = None,
2147
+ swift_code: Optional[str] = None,
2148
+ currency: str = "AED"
2149
+ ) -> Dict[str, Any]:
2150
+ """
2151
+ Create a new bank account for user/admin (stores only last 4 digits of account number)
2152
+
2153
+ Args:
2154
+ db: Database instance
2155
+ user_id: User's ID
2156
+ user_role: "investor" or "admin"
2157
+ bank_name: Bank name
2158
+ account_holder_name: Account holder name
2159
+ account_number: Full account number (will store last 4 digits securely)
2160
+ account_type: Account type (savings, current, business)
2161
+ iban: IBAN (optional)
2162
+ swift_code: SWIFT code (optional)
2163
+ currency: Currency code (default AED)
2164
+
2165
+ Returns:
2166
+ Created bank document (secure - masked account number)
2167
+ """
2168
+ logger.info(f"[REPO] Creating bank account for {user_role} user: {user_id}")
2169
+
2170
+ # Store last 4 digits for display
2171
+ account_number_last_four = account_number[-4:]
2172
+ iban_last_four = iban[-4:] if iban else None
2173
+
2174
+ # Check if bank already exists for this user+role
2175
+ existing = banks_col(db).find_one({
2176
+ "user_id": _to_object_id(user_id),
2177
+ "user_role": user_role,
2178
+ "account_number_last_four": account_number_last_four,
2179
+ "bank_name": bank_name,
2180
+ "is_active": True
2181
+ })
2182
+
2183
+ if existing:
2184
+ logger.warning(f"[REPO] Bank account already exists for {user_role} user {user_id}")
2185
+ raise ValueError("Bank account already exists")
2186
+
2187
+ # Check if this should be default (first bank for this user+role)
2188
+ existing_count = banks_col(db).count_documents({
2189
+ "user_id": _to_object_id(user_id),
2190
+ "user_role": user_role,
2191
+ "is_active": True
2192
+ })
2193
+ is_default = existing_count == 0
2194
+
2195
+ now = _now()
2196
+ bank_doc = {
2197
+ "user_id": _to_object_id(user_id),
2198
+ "user_role": user_role,
2199
+ "bank_name": bank_name,
2200
+ "account_holder_name": account_holder_name,
2201
+ "account_number_last_four": account_number_last_four,
2202
+ "account_number_encrypted": account_number, # In production, encrypt this
2203
+ "iban_last_four": iban_last_four if iban_last_four else None,
2204
+ "iban_encrypted": iban if iban else None, # In production, encrypt this
2205
+ "swift_code": swift_code if swift_code else None,
2206
+ "account_type": account_type,
2207
+ "currency": currency,
2208
+ "is_default": is_default,
2209
+ "is_verified": False, # Can be verified via microdeposits
2210
+ "is_active": True,
2211
+ "created_at": now,
2212
+ "updated_at": now
2213
+ }
2214
+
2215
+ result = banks_col(db).insert_one(bank_doc)
2216
+ bank_doc["_id"] = result.inserted_id
2217
+
2218
+ # Remove encrypted fields from return value
2219
+ bank_doc.pop("account_number_encrypted", None)
2220
+ bank_doc.pop("iban_encrypted", None)
2221
+
2222
+ # Remove None values for cleaner output
2223
+ bank_doc = {k: v for k, v in bank_doc.items() if v is not None}
2224
+
2225
+ logger.info(f"[REPO] Bank account created with ID: {result.inserted_id}")
2226
+ return _clean_object(bank_doc)
2227
+
2228
+
2229
+ def get_banks(db, user_id: str, user_role: str) -> List[Dict[str, Any]]:
2230
+ """Get all active bank accounts for a user/admin"""
2231
+ logger.info(f"[REPO] Getting bank accounts for {user_role} user: {user_id}")
2232
+
2233
+ banks = list(banks_col(db).find({
2234
+ "user_id": _to_object_id(user_id),
2235
+ "user_role": user_role,
2236
+ "is_active": True
2237
+ }).sort("created_at", DESCENDING))
2238
+
2239
+ # Remove encrypted fields and None values from output
2240
+ result = []
2241
+ for bank in banks:
2242
+ bank_clean = _clean_object(bank)
2243
+ bank_clean.pop("account_number_encrypted", None)
2244
+ bank_clean.pop("iban_encrypted", None)
2245
+ # Remove None values
2246
+ bank_clean = {k: v for k, v in bank_clean.items() if v is not None}
2247
+ result.append(bank_clean)
2248
+
2249
+ return result
2250
+
2251
+
2252
+ def delete_bank(db, user_id: str, user_role: str, bank_id: str) -> bool:
2253
+ """Soft delete a user's/admin's bank account"""
2254
+ logger.info(f"[REPO] Deleting bank {bank_id} for {user_role} user {user_id}")
2255
+
2256
+ result = banks_col(db).update_one(
2257
+ {
2258
+ "_id": _to_object_id(bank_id),
2259
+ "user_id": _to_object_id(user_id),
2260
+ "user_role": user_role
2261
+ },
2262
+ {"$set": {"is_active": False, "updated_at": _now()}}
2263
+ )
2264
+
2265
+ if result.modified_count > 0:
2266
+ # If deleted bank was default, set another bank as default
2267
+ deleted_bank = banks_col(db).find_one({"_id": _to_object_id(bank_id)})
2268
+ if deleted_bank and deleted_bank.get("is_default"):
2269
+ next_bank = banks_col(db).find_one({
2270
+ "user_id": _to_object_id(user_id),
2271
+ "user_role": user_role,
2272
+ "is_active": True
2273
+ })
2274
+ if next_bank:
2275
+ banks_col(db).update_one(
2276
+ {"_id": next_bank["_id"]},
2277
+ {"$set": {"is_default": True}}
2278
+ )
2279
+
2280
+ return result.modified_count > 0
2281
+
2282
+
2283
+ def set_default_bank(db, user_id: str, user_role: str, bank_id: str) -> bool:
2284
+ """Set a bank account as the default for withdrawals"""
2285
+ logger.info(f"[REPO] Setting bank {bank_id} as default for {user_role} user {user_id}")
2286
+
2287
+ # Unset current default for this user+role
2288
+ banks_col(db).update_many(
2289
+ {"user_id": _to_object_id(user_id), "user_role": user_role, "is_default": True},
2290
+ {"$set": {"is_default": False}}
2291
+ )
2292
+
2293
+ # Set new default
2294
+ result = banks_col(db).update_one(
2295
+ {
2296
+ "_id": _to_object_id(bank_id),
2297
+ "user_id": _to_object_id(user_id),
2298
+ "user_role": user_role,
2299
+ "is_active": True
2300
+ },
2301
+ {"$set": {"is_default": True}}
2302
+ )
2303
+
2304
+ return result.modified_count > 0
2305
+
2306
+
2307
+ # Legacy aliases for backward compatibility
2308
+ def create_user_bank(db, user_id, bank_name, account_holder_name, account_number, account_type="savings", iban=None, swift_code=None, currency="AED"):
2309
+ return create_bank(db, user_id, "investor", bank_name, account_holder_name, account_number, account_type, iban, swift_code, currency)
2310
+
2311
+ def get_user_banks(db, user_id):
2312
+ return get_banks(db, user_id, "investor")
2313
+
2314
+ def delete_user_bank(db, user_id, bank_id):
2315
+ return delete_bank(db, user_id, "investor", bank_id)
2316
+
repo_otp.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ OTP Repository Layer
3
+ Handles OTP storage and verification in MongoDB
4
+ """
5
+ from datetime import datetime
6
+ from typing import Optional, Dict, Any
7
+ from pymongo.collection import Collection
8
+ from bson import ObjectId
9
+ import logging
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def _now():
15
+ """Get current UTC timestamp"""
16
+ return datetime.utcnow()
17
+
18
+
19
+ def _clean_object(data):
20
+ """Clean MongoDB object by converting ObjectId to string"""
21
+ if isinstance(data, dict):
22
+ cleaned = {}
23
+ for key, value in data.items():
24
+ if key == "_id":
25
+ cleaned["id"] = str(value) if isinstance(value, ObjectId) else value
26
+ else:
27
+ cleaned[key] = value
28
+ return cleaned
29
+ return data
30
+
31
+
32
+ def otps_col(db) -> Collection:
33
+ """Get OTPs collection"""
34
+ return db.otps
35
+
36
+
37
+ def create_otp(db, email: str, otp: str, purpose: str, expires_at: datetime) -> Dict[str, Any]:
38
+ """
39
+ Create or update OTP record for an email
40
+
41
+ Args:
42
+ db: Database connection
43
+ email: User email
44
+ otp: 6-digit OTP code
45
+ purpose: 'login' or 'registration'
46
+ expires_at: Expiry datetime
47
+
48
+ Returns:
49
+ OTP document
50
+ """
51
+ logger.info(f"[OTP_REPO] Creating OTP for {email}, purpose: {purpose}")
52
+
53
+ now = _now()
54
+
55
+ # Delete any existing OTPs for this email and purpose
56
+ otps_col(db).delete_many({"email": email, "purpose": purpose})
57
+
58
+ doc = {
59
+ "email": email,
60
+ "otp": otp,
61
+ "purpose": purpose,
62
+ "expires_at": expires_at,
63
+ "verified": False,
64
+ "attempts": 0,
65
+ "created_at": now,
66
+ "updated_at": now,
67
+ }
68
+
69
+ result = otps_col(db).insert_one(doc)
70
+ doc["_id"] = result.inserted_id
71
+
72
+ logger.info(f"[OTP_REPO] [SUCCESS] OTP created with ID: {result.inserted_id}")
73
+ return _clean_object(doc)
74
+
75
+
76
+ def get_otp(db, email: str, purpose: str) -> Optional[Dict[str, Any]]:
77
+ """
78
+ Get active OTP for email and purpose
79
+
80
+ Args:
81
+ db: Database connection
82
+ email: User email
83
+ purpose: 'login' or 'registration'
84
+
85
+ Returns:
86
+ OTP document if found and not expired, None otherwise
87
+ """
88
+ doc = otps_col(db).find_one({
89
+ "email": email,
90
+ "purpose": purpose,
91
+ "verified": False,
92
+ "expires_at": {"$gt": _now()}
93
+ })
94
+
95
+ return _clean_object(doc) if doc else None
96
+
97
+
98
+ def verify_otp(db, email: str, otp: str, purpose: str) -> tuple[bool, str]:
99
+ """
100
+ Verify OTP code
101
+
102
+ Args:
103
+ db: Database connection
104
+ email: User email
105
+ otp: OTP code to verify
106
+ purpose: 'login' or 'registration'
107
+
108
+ Returns:
109
+ tuple: (success: bool, message: str)
110
+ """
111
+ logger.info(f"[OTP_REPO] Verifying OTP for {email}, purpose: {purpose}")
112
+
113
+ # Get OTP record
114
+ otp_doc = get_otp(db, email, purpose)
115
+
116
+ if not otp_doc:
117
+ logger.warning(f"[OTP_REPO] [ERROR] No valid OTP found for {email}")
118
+ return False, "OTP expired or not found. Please request a new one."
119
+
120
+ # Check if max attempts exceeded (5 attempts)
121
+ if otp_doc.get("attempts", 0) >= 5:
122
+ logger.warning(f"[OTP_REPO] [ERROR] Max attempts exceeded for {email}")
123
+ # Delete the OTP
124
+ otps_col(db).delete_one({"_id": ObjectId(otp_doc["id"])})
125
+ return False, "Maximum verification attempts exceeded. Please request a new OTP."
126
+
127
+ # Increment attempts
128
+ otps_col(db).update_one(
129
+ {"_id": ObjectId(otp_doc["id"])},
130
+ {
131
+ "$inc": {"attempts": 1},
132
+ "$set": {"updated_at": _now()}
133
+ }
134
+ )
135
+
136
+ # Verify OTP
137
+ if otp_doc["otp"] != otp:
138
+ remaining = 5 - (otp_doc.get("attempts", 0) + 1)
139
+ logger.warning(f"[OTP_REPO] [ERROR] Invalid OTP for {email}. {remaining} attempts remaining")
140
+ return False, f"Invalid OTP code. {remaining} attempts remaining."
141
+
142
+ # Mark as verified
143
+ otps_col(db).update_one(
144
+ {"_id": ObjectId(otp_doc["id"])},
145
+ {
146
+ "$set": {
147
+ "verified": True,
148
+ "verified_at": _now(),
149
+ "updated_at": _now()
150
+ }
151
+ }
152
+ )
153
+
154
+ logger.info(f"[OTP_REPO] [SUCCESS] OTP verified successfully for {email}")
155
+ return True, "OTP verified successfully"
156
+
157
+
158
+ def delete_otp(db, email: str, purpose: str) -> bool:
159
+ """
160
+ Delete OTP record
161
+
162
+ Args:
163
+ db: Database connection
164
+ email: User email
165
+ purpose: 'login' or 'registration'
166
+
167
+ Returns:
168
+ bool: True if deleted, False otherwise
169
+ """
170
+ result = otps_col(db).delete_many({"email": email, "purpose": purpose})
171
+ logger.info(f"[OTP_REPO] Deleted {result.deleted_count} OTP(s) for {email}")
172
+ return result.deleted_count > 0
173
+
174
+
175
+ def cleanup_expired_otps(db) -> int:
176
+ """
177
+ Clean up expired OTPs (run periodically)
178
+
179
+ Returns:
180
+ int: Number of deleted OTPs
181
+ """
182
+ result = otps_col(db).delete_many({"expires_at": {"$lt": _now()}})
183
+ if result.deleted_count > 0:
184
+ logger.info(f"[OTP_REPO] Cleaned up {result.deleted_count} expired OTP(s)")
185
+ return result.deleted_count
requirements.txt ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =============================================================================
2
+ # AtriumChain Platform - Backend Python Requirements
3
+ # Real Estate Tokenization Platform using XRP Ledger
4
+ # =============================================================================
5
+ #
6
+ # Installation:
7
+ # cd backend
8
+ # python -m venv .venv
9
+ # source .venv/bin/activate (Linux/Mac) or .venv\Scripts\activate (Windows)
10
+ # pip install -r requirements.txt
11
+ #
12
+ # Python Version: 3.10+
13
+ # =============================================================================
14
+
15
+ # -----------------------------------------------------------------------------
16
+ # Core Web Framework & Server
17
+ # -----------------------------------------------------------------------------
18
+ fastapi==0.112.2
19
+ uvicorn[standard]==0.30.6
20
+ python-multipart==0.0.9
21
+
22
+ # -----------------------------------------------------------------------------
23
+ # Database (MongoDB)
24
+ # -----------------------------------------------------------------------------
25
+ pymongo==4.8.0
26
+ dnspython==2.6.1
27
+
28
+ # -----------------------------------------------------------------------------
29
+ # Data Validation & Settings
30
+ # -----------------------------------------------------------------------------
31
+ pydantic==2.9.2
32
+ pydantic-settings==2.4.0
33
+ email-validator==2.1.0
34
+
35
+ # -----------------------------------------------------------------------------
36
+ # Authentication & Security
37
+ # -----------------------------------------------------------------------------
38
+ python-jose[cryptography]==3.3.0
39
+ passlib[bcrypt]==1.7.4
40
+ bcrypt==4.1.1
41
+ cryptography==43.0.1
42
+ bleach==6.1.0
43
+
44
+ # -----------------------------------------------------------------------------
45
+ # XRP Ledger Blockchain Integration
46
+ # -----------------------------------------------------------------------------
47
+ xrpl-py==3.0.0
48
+
49
+ # -----------------------------------------------------------------------------
50
+ # HTTP Requests (for IPFS, external APIs)
51
+ # -----------------------------------------------------------------------------
52
+ requests>=2.31.0
53
+
54
+ # -----------------------------------------------------------------------------
55
+ # Rate Limiting & Performance
56
+ # -----------------------------------------------------------------------------
57
+ slowapi==0.1.9
58
+ psutil>=5.9.8
59
+
60
+ # -----------------------------------------------------------------------------
61
+ # Environment Configuration
62
+ # -----------------------------------------------------------------------------
63
+ python-dotenv==1.0.0
64
+
65
+ # -----------------------------------------------------------------------------
66
+ # Optional: Redis (for caching - uncomment if using)
67
+ # -----------------------------------------------------------------------------
68
+ # redis==5.0.1
69
+
70
+ # -----------------------------------------------------------------------------
71
+ # Optional: IPFS Client (uncomment if using local IPFS node)
72
+ # -----------------------------------------------------------------------------
73
+ # ipfshttpclient>=0.8.0a2
routes/admin.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Production Admin Routes - Main Router
3
+ Admin-only endpoints for platform management with security enhancements
4
+
5
+ This file serves as the main router that imports and includes all admin sub-routers:
6
+ - admin_properties: Property CRUD operations
7
+ - admin_users: User management endpoints
8
+ - admin_analytics: Stats, analytics, investments, transactions
9
+ - admin_wallet: Wallet info, connect, cards, banks, deposit/withdraw
10
+ - admin_tokenization: Property tokenization status
11
+ - admin_kyc: KYC document management and blockchain integration
12
+ - admin_rent: Rent distribution to token holders
13
+ """
14
+ from fastapi import APIRouter
15
+
16
+ router = APIRouter()
17
+
18
+ # ============================================================================
19
+ # INCLUDE SUB-ROUTERS FOR MODULAR ORGANIZATION
20
+ # ============================================================================
21
+
22
+ # Properties: create_property, update_property
23
+ from routes.admin_properties import router as properties_router
24
+ router.include_router(properties_router, tags=["Admin - Properties"])
25
+
26
+ # Users: list/create/update/delete users, user transactions, user wallet
27
+ from routes.admin_users import router as users_router
28
+ router.include_router(users_router, tags=["Admin - Users"])
29
+
30
+ # Analytics & Stats: admin stats, analytics, investments listing, transactions
31
+ from routes.admin_analytics import router as analytics_router
32
+ router.include_router(analytics_router, tags=["Admin - Analytics"])
33
+
34
+ # Wallet: wallet info, connect wallet, cards, banks, deposit/withdraw
35
+ from routes.admin_wallet import router as wallet_router
36
+ router.include_router(wallet_router, tags=["Admin - Wallet"])
37
+
38
+ # Tokenization: property tokenization status
39
+ from routes.admin_tokenization import router as tokenization_router
40
+ router.include_router(tokenization_router, tags=["Admin - Tokenization"])
41
+
42
+ # KYC: pending KYC, approve/reject, blockchain records
43
+ from routes.admin_kyc import router as kyc_router
44
+ router.include_router(kyc_router, tags=["Admin - KYC"])
45
+
46
+ # Rent Distribution: distribute rent, rent history
47
+ from routes.admin_rent import router as rent_router
48
+ router.include_router(rent_router, tags=["Admin - Rent Distribution"])
49
+
50
+ # ============================================================================
51
+ # All admin endpoints are now organized in the following sub-modules:
52
+ #
53
+ # routes/admin_properties.py - Property CRUD (create, update)
54
+ # routes/admin_users.py - User management (list, create, update, delete, transactions, wallet)
55
+ # routes/admin_analytics.py - Stats, analytics, investments, transactions
56
+ # routes/admin_wallet.py - Wallet info, connect, cards, banks, deposit/withdraw
57
+ # routes/admin_tokenization.py - Property tokenization status
58
+ # routes/admin_kyc.py - KYC management (pending, approve, reject, blockchain records)
59
+ # routes/admin_rent.py - Rent distribution (distribute, history)
60
+ # ============================================================================
routes/admin_analytics.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Admin Analytics and Stats Routes
3
+ Endpoints for platform statistics, analytics, investments listing, and transactions
4
+ """
5
+ from fastapi import APIRouter, Depends, HTTPException, status
6
+ from typing import List, Dict, Any
7
+ from bson import ObjectId
8
+
9
+ import schemas
10
+ import repo
11
+ from db import get_mongo
12
+ from routes.auth import get_current_admin
13
+
14
+ router = APIRouter()
15
+
16
+
17
+ # ============================================================================
18
+ # ADMIN STATS & ANALYTICS
19
+ # ============================================================================
20
+
21
+ @router.get("/admin/stats", response_model=schemas.AdminStatsOut)
22
+ def get_admin_stats(db=Depends(get_mongo), admin_user=Depends(get_current_admin)):
23
+ """Get platform statistics (Admin only). Returns comprehensive metrics for the admin dashboard."""
24
+ print(f"\n[ADMIN] Fetching platform stats - Requested by: {admin_user['email']}")
25
+
26
+ stats = repo.get_admin_stats(db)
27
+
28
+ print(f"[ADMIN] [SUCCESS] Stats retrieved: Users={stats['total_users']}, Properties={stats['total_properties']}")
29
+ return schemas.AdminStatsOut(**stats)
30
+
31
+
32
+ @router.get("/admin/analytics")
33
+ def get_admin_analytics(db=Depends(get_mongo), admin_user=Depends(get_current_admin)):
34
+ """Get comprehensive admin analytics (Admin only). Returns data ONLY for properties created by the current admin."""
35
+ print(f"\n[ADMIN] Fetching analytics - Requested by: {admin_user['email']}")
36
+
37
+ stats = repo.get_admin_specific_stats(db, admin_user['id'])
38
+ admin_properties = repo.get_properties_by_creator(db, admin_user['id'])
39
+ property_performance = []
40
+
41
+ for prop in admin_properties:
42
+ try:
43
+ prop_oid = ObjectId(prop["id"])
44
+ except:
45
+ prop_oid = prop["id"]
46
+
47
+ investments = list(db.investments.find({"property_id": prop_oid}))
48
+ tokens_sold = sum(inv.get("tokens_purchased", 0) for inv in investments)
49
+ revenue = sum(inv.get("amount", 0) for inv in investments)
50
+
51
+ property_performance.append({
52
+ "property_id": prop["id"],
53
+ "property_name": prop.get("title", prop.get("name", "Unknown")),
54
+ "tokens_sold": tokens_sold,
55
+ "revenue": revenue,
56
+ "transaction_count": len(investments)
57
+ })
58
+
59
+ analytics = {
60
+ "total_users": stats["total_users"],
61
+ "total_properties": stats["total_properties"],
62
+ "total_investments": stats["total_investments"],
63
+ "total_volume": stats["total_volume"],
64
+ "total_revenue": stats["total_revenue"],
65
+ "active_users": stats["active_users"],
66
+ "total_tokens_sold": stats["total_tokens_sold"],
67
+ "property_performance": property_performance,
68
+ "user_stats": {"total_users": stats["total_users"], "active_buyers": stats["active_users"], "conversion_rate": (stats["active_users"] / stats["total_users"] * 100) if stats["total_users"] > 0 else 0},
69
+ "property_stats": {"total_properties": stats["total_properties"], "tokens_sold": stats["total_tokens_sold"]},
70
+ "sales_stats": {"total_revenue_aed": stats["total_volume"], "total_tokens_sold": stats["total_tokens_sold"]}
71
+ }
72
+
73
+ return analytics
74
+
75
+
76
+ # ============================================================================
77
+ # INVESTMENTS LISTING
78
+ # ============================================================================
79
+
80
+ @router.get("/admin/investments", response_model=List[schemas.InvestmentOut])
81
+ def list_all_investments(property_id: str = None, skip: int = 0, limit: int = 100, db=Depends(get_mongo), admin_user=Depends(get_current_admin)):
82
+ """List all investments for properties created by this admin (Admin only). Optionally filter by property_id."""
83
+ print(f"\n[ADMIN] Listing investments - Requested by: {admin_user['email']}")
84
+
85
+ admin_properties = repo.get_properties_by_creator(db, admin_user['id'])
86
+ admin_property_ids = [ObjectId(prop["id"]) for prop in admin_properties]
87
+
88
+ query_filter = {"property_id": {"$in": admin_property_ids}}
89
+
90
+ if property_id:
91
+ prop_oid = ObjectId(property_id)
92
+ if prop_oid not in admin_property_ids:
93
+ return []
94
+ query_filter['property_id'] = prop_oid
95
+
96
+ investments = list(db.investments.find(query_filter).skip(skip).limit(limit).sort("created_at", -1))
97
+
98
+ enriched_investments = []
99
+ for inv in investments:
100
+ cleaned_inv = repo._clean_object(inv)
101
+ user_id = inv.get("user_id")
102
+ prop_id = inv.get("property_id")
103
+
104
+ admin_tx = db.transactions.find_one({"type": "admin_wallet_credit_purchase", "property_id": prop_id, "metadata.counterparty_user_id": str(user_id)})
105
+
106
+ if admin_tx and admin_tx.get("metadata"):
107
+ meta = admin_tx["metadata"]
108
+ cleaned_inv["user_name"] = meta.get("counterparty_username", "Unknown")
109
+ cleaned_inv["user_email"] = meta.get("counterparty_email", "")
110
+ else:
111
+ user = db.users.find_one({"_id": user_id})
112
+ cleaned_inv["user_name"] = user.get("name", "Unknown") if user else "Unknown"
113
+ cleaned_inv["user_email"] = user.get("email", "") if user else ""
114
+
115
+ enriched_investments.append(cleaned_inv)
116
+
117
+ return [schemas.InvestmentOut(**inv) for inv in enriched_investments]
118
+
119
+
120
+ @router.get("/admin/all-investments")
121
+ def get_all_investments(skip: int = 0, limit: int = 100, admin_user=Depends(get_current_admin)):
122
+ """Get all investments with user and property details (Admin only)."""
123
+ from db import get_db
124
+ db = get_db()
125
+
126
+ investments = list(db.investments.find().skip(skip).limit(limit).sort("created_at", -1))
127
+ enriched = []
128
+
129
+ for inv in investments:
130
+ cleaned = repo._clean_object(inv)
131
+ user = db.users.find_one({"_id": inv.get("user_id")})
132
+ prop = db.properties.find_one({"_id": inv.get("property_id")})
133
+
134
+ cleaned["user_name"] = user.get("name") if user else "Unknown"
135
+ cleaned["user_email"] = user.get("email") if user else ""
136
+ cleaned["property_name"] = prop.get("title") if prop else "Unknown"
137
+
138
+ enriched.append(cleaned)
139
+
140
+ return enriched
141
+
142
+
143
+ # ============================================================================
144
+ # TRANSACTIONS LISTING
145
+ # ============================================================================
146
+
147
+ @router.get("/admin/transactions", response_model=List[schemas.TransactionOut])
148
+ def list_all_transactions(skip: int = 0, limit: int = 100, db=Depends(get_mongo), admin_user=Depends(get_current_admin)):
149
+ """List all transactions for properties created by this admin (Admin only)."""
150
+ print(f"\n[ADMIN] Listing transactions - Requested by: {admin_user['email']}")
151
+
152
+ admin_properties = repo.get_properties_by_creator(db, admin_user['id'])
153
+ admin_property_ids = [ObjectId(prop["id"]) for prop in admin_properties]
154
+
155
+ query_filter = {
156
+ "$or": [
157
+ {"property_id": {"$in": admin_property_ids}},
158
+ {"user_id": repo._to_object_id(admin_user['id']), "type": {"$regex": "^admin_wallet_"}}
159
+ ]
160
+ }
161
+
162
+ transactions = list(db.transactions.find(query_filter).skip(skip).limit(limit).sort("created_at", -1))
163
+ cleaned_transactions = [repo._clean_object(tx) for tx in transactions]
164
+
165
+ return [schemas.TransactionOut(**tx) for tx in cleaned_transactions]
166
+
167
+
168
+ @router.post("/admin/wallet/{user_id}/adjust", response_model=schemas.WalletOut)
169
+ def adjust_user_wallet(user_id: str, amount: float, operation: str, db=Depends(get_mongo), admin_user=Depends(get_current_admin)):
170
+ """Adjust user wallet balance (Admin only). Used for refunds, bonuses, or corrections."""
171
+ print(f"\n[ADMIN] Adjusting wallet for user {user_id}, {operation} {amount} AED")
172
+
173
+ if operation not in ["add", "subtract"]:
174
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Operation must be 'add' or 'subtract'")
175
+
176
+ if amount <= 0:
177
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Amount must be positive")
178
+
179
+ user = repo.get_user_by_id(db, user_id)
180
+ if not user:
181
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
182
+
183
+ wallet = repo.get_wallet_by_user(db, user_id)
184
+ if not wallet:
185
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Wallet not found")
186
+
187
+ updated_wallet = repo.update_wallet_balance(db, wallet['id'], amount, operation=operation)
188
+
189
+ repo.create_transaction(db, user_id=user_id, wallet_id=wallet['id'], tx_type="deposit" if operation == "add" else "withdrawal", amount=amount, status="completed", metadata={"admin_adjustment": True, "admin_user_id": admin_user['id'], "admin_email": admin_user['email'], "reason": "Admin wallet adjustment"})
190
+
191
+ return schemas.WalletOut(**updated_wallet)
routes/admin_kyc.py ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Admin KYC Management Routes
3
+ Endpoints for KYC document review, approval, rejection, and blockchain records
4
+ """
5
+ from fastapi import APIRouter, Depends, HTTPException, status, Body
6
+ from typing import List, Dict, Any
7
+ from datetime import datetime
8
+ from bson import ObjectId
9
+ import base64
10
+
11
+ import schemas
12
+ import repo
13
+ from db import get_mongo
14
+ from routes.auth import get_current_admin
15
+ from config import settings
16
+ from utils.crypto_utils import decrypt_secret
17
+
18
+ router = APIRouter()
19
+
20
+
21
+ # ============================================================================
22
+ # KYC MANAGEMENT ENDPOINTS
23
+ # ============================================================================
24
+
25
+ @router.get("/admin/kyc/count")
26
+ def get_pending_kyc_count(db=Depends(get_mongo), admin_user=Depends(get_current_admin)):
27
+ """Return count of pending KYC submissions"""
28
+ pending_count = db.kyc_documents.count_documents({"status": "pending"})
29
+ return {"count": int(pending_count)}
30
+
31
+
32
+ @router.get("/admin/kyc/pending")
33
+ def get_pending_kyc(db=Depends(get_mongo), admin_user=Depends(get_current_admin)):
34
+ """Get all pending KYC documents for review"""
35
+ print(f"\n[ADMIN-KYC] Fetching pending KYC documents - Requested by: {admin_user['email']}")
36
+
37
+ kyc_docs = list(db.kyc_documents.find({"status": "pending"}).sort("uploaded_at", -1))
38
+
39
+ result = []
40
+ for doc in kyc_docs:
41
+ user = repo.get_user_by_id(db, str(doc["user_id"]))
42
+ result.append({
43
+ "id": str(doc["_id"]),
44
+ "document_id": str(doc["_id"]),
45
+ "user_id": str(doc["user_id"]),
46
+ "user_name": user.get("name") if user else "Unknown",
47
+ "user_email": user.get("email") if user else "Unknown",
48
+ "full_name": doc.get("full_name"),
49
+ "date_of_birth": doc.get("date_of_birth"),
50
+ "gender": doc.get("gender"),
51
+ "address": doc.get("address"),
52
+ "document_url": doc.get("document_url"),
53
+ "file_size": doc.get("file_size"),
54
+ "content_type": doc.get("content_type"),
55
+ "status": doc.get("status"),
56
+ "uploaded_at": doc.get("uploaded_at")
57
+ })
58
+
59
+ print(f"[ADMIN-KYC] Found {len(result)} pending KYC documents\n")
60
+ return {"total": len(result), "pending_kyc": result}
61
+
62
+
63
+ @router.post("/admin/kyc/{document_id}/approve")
64
+ async def approve_kyc(document_id: str, db=Depends(get_mongo), admin_user=Depends(get_current_admin)):
65
+ """
66
+ Approve KYC document and write to blockchain (IPFS + XRP Ledger)
67
+
68
+ This triggers the hybrid decentralized storage:
69
+ 1. Upload KYC document to IPFS
70
+ 2. Write user data + IPFS hash to XRP Ledger memo
71
+ 3. Store blockchain proof in database
72
+ """
73
+ print(f"\n[ADMIN-KYC] APPROVING KYC DOCUMENT: {document_id}")
74
+
75
+ from services.ipfs_service import ipfs_service
76
+ from services.xrp_service import xrpl_service
77
+
78
+ kyc_doc = db.kyc_documents.find_one({"_id": repo._to_object_id(document_id)})
79
+
80
+ if not kyc_doc:
81
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="KYC document not found")
82
+
83
+ if kyc_doc.get('status') == 'approved':
84
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="KYC already approved")
85
+
86
+ user_id = str(kyc_doc.get('user_id'))
87
+ user = repo.get_user_by_id(db, user_id)
88
+
89
+ if not user:
90
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
91
+
92
+ # STEP 1: UPLOAD TO IPFS
93
+ document_url = kyc_doc.get('document_url')
94
+ if not document_url or not document_url.startswith('data:'):
95
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid document data")
96
+
97
+ parts = document_url.split(',', 1)
98
+ if len(parts) != 2:
99
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid document data URL format")
100
+
101
+ base64_data = parts[1]
102
+ content_type = kyc_doc.get('content_type', 'application/octet-stream')
103
+ file_bytes = base64.b64decode(base64_data)
104
+ file_ext = 'pdf' if 'pdf' in content_type else 'jpg'
105
+ filename = f"kyc_{user_id}_{document_id}.{file_ext}"
106
+
107
+ try:
108
+ ipfs_result = ipfs_service.upload_file(file_bytes, filename, content_type)
109
+ except Exception as ipfs_error:
110
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"IPFS upload exception: {str(ipfs_error)}")
111
+
112
+ if not ipfs_result.get('success'):
113
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"IPFS upload failed: {ipfs_result.get('error')}")
114
+
115
+ ipfs_cid = ipfs_result.get('cid')
116
+ ipfs_url = ipfs_result.get('url')
117
+ ipfs_hash = ipfs_result.get('hash')
118
+
119
+ # STEP 2: WRITE TO BLOCKCHAIN
120
+ now = datetime.utcnow().isoformat()
121
+ kyc_blockchain_data = {
122
+ 'user_id': user_id,
123
+ 'name': kyc_doc.get('full_name') or user.get('name'),
124
+ 'email': user.get('email'),
125
+ 'phone': user.get('phone') or 'N/A',
126
+ 'address': kyc_doc.get('address') or 'N/A',
127
+ 'ipfs_hash': ipfs_cid,
128
+ 'approved_by': admin_user.get('email'),
129
+ 'approved_at': now
130
+ }
131
+
132
+ admin_wallet = repo.get_wallet_by_user(db, admin_user['id'])
133
+ if not admin_wallet or not admin_wallet.get('xrp_seed'):
134
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Admin XRP wallet not connected")
135
+
136
+ try:
137
+ if settings.encryption_enabled():
138
+ admin_xrp_seed = decrypt_secret(admin_wallet.get('xrp_seed'), settings.ENCRYPTION_KEY)
139
+ else:
140
+ admin_xrp_seed = base64.b64decode(admin_wallet.get('xrp_seed')).decode()
141
+ except Exception:
142
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to decrypt admin wallet seed")
143
+
144
+ try:
145
+ blockchain_result = await xrpl_service.write_kyc_to_blockchain(kyc_blockchain_data, issuer_seed=admin_xrp_seed)
146
+ except Exception as blockchain_error:
147
+ blockchain_result = {'success': False, 'error': str(blockchain_error)}
148
+
149
+ blockchain_tx_hash = blockchain_result.get('tx_hash')
150
+ blockchain_explorer_url = blockchain_result.get('explorer_url')
151
+
152
+ # STEP 3: UPDATE DATABASE
153
+ db.kyc_documents.update_one(
154
+ {"_id": repo._to_object_id(document_id)},
155
+ {"$set": {
156
+ "status": "approved",
157
+ "reviewed_at": datetime.utcnow(),
158
+ "reviewed_by": admin_user.get('id'),
159
+ "ipfs_cid": ipfs_cid,
160
+ "ipfs_url": ipfs_url,
161
+ "ipfs_hash": ipfs_hash,
162
+ "blockchain_tx_hash": blockchain_tx_hash,
163
+ "blockchain_explorer_url": blockchain_explorer_url,
164
+ "blockchain_success": blockchain_result.get('success', False)
165
+ }}
166
+ )
167
+
168
+ repo.update_user(db, user_id, {
169
+ "kyc_status": "approved",
170
+ "kyc_approved_at": datetime.utcnow(),
171
+ "kyc_approved_by": admin_user.get('id'),
172
+ "kyc_ipfs_cid": ipfs_cid,
173
+ "kyc_blockchain_tx": blockchain_tx_hash
174
+ })
175
+
176
+ return {
177
+ "success": True,
178
+ "message": f"KYC approved for {user.get('name')}",
179
+ "user": {"id": user_id, "name": user.get('name'), "email": user.get('email')},
180
+ "ipfs": {"cid": ipfs_cid, "url": ipfs_url, "hash": ipfs_hash, "gateway_url": f"https://cloudflare-ipfs.com/ipfs/{ipfs_cid}"},
181
+ "blockchain": {"tx_hash": blockchain_tx_hash, "explorer_url": blockchain_explorer_url, "success": blockchain_result.get('success', False)},
182
+ "approved_at": now,
183
+ "approved_by": admin_user.get('email')
184
+ }
185
+
186
+
187
+ @router.post("/admin/kyc/{document_id}/reject")
188
+ def reject_kyc(document_id: str, reason: str = Body(None, embed=True), db=Depends(get_mongo), admin_user=Depends(get_current_admin)):
189
+ """Reject a KYC document and mark it with rejection reason"""
190
+ print(f"\n[ADMIN-KYC] Rejecting KYC document {document_id}")
191
+
192
+ kyc_doc = db.kyc_documents.find_one({"_id": ObjectId(document_id)})
193
+ if not kyc_doc:
194
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="KYC document not found")
195
+
196
+ db.kyc_documents.update_one(
197
+ {"_id": ObjectId(document_id)},
198
+ {"$set": {
199
+ "status": "rejected",
200
+ "rejection_reason": reason or "KYC document did not meet verification requirements",
201
+ "reviewed_at": datetime.utcnow(),
202
+ "reviewed_by": admin_user['id']
203
+ }}
204
+ )
205
+
206
+ repo.update_user(db, str(kyc_doc["user_id"]), {"kyc_status": "rejected", "kyc_document_id": str(document_id)})
207
+
208
+ return {"success": True, "message": "KYC document rejected successfully", "reason": reason or "KYC document did not meet verification requirements"}
209
+
210
+
211
+ @router.get("/admin/users/{user_id}/kyc")
212
+ def get_user_kyc_document(user_id: str, db=Depends(get_mongo), admin_user=Depends(get_current_admin)):
213
+ """Fetch the latest KYC document for a user (if any)."""
214
+ user = repo.get_user_by_id(db, user_id)
215
+ doc = None
216
+
217
+ if user and user.get("kyc_document_id"):
218
+ try:
219
+ doc = db.kyc_documents.find_one({"_id": ObjectId(user["kyc_document_id"])})
220
+ except Exception:
221
+ doc = None
222
+
223
+ if not doc:
224
+ try:
225
+ cursor = db.kyc_documents.find({"user_id": repo._to_object_id(user_id)}).sort("uploaded_at", -1).limit(1)
226
+ doc = next(cursor, None)
227
+ except Exception:
228
+ doc = None
229
+
230
+ if not doc:
231
+ return {"kyc_document": None}
232
+
233
+ return {
234
+ "kyc_document": {
235
+ "id": str(doc.get("_id")),
236
+ "user_id": str(doc.get("user_id")),
237
+ "full_name": doc.get("full_name"),
238
+ "date_of_birth": doc.get("date_of_birth"),
239
+ "gender": doc.get("gender"),
240
+ "address": doc.get("address"),
241
+ "document_url": doc.get("document_url") or doc.get("file_data"),
242
+ "file_size": doc.get("file_size"),
243
+ "content_type": doc.get("content_type"),
244
+ "status": doc.get("status", "pending"),
245
+ "uploaded_at": doc.get("uploaded_at"),
246
+ "reviewed_at": doc.get("reviewed_at"),
247
+ "reviewed_by": doc.get("reviewed_by"),
248
+ "rejection_reason": doc.get("rejection_reason"),
249
+ }
250
+ }
251
+
252
+
253
+ @router.get("/admin/kyc/blockchain-records")
254
+ def get_blockchain_kyc_records(db=Depends(get_mongo), admin_user=Depends(get_current_admin)):
255
+ """Get all KYC records that have been written to blockchain. Shows IPFS links and blockchain explorer links."""
256
+ print(f"\n[ADMIN-KYC] Fetching blockchain KYC records...\n")
257
+
258
+ kyc_docs = list(db.kyc_documents.find({
259
+ "status": "approved",
260
+ "blockchain_tx_hash": {"$exists": True, "$ne": None}
261
+ }).sort("reviewed_at", -1))
262
+
263
+ result = []
264
+ for doc in kyc_docs:
265
+ user = repo.get_user_by_id(db, str(doc.get('user_id')))
266
+ result.append({
267
+ "user_id": str(doc.get('user_id')),
268
+ "user_name": user.get('name') if user else 'Unknown',
269
+ "user_email": user.get('email') if user else 'Unknown',
270
+ "full_name": doc.get('full_name'),
271
+ "approved_at": doc.get('reviewed_at'),
272
+ "ipfs": {"cid": doc.get('ipfs_cid'), "url": doc.get('ipfs_url'), "gateway_url": f"https://cloudflare-ipfs.com/ipfs/{doc.get('ipfs_cid')}" if doc.get('ipfs_cid') else None, "hash": doc.get('ipfs_hash')},
273
+ "blockchain": {"tx_hash": doc.get('blockchain_tx_hash'), "explorer_url": doc.get('blockchain_explorer_url'), "success": doc.get('blockchain_success', False)}
274
+ })
275
+
276
+ return {"total": len(result), "blockchain_records": result}
routes/admin_properties.py ADDED
@@ -0,0 +1,336 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Admin Property Routes
3
+ CRUD endpoints for property management
4
+ """
5
+ from fastapi import APIRouter, Depends, HTTPException, status
6
+ from typing import List, Dict, Any
7
+
8
+ import schemas
9
+ import repo
10
+ from db import get_mongo
11
+ from routes.auth import get_current_admin
12
+ from services.xrp_service import XRPLService
13
+ from config import settings
14
+ from middleware.security import sanitize_input
15
+
16
+ router = APIRouter()
17
+
18
+
19
+ # ============================================================================
20
+ # PROPERTY MANAGEMENT ENDPOINTS
21
+ # ============================================================================
22
+
23
+ @router.post("/admin/properties", response_model=schemas.PropertyOut, status_code=status.HTTP_201_CREATED)
24
+ def create_property(
25
+ property_data: schemas.PropertyCreate,
26
+ db=Depends(get_mongo),
27
+ admin_user=Depends(get_current_admin)
28
+ ):
29
+ """
30
+ Create a new property (Admin only)
31
+ Automatically creates related specifications, amenities, images, and documents
32
+ Requires admin to have connected XRP wallet (used as issuer)
33
+ """
34
+ print(f"\n[ADMIN] Creating property: {property_data.title}")
35
+ print(f"[ADMIN] Created by: {admin_user['email']}")
36
+ print(f"[ADMIN] Property data received: {property_data.dict()}")
37
+
38
+ # ========================================================================
39
+ # SECURITY: Sanitize all text inputs to prevent XSS
40
+ # ========================================================================
41
+ property_data.title = sanitize_input(property_data.title)
42
+ property_data.description = sanitize_input(property_data.description)
43
+ property_data.location = sanitize_input(property_data.location)
44
+
45
+ # ========================================================================
46
+ # VALIDATION: Admin must have connected XRP wallet
47
+ # ========================================================================
48
+ admin_wallet = repo.get_wallet_by_user(db, admin_user['id'])
49
+ if not admin_wallet or not admin_wallet.get('xrp_address'):
50
+ print(f"[ADMIN] [ERROR] Admin wallet not connected")
51
+ raise HTTPException(
52
+ status_code=status.HTTP_400_BAD_REQUEST,
53
+ detail="Admin XRP wallet not connected. Please connect your wallet before creating properties."
54
+ )
55
+
56
+ admin_xrp_address = admin_wallet.get('xrp_address')
57
+ admin_xrp_seed = admin_wallet.get('xrp_seed') # Encrypted seed
58
+ print(f"[ADMIN] [SUCCESS] Admin wallet connected: {admin_xrp_address}")
59
+
60
+ # Prepare property data
61
+ prop_dict = property_data.dict(exclude={'specifications', 'amenities', 'images', 'documents'}, exclude_none=True)
62
+
63
+ # IOU model: pre-generate currency code and attach
64
+ if settings.XRPL_TOKEN_MODEL.upper() == "IOU":
65
+ try:
66
+ code = XRPLService().generate_currency_code(getattr(property_data, 'total_tokens', 0) and prop_dict.get('total_tokens', 0) and repo.get_next_sequence if False else property_data.total_tokens and property_data.total_tokens) # placeholder; will regenerate with property id below
67
+ except Exception:
68
+ code = None
69
+ if code:
70
+ prop_dict['currency_code_pending'] = code # temporary marker
71
+
72
+ # Create main property
73
+ property_obj = repo.create_property(db, prop_dict, admin_user['id'])
74
+ print(f"[ADMIN] [SUCCESS] Property created: {property_obj['id']}")
75
+
76
+ # Create specifications - ALWAYS create with defaults if not provided
77
+ spec = None
78
+ if property_data.specifications:
79
+ spec_data = property_data.specifications.dict()
80
+ spec = repo.create_property_specification(db, property_obj['id'], spec_data)
81
+ print(f"[ADMIN] [SUCCESS] Specifications added from request")
82
+ else:
83
+ # Create default specifications
84
+ default_spec = {
85
+ "balcony": 0,
86
+ "kitchen": 1,
87
+ "bedroom": 1,
88
+ "bathroom": 1,
89
+ "area": 1000.0 # Default 1000 sqft
90
+ }
91
+ spec = repo.create_property_specification(db, property_obj['id'], default_spec)
92
+ print(f"[ADMIN] [SUCCESS] Default specifications created")
93
+
94
+ # Create amenities if provided
95
+ amenities = []
96
+ if property_data.amenities and len(property_data.amenities) > 0:
97
+ amenities = repo.create_amenities(db, property_obj['id'], property_data.amenities)
98
+ print(f"[ADMIN] [SUCCESS] Amenities added: {len(amenities)}")
99
+ else:
100
+ print(f"[ADMIN] ⚠ No amenities provided")
101
+
102
+ # Create images if provided
103
+ images = []
104
+ if property_data.images and len(property_data.images) > 0:
105
+ images_data = [img.dict() for img in property_data.images]
106
+ images = repo.create_property_images(db, property_obj['id'], images_data, admin_user['id'])
107
+ print(f"[ADMIN] [SUCCESS] Images added: {len(images)}")
108
+ else:
109
+ print(f"[ADMIN] ⚠ No images provided")
110
+
111
+ # Create documents if provided
112
+ documents = []
113
+ if property_data.documents and len(property_data.documents) > 0:
114
+ documents = repo.create_property_documents(db, property_obj['id'], property_data.documents, admin_user['id'])
115
+ print(f"[ADMIN] [SUCCESS] Documents added: {len(documents)}")
116
+ else:
117
+ print(f"[ADMIN] ⚠ No documents provided")
118
+
119
+ # ========================================================================
120
+ # STEP 5: TOKENIZATION ON XRP LEDGER (IOU MODEL - FUNGIBLE TOKENS)
121
+ # ========================================================================
122
+ print(f"\n[ADMIN] 🔗 Tokenization on XRP Ledger (model={settings.XRPL_TOKEN_MODEL})...")
123
+
124
+ tokenization_success = False
125
+ currency_code = None
126
+ issuer_address = None
127
+
128
+ try:
129
+ if settings.XRPL_TOKEN_MODEL.upper() == "IOU":
130
+ # Generate currency code for this property
131
+ currency_code = XRPLService().generate_currency_code(property_obj['id'])
132
+
133
+ # Use ADMIN's connected wallet as issuer (not master wallet)
134
+ print(f"[ADMIN] Using admin's connected wallet as issuer...")
135
+ issuer_address = admin_xrp_address
136
+
137
+ print(f"[ADMIN] ✅ Using Admin Wallet as Issuer!")
138
+ print(f"[ADMIN] Address: {issuer_address}")
139
+ print(f"[ADMIN] Currency Code: {currency_code}")
140
+ print(f"[ADMIN] Total Token Supply: {property_obj['total_tokens']:,}")
141
+
142
+ # Check if wallet is funded
143
+ xrpl_service = XRPLService()
144
+ xrp_balance = xrpl_service.get_xrp_balance(issuer_address)
145
+ wallet_funded = xrp_balance > 10 # Need at least 10 XRP
146
+
147
+ if wallet_funded:
148
+ print(f"[ADMIN] ✅ Wallet is funded! Balance: {xrp_balance:.2f} XRP")
149
+ else:
150
+ print(f"[ADMIN] [WARNING] Wallet balance low: {xrp_balance:.2f} XRP")
151
+ print(f"[ADMIN] Fund at: https://faucet.altnet.rippletest.net/")
152
+
153
+ # Create token metadata in database with admin wallet info
154
+ token_result = repo.upsert_token(db, property_obj['id'], {
155
+ "token_type": "IOU",
156
+ "currency_code": currency_code,
157
+ "blockchain": "XRP Ledger",
158
+ "issuer_address": issuer_address,
159
+ "wallet_funded": wallet_funded,
160
+ "wallet_balance": xrp_balance,
161
+ "blockchain_status": "ready" if wallet_funded else "pending_funding",
162
+ "total_supply": property_obj['total_tokens'],
163
+ "network": settings.XRPL_NETWORK,
164
+ "wallet_type": "admin_wallet",
165
+ "encrypted_issuer_seed": admin_xrp_seed # Store encrypted seed for token issuance
166
+ })
167
+
168
+ print(f"[ADMIN] ℹ️ Note: IOU tokens are issued on-demand during purchase (no pre-minting required)")
169
+ tokenization_success = True
170
+
171
+ # IOU-only model - NFTs are not used for fractional ownership
172
+ # All tokens are fungible IOUs issued on-demand during purchase
173
+
174
+ except Exception as token_error:
175
+ print(f"[ADMIN] ⚠ Tokenization error: {token_error}")
176
+ # Don't fail property creation if tokenization fails
177
+ import traceback
178
+ traceback.print_exc()
179
+
180
+ print(f"[ADMIN] ✅ Property creation complete (Tokenization={'yes' if tokenization_success else 'no'})")
181
+ print(f"[ADMIN] Summary: Property={property_obj['id']}, Specs=1, Amenities={len(amenities)}, Images={len(images)}, Documents={len(documents)}, Tokens={'[SUCCESS] Configured' if tokenization_success else '⚠ Pending'}")
182
+
183
+ # Ensure all required PropertyOut fields have default values
184
+ if 'property_type' not in property_obj or not property_obj.get('property_type'):
185
+ property_obj['property_type'] = 'Residential' # Default type
186
+ if 'funded_date' not in property_obj:
187
+ property_obj['funded_date'] = None
188
+
189
+ return schemas.PropertyOut(
190
+ **property_obj,
191
+ specifications=schemas.PropertySpecificationOut(**spec) if spec else None,
192
+ amenities=[schemas.AmenityOut(**a) for a in amenities] if amenities else None,
193
+ images=[schemas.PropertyImageOut(**img) for img in images] if images else None,
194
+ documents=[schemas.DocumentOut(**d) for d in documents] if documents else None
195
+ )
196
+
197
+
198
+ @router.put("/admin/properties/{property_id}", response_model=schemas.PropertyOut)
199
+ def update_property(
200
+ property_id: str,
201
+ property_data: schemas.PropertyCreate,
202
+ db=Depends(get_mongo),
203
+ admin_user=Depends(get_current_admin)
204
+ ):
205
+ """
206
+ Update an existing property (Admin only)
207
+ Updates property and all related data (specifications, amenities, images, documents)
208
+ """
209
+ from bson import ObjectId
210
+
211
+ print(f"\n[ADMIN] ====== PROPERTY UPDATE REQUEST ======")
212
+ print(f"[ADMIN] Property ID: {property_id}")
213
+ print(f"[ADMIN] Updated by: {admin_user['email']}")
214
+ print(f"[ADMIN] Request data fields: {list(property_data.dict(exclude_none=True).keys())}")
215
+
216
+ # ========================================================================
217
+ # SECURITY: Sanitize all text inputs to prevent XSS
218
+ # ========================================================================
219
+ property_data.title = sanitize_input(property_data.title)
220
+ property_data.description = sanitize_input(property_data.description)
221
+ property_data.location = sanitize_input(property_data.location)
222
+
223
+ try:
224
+ # Convert property_id to ObjectId for database queries
225
+ property_oid = ObjectId(property_id)
226
+ print(f"[ADMIN] [SUCCESS] Converted to ObjectId: {property_oid}")
227
+
228
+ # Check if property exists
229
+ existing_property = repo.get_property_by_id(db, property_id)
230
+ if not existing_property:
231
+ raise HTTPException(
232
+ status_code=status.HTTP_404_NOT_FOUND,
233
+ detail=f"Property {property_id} not found"
234
+ )
235
+
236
+ # ========================================================================
237
+ # OWNERSHIP VALIDATION: Admin can only edit their own properties
238
+ # ========================================================================
239
+ property_creator_id = str(existing_property.get('created_by', ''))
240
+ current_admin_id = str(admin_user['id'])
241
+
242
+ if property_creator_id != current_admin_id:
243
+ print(f"[ADMIN] [ERROR] Ownership validation failed")
244
+ print(f"[ADMIN] Property creator: {property_creator_id}")
245
+ print(f"[ADMIN] Current admin: {current_admin_id}")
246
+ raise HTTPException(
247
+ status_code=status.HTTP_403_FORBIDDEN,
248
+ detail="You can only edit properties that you created. This property belongs to another admin."
249
+ )
250
+
251
+ print(f"[ADMIN] [SUCCESS] Ownership validated: Admin owns this property")
252
+
253
+ # Prepare property data (exclude nested objects)
254
+ prop_dict = property_data.dict(exclude={'specifications', 'amenities', 'images', 'documents'}, exclude_none=True)
255
+
256
+ # Update main property
257
+ updated_property = repo.update_property(db, property_id, prop_dict)
258
+ print(f"[ADMIN] [SUCCESS] Property updated: {property_id}")
259
+
260
+ # Update specifications - delete old, create new
261
+ spec = None
262
+ if property_data.specifications:
263
+ spec_data = property_data.specifications.dict()
264
+ # Delete existing specifications (using ObjectId)
265
+ delete_result = db.property_specifications.delete_many({"property_id": property_oid})
266
+ print(f"[ADMIN] Deleted {delete_result.deleted_count} old specifications")
267
+ # Create new specifications
268
+ spec = repo.create_property_specification(db, property_id, spec_data)
269
+ print(f"[ADMIN] [SUCCESS] Specifications updated")
270
+ else:
271
+ # Keep existing spec if not provided
272
+ spec = db.property_specifications.find_one({"property_id": property_oid})
273
+
274
+ # Update amenities - delete old, create new
275
+ delete_result = db.amenities.delete_many({"property_id": property_oid})
276
+ print(f"[ADMIN] Deleted {delete_result.deleted_count} old amenities")
277
+ amenities = []
278
+ if property_data.amenities and len(property_data.amenities) > 0:
279
+ amenities = repo.create_amenities(db, property_id, property_data.amenities)
280
+ print(f"[ADMIN] [SUCCESS] Amenities updated: {len(amenities)}")
281
+
282
+ # Update images - delete old, create new
283
+ delete_result = db.property_images.delete_many({"property_id": property_oid})
284
+ print(f"[ADMIN] Deleted {delete_result.deleted_count} old images")
285
+ images = []
286
+ if property_data.images and len(property_data.images) > 0:
287
+ images_data = [img.dict() for img in property_data.images]
288
+ images = repo.create_property_images(db, property_id, images_data, admin_user['id'])
289
+ print(f"[ADMIN] [SUCCESS] Images updated: {len(images)}")
290
+
291
+ # Update documents - delete old, create new
292
+ delete_result = db.documents.delete_many({"property_id": property_oid})
293
+ print(f"[ADMIN] Deleted {delete_result.deleted_count} old documents")
294
+ documents = []
295
+ if property_data.documents and len(property_data.documents) > 0:
296
+ documents = repo.create_property_documents(db, property_id, property_data.documents, admin_user['id'])
297
+ print(f"[ADMIN] [SUCCESS] Documents updated: {len(documents)}")
298
+
299
+ print(f"[ADMIN] ✅ Property update complete")
300
+
301
+ # Get fresh data using repo functions (they clean ObjectId to string)
302
+ updated_property = repo.get_property_by_id(db, property_id)
303
+ if not updated_property:
304
+ raise HTTPException(
305
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
306
+ detail="Failed to retrieve updated property"
307
+ )
308
+
309
+ # Use repo functions to get cleaned data
310
+ spec = repo.get_property_specification(db, property_id)
311
+ amenities = repo.get_property_amenities(db, property_id)
312
+ images = repo.get_property_images(db, property_id)
313
+ documents = repo.get_property_documents(db, property_id)
314
+
315
+ # Ensure funded_date field
316
+ if 'funded_date' not in updated_property:
317
+ updated_property['funded_date'] = None
318
+
319
+ return schemas.PropertyOut(
320
+ **updated_property,
321
+ specifications=schemas.PropertySpecificationOut(**spec) if spec else None,
322
+ amenities=[schemas.AmenityOut(**a) for a in amenities] if amenities else None,
323
+ images=[schemas.PropertyImageOut(**img) for img in images] if images else None,
324
+ documents=[schemas.DocumentOut(**d) for d in documents] if documents else None
325
+ )
326
+
327
+ except HTTPException:
328
+ raise
329
+ except Exception as e:
330
+ print(f"[ADMIN] [ERROR] Error updating property: {str(e)}")
331
+ import traceback
332
+ traceback.print_exc()
333
+ raise HTTPException(
334
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
335
+ detail=f"Failed to update property: {str(e)}"
336
+ )
routes/admin_rent.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Admin Rent Distribution Routes
3
+ Endpoints for managing rent distributions to token holders
4
+ """
5
+ from fastapi import APIRouter, Depends, HTTPException, status
6
+ from typing import Dict, Any
7
+
8
+ import schemas
9
+ import repo
10
+ from db import get_mongo
11
+ from routes.auth import get_current_admin
12
+ from middleware.security import sanitize_input
13
+
14
+ router = APIRouter()
15
+
16
+
17
+ # ============================================================================
18
+ # RENT DISTRIBUTION ENDPOINTS
19
+ # ============================================================================
20
+
21
+ @router.post("/admin/distribute-rent")
22
+ def distribute_rent(
23
+ rent_data: schemas.RentDistributionCreate,
24
+ db=Depends(get_mongo),
25
+ admin_user=Depends(get_current_admin)
26
+ ):
27
+ """
28
+ Distribute rent to all token holders of a property (Admin only)
29
+
30
+ This endpoint:
31
+ 1. Validates the property exists
32
+ 2. Calculates rent per token
33
+ 3. Credits rent to each investor's wallet proportionally
34
+ 4. Creates transaction records for each payment
35
+ 5. Creates rent payment records for tracking
36
+
37
+ Returns detailed distribution results including:
38
+ - Total investors who received rent
39
+ - Number of successful/failed payments
40
+ - Individual payment details
41
+ """
42
+ print(f"\n[ADMIN] Rent distribution request by: {admin_user['email']}")
43
+ print(f"[ADMIN] Property ID: {rent_data.property_id}")
44
+ print(f"[ADMIN] Total Rent Amount: {rent_data.total_rent_amount} AED")
45
+ print(f"[ADMIN] Period: {rent_data.rent_period_start} to {rent_data.rent_period_end}")
46
+
47
+ # ========================================================================
48
+ # SECURITY: Sanitize inputs
49
+ # ========================================================================
50
+ if rent_data.notes:
51
+ rent_data.notes = sanitize_input(rent_data.notes)
52
+
53
+ # ========================================================================
54
+ # VALIDATION: Check property exists and admin has access
55
+ # ========================================================================
56
+ property_data = repo.get_property_by_id(db, rent_data.property_id)
57
+ if not property_data:
58
+ print(f"[ADMIN] [ERROR] Property not found: {rent_data.property_id}")
59
+ raise HTTPException(
60
+ status_code=status.HTTP_404_NOT_FOUND,
61
+ detail=f"Property not found: {rent_data.property_id}"
62
+ )
63
+
64
+ print(f"[ADMIN] ✅ Property found: {property_data.get('title')}")
65
+
66
+ # ========================================================================
67
+ # BUSINESS LOGIC: Distribute rent using service
68
+ # ========================================================================
69
+ try:
70
+ from services.rent_distribution_service import RentDistributionService
71
+
72
+ rent_service = RentDistributionService(db)
73
+ result = rent_service.distribute_rent(
74
+ property_id=rent_data.property_id,
75
+ total_rent_amount=rent_data.total_rent_amount,
76
+ rent_period_start=rent_data.rent_period_start,
77
+ rent_period_end=rent_data.rent_period_end,
78
+ distribution_date=rent_data.distribution_date,
79
+ notes=rent_data.notes
80
+ )
81
+
82
+ print(f"[ADMIN] ✅ Rent distribution completed")
83
+ print(f"[ADMIN] Distribution ID: {result['distribution_id']}")
84
+ print(f"[ADMIN] Payments: {result['payments_completed']} succeeded, {result['payments_failed']} failed")
85
+
86
+ return {
87
+ "success": True,
88
+ "message": "Rent distributed successfully",
89
+ **result
90
+ }
91
+
92
+ except ValueError as e:
93
+ print(f"[ADMIN] [ERROR] Validation error: {e}")
94
+ raise HTTPException(
95
+ status_code=status.HTTP_400_BAD_REQUEST,
96
+ detail=str(e)
97
+ )
98
+ except Exception as e:
99
+ print(f"[ADMIN] [ERROR] Error during rent distribution: {e}")
100
+ raise HTTPException(
101
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
102
+ detail=f"Failed to distribute rent: {str(e)}"
103
+ )
104
+
105
+
106
+ @router.get("/admin/rent-distributions/{distribution_id}", response_model=schemas.RentDistributionOut)
107
+ def get_rent_distribution(
108
+ distribution_id: str,
109
+ db=Depends(get_mongo),
110
+ admin_user=Depends(get_current_admin)
111
+ ):
112
+ """
113
+ Get details of a specific rent distribution (Admin only)
114
+ """
115
+ print(f"\n[ADMIN] Getting rent distribution: {distribution_id}")
116
+
117
+ from services.rent_distribution_service import RentDistributionService
118
+
119
+ rent_service = RentDistributionService(db)
120
+ distribution = rent_service.get_distribution_details(distribution_id)
121
+
122
+ if not distribution:
123
+ raise HTTPException(
124
+ status_code=status.HTTP_404_NOT_FOUND,
125
+ detail=f"Rent distribution not found: {distribution_id}"
126
+ )
127
+
128
+ return distribution
129
+
130
+
131
+ @router.get("/admin/properties/{property_id}/rent-history")
132
+ def get_property_rent_history(
133
+ property_id: str,
134
+ db=Depends(get_mongo),
135
+ admin_user=Depends(get_current_admin)
136
+ ):
137
+ """
138
+ Get all rent distributions for a property with detailed payment information (Admin only)
139
+
140
+ Returns:
141
+ - All distribution records for the property
142
+ - For each distribution: list of investors who received payments with names, IDs, tokens, and amounts
143
+ """
144
+ print(f"\n[ADMIN] Getting rent history for property: {property_id}")
145
+
146
+ # Validate property exists
147
+ property_data = repo.get_property_by_id(db, property_id)
148
+ if not property_data:
149
+ raise HTTPException(
150
+ status_code=status.HTTP_404_NOT_FOUND,
151
+ detail=f"Property not found: {property_id}"
152
+ )
153
+
154
+ # Get all distributions for this property
155
+ distributions = list(db.rent_distributions.find({
156
+ "property_id": repo._to_object_id(property_id)
157
+ }).sort("distribution_date", -1))
158
+
159
+ result = []
160
+ for dist in distributions:
161
+ dist_obj = repo._clean_object(dist)
162
+
163
+ # Get all payments for this distribution with user details
164
+ payments = list(db.rent_payments.find({
165
+ "distribution_id": dist["_id"]
166
+ }))
167
+
168
+ investor_details = []
169
+ for payment in payments:
170
+ user_id = str(payment.get("user_id"))
171
+ user = repo.get_user_by_id(db, user_id)
172
+
173
+ investor_details.append({
174
+ "user_id": user_id,
175
+ "user_name": user.get("name") if user else "Unknown User",
176
+ "user_email": user.get("email") if user else "",
177
+ "tokens_owned": payment.get("tokens_owned", 0),
178
+ "rent_amount": payment.get("rent_amount", 0),
179
+ "payment_status": payment.get("payment_status", "unknown"),
180
+ "payment_date": payment.get("payment_date")
181
+ })
182
+
183
+ dist_obj["investor_payments"] = investor_details
184
+ dist_obj["total_investors_paid"] = len([p for p in investor_details if p["payment_status"] == "completed"])
185
+
186
+ result.append(dist_obj)
187
+
188
+ print(f"[ADMIN] Found {len(result)} rent distributions")
189
+
190
+ return {
191
+ "property_id": property_id,
192
+ "property_title": property_data.get("title"),
193
+ "total_distributions": len(result),
194
+ "distributions": result
195
+ }
routes/admin_tokenization.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Admin Tokenization Routes
3
+ Endpoints for property token status and blockchain tokenization details
4
+ """
5
+ from fastapi import APIRouter, Depends, HTTPException, status
6
+
7
+ import repo
8
+ from db import get_mongo
9
+ from routes.auth import get_current_admin
10
+ from config import settings
11
+
12
+ router = APIRouter()
13
+
14
+
15
+ # ============================================================================
16
+ # TOKENIZATION STATUS ENDPOINTS
17
+ # ============================================================================
18
+
19
+ @router.get("/admin/properties/{property_id}/tokenization")
20
+ def get_property_tokenization_status(property_id: str, db=Depends(get_mongo), admin_user=Depends(get_current_admin)):
21
+ """Get IOU tokenization status for a property (Admin only). Shows token issuance status, distribution, and blockchain details."""
22
+ print(f"\n[ADMIN] Getting tokenization status for property: {property_id}")
23
+
24
+ property_obj = repo.get_property_by_id(db, property_id)
25
+ if not property_obj:
26
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Property not found")
27
+
28
+ token_record = repo.get_property_token(db, property_id)
29
+
30
+ result = {
31
+ "property_id": property_id,
32
+ "property_title": property_obj.get("title"),
33
+ "tokenization_status": "configured" if token_record else "not_configured",
34
+ "tokenization_details": token_record,
35
+ "blockchain_network": "XRP Ledger Testnet",
36
+ "total_tokens_planned": property_obj.get("total_tokens"),
37
+ "available_for_purchase": property_obj.get("available_tokens"),
38
+ "token_type": "IOU"
39
+ }
40
+
41
+ print(f"[ADMIN] [SUCCESS] Tokenization status: {result['tokenization_status']}")
42
+ return result
43
+
44
+
45
+ @router.get("/admin/tokenization/all")
46
+ def get_all_properties_tokenization(db=Depends(get_mongo), admin_user=Depends(get_current_admin)):
47
+ """Get tokenization status for ALL properties (Admin only). Returns a map of property_id -> tokenization details."""
48
+ print(f"\n[ADMIN] Getting tokenization status for all properties")
49
+
50
+ tokens_col = db['tokens']
51
+ all_tokens = list(tokens_col.find({}))
52
+
53
+ tokenization_map = {}
54
+
55
+ for token_rec in all_tokens:
56
+ prop_id = str(token_rec.get('property_id'))
57
+ tokenization_map[prop_id] = {
58
+ "issuer_address": token_rec.get('issuer_address'),
59
+ "currency_code": token_rec.get('currency_code'),
60
+ "total_supply": token_rec.get('total_supply'),
61
+ "token_type": token_rec.get('token_type', 'IOU'),
62
+ "blockchain": token_rec.get('blockchain', 'XRP Ledger'),
63
+ "network": token_rec.get('network', settings.XRPL_NETWORK),
64
+ "wallet_funded": token_rec.get('wallet_funded', False),
65
+ "blockchain_status": token_rec.get('blockchain_status', 'ready'),
66
+ "status": "issued" if token_rec.get('wallet_funded') else "pending_funding"
67
+ }
68
+
69
+ print(f"[ADMIN] [SUCCESS] Found tokenization records for {len(tokenization_map)} properties")
70
+ return tokenization_map
routes/admin_users.py ADDED
@@ -0,0 +1,290 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Admin User Management Routes
3
+ Endpoints for user CRUD operations and user-specific data
4
+ """
5
+ from fastapi import APIRouter, Depends, HTTPException, status
6
+ from typing import List, Dict, Any
7
+
8
+ import schemas
9
+ import repo
10
+ from db import get_mongo
11
+ from routes.auth import get_current_admin
12
+ from middleware.security import sanitize_input
13
+
14
+ router = APIRouter()
15
+
16
+
17
+ # ============================================================================
18
+ # USER MANAGEMENT ENDPOINTS
19
+ # ============================================================================
20
+
21
+ @router.get("/admin/users")
22
+ def list_all_users(
23
+ skip: int = 0,
24
+ limit: int = 100,
25
+ include_wallet: bool = True,
26
+ db=Depends(get_mongo),
27
+ admin_user=Depends(get_current_admin)
28
+ ):
29
+ """
30
+ List all users with wallet information (Admin only)
31
+ """
32
+ print(f"\n[ADMIN] Listing users - Requested by: {admin_user['email']}")
33
+
34
+ users = repo.list_all_users(db, skip=skip, limit=limit)
35
+
36
+ # Enrich users with wallet information if requested
37
+ if include_wallet:
38
+ from bson import ObjectId
39
+ enriched_users = []
40
+ for user in users:
41
+ user_dict = dict(user)
42
+
43
+ # Get wallet for this user
44
+ try:
45
+ user_id_obj = ObjectId(user['id']) if isinstance(user['id'], str) else user['id']
46
+ wallet = db.wallets.find_one({"user_id": user_id_obj})
47
+
48
+ if wallet:
49
+ user_dict['aed_balance'] = wallet.get('balance', 0.0)
50
+ user_dict['xrp_address'] = wallet.get('xrp_address')
51
+ user_dict['xrp_balance'] = 0.0 # Placeholder, updated on detail view
52
+ else:
53
+ user_dict['aed_balance'] = 0.0
54
+ user_dict['xrp_balance'] = 0.0
55
+ user_dict['xrp_address'] = None
56
+
57
+ except Exception as e:
58
+ print(f"[ADMIN] [WARNING] Error fetching wallet for user {user.get('id')}: {e}")
59
+ user_dict['aed_balance'] = 0.0
60
+ user_dict['xrp_balance'] = 0.0
61
+ user_dict['xrp_address'] = None
62
+
63
+ # Get profile image for this user
64
+ try:
65
+ from routes.profile import profile_images_col
66
+ profile_image = profile_images_col(db).find_one({"user_id": user_id_obj})
67
+ if profile_image:
68
+ user_dict['profile_image'] = {
69
+ "image_url": profile_image.get("image_data"),
70
+ "file_size": profile_image.get("file_size"),
71
+ "uploaded_at": profile_image.get("uploaded_at")
72
+ }
73
+ else:
74
+ user_dict['profile_image'] = None
75
+ except Exception as e:
76
+ print(f"[ADMIN] [WARNING] Error fetching profile image for user {user.get('id')}: {e}")
77
+ user_dict['profile_image'] = None
78
+
79
+ enriched_users.append(user_dict)
80
+
81
+ print(f"[ADMIN] [SUCCESS] Found {len(enriched_users)} users with wallet info\n")
82
+ return enriched_users
83
+
84
+ print(f"[ADMIN] [SUCCESS] Found {len(users)} users\n")
85
+ return users
86
+
87
+
88
+ @router.patch("/admin/users/{user_id}", response_model=schemas.UserOut)
89
+ def update_user(
90
+ user_id: str,
91
+ user_update: schemas.UserUpdate,
92
+ db=Depends(get_mongo),
93
+ admin_user=Depends(get_current_admin)
94
+ ):
95
+ """
96
+ Update user details (Admin only)
97
+ """
98
+ print(f"\n[ADMIN] Updating user {user_id} - Requested by: {admin_user['email']}")
99
+
100
+ existing = repo.get_user_by_id(db, user_id)
101
+ if not existing:
102
+ print(f"[ADMIN] [ERROR] User not found: {user_id}\n")
103
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
104
+
105
+ update_data = user_update.dict(exclude_none=True)
106
+
107
+ if not update_data:
108
+ print(f"[ADMIN] ⚠ No fields to update\n")
109
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No fields to update")
110
+
111
+ updated_user = repo.update_user(db, user_id, update_data)
112
+
113
+ if not updated_user:
114
+ print(f"[ADMIN] [ERROR] Failed to update user\n")
115
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update user")
116
+
117
+ print(f"[ADMIN] [SUCCESS] User updated: {list(update_data.keys())}\n")
118
+ return schemas.UserOut(**updated_user)
119
+
120
+
121
+ @router.post("/admin/users", response_model=schemas.UserOut, status_code=status.HTTP_201_CREATED)
122
+ def create_user(
123
+ user_data: schemas.UserCreate,
124
+ db=Depends(get_mongo),
125
+ admin_user=Depends(get_current_admin)
126
+ ):
127
+ """
128
+ Create a new user (Admin only)
129
+ """
130
+ import re
131
+ print(f"\n[ADMIN] Creating user - Requested by: {admin_user['email']}")
132
+
133
+ user_data.name = sanitize_input(user_data.name).strip()
134
+ user_data.email = sanitize_input(user_data.email).lower().strip()
135
+ user_data.phone = sanitize_input(user_data.phone).strip()
136
+
137
+ if not re.match(r"^[a-zA-Z\s\-']+$", user_data.name):
138
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Name can only contain letters, spaces, hyphens, and apostrophes")
139
+
140
+ if not re.match(r"^[0-9]{10}$", user_data.phone):
141
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Phone number must be exactly 10 digits")
142
+
143
+ if not user_data.email or '@' not in user_data.email:
144
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid email format")
145
+
146
+ existing = repo.get_user_by_email(db, user_data.email)
147
+ if existing:
148
+ print(f"[ADMIN] [ERROR] User already exists: {user_data.email}\n")
149
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="User with this email already exists")
150
+
151
+ from passlib.context import CryptContext
152
+ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
153
+ password_hash = pwd_context.hash(user_data.password)
154
+
155
+ new_user = repo.create_user(
156
+ db, name=user_data.name, email=user_data.email, password_hash=password_hash,
157
+ phone=user_data.phone, role=user_data.role or "user"
158
+ )
159
+
160
+ repo.create_wallet(db, new_user['id'], balance=0.0, currency="AED")
161
+
162
+ print(f"[ADMIN] [SUCCESS] User created: {new_user['email']}\n")
163
+ return schemas.UserOut(**new_user)
164
+
165
+
166
+ @router.delete("/admin/users/{user_id}")
167
+ def delete_user(
168
+ user_id: str,
169
+ db=Depends(get_mongo),
170
+ admin_user=Depends(get_current_admin)
171
+ ):
172
+ """
173
+ Delete a user and ALL related data (Admin only)
174
+ Performs HARD DELETE - removes user and all associated data from database
175
+ """
176
+ from bson import ObjectId
177
+ print(f"\n[ADMIN] Deleting user {user_id} and ALL related data - Requested by: {admin_user['email']}")
178
+
179
+ existing = repo.get_user_by_id(db, user_id)
180
+ if not existing:
181
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
182
+
183
+ if existing['id'] == admin_user['id']:
184
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot delete your own account")
185
+
186
+ try:
187
+ user_oid = ObjectId(user_id)
188
+
189
+ wallet_delete = db.wallets.delete_many({"user_id": user_oid})
190
+ transaction_delete = db.transactions.delete_many({"user_id": user_oid})
191
+ certificate_delete = db.certificates.delete_many({"user_id": user_oid})
192
+ investment_delete = db.investments.delete_many({"user_id": user_oid})
193
+ portfolio_delete = db.portfolios.delete_many({"user_id": user_oid})
194
+ document_delete = db.documents.delete_many({"user_id": user_oid})
195
+ kyc_delete = db.kyc_documents.delete_many({"user_id": user_oid})
196
+ profile_image_delete = db.profile_images.delete_many({"user_id": user_oid})
197
+ user_delete = db.users.delete_one({"_id": user_oid})
198
+
199
+ total_deleted = (wallet_delete.deleted_count + transaction_delete.deleted_count +
200
+ certificate_delete.deleted_count + investment_delete.deleted_count +
201
+ portfolio_delete.deleted_count + document_delete.deleted_count +
202
+ kyc_delete.deleted_count + profile_image_delete.deleted_count +
203
+ user_delete.deleted_count)
204
+
205
+ print(f"[ADMIN] [SUCCESS] Successfully deleted user {user_id} and {total_deleted - 1} related records\n")
206
+
207
+ return {
208
+ "message": f"User and all related data deleted successfully",
209
+ "user_id": user_id,
210
+ "records_deleted": {
211
+ "user": user_delete.deleted_count,
212
+ "wallets": wallet_delete.deleted_count,
213
+ "transactions": transaction_delete.deleted_count,
214
+ "total": total_deleted
215
+ }
216
+ }
217
+
218
+ except Exception as e:
219
+ print(f"[ADMIN] [ERROR] Error during user deletion: {str(e)}\n")
220
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to delete user: {str(e)}")
221
+
222
+
223
+ @router.get("/admin/users/{user_id}/transactions", response_model=List[schemas.TransactionOut])
224
+ def get_user_transactions(
225
+ user_id: str,
226
+ skip: int = 0,
227
+ limit: int = 50,
228
+ db=Depends(get_mongo),
229
+ admin_user=Depends(get_current_admin)
230
+ ):
231
+ """
232
+ Get transactions for a specific user (Admin only)
233
+ """
234
+ from bson import ObjectId
235
+ print(f"\n[ADMIN] Getting transactions for user {user_id}")
236
+
237
+ user = repo.get_user_by_id(db, user_id)
238
+ if not user:
239
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
240
+
241
+ try:
242
+ user_oid = ObjectId(user_id)
243
+ except:
244
+ user_oid = user_id
245
+
246
+ transactions = list(db.transactions.find({"user_id": user_oid}).skip(skip).limit(limit).sort("created_at", -1))
247
+ cleaned_transactions = [repo._clean_object(tx) for tx in transactions]
248
+
249
+ print(f"[ADMIN] [SUCCESS] Found {len(cleaned_transactions)} transactions for user {user_id}\n")
250
+ return [schemas.TransactionOut(**tx) for tx in cleaned_transactions]
251
+
252
+
253
+ @router.get("/admin/users/{user_id}/wallet")
254
+ def get_user_wallet(
255
+ user_id: str,
256
+ db=Depends(get_mongo),
257
+ admin_user=Depends(get_current_admin)
258
+ ):
259
+ """
260
+ Get wallet information for a specific user (Admin only)
261
+ """
262
+ print(f"\n[ADMIN] Getting wallet info for user {user_id}")
263
+
264
+ user = repo.get_user_by_id(db, user_id)
265
+ if not user:
266
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
267
+
268
+ wallet = repo.get_wallet_by_user(db, user_id)
269
+
270
+ if not wallet:
271
+ return {"user_id": user_id, "balance": 0.0, "aed_balance": 0.0, "xrp_balance": 0.0, "xrp_address": None, "currency": "AED", "is_active": True}
272
+
273
+ xrp_balance = 0.0
274
+ if wallet.get("xrp_address"):
275
+ try:
276
+ from services.xrp_service import XRPLService
277
+ xrpl_service = XRPLService()
278
+ xrp_balance = xrpl_service.get_xrp_balance(wallet["xrp_address"])
279
+ except Exception as e:
280
+ print(f"[ADMIN] [WARNING] Could not fetch XRP balance: {e}")
281
+
282
+ return {
283
+ "user_id": user_id,
284
+ "balance": wallet.get("balance", 0.0),
285
+ "aed_balance": wallet.get("balance", 0.0),
286
+ "xrp_balance": xrp_balance,
287
+ "xrp_address": wallet.get("xrp_address"),
288
+ "currency": wallet.get("currency", "AED"),
289
+ "is_active": wallet.get("is_active", True)
290
+ }
routes/admin_wallet.py ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Admin Wallet Routes
3
+ Endpoints for wallet management, payment methods (cards/banks), and deposits/withdrawals
4
+ """
5
+ from fastapi import APIRouter, Depends, HTTPException, status, Body
6
+ from typing import List, Dict, Any
7
+ from datetime import datetime
8
+ import re
9
+ import base64
10
+
11
+ import schemas
12
+ import repo
13
+ from db import get_mongo
14
+ from routes.auth import get_current_admin
15
+ from services.xrp_service import XRPLService
16
+ from config import settings
17
+ from utils.crypto_utils import encrypt_secret
18
+
19
+ router = APIRouter()
20
+
21
+
22
+ # ============================================================================
23
+ # ADMIN WALLET INFO & CONNECTION
24
+ # ============================================================================
25
+
26
+ @router.get("/admin/wallet-info")
27
+ def get_admin_wallet_info(db=Depends(get_mongo), admin_user=Depends(get_current_admin)):
28
+ """Return admin AED/XRP wallet summary and recent transactions.
29
+ Only shows data for properties created by this admin.
30
+ """
31
+ from bson import ObjectId
32
+ admin_doc = db.users.find_one({"_id": repo._to_object_id(admin_user['id'])}) or admin_user
33
+ wallet_record = db.wallets.find_one({"user_id": repo._to_object_id(admin_user['id'])})
34
+
35
+ aed_balance_aed = wallet_record.get("balance", 0.0) if wallet_record else 0.0
36
+ wallet_address = wallet_record.get("xrp_address") if wallet_record else None
37
+ xrp_balance = 0.0
38
+ if wallet_record and wallet_record.get("xrp_address"):
39
+ try:
40
+ xrp_balance = XRPLService().get_xrp_balance(wallet_record.get("xrp_address"))
41
+ except Exception:
42
+ xrp_balance = 0.0
43
+
44
+ admin_wallet_info = {
45
+ "wallet_address": wallet_address,
46
+ "aed_balance": aed_balance_aed,
47
+ "xrp_balance": xrp_balance,
48
+ "wallet_connected": bool(wallet_address),
49
+ }
50
+
51
+ admin_properties = repo.get_properties_by_creator(db, admin_user['id'])
52
+ admin_property_ids = [ObjectId(prop["id"]) for prop in admin_properties]
53
+
54
+ filt = {
55
+ "user_id": repo._to_object_id(admin_user['id']),
56
+ "type": {"$regex": "^admin_wallet_"},
57
+ "$or": [
58
+ {"property_id": {"$in": admin_property_ids}},
59
+ {"property_id": {"$exists": False}}
60
+ ]
61
+ }
62
+ cursor = db.transactions.find(filt).sort("created_at", -1).limit(50)
63
+
64
+ transactions: List[Dict[str, Any]] = []
65
+ total_credits = 0
66
+ total_debits = 0
67
+ total_purchase_earnings = 0
68
+
69
+ for tx in cursor:
70
+ meta = tx.get("metadata") or {}
71
+ amount_aed = tx.get("amount", 0.0)
72
+ tx_type_val = tx.get("type") or ""
73
+ direction = meta.get("direction") or ("credit" if tx_type_val.startswith("admin_wallet_credit") else ("debit" if tx_type_val.startswith("admin_wallet_debit") else None))
74
+ if direction == "credit":
75
+ total_credits += amount_aed
76
+ elif direction == "debit":
77
+ total_debits += amount_aed
78
+ if tx.get("type") == "admin_wallet_credit_purchase":
79
+ total_purchase_earnings += amount_aed
80
+ signed_amount = amount_aed
81
+ if direction == "debit":
82
+ signed_amount = -signed_amount
83
+ created_at = tx.get("created_at")
84
+ created_at_str = created_at.isoformat() if isinstance(created_at, datetime) else str(created_at) if created_at else None
85
+ transactions.append({
86
+ "id": tx.get("id"),
87
+ "type": tx.get("type"),
88
+ "direction": direction,
89
+ "amount_aed": signed_amount,
90
+ "status": tx.get("status"),
91
+ "created_at": created_at_str,
92
+ "payment_method": meta.get("payment_method"),
93
+ "payment_reference": meta.get("payment_reference"),
94
+ "notes": meta.get("notes"),
95
+ "property": {"id": str(tx.get("property_id")) if tx.get("property_id") else None, "name": meta.get("property_name")} if tx.get("property_id") else None,
96
+ "counterparty": {"user_id": meta.get("counterparty_user_id"), "username": meta.get("counterparty_username"), "email": meta.get("counterparty_email")} if meta.get("counterparty_user_id") else None,
97
+ })
98
+
99
+ unique_buyers = len(set([str(inv.get("user_id")) for inv in db.investments.find({"property_id": {"$in": admin_property_ids}, "status": "completed"}, {"user_id": 1}) if inv.get("user_id")]))
100
+
101
+ flow_summary = {"total_credits_aed": total_credits, "total_debits_aed": total_debits, "net_flow_aed": (total_credits - total_debits)}
102
+
103
+ return {
104
+ "wallet_info": admin_wallet_info,
105
+ "total_earnings_aed": total_purchase_earnings if total_purchase_earnings else 0.0,
106
+ "total_buyers": unique_buyers,
107
+ "flow_summary": flow_summary,
108
+ "transactions": transactions,
109
+ "recent_transactions": transactions[:10],
110
+ }
111
+
112
+
113
+ @router.post("/admin/wallet/connect")
114
+ def connect_admin_wallet(
115
+ wallet_data: schemas.WalletImport,
116
+ db=Depends(get_mongo),
117
+ admin_user=Depends(get_current_admin)
118
+ ):
119
+ """Connect XRP wallet for admin user. This wallet will be used as the issuer for all property tokens."""
120
+ print(f"\n[ADMIN] Connect XRP wallet request for admin: {admin_user['email']}")
121
+
122
+ wallet = repo.get_wallet_by_user(db, admin_user['id'])
123
+ if not wallet:
124
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Admin wallet not found")
125
+
126
+ addr_pattern = re.compile(r"^r[1-9A-HJ-NP-Za-km-z]{25,34}$")
127
+ seed_pattern = re.compile(r"^s[1-9A-HJ-NP-Za-km-z]{15,34}$")
128
+ if not addr_pattern.match(wallet_data.xrp_address):
129
+ raise HTTPException(status_code=400, detail="Invalid XRP address format")
130
+ if not seed_pattern.match(wallet_data.xrp_seed):
131
+ raise HTTPException(status_code=400, detail="Invalid XRP seed format")
132
+
133
+ try:
134
+ xrp_service = XRPLService()
135
+ xrp_balance = xrp_service.get_xrp_balance(wallet_data.xrp_address)
136
+ print(f"[ADMIN] [SUCCESS] XRP wallet validated. Balance: {xrp_balance} XRP")
137
+ except Exception as e:
138
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid XRP wallet or network error: {str(e)}")
139
+
140
+ if settings.encryption_enabled():
141
+ encoded_seed = encrypt_secret(wallet_data.xrp_seed, settings.ENCRYPTION_KEY)
142
+ else:
143
+ encoded_seed = base64.b64encode(wallet_data.xrp_seed.encode()).decode()
144
+
145
+ updated_wallet = repo.update_wallet_xrp(db, wallet['id'], wallet_data.xrp_address, encoded_seed)
146
+
147
+ if not updated_wallet:
148
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to connect wallet")
149
+
150
+ return {"success": True, "message": "Admin wallet connected successfully", "wallet_address": wallet_data.xrp_address, "xrp_balance": xrp_balance}
151
+
152
+
153
+ @router.get("/admin/properties/{property_id}/buyers")
154
+ def get_property_buyers(property_id: str, db=Depends(get_mongo), admin_user=Depends(get_current_admin)):
155
+ """Aggregate buyer stats for a property based on investment transactions."""
156
+ property_obj = repo.get_property_by_id(db, property_id)
157
+ if not property_obj:
158
+ raise HTTPException(status_code=404, detail="Property not found")
159
+
160
+ txs = list(db.transactions.find({"property_id": repo._to_object_id(property_id), "type": "investment", "status": "completed"}).sort("created_at", -1))
161
+ buyers: Dict[str, Any] = {}
162
+ for tx in txs:
163
+ uid = str(tx.get("user_id"))
164
+ if uid not in buyers:
165
+ user_doc = db.users.find_one({"_id": tx.get("user_id")})
166
+ buyers[uid] = {"user_id": uid, "username": user_doc.get("name") if user_doc else None, "email": user_doc.get("email") if user_doc else None, "total_tokens": 0, "total_spent": 0, "transactions": []}
167
+ meta = tx.get("metadata") or {}
168
+ tokens = meta.get("tokens_purchased") or 0
169
+ amount_float = tx.get("amount") or 0.0
170
+ buyers[uid]["total_tokens"] += tokens
171
+ buyers[uid]["total_spent"] += amount_float
172
+ created_at = tx.get("created_at")
173
+ buyers[uid]["transactions"].append({"id": tx.get("id"), "tokens": tokens, "spent": amount_float, "date": created_at.isoformat() if isinstance(created_at, datetime) else str(created_at)})
174
+
175
+ buyer_list = list(buyers.values())
176
+ buyer_list.sort(key=lambda b: b["total_spent"], reverse=True)
177
+ return {"property_id": property_id, "property_name": property_obj.get("title"), "total_buyers": len(buyer_list), "total_tokens_sold": sum(b["total_tokens"] for b in buyer_list), "total_revenue": sum(b["total_spent"] for b in buyer_list), "buyers": buyer_list}
178
+
179
+
180
+ # ============================================================================
181
+ # ADMIN PAYMENT METHODS (CARDS & BANKS)
182
+ # ============================================================================
183
+
184
+ @router.get("/admin/wallet/cards")
185
+ def get_admin_cards(db=Depends(get_mongo), admin_user=Depends(get_current_admin)):
186
+ """Get all saved cards for admin"""
187
+ return repo.get_cards(db, admin_user['id'], "admin")
188
+
189
+
190
+ @router.post("/admin/wallet/cards", response_model=schemas.CardOut)
191
+ def add_admin_card(card_data: schemas.CardCreate, db=Depends(get_mongo), admin_user=Depends(get_current_admin)):
192
+ """Add a new card for admin"""
193
+ try:
194
+ card = repo.create_card(db=db, user_id=admin_user['id'], user_role="admin", card_number=card_data.card_number, expiry_month=card_data.expiry_month, expiry_year=card_data.expiry_year, cardholder_name=card_data.cardholder_name, bank_name=card_data.bank_name)
195
+ return schemas.CardOut(id=card['id'], user_role="admin", card_type=card.get('card_type', 'unknown'), last_four=card['last_four'], cardholder_name=card['cardholder_name'], bank_name=card.get('bank_name'), expiry_month=card['expiry_month'], expiry_year=card['expiry_year'], is_default=card.get('is_default', False), created_at=card['created_at'])
196
+ except ValueError as e:
197
+ raise HTTPException(status_code=400, detail=str(e))
198
+
199
+
200
+ @router.delete("/admin/wallet/cards/{card_id}")
201
+ def delete_admin_card(card_id: str, db=Depends(get_mongo), admin_user=Depends(get_current_admin)):
202
+ """Delete an admin's card"""
203
+ success = repo.delete_card(db, admin_user['id'], "admin", card_id)
204
+ if not success:
205
+ raise HTTPException(status_code=404, detail="Card not found or already deleted")
206
+ return {"success": True, "message": "Card deleted successfully"}
207
+
208
+
209
+ @router.get("/admin/wallet/banks")
210
+ def get_admin_banks(db=Depends(get_mongo), admin_user=Depends(get_current_admin)):
211
+ """Get all saved bank accounts for admin"""
212
+ return repo.get_banks(db, admin_user['id'], "admin")
213
+
214
+
215
+ @router.post("/admin/wallet/banks", response_model=schemas.BankOut)
216
+ def add_admin_bank(bank_data: schemas.BankCreate, db=Depends(get_mongo), admin_user=Depends(get_current_admin)):
217
+ """Add a new bank account for admin"""
218
+ try:
219
+ bank = repo.create_bank(db=db, user_id=admin_user['id'], user_role="admin", bank_name=bank_data.bank_name, account_holder_name=bank_data.account_holder_name, account_number=bank_data.account_number, account_type=bank_data.account_type.value if hasattr(bank_data.account_type, 'value') else bank_data.account_type, iban=bank_data.iban, swift_code=bank_data.swift_code, currency=bank_data.currency)
220
+ return schemas.BankOut(id=bank['id'], user_role="admin", bank_name=bank['bank_name'], account_holder_name=bank['account_holder_name'], account_number_last_four=bank['account_number_last_four'], iban_last_four=bank.get('iban_last_four'), swift_code=bank.get('swift_code'), account_type=bank.get('account_type', 'savings'), currency=bank.get('currency', 'AED'), is_default=bank.get('is_default', False), is_verified=bank.get('is_verified', False), created_at=bank['created_at'])
221
+ except ValueError as e:
222
+ raise HTTPException(status_code=400, detail=str(e))
223
+
224
+
225
+ @router.delete("/admin/wallet/banks/{bank_id}")
226
+ def delete_admin_bank(bank_id: str, db=Depends(get_mongo), admin_user=Depends(get_current_admin)):
227
+ """Delete an admin's bank account"""
228
+ success = repo.delete_bank(db, admin_user['id'], "admin", bank_id)
229
+ if not success:
230
+ raise HTTPException(status_code=404, detail="Bank account not found or already deleted")
231
+ return {"success": True, "message": "Bank account deleted successfully"}
232
+
233
+
234
+ # ============================================================================
235
+ # ADMIN DEPOSIT & WITHDRAW
236
+ # ============================================================================
237
+
238
+ @router.post("/admin/wallet/deposit")
239
+ def admin_deposit(amount: float = Body(..., embed=True), payment_method: str = Body("CARD", embed=True), db=Depends(get_mongo), admin_user=Depends(get_current_admin)):
240
+ """Deposit funds to admin wallet (simulated). Payment methods: CARD, BANK_TRANSFER"""
241
+ if amount <= 0:
242
+ raise HTTPException(status_code=400, detail="Amount must be greater than 0")
243
+ if amount > 1000000:
244
+ raise HTTPException(status_code=400, detail="Maximum deposit is AED 1,000,000")
245
+
246
+ wallet = repo.get_wallet_by_user(db, admin_user['id'])
247
+ if not wallet:
248
+ raise HTTPException(status_code=404, detail="Admin wallet not found")
249
+
250
+ new_balance = wallet.get('balance', 0) + amount
251
+ repo.update_wallet_balance(db, wallet['id'], new_balance)
252
+
253
+ repo.create_transaction(db=db, user_id=admin_user['id'], tx_type="admin_wallet_credit_deposit", amount=amount, status="completed", metadata={"payment_method": payment_method, "direction": "credit", "notes": f"Admin deposit via {payment_method}"})
254
+
255
+ return {"success": True, "message": f"Successfully deposited AED {amount}", "new_balance": new_balance}
256
+
257
+
258
+ @router.post("/admin/wallet/withdraw")
259
+ def admin_withdraw(amount: float = Body(..., embed=True), bank_id: str = Body(None, embed=True), db=Depends(get_mongo), admin_user=Depends(get_current_admin)):
260
+ """Withdraw funds from admin wallet (simulated). Optional bank_id to specify which bank to withdraw to"""
261
+ if amount <= 0:
262
+ raise HTTPException(status_code=400, detail="Amount must be greater than 0")
263
+
264
+ wallet = repo.get_wallet_by_user(db, admin_user['id'])
265
+ if not wallet:
266
+ raise HTTPException(status_code=404, detail="Admin wallet not found")
267
+
268
+ current_balance = wallet.get('balance', 0)
269
+ if amount > current_balance:
270
+ raise HTTPException(status_code=400, detail="Insufficient balance")
271
+
272
+ new_balance = current_balance - amount
273
+ repo.update_wallet_balance(db, wallet['id'], new_balance)
274
+
275
+ repo.create_transaction(db=db, user_id=admin_user['id'], tx_type="admin_wallet_debit_withdraw", amount=amount, status="completed", metadata={"bank_id": bank_id, "direction": "debit", "notes": "Admin withdrawal"})
276
+
277
+ return {"success": True, "message": f"Successfully withdrew AED {amount}", "new_balance": new_balance}
routes/auth.py ADDED
@@ -0,0 +1,497 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Production Auth Routes
3
+ Handles user authentication and registration with security enhancements
4
+ Includes device fingerprinting and session management
5
+ """
6
+ from fastapi import APIRouter, Depends, HTTPException, status, Request, Header
7
+ from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
8
+ from passlib.context import CryptContext
9
+ from datetime import datetime, timedelta
10
+ from jose import JWTError, jwt
11
+ from typing import Optional
12
+ import re
13
+ import logging
14
+
15
+ import schemas
16
+ from db import get_mongo
17
+ import repo
18
+ from config import settings
19
+ from middleware.security import (
20
+ limiter,
21
+ check_rate_limit,
22
+ record_failed_attempt,
23
+ reset_failed_attempts,
24
+ sanitize_input,
25
+ validate_redirect_url,
26
+ mask_email
27
+ )
28
+ from utils.security_logger import log_login_attempt, log_registration, log_account_lockout
29
+ from utils.fingerprint import generate_device_fingerprint, generate_session_id
30
+
31
+ router = APIRouter()
32
+ logger = logging.getLogger(__name__)
33
+
34
+ # Password hashing
35
+ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
36
+
37
+ # OAuth2 scheme
38
+ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
39
+
40
+
41
+ def hash_password(password: str) -> str:
42
+ """Hash a password"""
43
+ return pwd_context.hash(password)
44
+
45
+
46
+ def verify_password(plain_password: str, hashed_password: str) -> bool:
47
+ """Verify a password against its hash"""
48
+ return pwd_context.verify(plain_password, hashed_password)
49
+
50
+
51
+ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
52
+ """Create JWT access token with session_id"""
53
+ to_encode = data.copy()
54
+
55
+ if expires_delta:
56
+ expire = datetime.utcnow() + expires_delta
57
+ else:
58
+ expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
59
+
60
+ to_encode.update({"exp": expire})
61
+ encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
62
+
63
+ return encoded_jwt
64
+
65
+
66
+ def get_current_user(
67
+ request: Request,
68
+ token: str = Depends(oauth2_scheme),
69
+ db=Depends(get_mongo),
70
+ x_device_fingerprint: Optional[str] = Header(None)
71
+ ) -> dict:
72
+ """Get current authenticated user with session validation"""
73
+ credentials_exception = HTTPException(
74
+ status_code=status.HTTP_401_UNAUTHORIZED,
75
+ detail="Could not validate credentials",
76
+ headers={"WWW-Authenticate": "Bearer"},
77
+ )
78
+
79
+ try:
80
+ payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
81
+ user_id: str = payload.get("sub")
82
+ session_id: str = payload.get("session_id")
83
+
84
+ if user_id is None:
85
+ raise credentials_exception
86
+
87
+ # Validate session if session_id exists in token
88
+ if session_id:
89
+ device_fingerprint = generate_device_fingerprint(request, x_device_fingerprint)
90
+
91
+ if not repo.validate_session(db, session_id, device_fingerprint):
92
+ logger.warning(f"Session validation failed for user: {user_id}")
93
+ raise HTTPException(
94
+ status_code=status.HTTP_401_UNAUTHORIZED,
95
+ detail="Session invalid or device mismatch. Please login again.",
96
+ headers={"WWW-Authenticate": "Bearer"},
97
+ )
98
+
99
+ except JWTError:
100
+ raise credentials_exception
101
+
102
+ user = repo.get_user_by_id(db, user_id)
103
+
104
+ if user is None:
105
+ raise credentials_exception
106
+
107
+ if not user.get("is_active"):
108
+ raise HTTPException(status_code=400, detail="Inactive user")
109
+
110
+ return user
111
+
112
+
113
+ def get_current_admin(current_user: dict = Depends(get_current_user)) -> dict:
114
+ """Get current user and verify admin role"""
115
+ if current_user.get("role") != "admin":
116
+ raise HTTPException(
117
+ status_code=status.HTTP_403_FORBIDDEN,
118
+ detail="Not authorized. Admin access required."
119
+ )
120
+ return current_user
121
+
122
+
123
+ @router.post("/register", response_model=schemas.TokenOut, status_code=status.HTTP_201_CREATED)
124
+ @limiter.limit("3/minute")
125
+ def register(
126
+ request: Request,
127
+ user_data: schemas.UserCreate,
128
+ db=Depends(get_mongo),
129
+ x_device_fingerprint: Optional[str] = Header(None)
130
+ ):
131
+ """
132
+ Register a new user with OTP verification and create session
133
+ Creates user account, wallet, and device-bound session automatically
134
+ Rate limited to 3 attempts per minute per IP
135
+
136
+ NOTE: User must verify OTP via /otp/verify-otp before calling this endpoint
137
+ """
138
+ logger.info(f"Registration request for: {mask_email(user_data.email)}")
139
+
140
+ # Sanitize inputs
141
+ user_data.name = sanitize_input(user_data.name).strip()
142
+ user_data.email = sanitize_input(user_data.email).lower().strip()
143
+ user_data.country_code = sanitize_input(user_data.country_code).strip()
144
+ user_data.phone = sanitize_input(user_data.phone).strip()
145
+
146
+ # Additional validation
147
+ if not re.match(r"^[a-zA-Z\s\-']+$", user_data.name):
148
+ raise HTTPException(
149
+ status_code=status.HTTP_400_BAD_REQUEST,
150
+ detail="Name can only contain letters, spaces, hyphens, and apostrophes"
151
+ )
152
+
153
+ if not re.match(r"^\+[0-9]{1,4}$", user_data.country_code):
154
+ raise HTTPException(
155
+ status_code=status.HTTP_400_BAD_REQUEST,
156
+ detail="Invalid country code format. Must start with + and contain 1-4 digits"
157
+ )
158
+
159
+ if not re.match(r"^[0-9]{6,15}$", user_data.phone):
160
+ raise HTTPException(
161
+ status_code=status.HTTP_400_BAD_REQUEST,
162
+ detail="Phone number must be 6-15 digits"
163
+ )
164
+
165
+ if not user_data.email or '@' not in user_data.email:
166
+ raise HTTPException(
167
+ status_code=status.HTTP_400_BAD_REQUEST,
168
+ detail="Invalid email format"
169
+ )
170
+
171
+ # Check if user already exists
172
+ existing_user = repo.get_user_by_email(db, user_data.email)
173
+ if existing_user:
174
+ logger.warning(f"Registration failed - user already exists: {mask_email(user_data.email)}")
175
+ raise HTTPException(
176
+ status_code=status.HTTP_400_BAD_REQUEST,
177
+ detail="Email already registered"
178
+ )
179
+
180
+ # Verify OTP was validated (check if verified OTP exists)
181
+ import repo_otp
182
+ otp_doc = db.otps.find_one({
183
+ "email": user_data.email.lower(),
184
+ "purpose": "registration",
185
+ "verified": True
186
+ })
187
+
188
+ if not otp_doc:
189
+ logger.warning(f"Registration failed - OTP not verified for: {mask_email(user_data.email)}")
190
+ raise HTTPException(
191
+ status_code=status.HTTP_400_BAD_REQUEST,
192
+ detail="Please verify your email with OTP first"
193
+ )
194
+
195
+ # Create user
196
+ hashed_pwd = hash_password(user_data.password)
197
+ user = repo.create_user(
198
+ db,
199
+ name=user_data.name,
200
+ email=user_data.email,
201
+ password_hash=hashed_pwd,
202
+ country_code=user_data.country_code,
203
+ phone=user_data.phone,
204
+ role=user_data.role.value if user_data.role else "user"
205
+ )
206
+
207
+ logger.info(f"User created: {mask_email(user['email'])}")
208
+
209
+ # Create AED wallet for the user (XRP can be added/imported later via wallet endpoints)
210
+ wallet = repo.create_wallet(db, user['id'], balance=0.0, currency="AED")
211
+ logger.info("User wallet created")
212
+
213
+ logger.debug(f"Wallet created for user: {wallet['id']}")
214
+
215
+ # Delete the verified OTP
216
+ repo_otp.delete_otp(db, user_data.email.lower(), "registration")
217
+
218
+ # Generate device fingerprint
219
+ device_fingerprint = generate_device_fingerprint(request, x_device_fingerprint)
220
+
221
+ # Create access token with session_id placeholder (will be updated)
222
+ token_expiry = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
223
+ expires_at = datetime.utcnow() + token_expiry
224
+
225
+ # Create JWT token
226
+ access_token = create_access_token(data={"sub": user['id'], "role": user['role']})
227
+
228
+ # Generate session ID
229
+ session_id = generate_session_id(user['id'], access_token, device_fingerprint)
230
+
231
+ # Create token with session_id
232
+ access_token = create_access_token(
233
+ data={"sub": user['id'], "role": user['role'], "session_id": session_id}
234
+ )
235
+
236
+ # Create session in database
237
+ repo.create_session(
238
+ db=db,
239
+ session_id=session_id,
240
+ user_id=user['id'],
241
+ device_fingerprint=device_fingerprint,
242
+ token=access_token,
243
+ expires_at=expires_at
244
+ )
245
+
246
+ logger.info(f"Registration successful for: {mask_email(user_data.email)}")
247
+
248
+ # Log successful registration
249
+ client_ip = request.client.host if request.client else "unknown"
250
+ log_registration(user_data.email, client_ip, success=True)
251
+
252
+ return schemas.TokenOut(
253
+ access_token=access_token,
254
+ token_type="bearer",
255
+ user_role=schemas.UserRole(user['role']),
256
+ user=schemas.UserOut(**user)
257
+ )
258
+
259
+
260
+ @router.post("/login", response_model=schemas.TokenOut)
261
+ @limiter.limit("5/minute")
262
+ def login(
263
+ request: Request,
264
+ form_data: OAuth2PasswordRequestForm = Depends(),
265
+ db=Depends(get_mongo),
266
+ x_device_fingerprint: Optional[str] = Header(None)
267
+ ):
268
+ """
269
+ User login endpoint with OTP verification and session management
270
+ Returns JWT token with device-bound session
271
+ Rate limited to 5 attempts per minute per IP
272
+ Implements account lockout after 5 failed attempts
273
+
274
+ NOTE: User must verify OTP via /otp/verify-otp before calling this endpoint
275
+ """
276
+ logger.info(f"Login attempt for: {mask_email(form_data.username)}")
277
+
278
+ # Get client IP
279
+ client_ip = request.client.host if request.client else "unknown"
280
+
281
+ # Check rate limit for failed attempts
282
+ if not check_rate_limit(client_ip, "login", max_attempts=5, window_minutes=15):
283
+ logger.warning(f"Account locked due to too many failed attempts: {mask_email(form_data.username)}")
284
+ log_account_lockout(form_data.username, client_ip, duration_minutes=15)
285
+ raise HTTPException(
286
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
287
+ detail="Account temporarily locked due to too many failed login attempts. Please try again in 15 minutes.",
288
+ headers={"WWW-Authenticate": "Bearer"},
289
+ )
290
+
291
+ # Get user by email (username field contains email)
292
+ user = repo.get_user_by_email(db, form_data.username)
293
+
294
+ if not user:
295
+ logger.warning(f"Login failed - user not found: {mask_email(form_data.username)}")
296
+ record_failed_attempt(client_ip, "login")
297
+ log_login_attempt(form_data.username, client_ip, success=False, reason="User not found")
298
+ raise HTTPException(
299
+ status_code=status.HTTP_401_UNAUTHORIZED,
300
+ detail="Incorrect email or password",
301
+ headers={"WWW-Authenticate": "Bearer"},
302
+ )
303
+
304
+ # Verify password
305
+ if not verify_password(form_data.password, user['password_hash']):
306
+ logger.warning(f"Login failed - invalid password for: {mask_email(form_data.username)}")
307
+ record_failed_attempt(client_ip, "login")
308
+ log_login_attempt(form_data.username, client_ip, success=False, reason="Invalid password")
309
+ raise HTTPException(
310
+ status_code=status.HTTP_401_UNAUTHORIZED,
311
+ detail="Incorrect email or password",
312
+ headers={"WWW-Authenticate": "Bearer"},
313
+ )
314
+
315
+ # Check if user is active
316
+ if not user.get('is_active'):
317
+ logger.warning(f"Login failed - inactive user: {mask_email(form_data.username)}")
318
+ log_login_attempt(form_data.username, client_ip, success=False, reason="Inactive account")
319
+ raise HTTPException(
320
+ status_code=status.HTTP_400_BAD_REQUEST,
321
+ detail="Account is inactive"
322
+ )
323
+
324
+ # Verify OTP was validated (check if verified OTP exists)
325
+ import repo_otp
326
+ otp_doc = db.otps.find_one({
327
+ "email": form_data.username.lower(),
328
+ "purpose": "login",
329
+ "verified": True
330
+ })
331
+
332
+ if not otp_doc:
333
+ logger.warning(f"Login failed - OTP not verified for: {mask_email(form_data.username)}")
334
+ raise HTTPException(
335
+ status_code=status.HTTP_400_BAD_REQUEST,
336
+ detail="Please verify your email with OTP first"
337
+ )
338
+
339
+ # Delete the verified OTP
340
+ repo_otp.delete_otp(db, form_data.username.lower(), "login")
341
+
342
+ # Reset failed attempts on successful login
343
+ reset_failed_attempts(client_ip, "login")
344
+
345
+ # Generate device fingerprint
346
+ device_fingerprint = generate_device_fingerprint(request, x_device_fingerprint)
347
+
348
+ # Create token expiry
349
+ token_expiry = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
350
+ expires_at = datetime.utcnow() + token_expiry
351
+
352
+ # Create JWT token
353
+ access_token = create_access_token(data={"sub": user['id'], "role": user['role']})
354
+
355
+ # Generate session ID
356
+ session_id = generate_session_id(user['id'], access_token, device_fingerprint)
357
+
358
+ # Create token with session_id
359
+ access_token = create_access_token(
360
+ data={"sub": user['id'], "role": user['role'], "session_id": session_id}
361
+ )
362
+
363
+ # Create session in database
364
+ repo.create_session(
365
+ db=db,
366
+ session_id=session_id,
367
+ user_id=user['id'],
368
+ device_fingerprint=device_fingerprint,
369
+ token=access_token,
370
+ expires_at=expires_at
371
+ )
372
+
373
+ logger.info(f"Login successful for: {mask_email(form_data.username)} (Role: {user['role']})")
374
+
375
+ # Log successful login
376
+ log_login_attempt(form_data.username, client_ip, success=True)
377
+
378
+ return schemas.TokenOut(
379
+ access_token=access_token,
380
+ token_type="bearer",
381
+ user_role=schemas.UserRole(user['role']),
382
+ user=schemas.UserOut(**user)
383
+ )
384
+
385
+
386
+ @router.get("/me", response_model=schemas.UserOut)
387
+ def get_current_user_info(current_user: dict = Depends(get_current_user)):
388
+ """
389
+ Get current user information
390
+ This endpoint validates the session and device fingerprint automatically
391
+ """
392
+ return schemas.UserOut(**current_user)
393
+
394
+
395
+ @router.post("/logout")
396
+ def logout(
397
+ request: Request,
398
+ token: str = Depends(oauth2_scheme),
399
+ db=Depends(get_mongo)
400
+ ):
401
+ """
402
+ Logout user and invalidate current session
403
+ """
404
+ try:
405
+ payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
406
+ session_id = payload.get("session_id")
407
+ user_id = payload.get("sub")
408
+
409
+ if session_id:
410
+ repo.invalidate_session(db, session_id)
411
+ logger.info(f"User logged out: {user_id}")
412
+ return {"message": "Logged out successfully"}
413
+ else:
414
+ # Old token without session_id
415
+ return {"message": "Logged out successfully (legacy token)"}
416
+
417
+ except JWTError:
418
+ raise HTTPException(
419
+ status_code=status.HTTP_401_UNAUTHORIZED,
420
+ detail="Invalid token"
421
+ )
422
+
423
+
424
+ @router.post("/logout-all")
425
+ def logout_all_devices(
426
+ current_user: dict = Depends(get_current_user),
427
+ db=Depends(get_mongo)
428
+ ):
429
+ """
430
+ Logout user from all devices by invalidating all sessions
431
+ """
432
+ count = repo.invalidate_user_sessions(db, current_user['id'])
433
+ logger.info(f"Invalidated {count} sessions for user: {current_user['id']}")
434
+ return {"message": f"Logged out from {count} device(s) successfully"}
435
+
436
+
437
+ @router.get("/sessions")
438
+ def get_active_sessions(
439
+ current_user: dict = Depends(get_current_user),
440
+ db=Depends(get_mongo)
441
+ ):
442
+ """
443
+ Get all active sessions for the current user
444
+ """
445
+ sessions = repo.get_user_active_sessions(db, current_user['id'])
446
+
447
+ # Clean sensitive data
448
+ cleaned_sessions = []
449
+ for session in sessions:
450
+ cleaned_sessions.append({
451
+ "session_id": session.get("session_id"),
452
+ "created_at": session.get("created_at"),
453
+ "last_activity": session.get("last_activity"),
454
+ "expires_at": session.get("expires_at")
455
+ })
456
+
457
+ return {"sessions": cleaned_sessions, "total": len(cleaned_sessions)}
458
+
459
+
460
+ @router.post("/refresh", response_model=schemas.TokenOut)
461
+ @limiter.limit("10/hour")
462
+ def refresh_token(
463
+ request: Request,
464
+ token: str = Depends(oauth2_scheme),
465
+ current_user: dict = Depends(get_current_user)
466
+ ):
467
+ """
468
+ Refresh access token (maintains same session)
469
+ Rate limited to 10 refreshes per hour to prevent token abuse
470
+ """
471
+ logger.debug(f"Token refresh for user: {mask_email(current_user['email'])}")
472
+
473
+ # Extract existing session_id from current token to maintain session continuity
474
+ session_id = None
475
+ try:
476
+ payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
477
+ session_id = payload.get("session_id")
478
+ except JWTError:
479
+ pass # If decoding fails, create token without session_id
480
+
481
+ # Create new access token with same session_id
482
+ token_data = {"sub": current_user['id'], "role": current_user['role']}
483
+ if session_id:
484
+ token_data["session_id"] = session_id
485
+
486
+ access_token = create_access_token(data=token_data)
487
+
488
+ return schemas.TokenOut(
489
+ access_token=access_token,
490
+ token_type="bearer",
491
+ user_role=schemas.UserRole(current_user['role']),
492
+ user=schemas.UserOut(**current_user)
493
+ )
494
+
495
+ # Note: duplicate definitions previously present here were removed to prevent
496
+ # accidental overrides. The synchronous get_current_user/get_current_admin above
497
+ # are the canonical accessors used by route dependencies.
routes/market.py ADDED
@@ -0,0 +1,1293 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Production Market Routes
3
+ Handles token purchases and marketplace operations with IOU token transfers
4
+ Implements atomic transactions with blockchain verification
5
+ """
6
+ from fastapi import APIRouter, Depends, HTTPException, status, Request
7
+ from pymongo.errors import OperationFailure
8
+ from typing import Optional, Dict, Any
9
+
10
+ import schemas
11
+ from db import get_mongo, get_client
12
+ import repo
13
+ from repo import record_admin_wallet_event
14
+ from routes.auth import get_current_user
15
+ from services.xrp_service import XRPLService
16
+ from config import settings
17
+ from utils.crypto_utils import decrypt_secret
18
+ from middleware.security import limiter
19
+ import re
20
+ import time
21
+
22
+ # Simple in-memory rate limiter (per-process). Not production-grade but prevents abuse bursts.
23
+ _PURCHASE_WINDOW_SECONDS = 30
24
+ _PURCHASE_MAX_REQUESTS = 5 # max purchases per user per window
25
+ _purchase_events: dict[str, list[float]] = {}
26
+
27
+ router = APIRouter()
28
+
29
+
30
+ @router.post("/market/buy", response_model=schemas.PurchaseResponse)
31
+ @limiter.limit("20/hour") # Prevent purchase abuse - 20 purchases per hour
32
+ def buy_tokens(
33
+ request: Request,
34
+ buy_request: schemas.BuyRequest,
35
+ db=Depends(get_mongo),
36
+ current_user=Depends(get_current_user)
37
+ ):
38
+ """
39
+ Purchase property tokens
40
+ PRODUCTION-READY: Handles database transactions, wallet deductions, and investment tracking
41
+
42
+ This endpoint:
43
+ 1. Validates user wallet balance
44
+ 2. Checks token availability
45
+ 3. Creates investment record
46
+ 4. Deducts from wallet
47
+ 5. Updates property available tokens
48
+ 6. Records all transactions
49
+ 7. Updates user portfolio
50
+
51
+ All operations are atomic when MongoDB transactions are supported
52
+ """
53
+ print("\n" + "="*80)
54
+ print(f"PROCESSING TOKEN PURCHASE")
55
+ print("="*80)
56
+ print(f"User: {current_user['name']} ({current_user['email']})")
57
+ print(f"Property ID: {buy_request.property_id}")
58
+ print(f"Tokens to buy: {buy_request.amount_tokens}")
59
+ print(f"Payment method: {buy_request.payment_method}")
60
+
61
+ # ========================================================================
62
+ # KYC CHECK - User must have approved KYC to purchase
63
+ # ========================================================================
64
+ print("\n[KYC CHECK] Verifying user KYC status...")
65
+ user_kyc_status = current_user.get('kyc_status', 'pending')
66
+
67
+ if user_kyc_status != 'approved':
68
+ print(f"[KYC CHECK] [ERROR] KYC not approved. Status: {user_kyc_status}")
69
+ raise HTTPException(
70
+ status_code=status.HTTP_403_FORBIDDEN,
71
+ detail="KYC verification required. Please complete your KYC verification before purchasing tokens."
72
+ )
73
+
74
+ print(f"[KYC CHECK] [SUCCESS] KYC approved. User can proceed with purchase.")
75
+
76
+ # ========================================================================
77
+ # RATE LIMIT CHECK (lightweight, per user)
78
+ # ========================================================================
79
+ now = time.time()
80
+ evts = _purchase_events.get(current_user['id'], [])
81
+ # prune old
82
+ evts = [t for t in evts if now - t <= _PURCHASE_WINDOW_SECONDS]
83
+ if len(evts) >= _PURCHASE_MAX_REQUESTS:
84
+ raise HTTPException(status_code=429, detail="Too many purchase attempts, please slow down")
85
+ evts.append(now)
86
+ _purchase_events[current_user['id']] = evts
87
+
88
+ # ========================================================================
89
+ # STEP 1: Validate Property
90
+ # ========================================================================
91
+ print("\n[STEP 1] Validating property...")
92
+ property_obj = repo.get_property_by_id(db, buy_request.property_id)
93
+
94
+ if not property_obj:
95
+ print(f"[ERROR] ERROR: Property not found: {buy_request.property_id}\n")
96
+ raise HTTPException(
97
+ status_code=status.HTTP_404_NOT_FOUND,
98
+ detail="Property not found"
99
+ )
100
+
101
+ if not property_obj.get('is_active'):
102
+ print(f"[ERROR] ERROR: Property is not active\n")
103
+ raise HTTPException(
104
+ status_code=status.HTTP_400_BAD_REQUEST,
105
+ detail="Property is not available for purchase"
106
+ )
107
+
108
+ print(f"[SUCCESS] Property validated: {property_obj['title']}")
109
+ print(f" Available tokens: {property_obj['available_tokens']}")
110
+ print(f" Token price: {property_obj['token_price']} AED")
111
+
112
+ # ========================================================================
113
+ # STEP 2: Check Token Availability
114
+ # ========================================================================
115
+ print("\n[STEP 2] Checking token availability...")
116
+
117
+ if buy_request.amount_tokens > property_obj['available_tokens']:
118
+ print(f"[ERROR] ERROR: Not enough tokens available")
119
+ print(f" Requested: {buy_request.amount_tokens}")
120
+ print(f" Available: {property_obj['available_tokens']}\n")
121
+ raise HTTPException(
122
+ status_code=status.HTTP_400_BAD_REQUEST,
123
+ detail=f"Only {property_obj['available_tokens']} tokens available"
124
+ )
125
+
126
+ print(f"[SUCCESS] Tokens available: {buy_request.amount_tokens} tokens OK")
127
+
128
+ # ========================================================================
129
+ # STEP 3: Calculate Total Cost
130
+ # ========================================================================
131
+ print("\n[STEP 3] Calculating purchase cost...")
132
+
133
+ total_cost = buy_request.amount_tokens * property_obj['token_price']
134
+
135
+ # Check minimum investment
136
+ if total_cost < property_obj.get('min_investment', 2000.0):
137
+ print(f"[ERROR] ERROR: Investment below minimum")
138
+ print(f" Total cost: {total_cost} AED")
139
+ print(f" Minimum required: {property_obj.get('min_investment', 2000.0)} AED\n")
140
+ raise HTTPException(
141
+ status_code=status.HTTP_400_BAD_REQUEST,
142
+ detail=f"Minimum investment is {property_obj.get('min_investment', 2000.0)} AED"
143
+ )
144
+
145
+ print(f"[SUCCESS] Purchase cost calculated:")
146
+ print(f" Tokens: {buy_request.amount_tokens}")
147
+ print(f" Price per token: {property_obj['token_price']} AED")
148
+ print(f" Total cost: {total_cost} AED")
149
+
150
+ # ========================================================================
151
+ # STEP 4: Validate Wallet Balance
152
+ # ========================================================================
153
+ print("\n[STEP 4] Validating wallet balance...")
154
+
155
+ wallet = repo.get_wallet_by_user(db, current_user['id'])
156
+
157
+ if not wallet:
158
+ print(f"[ERROR] ERROR: Wallet not found for user\n")
159
+ raise HTTPException(
160
+ status_code=status.HTTP_404_NOT_FOUND,
161
+ detail="Wallet not found. Please contact support."
162
+ )
163
+
164
+ if wallet['balance'] < total_cost:
165
+ print(f"[ERROR] ERROR: Insufficient wallet balance")
166
+ print(f" Current balance: {wallet['balance']} AED")
167
+ print(f" Required: {total_cost} AED")
168
+ print(f" Shortfall: {total_cost - wallet['balance']} AED\n")
169
+ raise HTTPException(
170
+ status_code=status.HTTP_400_BAD_REQUEST,
171
+ detail=f"Insufficient balance. You need {total_cost} AED but have {wallet['balance']} AED"
172
+ )
173
+
174
+ print(f"[SUCCESS] Wallet balance sufficient:")
175
+ print(f" Current balance: {wallet['balance']} AED")
176
+ print(f" After purchase: {wallet['balance'] - total_cost} AED")
177
+
178
+ # ========================================================================
179
+ # STEP 5: Calculate Ownership Percentage
180
+ # ========================================================================
181
+ print("\n[STEP 5] Calculating ownership details...")
182
+
183
+ ownership_percentage = (buy_request.amount_tokens / property_obj['total_tokens']) * 100
184
+
185
+ # Check if user already has investment in this property
186
+ existing_investment = repo.get_investment_by_user_and_property(
187
+ db, current_user['id'], buy_request.property_id
188
+ )
189
+
190
+ total_tokens_after = buy_request.amount_tokens
191
+ if existing_investment:
192
+ total_tokens_after += existing_investment.get('tokens_purchased', 0)
193
+
194
+ total_ownership = (total_tokens_after / property_obj['total_tokens']) * 100
195
+
196
+ print(f"[SUCCESS] Ownership calculated:")
197
+ print(f" New tokens: {buy_request.amount_tokens}")
198
+ print(f" New ownership: {ownership_percentage:.4f}%")
199
+ if existing_investment:
200
+ print(f" Previous tokens: {existing_investment.get('tokens_purchased', 0)}")
201
+ print(f" Total tokens: {total_tokens_after}")
202
+ print(f" Total ownership: {total_ownership:.4f}%")
203
+
204
+ # ========================================================================
205
+ # STEP 6: Execute Purchase (Atomic Transaction)
206
+ # ========================================================================
207
+ print("\n[STEP 6] Executing purchase transaction...")
208
+
209
+ client = get_client()
210
+ investment_id = None
211
+ transaction_id = None
212
+ blockchain_tx_hash = None
213
+
214
+ # ========================================================================
215
+ # CRITICAL: BLOCKCHAIN-FIRST APPROACH
216
+ # Execute blockchain transaction BEFORE database updates
217
+ # This ensures we never take user's money without giving them tokens
218
+ # ========================================================================
219
+
220
+ # Respect feature flag for on-ledger purchases
221
+ if not settings.XRPL_LEDGER_PURCHASE_ENABLED:
222
+ print("[BLOCKCHAIN] [ERROR] On-ledger purchases are disabled by configuration (XRPL_LEDGER_PURCHASE_ENABLED=False)")
223
+ raise HTTPException(
224
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
225
+ detail="On-ledger purchases are temporarily disabled. Please try again later."
226
+ )
227
+
228
+ print("\n[BLOCKCHAIN] Executing on-ledger token purchase...")
229
+ print(f" Token Model: {settings.XRPL_TOKEN_MODEL}")
230
+
231
+ # Get property token information
232
+ property_token = repo.get_property_token(db, buy_request.property_id)
233
+
234
+ if not property_token or not property_token.get('currency_code'):
235
+ print(f"[ERROR] ERROR: Property not tokenized on blockchain")
236
+ raise HTTPException(
237
+ status_code=status.HTTP_400_BAD_REQUEST,
238
+ detail="This property has not been tokenized on the blockchain yet. Please contact support."
239
+ )
240
+
241
+ currency_code = property_token['currency_code']
242
+ issuer_address = property_token.get('issuer_address')
243
+
244
+ if not issuer_address:
245
+ print(f"[ERROR] ERROR: No issuer address for property tokens")
246
+ raise HTTPException(
247
+ status_code=status.HTTP_400_BAD_REQUEST,
248
+ detail="Property token issuer not configured. Please contact support."
249
+ )
250
+
251
+ # Get user's XRP wallet
252
+ user_xrp_address = wallet.get('xrp_address')
253
+ encrypted_user_seed = wallet.get('xrp_seed')
254
+
255
+ if not user_xrp_address or not encrypted_user_seed:
256
+ print(f"[ERROR] ERROR: User does not have XRP wallet")
257
+ raise HTTPException(
258
+ status_code=status.HTTP_400_BAD_REQUEST,
259
+ detail="You need an XRP wallet to purchase tokens. Please create one in your wallet settings."
260
+ )
261
+
262
+ # Decrypt the user's XRP seed
263
+ print(f"[DEBUG] Encrypted seed starts with: {encrypted_user_seed[:20] if encrypted_user_seed else 'None'}...")
264
+ user_xrp_seed = decrypt_secret(encrypted_user_seed, settings.ENCRYPTION_KEY)
265
+ print(f"[DEBUG] Decrypted seed starts with: {user_xrp_seed[:5] if user_xrp_seed else 'None'}...")
266
+
267
+ if not user_xrp_seed:
268
+ print(f"[ERROR] ERROR: Failed to decrypt user's XRP wallet seed")
269
+ raise HTTPException(
270
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
271
+ detail="Wallet decryption failed. Please contact support."
272
+ )
273
+
274
+ # Calculate XRP cost for the purchase
275
+ from services.xrp_service import xrpl_service
276
+ from services.property_wallet_manager import property_wallet_manager
277
+ xrp_cost = xrpl_service.calculate_xrp_cost(buy_request.amount_tokens, property_obj['token_price'])
278
+
279
+ # Get property's dedicated issuer seed (encrypted)
280
+ encrypted_issuer_seed = property_token.get('encrypted_issuer_seed')
281
+ issuer_seed = None
282
+
283
+ if encrypted_issuer_seed:
284
+ try:
285
+ issuer_seed = property_wallet_manager._decrypt_seed(encrypted_issuer_seed)
286
+ print(f"[BLOCKCHAIN] Using property's dedicated wallet")
287
+ except Exception as e:
288
+ print(f"[BLOCKCHAIN] Warning: Could not decrypt property wallet seed: {e}")
289
+ print(f"[BLOCKCHAIN] Will attempt to use master wallet if available")
290
+
291
+ print(f"\n[BLOCKCHAIN] Purchase Details:")
292
+ print(f" Currency Code: {currency_code}")
293
+ print(f" Issuer: {issuer_address}")
294
+ print(f" Buyer: {user_xrp_address}")
295
+ print(f" Token Amount: {buy_request.amount_tokens}")
296
+ print(f" XRP Cost: {xrp_cost} XRP")
297
+ print(f" AED Equivalent: {total_cost} AED")
298
+ print(f" Wallet Type: {'Dedicated Property Wallet' if issuer_seed else 'Master Wallet'}")
299
+
300
+ # Execute blockchain purchase (this will handle trustline setup and token transfer)
301
+ try:
302
+ blockchain_result = xrpl_service.purchase_tokens_with_xrp(
303
+ buyer_seed=user_xrp_seed,
304
+ currency=currency_code,
305
+ issuer=issuer_address,
306
+ token_amount=buy_request.amount_tokens,
307
+ xrp_cost=xrp_cost,
308
+ issuer_seed=issuer_seed # Pass property's dedicated seed
309
+ )
310
+
311
+ if not blockchain_result.get('success'):
312
+ error_msg = blockchain_result.get('error', 'Unknown blockchain error')
313
+ print(f"[ERROR] BLOCKCHAIN TRANSACTION FAILED: {error_msg}")
314
+ raise HTTPException(
315
+ status_code=status.HTTP_400_BAD_REQUEST,
316
+ detail=f"Blockchain transaction failed: {error_msg}"
317
+ )
318
+
319
+ blockchain_tx_hash = blockchain_result.get('token_tx_hash') or blockchain_result.get('payment_tx_hash')
320
+ print(f"✅ BLOCKCHAIN TRANSACTION SUCCESS!")
321
+ print(f" Transaction Hash: {blockchain_tx_hash}")
322
+ print(f" Payment TX: {blockchain_result.get('payment_tx_hash')}")
323
+ print(f" Token TX: {blockchain_result.get('token_tx_hash')}")
324
+
325
+ except HTTPException:
326
+ raise
327
+ except Exception as blockchain_error:
328
+ print(f"[ERROR] BLOCKCHAIN ERROR: {blockchain_error}")
329
+ raise HTTPException(
330
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
331
+ detail=f"Blockchain transaction failed: {str(blockchain_error)}"
332
+ )
333
+
334
+ # ========================================================================
335
+ # Now update database - blockchain transaction succeeded
336
+ # ========================================================================
337
+
338
+ try:
339
+ # Try to use MongoDB transactions for atomicity
340
+ with client.start_session() as session:
341
+ with session.start_transaction():
342
+ print(" → Starting database transaction...")
343
+
344
+ # 6.1: Deduct from wallet
345
+ print(f" → Deducting {total_cost} AED from wallet...")
346
+ updated_wallet = repo.update_wallet_balance(
347
+ db, wallet['id'], total_cost, operation="subtract", session=session
348
+ )
349
+
350
+ if not updated_wallet:
351
+ raise Exception("Failed to deduct from wallet")
352
+
353
+ # 6.2: Decrement available tokens
354
+ print(f" → Decrementing {buy_request.amount_tokens} tokens from property...")
355
+ success = repo.decrement_available_tokens(
356
+ db, buy_request.property_id, buy_request.amount_tokens, session=session
357
+ )
358
+
359
+ if not success:
360
+ raise Exception("Failed to decrement available tokens")
361
+
362
+ # 6.3: Create or update investment
363
+ print(f" → Creating/updating investment record...")
364
+ investment = repo.upsert_investment(
365
+ db,
366
+ user_id=current_user['id'],
367
+ property_id=buy_request.property_id,
368
+ tokens_purchased=buy_request.amount_tokens,
369
+ amount=total_cost,
370
+ session=session
371
+ )
372
+ investment_id = investment['id']
373
+
374
+ # 6.4: Create purchase transaction
375
+ print(f" → Recording purchase transaction...")
376
+ transaction = repo.create_transaction(
377
+ db,
378
+ user_id=current_user['id'],
379
+ wallet_id=wallet['id'],
380
+ tx_type="investment",
381
+ amount=total_cost,
382
+ property_id=buy_request.property_id,
383
+ status="completed",
384
+ metadata={
385
+ "tokens_purchased": buy_request.amount_tokens,
386
+ "token_price": property_obj['token_price'],
387
+ "property_name": property_obj['title'],
388
+ "ownership_percentage": ownership_percentage,
389
+ "payment_method": buy_request.payment_method,
390
+ "blockchain_tx_hash": blockchain_tx_hash,
391
+ "currency_code": currency_code,
392
+ "issuer_address": issuer_address,
393
+ "xrp_cost": xrp_cost
394
+ },
395
+ session=session
396
+ )
397
+ transaction_id = transaction['id']
398
+
399
+ # Mirror admin wallet credit event (platform receives funds) in fils
400
+ try:
401
+ record_admin_wallet_event(
402
+ db,
403
+ delta_fils=int(total_cost * 100),
404
+ event_type="admin_wallet_credit_purchase",
405
+ notes=f"Received AED {total_cost:.2f} from {current_user.get('name')} for {buy_request.amount_tokens} tokens of {property_obj.get('title')}",
406
+ counterparty_user=current_user,
407
+ property_obj=property_obj,
408
+ payment_method=buy_request.payment_method,
409
+ payment_reference=f"INVEST_{buy_request.property_id}_{buy_request.amount_tokens}",
410
+ metadata={
411
+ "amount": buy_request.amount_tokens,
412
+ "price_per_token": property_obj['token_price'],
413
+ "blockchain_tx_hash": blockchain_tx_hash,
414
+ },
415
+ session=session,
416
+ )
417
+ except Exception as admin_evt_err:
418
+ print(f"[MARKET] ⚠ Failed to record admin wallet event: {admin_evt_err}")
419
+
420
+ # 6.5: Update portfolio
421
+ print(f" → Updating user portfolio...")
422
+ repo.create_or_update_portfolio(db, current_user['id'], session=session)
423
+
424
+ print(" [SUCCESS] Database transaction committed successfully!")
425
+
426
+ except OperationFailure as e:
427
+ # Fallback for standalone MongoDB (no transaction support)
428
+ if "Transaction numbers" in str(e) or "replica set" in str(e):
429
+ print(" ⚠ Transactions not supported, executing sequentially...")
430
+
431
+ # Execute without session (less safe but works)
432
+ updated_wallet = repo.update_wallet_balance(
433
+ db, wallet['id'], total_cost, operation="subtract"
434
+ )
435
+
436
+ if not updated_wallet:
437
+ raise HTTPException(
438
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
439
+ detail="Failed to deduct from wallet"
440
+ )
441
+
442
+ success = repo.decrement_available_tokens(
443
+ db, buy_request.property_id, buy_request.amount_tokens
444
+ )
445
+
446
+ if not success:
447
+ # Rollback wallet deduction
448
+ repo.update_wallet_balance(db, wallet['id'], total_cost, operation="add")
449
+ raise HTTPException(
450
+ status_code=status.HTTP_400_BAD_REQUEST,
451
+ detail="Failed to reserve tokens"
452
+ )
453
+
454
+ investment = repo.upsert_investment(
455
+ db,
456
+ user_id=current_user['id'],
457
+ property_id=buy_request.property_id,
458
+ tokens_purchased=buy_request.amount_tokens,
459
+ amount=total_cost
460
+ )
461
+ investment_id = investment['id']
462
+
463
+ transaction = repo.create_transaction(
464
+ db,
465
+ user_id=current_user['id'],
466
+ wallet_id=wallet['id'],
467
+ tx_type="investment",
468
+ amount=total_cost,
469
+ property_id=buy_request.property_id,
470
+ status="completed",
471
+ metadata={
472
+ "tokens_purchased": buy_request.amount_tokens,
473
+ "token_price": property_obj['token_price'],
474
+ "property_name": property_obj['title'],
475
+ "ownership_percentage": ownership_percentage,
476
+ "payment_method": buy_request.payment_method,
477
+ "blockchain_tx_hash": blockchain_tx_hash,
478
+ "currency_code": currency_code,
479
+ "issuer_address": issuer_address,
480
+ "xrp_cost": xrp_cost
481
+ }
482
+ )
483
+ transaction_id = transaction['id']
484
+
485
+ # Admin wallet credit outside transaction fallback
486
+ try:
487
+ record_admin_wallet_event(
488
+ db,
489
+ delta_fils=int(total_cost * 100),
490
+ event_type="admin_wallet_credit_purchase",
491
+ notes=f"Received AED {total_cost:.2f} from {current_user.get('name')} for {buy_request.amount_tokens} tokens of {property_obj.get('title')}",
492
+ counterparty_user=current_user,
493
+ property_obj=property_obj,
494
+ payment_method=buy_request.payment_method,
495
+ payment_reference=f"INVEST_{buy_request.property_id}_{buy_request.amount_tokens}",
496
+ metadata={
497
+ "amount": buy_request.amount_tokens,
498
+ "price_per_token": property_obj['token_price'],
499
+ "blockchain_tx_hash": blockchain_tx_hash,
500
+ },
501
+ )
502
+ except Exception as admin_evt_err:
503
+ print(f"[MARKET] ⚠ Failed to record admin wallet event (fallback): {admin_evt_err}")
504
+
505
+ repo.create_or_update_portfolio(db, current_user['id'])
506
+
507
+ print(" [SUCCESS] Sequential execution completed!")
508
+ else:
509
+ raise
510
+
511
+ # ========================================================================
512
+ # STEP 7: Return Success Response
513
+ # ========================================================================
514
+ # Get updated investment to calculate total tokens
515
+ updated_investment = repo.get_investment_by_user_and_property(
516
+ db, current_user['id'], buy_request.property_id
517
+ )
518
+ user_total_tokens = updated_investment.get('tokens_purchased', buy_request.amount_tokens) if updated_investment else buy_request.amount_tokens
519
+
520
+ # Get updated property data
521
+ updated_property = repo.get_property_by_id(db, buy_request.property_id)
522
+ tokens_remaining = updated_property.get('available_tokens', 0) if updated_property else 0
523
+
524
+ # Get updated wallet balance
525
+ updated_wallet_data = repo.get_wallet_by_user(db, current_user['id'])
526
+ remaining_balance = updated_wallet_data.get('balance', 0) if updated_wallet_data else 0
527
+
528
+ print("\n[STEP 7] Purchase completed successfully!")
529
+ print("="*80)
530
+ print("PURCHASE SUMMARY")
531
+ print("="*80)
532
+ print(f"Transaction ID: {transaction_id}")
533
+ print(f"Investment ID: {investment_id}")
534
+ print(f"Tokens purchased: {buy_request.amount_tokens}")
535
+ print(f"Total cost: {total_cost} AED")
536
+ print(f"Ownership: {ownership_percentage:.4f}%")
537
+ print(f"Your total tokens: {user_total_tokens}")
538
+ print(f"Tokens remaining: {tokens_remaining}")
539
+ print(f"Remaining wallet balance: {remaining_balance} AED")
540
+ print(f"Blockchain TX Hash: {blockchain_tx_hash}")
541
+ print("="*80 + "\n")
542
+
543
+ return schemas.PurchaseResponse(
544
+ success=True,
545
+ message=f"Successfully purchased {buy_request.amount_tokens} tokens on the blockchain!",
546
+ transaction_id=transaction_id,
547
+ investment_id=investment_id,
548
+ tokens_purchased=buy_request.amount_tokens,
549
+ total_cost_aed=total_cost,
550
+ aed_wallet_balance=remaining_balance,
551
+ your_total_tokens=user_total_tokens,
552
+ tokens_remaining=tokens_remaining,
553
+ blockchain_tx_hash=blockchain_tx_hash or "",
554
+ ownership_percentage=ownership_percentage,
555
+ property_details={
556
+ "id": property_obj['id'],
557
+ "title": property_obj['title'],
558
+ "location": property_obj['location'],
559
+ "total_tokens": property_obj['total_tokens'],
560
+ "available_tokens": tokens_remaining,
561
+ "token_price": property_obj['token_price']
562
+ }
563
+ )
564
+
565
+
566
+ @router.get("/market/properties/available")
567
+ @limiter.limit("30/minute") # Prevent API abuse
568
+ def get_available_properties(
569
+ request: Request,
570
+ db=Depends(get_mongo),
571
+ current_user: dict = Depends(get_current_user)
572
+ ):
573
+ """
574
+ Get all properties with available tokens for purchase
575
+ Rate Limited: 30 requests per minute to prevent abuse
576
+ Requires authentication
577
+ """
578
+ print("\n[MARKET] Fetching available properties...")
579
+
580
+ # Get all active properties
581
+ all_properties = repo.list_properties(db, is_active=True, limit=500)
582
+
583
+ # Filter only those with available tokens
584
+ available = [p for p in all_properties if p.get('available_tokens', 0) > 0]
585
+
586
+ print(f"[MARKET] [SUCCESS] Found {len(available)} properties with available tokens\n")
587
+
588
+ return {
589
+ "total_available": len(available),
590
+ "properties": available
591
+ }
592
+
593
+
594
+ @router.post("/market/calculate-cost")
595
+ @limiter.limit("60/minute") # Allow frequent calculations but prevent abuse
596
+ def calculate_purchase_cost(
597
+ request: Request,
598
+ cost_request: dict,
599
+ db=Depends(get_mongo),
600
+ current_user=Depends(get_current_user)
601
+ ):
602
+ """
603
+ Calculate the real-time XRP cost for purchasing tokens
604
+ Used by frontend to show accurate blockchain transaction costs
605
+ Rate Limited: 60 requests per minute per user
606
+ """
607
+ property_id = cost_request.get('property_id')
608
+ amount_tokens = cost_request.get('amount_tokens', 0)
609
+
610
+ if not property_id or amount_tokens <= 0:
611
+ raise HTTPException(status_code=400, detail="Invalid property_id or amount_tokens")
612
+
613
+ # Get property details
614
+ property_obj = repo.get_property_by_id(db, property_id)
615
+ if not property_obj:
616
+ raise HTTPException(status_code=404, detail="Property not found")
617
+
618
+ if property_obj.get('available_tokens', 0) < amount_tokens:
619
+ raise HTTPException(status_code=400, detail="Not enough tokens available")
620
+
621
+ # Calculate costs using live rates
622
+ from services.xrp_service import xrpl_service
623
+ xrp_cost = xrpl_service.calculate_xrp_cost(amount_tokens, property_obj['token_price'])
624
+ aed_cost = amount_tokens * property_obj['token_price']
625
+
626
+ return {
627
+ "property_id": property_id,
628
+ "amount_tokens": amount_tokens,
629
+ "token_price_aed": property_obj['token_price'],
630
+ "total_cost_aed": aed_cost,
631
+ "xrp_cost": xrp_cost,
632
+ "ownership_percentage": (amount_tokens / property_obj['total_tokens']) * 100
633
+ }
634
+
635
+
636
+ @router.post("/market/place-offer", response_model=Dict[str, Any])
637
+ @limiter.limit("10/hour") # Limit sell-back requests to prevent abuse
638
+ def place_sell_offer(
639
+ request: Request,
640
+ sell_request: schemas.PlaceOfferRequest,
641
+ db=Depends(get_mongo),
642
+ current_user=Depends(get_current_user)
643
+ ):
644
+ """
645
+ Sell tokens back to admin/issuer
646
+ User sells their tokens back to the platform for refund
647
+ Rate Limited: 10 sell requests per hour
648
+
649
+ This endpoint:
650
+ 1. Validates user has enough tokens
651
+ 2. Verifies blockchain balance
652
+ 3. Transfers tokens back to issuer on blockchain
653
+ 4. Refunds user's AED wallet
654
+ 5. Updates investment records
655
+ 6. Updates property available tokens
656
+ 7. Records transaction in transactions table
657
+ 8. Records transaction in secondary_market_transactions table (NEW!)
658
+ 9. Records admin wallet events
659
+
660
+ All operations are atomic with comprehensive history tracking
661
+ """
662
+ print(f"\n[SELL OFFER] User {current_user['email']} selling tokens back")
663
+ print(f" Property ID: {sell_request.property_id}")
664
+ print(f" Amount: {sell_request.amount_tokens} tokens")
665
+
666
+ # ========================================================================
667
+ # STEP 1: Validate Property
668
+ # ========================================================================
669
+ property_obj = repo.get_property_by_id(db, sell_request.property_id)
670
+ if not property_obj:
671
+ raise HTTPException(status_code=404, detail="Property not found")
672
+
673
+ # ========================================================================
674
+ # STEP 2: Validate User Investment
675
+ # ========================================================================
676
+ investment = repo.get_investment_by_user_and_property(db, current_user['id'], sell_request.property_id)
677
+ if not investment:
678
+ raise HTTPException(
679
+ status_code=status.HTTP_400_BAD_REQUEST,
680
+ detail="You don't own any tokens for this property"
681
+ )
682
+
683
+ user_tokens = investment.get('tokens_purchased', 0)
684
+ if sell_request.amount_tokens > user_tokens:
685
+ raise HTTPException(
686
+ status_code=status.HTTP_400_BAD_REQUEST,
687
+ detail=f"Cannot sell {sell_request.amount_tokens} tokens. You only own {user_tokens} tokens."
688
+ )
689
+
690
+ # ========================================================================
691
+ # STEP 3: Get Wallet Information
692
+ # ========================================================================
693
+ wallet = repo.get_wallet_by_user(db, current_user['id'])
694
+ if not wallet:
695
+ raise HTTPException(status_code=400, detail="Wallet not found")
696
+
697
+ user_xrp_address = wallet.get('xrp_address')
698
+ encrypted_user_seed = wallet.get('xrp_seed')
699
+
700
+ if not user_xrp_address or not encrypted_user_seed:
701
+ raise HTTPException(
702
+ status_code=status.HTTP_400_BAD_REQUEST,
703
+ detail="XRP wallet required to sell tokens"
704
+ )
705
+
706
+ # Decrypt user's wallet seed
707
+ user_xrp_seed = decrypt_secret(encrypted_user_seed, settings.ENCRYPTION_KEY)
708
+ if not user_xrp_seed:
709
+ raise HTTPException(
710
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
711
+ detail="Failed to decrypt wallet"
712
+ )
713
+
714
+ # ========================================================================
715
+ # STEP 4: Get Property Token Information
716
+ # ========================================================================
717
+ property_token = repo.get_property_token(db, sell_request.property_id)
718
+ if not property_token or not property_token.get('currency_code'):
719
+ raise HTTPException(
720
+ status_code=status.HTTP_400_BAD_REQUEST,
721
+ detail="Property not tokenized on blockchain"
722
+ )
723
+
724
+ currency_code = property_token['currency_code']
725
+ issuer_address = property_token.get('issuer_address')
726
+ encrypted_issuer_seed = property_token.get('encrypted_issuer_seed')
727
+
728
+ if not issuer_address:
729
+ raise HTTPException(
730
+ status_code=status.HTTP_400_BAD_REQUEST,
731
+ detail="Property issuer not configured"
732
+ )
733
+
734
+ # Get admin user info
735
+ admin_user = repo.get_user_by_role(db, 'admin')
736
+ if not admin_user:
737
+ raise HTTPException(status_code=500, detail="Admin user not found")
738
+
739
+ # Decrypt issuer seed if available
740
+ issuer_seed = None
741
+ if encrypted_issuer_seed:
742
+ try:
743
+ from services.property_wallet_manager import property_wallet_manager
744
+ issuer_seed = property_wallet_manager._decrypt_seed(encrypted_issuer_seed)
745
+ except Exception as e:
746
+ print(f"[SELL] Warning: Could not decrypt issuer seed: {e}")
747
+
748
+ # ========================================================================
749
+ # STEP 4.5: VERIFY BLOCKCHAIN BALANCE (CRITICAL!)
750
+ # ========================================================================
751
+ print(f"\n[BLOCKCHAIN VERIFICATION] Checking user's on-chain token balance...")
752
+
753
+ from services.xrp_service import xrpl_service
754
+
755
+ try:
756
+ blockchain_balance = xrpl_service.get_user_token_balance(
757
+ user_xrp_address,
758
+ currency_code,
759
+ issuer_address
760
+ )
761
+ blockchain_balance = float(blockchain_balance or 0)
762
+
763
+ print(f" User XRP Address: {user_xrp_address}")
764
+ print(f" Currency: {currency_code}")
765
+ print(f" Issuer: {issuer_address}")
766
+ print(f" Blockchain Balance: {blockchain_balance} tokens")
767
+ print(f" Database Balance: {user_tokens} tokens")
768
+ print(f" Trying to Sell: {sell_request.amount_tokens} tokens")
769
+
770
+ if blockchain_balance < sell_request.amount_tokens:
771
+ raise HTTPException(
772
+ status_code=status.HTTP_400_BAD_REQUEST,
773
+ detail=f"Insufficient blockchain balance. You have {blockchain_balance} tokens on blockchain but trying to sell {sell_request.amount_tokens}. Database shows {user_tokens} tokens. Please use the sync endpoint to fix this issue."
774
+ )
775
+
776
+ print(f"[BLOCKCHAIN VERIFICATION] ✅ User has sufficient tokens on blockchain")
777
+
778
+ except HTTPException:
779
+ raise
780
+ except Exception as e:
781
+ print(f"[BLOCKCHAIN VERIFICATION] ❌ Error checking balance: {e}")
782
+ raise HTTPException(
783
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
784
+ detail=f"Failed to verify blockchain balance: {str(e)}"
785
+ )
786
+
787
+ # ========================================================================
788
+ # STEP 5: Calculate Refund Amount
789
+ # ========================================================================
790
+ token_price = property_obj['token_price']
791
+ refund_amount = sell_request.amount_tokens * token_price
792
+
793
+ print(f"\n[SELL] Refund Calculation:")
794
+ print(f" Token Price: {token_price} AED")
795
+ print(f" Tokens Selling: {sell_request.amount_tokens}")
796
+ print(f" Refund Amount: {refund_amount} AED")
797
+
798
+ # ========================================================================
799
+ # STEP 6: Execute Blockchain Transfer (Transfer tokens back to issuer)
800
+ # ========================================================================
801
+ print(f"\n[BLOCKCHAIN] Transferring tokens back to issuer...")
802
+ print(f" Seller: {user_xrp_address}")
803
+ print(f" Issuer: {issuer_address}")
804
+ print(f" Currency: {currency_code}")
805
+ print(f" Amount: {sell_request.amount_tokens}")
806
+
807
+ from services.xrp_service import xrpl_service
808
+
809
+ blockchain_result = xrpl_service.transfer_tokens_to_issuer(
810
+ seller_seed=user_xrp_seed,
811
+ currency=currency_code,
812
+ issuer=issuer_address,
813
+ token_amount=sell_request.amount_tokens,
814
+ issuer_seed=issuer_seed
815
+ )
816
+
817
+ if not blockchain_result.get('success'):
818
+ error_msg = blockchain_result.get('error', 'Blockchain transfer failed')
819
+ print(f"[BLOCKCHAIN] ❌ Transfer failed: {error_msg}")
820
+ raise HTTPException(
821
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
822
+ detail=f"Failed to transfer tokens on blockchain: {error_msg}"
823
+ )
824
+
825
+ blockchain_tx_hash = blockchain_result.get('tx_hash')
826
+ print(f"[BLOCKCHAIN] ✅ Tokens transferred. TX: {blockchain_tx_hash}")
827
+
828
+ # ========================================================================
829
+ # STEP 7: Execute Database Updates (Atomic Transaction with History)
830
+ # ========================================================================
831
+ print(f"\n[DATABASE] Executing sell transaction with full history tracking...")
832
+
833
+ import uuid
834
+ from datetime import datetime
835
+
836
+ market_transaction_id = f"MKT-{uuid.uuid4().hex[:12].upper()}"
837
+
838
+ client = get_client()
839
+ sell_transaction_id = None
840
+
841
+ try:
842
+ with client.start_session() as session:
843
+ with session.start_transaction():
844
+ # 7.1: Create market transaction record (NEW!)
845
+ market_tx = repo.create_market_transaction(
846
+ db,
847
+ transaction_id=market_transaction_id,
848
+ transaction_type="sell_to_admin",
849
+ property_id=sell_request.property_id,
850
+ property_title=property_obj.get('title', 'Unknown'),
851
+ token_currency=currency_code,
852
+ seller_id=current_user['id'],
853
+ seller_email=current_user['email'],
854
+ seller_xrp_address=user_xrp_address,
855
+ buyer_id=admin_user['id'],
856
+ buyer_email=admin_user['email'],
857
+ buyer_xrp_address=issuer_address,
858
+ tokens_amount=sell_request.amount_tokens,
859
+ price_per_token=token_price,
860
+ total_amount=refund_amount,
861
+ blockchain_tx_hash=blockchain_tx_hash,
862
+ notes=f"User {current_user['email']} sold {sell_request.amount_tokens} tokens back to admin",
863
+ session=session
864
+ )
865
+ print(f"[DATABASE] ✅ Market transaction created: {market_transaction_id}")
866
+
867
+ # 7.2: Reduce user's investment
868
+ updated_investment = repo.reduce_investment(
869
+ db,
870
+ current_user['id'],
871
+ sell_request.property_id,
872
+ sell_request.amount_tokens,
873
+ session=session
874
+ )
875
+
876
+ if not updated_investment:
877
+ raise HTTPException(
878
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
879
+ detail="Failed to update investment"
880
+ )
881
+ print(f"[DATABASE] ✅ Investment reduced")
882
+
883
+ # 7.3: Refund user's AED wallet
884
+ updated_wallet = repo.update_wallet_balance(
885
+ db,
886
+ wallet['id'],
887
+ refund_amount,
888
+ session=session
889
+ )
890
+
891
+ if not updated_wallet:
892
+ raise HTTPException(
893
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
894
+ detail="Failed to refund wallet"
895
+ )
896
+ print(f"[DATABASE] ✅ Wallet refunded: {refund_amount} AED")
897
+
898
+ # 7.4: Increase property available tokens
899
+ new_available = property_obj.get('available_tokens', 0) + sell_request.amount_tokens
900
+ repo.update_property(
901
+ db,
902
+ sell_request.property_id,
903
+ {'available_tokens': new_available},
904
+ session=session
905
+ )
906
+ print(f"[DATABASE] ✅ Property updated: {new_available} tokens available")
907
+
908
+ # 7.5: Record sell transaction
909
+ sell_transaction = repo.create_transaction(
910
+ db,
911
+ user_id=current_user['id'],
912
+ wallet_id=wallet['id'],
913
+ tx_type='token_buyback',
914
+ amount=refund_amount,
915
+ property_id=sell_request.property_id,
916
+ status='completed',
917
+ metadata={
918
+ 'tokens_sold': sell_request.amount_tokens,
919
+ 'token_price': token_price,
920
+ 'blockchain_tx_hash': blockchain_tx_hash,
921
+ 'issuer_address': issuer_address,
922
+ 'seller_address': user_xrp_address,
923
+ 'market_transaction_id': market_transaction_id
924
+ },
925
+ session=session
926
+ )
927
+ sell_transaction_id = sell_transaction['id']
928
+ print(f"[DATABASE] ✅ Sell transaction recorded: {sell_transaction_id}")
929
+
930
+ # 7.6: Record admin wallet credit event
931
+ record_admin_wallet_event(
932
+ db,
933
+ delta_fils=int(refund_amount), # Convert AED to fils (admin wallet uses AED but function expects delta_fils)
934
+ event_type='token_buyback',
935
+ notes=f"User {current_user['email']} sold {sell_request.amount_tokens} tokens back to admin",
936
+ property_obj=property_obj,
937
+ metadata={
938
+ 'tokens_bought_back': sell_request.amount_tokens,
939
+ 'refund_amount': refund_amount,
940
+ 'blockchain_tx_hash': blockchain_tx_hash,
941
+ 'transaction_id': sell_transaction_id,
942
+ 'market_transaction_id': market_transaction_id,
943
+ 'user_id': current_user['id'],
944
+ 'property_id': sell_request.property_id
945
+ },
946
+ session=session
947
+ )
948
+ print(f"[DATABASE] ✅ Admin wallet event recorded")
949
+
950
+ # 7.7: Update market transaction with database update flags
951
+ repo.update_market_transaction_status(
952
+ db,
953
+ market_transaction_id,
954
+ status="completed",
955
+ db_updates={
956
+ "investment_updated": True,
957
+ "wallet_updated": True,
958
+ "property_updated": True,
959
+ "transaction_recorded": True
960
+ },
961
+ session=session
962
+ )
963
+ print(f"[DATABASE] ✅ Market transaction status updated to completed")
964
+ print(f"[DATABASE] ✅ Market transaction status updated to completed")
965
+
966
+ print(f"[DATABASE] ✅ All updates committed successfully")
967
+
968
+ except Exception as e:
969
+ print(f"[DATABASE] ❌ Transaction failed: {str(e)}")
970
+
971
+ # Try to mark market transaction as failed
972
+ try:
973
+ repo.update_market_transaction_status(
974
+ db,
975
+ market_transaction_id,
976
+ status="failed",
977
+ error_message=str(e)
978
+ )
979
+ except:
980
+ pass
981
+
982
+ raise HTTPException(
983
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
984
+ detail=f"Database transaction failed: {str(e)}"
985
+ )
986
+
987
+ # ========================================================================
988
+ # STEP 8: Return Success Response
989
+ # ========================================================================
990
+ remaining_tokens = user_tokens - sell_request.amount_tokens
991
+ new_wallet_balance = updated_wallet['balance']
992
+
993
+ print(f"\n[SELL OFFER] ✅ Sale completed successfully!")
994
+ print(f" Market Transaction ID: {market_transaction_id}")
995
+ print(f" Wallet Transaction ID: {sell_transaction_id}")
996
+ print(f" Tokens Sold: {sell_request.amount_tokens}")
997
+ print(f" Refund: {refund_amount} AED")
998
+ print(f" Remaining Tokens: {remaining_tokens}")
999
+ print(f" New Wallet Balance: {new_wallet_balance} AED")
1000
+
1001
+ return {
1002
+ 'success': True,
1003
+ 'message': f'Successfully sold {sell_request.amount_tokens} tokens!',
1004
+ 'market_transaction_id': market_transaction_id,
1005
+ 'transaction_id': sell_transaction_id,
1006
+ 'tokens_sold': sell_request.amount_tokens,
1007
+ 'refund_amount': refund_amount,
1008
+ 'token_price': token_price,
1009
+ 'remaining_tokens': remaining_tokens,
1010
+ 'wallet_balance': new_wallet_balance,
1011
+ 'blockchain_tx_hash': blockchain_tx_hash,
1012
+ 'property_available_tokens': new_available
1013
+ }
1014
+
1015
+
1016
+ @router.post("/market/sync-balance", response_model=schemas.SyncBalanceResponse)
1017
+ @limiter.limit("5/hour") # Limit sync requests
1018
+ def sync_balance_with_blockchain(
1019
+ request: Request,
1020
+ sync_request: schemas.SyncBalanceRequest,
1021
+ db=Depends(get_mongo),
1022
+ current_user=Depends(get_current_user)
1023
+ ):
1024
+ """
1025
+ Sync database token balance with blockchain balance
1026
+ This fixes sync issues where database and blockchain balances don't match
1027
+ Rate Limited: 5 sync requests per hour
1028
+
1029
+ This endpoint:
1030
+ 1. Fetches actual blockchain balance
1031
+ 2. Compares with database balance
1032
+ 3. Updates database to match blockchain (source of truth)
1033
+ 4. Records sync transaction for audit trail
1034
+ """
1035
+ print(f"\n[SYNC BALANCE] User {current_user['email']} syncing balance")
1036
+ print(f" Property ID: {sync_request.property_id}")
1037
+
1038
+ # ========================================================================
1039
+ # STEP 1: Validate Property
1040
+ # ========================================================================
1041
+ property_obj = repo.get_property_by_id(db, sync_request.property_id)
1042
+ if not property_obj:
1043
+ raise HTTPException(status_code=404, detail="Property not found")
1044
+
1045
+ # ========================================================================
1046
+ # STEP 2: Get User Investment
1047
+ # ========================================================================
1048
+ investment = repo.get_investment_by_user_and_property(db, current_user['id'], sync_request.property_id)
1049
+ if not investment:
1050
+ raise HTTPException(
1051
+ status_code=status.HTTP_400_BAD_REQUEST,
1052
+ detail="You don't have any investment in this property"
1053
+ )
1054
+
1055
+ db_balance_before = investment.get('tokens_purchased', 0)
1056
+
1057
+ # ========================================================================
1058
+ # STEP 3: Get Wallet and Token Information
1059
+ # ========================================================================
1060
+ wallet = repo.get_wallet_by_user(db, current_user['id'])
1061
+ if not wallet or not wallet.get('xrp_address'):
1062
+ raise HTTPException(status_code=400, detail="XRP wallet not found")
1063
+
1064
+ property_token = repo.get_property_token(db, sync_request.property_id)
1065
+ if not property_token or not property_token.get('currency_code'):
1066
+ raise HTTPException(
1067
+ status_code=status.HTTP_400_BAD_REQUEST,
1068
+ detail="Property not tokenized on blockchain"
1069
+ )
1070
+
1071
+ user_xrp_address = wallet['xrp_address']
1072
+ currency_code = property_token['currency_code']
1073
+ issuer_address = property_token['issuer_address']
1074
+
1075
+ # ========================================================================
1076
+ # STEP 4: Fetch Blockchain Balance (Source of Truth)
1077
+ # ========================================================================
1078
+ print(f"\n[SYNC] Fetching blockchain balance...")
1079
+ print(f" User XRP Address: {user_xrp_address}")
1080
+ print(f" Currency: {currency_code}")
1081
+ print(f" Issuer: {issuer_address}")
1082
+ print(f" Database Balance: {db_balance_before} tokens")
1083
+
1084
+ from services.xrp_service import xrpl_service
1085
+
1086
+ try:
1087
+ blockchain_balance = xrpl_service.get_user_token_balance(
1088
+ user_xrp_address,
1089
+ currency_code,
1090
+ issuer_address
1091
+ )
1092
+ blockchain_balance = float(blockchain_balance or 0)
1093
+ blockchain_balance_int = int(blockchain_balance)
1094
+
1095
+ print(f" Blockchain Balance: {blockchain_balance} tokens")
1096
+
1097
+ except Exception as e:
1098
+ print(f"[SYNC] ❌ Error fetching blockchain balance: {e}")
1099
+ raise HTTPException(
1100
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
1101
+ detail=f"Failed to fetch blockchain balance: {str(e)}"
1102
+ )
1103
+
1104
+ # ========================================================================
1105
+ # STEP 5: Compare and Sync
1106
+ # ========================================================================
1107
+ if db_balance_before == blockchain_balance_int and not sync_request.force_sync:
1108
+ print(f"[SYNC] ℹ️ Balances already match, no sync needed")
1109
+ return {
1110
+ "success": True,
1111
+ "property_id": sync_request.property_id,
1112
+ "property_title": property_obj.get('title', 'Unknown'),
1113
+ "user_id": current_user['id'],
1114
+ "db_balance_before": db_balance_before,
1115
+ "blockchain_balance": blockchain_balance,
1116
+ "db_balance_after": db_balance_before,
1117
+ "tokens_adjusted": 0,
1118
+ "was_out_of_sync": False,
1119
+ "sync_action": "no_change",
1120
+ "message": "Balances already match. No sync needed."
1121
+ }
1122
+
1123
+ tokens_adjusted = blockchain_balance_int - db_balance_before
1124
+ sync_action = "increased" if tokens_adjusted > 0 else ("decreased" if tokens_adjusted < 0 else "no_change")
1125
+
1126
+ print(f"\n[SYNC] Syncing database to match blockchain...")
1127
+ print(f" Adjustment: {tokens_adjusted:+d} tokens")
1128
+ print(f" Action: {sync_action}")
1129
+
1130
+ # ========================================================================
1131
+ # STEP 6: Update Database
1132
+ # ========================================================================
1133
+ client = get_client()
1134
+
1135
+ try:
1136
+ with client.start_session() as session:
1137
+ with session.start_transaction():
1138
+ # Update investment
1139
+ repo.update_investment(
1140
+ db,
1141
+ investment['id'],
1142
+ {'tokens_purchased': blockchain_balance_int},
1143
+ session=session
1144
+ )
1145
+
1146
+ # Record sync transaction
1147
+ repo.create_transaction(
1148
+ db,
1149
+ user_id=current_user['id'],
1150
+ wallet_id=wallet['id'],
1151
+ tx_type='wallet_add' if tokens_adjusted > 0 else 'wallet_deduct',
1152
+ amount=0, # No monetary transaction
1153
+ property_id=sync_request.property_id,
1154
+ status='completed',
1155
+ metadata={
1156
+ 'sync_operation': True,
1157
+ 'db_balance_before': db_balance_before,
1158
+ 'blockchain_balance': blockchain_balance,
1159
+ 'tokens_adjusted': tokens_adjusted,
1160
+ 'sync_action': sync_action,
1161
+ 'forced_sync': sync_request.force_sync
1162
+ },
1163
+ session=session
1164
+ )
1165
+
1166
+ print(f"[SYNC] ✅ Database updated successfully")
1167
+
1168
+ except Exception as e:
1169
+ print(f"[SYNC] ❌ Database update failed: {e}")
1170
+ raise HTTPException(
1171
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
1172
+ detail=f"Failed to sync database: {str(e)}"
1173
+ )
1174
+
1175
+ # ========================================================================
1176
+ # STEP 7: Return Success Response
1177
+ # ========================================================================
1178
+ print(f"\n[SYNC] ✅ Balance sync completed!")
1179
+ print(f" Before: {db_balance_before} tokens")
1180
+ print(f" After: {blockchain_balance_int} tokens")
1181
+ print(f" Adjusted: {tokens_adjusted:+d} tokens")
1182
+
1183
+ return {
1184
+ "success": True,
1185
+ "property_id": sync_request.property_id,
1186
+ "property_title": property_obj.get('title', 'Unknown'),
1187
+ "user_id": current_user['id'],
1188
+ "db_balance_before": db_balance_before,
1189
+ "blockchain_balance": blockchain_balance,
1190
+ "db_balance_after": blockchain_balance_int,
1191
+ "tokens_adjusted": tokens_adjusted,
1192
+ "was_out_of_sync": True,
1193
+ "sync_action": sync_action,
1194
+ "message": f"Balance synced successfully. Adjusted {tokens_adjusted:+d} tokens to match blockchain."
1195
+ }
1196
+
1197
+
1198
+ @router.get("/market/history", response_model=schemas.MarketHistoryResponse)
1199
+ @limiter.limit("30/hour")
1200
+ def get_market_history(
1201
+ request: Request,
1202
+ transaction_type: Optional[str] = None,
1203
+ status: Optional[str] = None,
1204
+ limit: int = 100,
1205
+ db=Depends(get_mongo),
1206
+ current_user=Depends(get_current_user)
1207
+ ):
1208
+ """
1209
+ Get user's secondary market transaction history
1210
+ Shows all sell/buy transactions in the secondary market
1211
+ Rate Limited: 30 requests per hour
1212
+ """
1213
+ print(f"\n[MARKET HISTORY] Fetching history for user {current_user['email']}")
1214
+
1215
+ transactions = repo.get_user_market_history(
1216
+ db,
1217
+ current_user['id'],
1218
+ transaction_type=transaction_type,
1219
+ status=status,
1220
+ limit=limit
1221
+ )
1222
+
1223
+ # Calculate stats
1224
+ total_transactions = len(transactions)
1225
+ total_sold = sum(
1226
+ tx['tokens_amount']
1227
+ for tx in transactions
1228
+ if tx.get('transaction_type') == 'sell_to_admin' and tx.get('status') == 'completed'
1229
+ )
1230
+ total_earned = sum(
1231
+ tx['total_amount']
1232
+ for tx in transactions
1233
+ if tx.get('transaction_type') == 'sell_to_admin' and tx.get('status') == 'completed'
1234
+ )
1235
+
1236
+ print(f"[MARKET HISTORY] Found {total_transactions} transactions")
1237
+ print(f" Total Sold: {total_sold} tokens")
1238
+ print(f" Total Earned: {total_earned} AED")
1239
+
1240
+ return {
1241
+ "total_transactions": total_transactions,
1242
+ "total_sold": total_sold,
1243
+ "total_earned": total_earned,
1244
+ "transactions": transactions
1245
+ }
1246
+
1247
+
1248
+ @router.get("/market/property-history/{property_id}", response_model=schemas.MarketHistoryResponse)
1249
+ @limiter.limit("30/hour")
1250
+ def get_property_market_history(
1251
+ request: Request,
1252
+ property_id: str,
1253
+ status: Optional[str] = None,
1254
+ limit: int = 100,
1255
+ db=Depends(get_mongo),
1256
+ current_user=Depends(get_current_user)
1257
+ ):
1258
+ """
1259
+ Get property's secondary market transaction history
1260
+ Shows all trading activity for a specific property
1261
+ Rate Limited: 30 requests per hour
1262
+ """
1263
+ print(f"\n[PROPERTY MARKET HISTORY] Fetching history for property {property_id}")
1264
+
1265
+ # Verify property exists
1266
+ property_obj = repo.get_property_by_id(db, property_id)
1267
+ if not property_obj:
1268
+ raise HTTPException(status_code=404, detail="Property not found")
1269
+
1270
+ transactions = repo.get_property_market_history(
1271
+ db,
1272
+ property_id,
1273
+ status=status,
1274
+ limit=limit
1275
+ )
1276
+
1277
+ # Calculate stats
1278
+ total_transactions = len(transactions)
1279
+ total_sold = sum(tx['tokens_amount'] for tx in transactions if tx.get('status') == 'completed')
1280
+ total_volume = sum(tx['total_amount'] for tx in transactions if tx.get('status') == 'completed')
1281
+
1282
+ print(f"[PROPERTY MARKET HISTORY] Found {total_transactions} transactions")
1283
+ print(f" Total Tokens Traded: {total_sold}")
1284
+ print(f" Total Volume: {total_volume} AED")
1285
+
1286
+ return {
1287
+ "total_transactions": total_transactions,
1288
+ "total_sold": total_sold,
1289
+ "total_earned": total_volume,
1290
+ "transactions": transactions
1291
+ }
1292
+
1293
+
routes/otp.py ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ OTP Routes for Email Verification
3
+ Handles OTP generation, sending, and verification
4
+ """
5
+ from fastapi import APIRouter, Depends, HTTPException, status, Request
6
+ from pydantic import BaseModel, EmailStr
7
+ from typing import Optional
8
+ import logging
9
+
10
+ from db import get_mongo
11
+ import repo
12
+ import repo_otp
13
+ from utils.email_service import generate_otp, send_otp_email, get_otp_expiry
14
+ from middleware.security import limiter, sanitize_input
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ router = APIRouter()
19
+
20
+
21
+ # ============================================================================
22
+ # REQUEST/RESPONSE MODELS
23
+ # ============================================================================
24
+
25
+ class SendOTPRequest(BaseModel):
26
+ email: EmailStr
27
+ purpose: str # 'login' or 'registration'
28
+
29
+
30
+ class VerifyOTPRequest(BaseModel):
31
+ email: EmailStr
32
+ otp: str
33
+ purpose: str # 'login' or 'registration'
34
+
35
+
36
+ class OTPResponse(BaseModel):
37
+ success: bool
38
+ message: str
39
+ expires_in_minutes: Optional[int] = None
40
+
41
+
42
+ # ============================================================================
43
+ # OTP ENDPOINTS
44
+ # ============================================================================
45
+
46
+ @router.post("/send-otp", response_model=OTPResponse)
47
+ @limiter.limit("3/minute")
48
+ async def send_otp(request: Request, data: SendOTPRequest, db=Depends(get_mongo)):
49
+ """
50
+ Send OTP to user's email
51
+
52
+ Rate limited to 3 requests per minute per IP
53
+
54
+ Purpose:
55
+ - 'registration': Send OTP during registration
56
+ - 'login': Send OTP during login
57
+ """
58
+ logger.info(f"\n[OTP] Send OTP request for: {data.email}, purpose: {data.purpose}")
59
+
60
+ # Sanitize input
61
+ email = sanitize_input(data.email.lower())
62
+ purpose = sanitize_input(data.purpose.lower())
63
+
64
+ # Validate purpose
65
+ if purpose not in ['login', 'registration']:
66
+ raise HTTPException(
67
+ status_code=status.HTTP_400_BAD_REQUEST,
68
+ detail="Invalid purpose. Must be 'login' or 'registration'"
69
+ )
70
+
71
+ # For registration: Check if user already exists
72
+ if purpose == 'registration':
73
+ existing_user = repo.get_user_by_email(db, email)
74
+ if existing_user:
75
+ logger.warning(f"[OTP] [ERROR] User already exists: {email}")
76
+ raise HTTPException(
77
+ status_code=status.HTTP_400_BAD_REQUEST,
78
+ detail="Email already registered. Please login instead."
79
+ )
80
+
81
+ # For login: Check if user exists
82
+ if purpose == 'login':
83
+ user = repo.get_user_by_email(db, email)
84
+ if not user:
85
+ logger.warning(f"[OTP] [ERROR] User not found: {email}")
86
+ raise HTTPException(
87
+ status_code=status.HTTP_404_NOT_FOUND,
88
+ detail="Email not registered. Please sign up first."
89
+ )
90
+
91
+ # Check if user is active
92
+ if not user.get('is_active'):
93
+ logger.warning(f"[OTP] [ERROR] Inactive user: {email}")
94
+ raise HTTPException(
95
+ status_code=status.HTTP_400_BAD_REQUEST,
96
+ detail="Account is inactive. Please contact support."
97
+ )
98
+
99
+ # Generate OTP
100
+ otp = generate_otp()
101
+ expires_at = get_otp_expiry()
102
+
103
+ logger.info(f"[OTP] Generated OTP for {email}, purpose: {purpose} (expires at {expires_at})")
104
+
105
+ # Print OTP to console for testing/debugging
106
+ print("\n" + "="*80)
107
+ print(f"[LOCK] OTP CODE FOR: {email}")
108
+ print(f" Purpose: {purpose}")
109
+ print(f" OTP: {otp}")
110
+ print(f" Expires: {expires_at}")
111
+ print("="*80 + "\n")
112
+
113
+ # Log OTP to file for agent access
114
+ logger.info(f"[AGENT_ACCESS] OTP CODE: {otp}")
115
+
116
+ # Save OTP to database
117
+ repo_otp.create_otp(db, email, otp, purpose, expires_at)
118
+
119
+ # Send OTP via email
120
+ email_sent = send_otp_email(email, otp, purpose)
121
+
122
+ if not email_sent:
123
+ logger.error(f"[OTP] [ERROR] Failed to send email to {email}")
124
+ logger.error(f"[OTP] [WARNING] Gmail App Password not configured!")
125
+ logger.error(f"[OTP] Run: python backend/setup_gmail.py to configure")
126
+ raise HTTPException(
127
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
128
+ detail="Failed to send OTP email. Gmail App Password not configured. Please contact administrator."
129
+ )
130
+
131
+ logger.info(f"[OTP] [SUCCESS] OTP sent successfully to {email}\n")
132
+
133
+ return OTPResponse(
134
+ success=True,
135
+ message=f"OTP sent to {email}. Please check your inbox.",
136
+ expires_in_minutes=10
137
+ )
138
+
139
+
140
+ @router.post("/verify-otp", response_model=OTPResponse)
141
+ @limiter.limit("10/minute")
142
+ async def verify_otp(request: Request, data: VerifyOTPRequest, db=Depends(get_mongo)):
143
+ """
144
+ Verify OTP code
145
+
146
+ Rate limited to 10 requests per minute per IP
147
+
148
+ Returns success if OTP is valid and not expired
149
+ """
150
+ logger.info(f"\n[OTP] Verify OTP request for: {data.email}, purpose: {data.purpose}")
151
+
152
+ # Sanitize input
153
+ email = sanitize_input(data.email.lower())
154
+ otp = sanitize_input(data.otp.strip())
155
+ purpose = sanitize_input(data.purpose.lower())
156
+
157
+ # Validate purpose
158
+ if purpose not in ['login', 'registration']:
159
+ raise HTTPException(
160
+ status_code=status.HTTP_400_BAD_REQUEST,
161
+ detail="Invalid purpose. Must be 'login' or 'registration'"
162
+ )
163
+
164
+ # Validate OTP format (6 digits)
165
+ if not otp.isdigit() or len(otp) != 6:
166
+ raise HTTPException(
167
+ status_code=status.HTTP_400_BAD_REQUEST,
168
+ detail="Invalid OTP format. Must be 6 digits."
169
+ )
170
+
171
+ # Verify OTP
172
+ success, message = repo_otp.verify_otp(db, email, otp, purpose)
173
+
174
+ if not success:
175
+ logger.warning(f"[OTP] [ERROR] Verification failed for {email}: {message}")
176
+ raise HTTPException(
177
+ status_code=status.HTTP_400_BAD_REQUEST,
178
+ detail=message
179
+ )
180
+
181
+ logger.info(f"[OTP] [SUCCESS] OTP verified successfully for {email}\n")
182
+
183
+ return OTPResponse(
184
+ success=True,
185
+ message=message
186
+ )
187
+
188
+
189
+ @router.post("/resend-otp", response_model=OTPResponse)
190
+ @limiter.limit("2/minute")
191
+ async def resend_otp(request: Request, data: SendOTPRequest, db=Depends(get_mongo)):
192
+ """
193
+ Resend OTP to user's email
194
+
195
+ Rate limited to 2 requests per minute per IP (stricter than send-otp)
196
+ """
197
+ logger.info(f"\n[OTP] Resend OTP request for: {data.email}, purpose: {data.purpose}")
198
+
199
+ # Delete existing OTP
200
+ repo_otp.delete_otp(db, data.email.lower(), data.purpose.lower())
201
+
202
+ # Use the same logic as send_otp
203
+ return await send_otp(request, data, db)
routes/portfolio.py ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Production Portfolio Routes
3
+ Handles user investment portfolio and analytics with real-time blockchain verification
4
+ """
5
+ from fastapi import APIRouter, Depends, HTTPException, status
6
+
7
+ import schemas
8
+ from db import get_mongo
9
+ import repo
10
+ from routes.auth import get_current_user
11
+ from services.xrp_service import XRPLService
12
+ from config import settings
13
+ from utils.cache import cache, get_cached_token_balance, cache_token_balance
14
+
15
+ router = APIRouter()
16
+ xrpl_service = XRPLService()
17
+
18
+
19
+ @router.get("/portfolio", response_model=schemas.PortfolioResponse)
20
+ def get_portfolio(
21
+ db=Depends(get_mongo),
22
+ current_user=Depends(get_current_user)
23
+ ):
24
+ """
25
+ Get user's complete investment portfolio
26
+ Includes all properties, tokens owned, and financial analytics
27
+ OPTIMIZED: Uses aggregation pipeline to fetch all data in single query
28
+ """
29
+ print(f"\n[PORTFOLIO] Fetching portfolio for user: {current_user['email']}")
30
+
31
+ # Get or create portfolio
32
+ portfolio = repo.create_or_update_portfolio(db, current_user['id'])
33
+
34
+ # Get all user investments with related data in ONE aggregation query
35
+ portfolio_data = repo.get_user_portfolio_optimized(db, current_user['id'])
36
+
37
+ print(f"[PORTFOLIO] Found {len(portfolio_data)} investments")
38
+
39
+ # Build portfolio items from aggregated data
40
+ portfolio_items = []
41
+
42
+ for data in portfolio_data:
43
+ # Extract data from aggregation result
44
+ investment = {
45
+ 'id': data.get('id'),
46
+ 'tokens_purchased': data.get('tokens_purchased', 0),
47
+ 'amount': data.get('amount', 0),
48
+ 'status': data.get('status'),
49
+ 'created_at': data.get('created_at'),
50
+ 'property_id': data.get('property_id')
51
+ }
52
+ property_obj = data.get('property', {})
53
+ spec = data.get('specifications')
54
+ amenities = data.get('amenities', [])
55
+ images = data.get('images', [])
56
+ token_rec = data.get('tokens')
57
+
58
+ if not property_obj:
59
+ print(f"[PORTFOLIO] ⚠ Property not found in aggregation result")
60
+ continue
61
+
62
+
63
+ # Calculate current value and profit/loss
64
+ tokens_owned = investment.get('tokens_purchased', 0)
65
+ investment_amount = investment.get('amount', 0)
66
+ current_token_price = property_obj.get('token_price', 0)
67
+ current_value = tokens_owned * current_token_price
68
+ profit_loss = current_value - investment_amount
69
+ ownership_percentage = (tokens_owned / property_obj.get('total_tokens', 1)) * 100
70
+
71
+ # Ensure all required PropertyOut fields have default values
72
+ if 'property_type' not in property_obj or not property_obj.get('property_type'):
73
+ property_obj['property_type'] = 'Residential' # Default type
74
+ if 'funded_date' not in property_obj:
75
+ property_obj['funded_date'] = None
76
+
77
+ # Build property output
78
+ property_out = schemas.PropertyOut(
79
+ **property_obj,
80
+ specifications=schemas.PropertySpecificationOut(**spec) if spec else None,
81
+ amenities=[schemas.AmenityOut(**a) for a in amenities] if amenities else None,
82
+ images=[schemas.PropertyImageOut(**img) for img in images] if images else None
83
+ )
84
+
85
+ # Get blockchain verification for IOU tokens
86
+ blockchain_balance = 0.0
87
+ blockchain_verified = False
88
+
89
+ try:
90
+ # Get user's wallet to check blockchain balance (cached query)
91
+ user_wallet = repo.get_wallet_by_user(db, current_user['id'])
92
+
93
+ if user_wallet and token_rec:
94
+ xrp_address = user_wallet.get('xrp_address')
95
+ currency_code = token_rec.get('currency_code') or settings.IOU_TOKEN_CURRENCY
96
+ issuer_address = token_rec.get('issuer_address')
97
+
98
+ if xrp_address and currency_code and issuer_address:
99
+ # Try cached token balance first (5min TTL)
100
+ blockchain_balance = get_cached_token_balance(xrp_address, currency_code, issuer_address)
101
+
102
+ if blockchain_balance is None:
103
+ # Cache miss - fetch from blockchain
104
+ balance_val = xrpl_service.get_user_token_balance(
105
+ xrp_address,
106
+ currency_code,
107
+ issuer_address,
108
+ )
109
+ blockchain_balance = float(balance_val or 0.0)
110
+ # Cache for 5 minutes
111
+ cache_token_balance(xrp_address, currency_code, issuer_address, blockchain_balance, ttl=300)
112
+ print(f"[PORTFOLIO] [SUCCESS] Blockchain verified (live): {blockchain_balance} {currency_code}")
113
+ else:
114
+ print(f"[PORTFOLIO] [SUCCESS] Blockchain verified (cached): {blockchain_balance} {currency_code}")
115
+
116
+ blockchain_verified = True
117
+ except Exception as e:
118
+ print(f"[PORTFOLIO] ⚠ Blockchain verification error: {e}")
119
+
120
+ # Compare database vs blockchain balance
121
+ balance_mismatch = blockchain_verified and abs(tokens_owned - blockchain_balance) > 0.001
122
+
123
+ # Convert purchase_date to string if it's a datetime object
124
+ purchase_date = investment.get('created_at')
125
+ if purchase_date and hasattr(purchase_date, 'isoformat'):
126
+ purchase_date = purchase_date.isoformat()
127
+
128
+ # Get rent summary for this property
129
+ rent_summary = None
130
+ try:
131
+ rent_data = repo.get_user_rent_summary(db, current_user['id'], property_obj['id'])
132
+ if rent_data and rent_data.get('total_rent_received', 0) > 0:
133
+ # Calculate annual rental yield if possible
134
+ annual_yield = None
135
+ if investment_amount > 0 and rent_data.get('total_rent_received'):
136
+ # Estimate annual yield based on total rent received and payment count
137
+ num_payments = rent_data.get('total_rent_payments', 1)
138
+ if num_payments > 0:
139
+ avg_monthly = rent_data['total_rent_received'] / num_payments
140
+ annual_rent = avg_monthly * 12
141
+ annual_yield = (annual_rent / investment_amount) * 100
142
+
143
+ rent_summary = {
144
+ "property_id": property_obj['id'],
145
+ "property_title": property_obj.get('title', ''),
146
+ "total_tokens_owned": tokens_owned,
147
+ "total_rent_received": rent_data.get('total_rent_received', 0.0),
148
+ "total_rent_payments": rent_data.get('total_rent_payments', 0),
149
+ "last_rent_amount": rent_data.get('last_rent_amount'),
150
+ "last_rent_date": rent_data.get('last_rent_date').isoformat() if rent_data.get('last_rent_date') and hasattr(rent_data.get('last_rent_date'), 'isoformat') else None,
151
+ "average_monthly_rent": rent_data.get('average_monthly_rent'),
152
+ "annual_rental_yield": annual_yield
153
+ }
154
+ print(f"[PORTFOLIO] → Rent received: {rent_data.get('total_rent_received', 0):.2f} AED ({rent_data.get('total_rent_payments', 0)} payments)")
155
+ except Exception as e:
156
+ print(f"[PORTFOLIO] ⚠ Error fetching rent data: {e}")
157
+
158
+ # Build portfolio item with blockchain verification
159
+ item = schemas.PortfolioItemOut(
160
+ property=property_out,
161
+ tokens_owned=tokens_owned,
162
+ token_balance=tokens_owned, # Frontend compatibility
163
+ blockchain_balance=blockchain_balance, # Real blockchain balance
164
+ investment_amount=investment_amount,
165
+ current_value=current_value,
166
+ profit_loss=profit_loss,
167
+ ownership_percentage=ownership_percentage,
168
+ purchase_date=purchase_date,
169
+ purchase_price_per_token=investment_amount / tokens_owned if tokens_owned > 0 else 0,
170
+ failed_transactions_count=0,
171
+ rent_data=rent_summary, # Add rent data
172
+ # Add blockchain verification metadata
173
+ blockchain_verified=blockchain_verified,
174
+ balance_mismatch=balance_mismatch
175
+ )
176
+
177
+ portfolio_items.append(item)
178
+
179
+ print(f"[PORTFOLIO] - {property_obj['title']}: {tokens_owned} tokens, "
180
+ f"Blockchain: {blockchain_balance} ({'[SUCCESS]' if blockchain_verified else '✗'}), "
181
+ f"Value: {current_value:.2f} AED, P/L: {profit_loss:.2f} AED"
182
+ f"{' [MISMATCH]' if balance_mismatch else ''}")
183
+
184
+ print(f"[PORTFOLIO] [SUCCESS] Portfolio summary:")
185
+ print(f"[PORTFOLIO] Total invested: {portfolio['total_invested']:.2f} AED")
186
+ print(f"[PORTFOLIO] Current value: {portfolio['total_current_value']:.2f} AED")
187
+ print(f"[PORTFOLIO] Total profit: {portfolio['total_profit']:.2f} AED\n")
188
+
189
+ return schemas.PortfolioResponse(
190
+ portfolio=schemas.PortfolioOut(**portfolio),
191
+ items=portfolio_items,
192
+ total_properties=len(portfolio_items)
193
+ )
194
+
195
+
196
+ @router.get("/portfolio/summary")
197
+ def get_portfolio_summary(
198
+ db=Depends(get_mongo),
199
+ current_user=Depends(get_current_user)
200
+ ):
201
+ """Get quick portfolio summary without full details"""
202
+ print(f"\n[PORTFOLIO] Fetching summary for user: {current_user['email']}")
203
+
204
+ portfolio = repo.create_or_update_portfolio(db, current_user['id'])
205
+ investments = repo.get_user_investments(db, current_user['id'])
206
+
207
+ # Count confirmed investments only
208
+ confirmed_count = sum(1 for inv in investments if inv.get('status') == 'confirmed')
209
+
210
+ summary = {
211
+ "total_invested": portfolio.get('total_invested', 0),
212
+ "total_current_value": portfolio.get('total_current_value', 0),
213
+ "total_profit": portfolio.get('total_profit', 0),
214
+ "profit_percentage": (portfolio.get('total_profit', 0) / portfolio.get('total_invested', 1)) * 100 if portfolio.get('total_invested', 0) > 0 else 0,
215
+ "total_properties": confirmed_count,
216
+ "total_investments": len(investments)
217
+ }
218
+
219
+ print(f"[PORTFOLIO] [SUCCESS] Summary: {confirmed_count} properties, "
220
+ f"{summary['total_invested']:.2f} AED invested\n")
221
+
222
+ return summary
223
+
224
+
225
+ @router.get("/portfolio/rent-payments/{property_id}")
226
+ def get_property_rent_payments(
227
+ property_id: str,
228
+ db=Depends(get_mongo),
229
+ current_user=Depends(get_current_user)
230
+ ):
231
+ """
232
+ Get all rent payments for a specific property for the current user
233
+ """
234
+ try:
235
+ # Get rent payments for this user and property
236
+ rent_payments = repo.get_user_rent_payments(db, current_user['id'], property_id)
237
+ return rent_payments
238
+ except Exception as e:
239
+ print(f"[PORTFOLIO] Error fetching rent payments: {e}")
240
+ raise HTTPException(
241
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
242
+ detail="Failed to fetch rent payment history"
243
+ )
routes/profile.py ADDED
@@ -0,0 +1,772 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Profile Management Routes
3
+ Handles user profile operations including image upload, personal details CRUD, and KYC documents
4
+ Security: All endpoints require authentication, sensitive data is masked
5
+ """
6
+ from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, Request
7
+ from typing import Optional, List
8
+ import base64
9
+ from datetime import datetime
10
+ import logging
11
+
12
+ import schemas
13
+ from db import get_mongo
14
+ import repo
15
+ from routes.auth import get_current_user
16
+ from middleware.security import (
17
+ sanitize_input, mask_email, mask_phone, mask_sensitive_data,
18
+ validate_name, validate_phone, validate_date, validate_gender, validate_address,
19
+ limiter
20
+ )
21
+
22
+ router = APIRouter()
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+
27
+ # ============================================================================
28
+ # PROFILE IMAGE OPERATIONS
29
+ # ============================================================================
30
+
31
+ def profile_images_col(db):
32
+ """Get profile images collection"""
33
+ return db.profile_images
34
+
35
+
36
+ @router.post("/upload-image", status_code=status.HTTP_200_OK)
37
+ @limiter.limit("10/minute")
38
+ async def upload_profile_image(
39
+ request: Request,
40
+ file: UploadFile = File(...),
41
+ current_user: dict = Depends(get_current_user),
42
+ db=Depends(get_mongo)
43
+ ):
44
+ """
45
+ Upload profile image (max 10KB)
46
+ Stores image as base64 in separate collection
47
+ """
48
+ logger.info(f"Profile image upload request from user: {mask_email(current_user['email'])}")
49
+
50
+ # Read file content
51
+ content = await file.read()
52
+ file_size = len(content)
53
+
54
+ # Validate file size (10KB = 10240 bytes)
55
+ if file_size > 10240:
56
+ logger.warning(f"Profile image too large: {file_size} bytes")
57
+ raise HTTPException(
58
+ status_code=status.HTTP_400_BAD_REQUEST,
59
+ detail=f"File size ({file_size} bytes) exceeds 10KB limit"
60
+ )
61
+
62
+ # Validate file type
63
+ if not file.content_type or not file.content_type.startswith('image/'):
64
+ logger.warning(f"Invalid profile image type: {file.content_type}")
65
+ raise HTTPException(
66
+ status_code=status.HTTP_400_BAD_REQUEST,
67
+ detail="Only image files are allowed"
68
+ )
69
+
70
+ # Convert to base64
71
+ base64_image = base64.b64encode(content).decode('utf-8')
72
+ image_data_url = f"data:{file.content_type};base64,{base64_image}"
73
+
74
+ # Store in profile_images collection
75
+ now = datetime.utcnow()
76
+ profile_image_doc = {
77
+ "user_id": repo._to_object_id(current_user['id']),
78
+ "image_data": image_data_url,
79
+ "content_type": file.content_type,
80
+ "file_size": file_size,
81
+ "uploaded_at": now,
82
+ "updated_at": now
83
+ }
84
+
85
+ # Upsert (update if exists, insert if not)
86
+ profile_images_col(db).update_one(
87
+ {"user_id": repo._to_object_id(current_user['id'])},
88
+ {"$set": profile_image_doc},
89
+ upsert=True
90
+ )
91
+
92
+ logger.info(f"Profile image uploaded successfully: {file_size} bytes")
93
+
94
+ return {
95
+ "success": True,
96
+ "message": "Profile image uploaded successfully",
97
+ "image_url": image_data_url,
98
+ "file_size": file_size
99
+ }
100
+
101
+
102
+ @router.get("/image")
103
+ def get_profile_image(
104
+ current_user: dict = Depends(get_current_user),
105
+ db=Depends(get_mongo)
106
+ ):
107
+ """Get user's profile image"""
108
+ profile_image = profile_images_col(db).find_one(
109
+ {"user_id": repo._to_object_id(current_user['id'])}
110
+ )
111
+
112
+ if not profile_image:
113
+ return {"image_url": None}
114
+
115
+ return {
116
+ "image_url": profile_image.get("image_data"),
117
+ "file_size": profile_image.get("file_size"),
118
+ "uploaded_at": profile_image.get("uploaded_at")
119
+ }
120
+
121
+
122
+ @router.get("/image/user/{user_id}")
123
+ @limiter.limit("30/minute")
124
+ def get_user_profile_image(
125
+ request: Request,
126
+ user_id: str,
127
+ db=Depends(get_mongo),
128
+ current_user: dict = Depends(get_current_user)
129
+ ):
130
+ """
131
+ Get any user's profile image by user ID
132
+ Requires authentication - users can only view profile images if logged in
133
+ Rate limited to 30 requests per minute per IP to prevent abuse
134
+ """
135
+ try:
136
+ profile_image = profile_images_col(db).find_one(
137
+ {"user_id": repo._to_object_id(user_id)}
138
+ )
139
+
140
+ if not profile_image:
141
+ return {"image_url": None}
142
+
143
+ return {
144
+ "image_url": profile_image.get("image_data"),
145
+ "file_size": profile_image.get("file_size"),
146
+ "uploaded_at": profile_image.get("uploaded_at")
147
+ }
148
+ except Exception as e:
149
+ logger.error(f"Error fetching user profile image: {e}")
150
+ return {"image_url": None}
151
+
152
+
153
+ @router.delete("/image")
154
+ @limiter.limit("5/minute")
155
+ def delete_profile_image(
156
+ request: Request,
157
+ current_user: dict = Depends(get_current_user),
158
+ db=Depends(get_mongo)
159
+ ):
160
+ """Delete user's profile image"""
161
+ logger.info(f"Deleting profile image for user: {mask_email(current_user['email'])}")
162
+
163
+ result = profile_images_col(db).delete_one(
164
+ {"user_id": repo._to_object_id(current_user['id'])}
165
+ )
166
+
167
+ if result.deleted_count == 0:
168
+ raise HTTPException(
169
+ status_code=status.HTTP_404_NOT_FOUND,
170
+ detail="No profile image found"
171
+ )
172
+
173
+ logger.info("Profile image deleted successfully")
174
+
175
+ return {"success": True, "message": "Profile image deleted successfully"}
176
+
177
+
178
+ # ============================================================================
179
+ # PROFILE DETAILS OPERATIONS
180
+ # ============================================================================
181
+
182
+ @router.get("/details", response_model=schemas.UserOut)
183
+ def get_profile_details(
184
+ current_user: dict = Depends(get_current_user),
185
+ db=Depends(get_mongo),
186
+ mask_data: bool = True,
187
+ show_full_data: bool = False
188
+ ):
189
+ """
190
+ Get complete user profile details
191
+ Returns masked sensitive data by default
192
+ Set show_full_data=True only for specific authorized operations
193
+ """
194
+ # Get fresh user data
195
+ user = repo.get_user_by_id(db, current_user['id'])
196
+
197
+ if not user:
198
+ raise HTTPException(
199
+ status_code=status.HTTP_404_NOT_FOUND,
200
+ detail="User not found"
201
+ )
202
+
203
+ # Get wallet information
204
+ wallet = repo.get_wallet_by_user(db, user['id'])
205
+ if wallet:
206
+ user['aed_balance'] = wallet.get('balance', 0.0)
207
+ user['xrp_address'] = wallet.get('xrp_address')
208
+ user['xrp_balance'] = wallet.get('xrp_balance', 0.0)
209
+
210
+ # Apply masking unless explicitly requested not to
211
+ if mask_data and not show_full_data:
212
+ # Mask email
213
+ if user.get('email'):
214
+ user['email_masked'] = mask_email(user['email'])
215
+
216
+ # Mask phone
217
+ if user.get('phone'):
218
+ user['phone_masked'] = mask_phone(user['phone'])
219
+ user['phone'] = user['phone_masked'] # Replace original with masked
220
+
221
+ # Mask wallet address
222
+ if user.get('xrp_address'):
223
+ user['xrp_address_masked'] = mask_sensitive_data(user['xrp_address'], visible_chars=8)
224
+ user['xrp_address'] = user['xrp_address_masked'] # Replace with masked
225
+
226
+ # Mask date of birth (show only year)
227
+ if user.get('date_of_birth'):
228
+ dob = str(user['date_of_birth'])
229
+ if len(dob) >= 4:
230
+ user['date_of_birth'] = f"****-**-** ({dob[:4]})"
231
+
232
+ # Mask address (show only city/state)
233
+ if user.get('address'):
234
+ address_parts = user['address'].split(',')
235
+ if len(address_parts) > 1:
236
+ user['address'] = f"****, {address_parts[-1].strip()}"
237
+ else:
238
+ user['address'] = "****"
239
+
240
+ return schemas.UserOut(**user)
241
+
242
+
243
+ @router.put("/details", response_model=schemas.UserOut)
244
+ @limiter.limit("10/minute")
245
+ def update_profile_details(
246
+ request: Request,
247
+ profile_data: schemas.UserUpdate,
248
+ current_user: dict = Depends(get_current_user),
249
+ db=Depends(get_mongo)
250
+ ):
251
+ """Update user profile details with comprehensive input validation"""
252
+ logger.info(f"Updating profile for user: {mask_email(current_user['email'])}")
253
+
254
+ # Prepare update fields with validation
255
+ update_fields = {}
256
+
257
+ # Validate name (required)
258
+ if profile_data.name is not None:
259
+ is_valid, result = validate_name(profile_data.name)
260
+ if not is_valid:
261
+ raise HTTPException(
262
+ status_code=status.HTTP_400_BAD_REQUEST,
263
+ detail=result
264
+ )
265
+ update_fields['name'] = result
266
+
267
+ # Validate phone
268
+ if profile_data.phone is not None:
269
+ is_valid, result = validate_phone(profile_data.phone)
270
+ if not is_valid:
271
+ raise HTTPException(
272
+ status_code=status.HTTP_400_BAD_REQUEST,
273
+ detail=result
274
+ )
275
+ update_fields['phone'] = result if result else None
276
+
277
+ # Validate date of birth (optional)
278
+ if profile_data.date_of_birth is not None:
279
+ is_valid, result = validate_date(str(profile_data.date_of_birth))
280
+ if not is_valid:
281
+ raise HTTPException(
282
+ status_code=status.HTTP_400_BAD_REQUEST,
283
+ detail=result
284
+ )
285
+ update_fields['date_of_birth'] = result
286
+
287
+ # Validate gender (optional)
288
+ if profile_data.gender is not None:
289
+ is_valid, result = validate_gender(profile_data.gender)
290
+ if not is_valid:
291
+ raise HTTPException(
292
+ status_code=status.HTTP_400_BAD_REQUEST,
293
+ detail=result
294
+ )
295
+ update_fields['gender'] = result
296
+
297
+ # Validate address (optional)
298
+ if profile_data.address is not None:
299
+ is_valid, result = validate_address(profile_data.address)
300
+ if not is_valid:
301
+ raise HTTPException(
302
+ status_code=status.HTTP_400_BAD_REQUEST,
303
+ detail=result
304
+ )
305
+ update_fields['address'] = result if result else None
306
+
307
+ # Add country, state, city fields (optional, no special validation needed)
308
+ if profile_data.country is not None:
309
+ update_fields['country'] = sanitize_input(profile_data.country) if profile_data.country else None
310
+
311
+ if profile_data.state is not None:
312
+ update_fields['state'] = sanitize_input(profile_data.state) if profile_data.state else None
313
+
314
+ if profile_data.city is not None:
315
+ update_fields['city'] = sanitize_input(profile_data.city) if profile_data.city else None
316
+
317
+ # Add employment fields (optional)
318
+ if profile_data.employment_status is not None:
319
+ update_fields['employment_status'] = sanitize_input(profile_data.employment_status) if profile_data.employment_status else None
320
+
321
+ if profile_data.employment_details is not None:
322
+ update_fields['employment_details'] = sanitize_input(profile_data.employment_details) if profile_data.employment_details else None
323
+
324
+ if not update_fields:
325
+ raise HTTPException(
326
+ status_code=status.HTTP_400_BAD_REQUEST,
327
+ detail="No valid fields to update"
328
+ )
329
+
330
+ logger.debug(f"Validated update fields: {list(update_fields.keys())}")
331
+
332
+ # Update user
333
+ updated_user = repo.update_user(db, current_user['id'], update_fields)
334
+
335
+ if not updated_user:
336
+ raise HTTPException(
337
+ status_code=status.HTTP_404_NOT_FOUND,
338
+ detail="User not found"
339
+ )
340
+
341
+ # Get wallet information
342
+ wallet = repo.get_wallet_by_user(db, updated_user['id'])
343
+ if wallet:
344
+ updated_user['aed_balance'] = wallet.get('balance', 0.0)
345
+ updated_user['xrp_address'] = wallet.get('xrp_address')
346
+ updated_user['xrp_balance'] = wallet.get('xrp_balance', 0.0)
347
+
348
+ logger.info("Profile updated successfully")
349
+
350
+ return schemas.UserOut(**updated_user)
351
+
352
+
353
+ # ============================================================================
354
+ # PROFILE STATISTICS
355
+ # ============================================================================
356
+
357
+ @router.get("/stats")
358
+ def get_profile_stats(
359
+ current_user: dict = Depends(get_current_user),
360
+ db=Depends(get_mongo)
361
+ ):
362
+ """Get user profile statistics"""
363
+ user_id = current_user['id']
364
+
365
+ # Get investments
366
+ investments = repo.get_user_investments(db, user_id)
367
+
368
+ # Get transactions
369
+ transactions = repo.get_user_transactions(db, user_id, limit=1000)
370
+
371
+ # Calculate stats
372
+ total_invested = sum(inv.get('amount', 0) for inv in investments)
373
+ total_properties = len(set(inv.get('property_id') for inv in investments))
374
+ total_tokens = sum(inv.get('tokens_purchased', 0) for inv in investments)
375
+
376
+ # Calculate current value
377
+ total_current_value = 0
378
+ for inv in investments:
379
+ property_obj = repo.get_property_by_id(db, inv['property_id'])
380
+ if property_obj:
381
+ current_value = inv.get('tokens_purchased', 0) * property_obj.get('token_price', 0)
382
+ total_current_value += current_value
383
+
384
+ total_profit = total_current_value - total_invested
385
+ profit_percentage = (total_profit / total_invested * 100) if total_invested > 0 else 0
386
+
387
+ # Get wallet
388
+ wallet = repo.get_wallet_by_user(db, user_id)
389
+ wallet_balance = wallet.get('balance', 0.0) if wallet else 0.0
390
+
391
+ # Transaction counts
392
+ completed_transactions = len([t for t in transactions if t.get('status') == 'completed'])
393
+ pending_transactions = len([t for t in transactions if t.get('status') == 'pending'])
394
+
395
+ return {
396
+ "total_invested": total_invested,
397
+ "total_current_value": total_current_value,
398
+ "total_profit": total_profit,
399
+ "profit_percentage": round(profit_percentage, 2),
400
+ "total_properties": total_properties,
401
+ "total_tokens": total_tokens,
402
+ "wallet_balance": wallet_balance,
403
+ "total_transactions": len(transactions),
404
+ "completed_transactions": completed_transactions,
405
+ "pending_transactions": pending_transactions,
406
+ "member_since": current_user.get('created_at')
407
+ }
408
+
409
+
410
+ # ============================================================================
411
+ # KYC DOCUMENT OPERATIONS
412
+ # ============================================================================
413
+
414
+ def kyc_documents_col(db):
415
+ """Get KYC documents collection"""
416
+ return db.kyc_documents
417
+
418
+
419
+ @router.post("/kyc/upload", status_code=status.HTTP_200_OK)
420
+ @limiter.limit("5/minute")
421
+ async def upload_kyc_document(
422
+ request: Request,
423
+ file: UploadFile = File(...),
424
+ document_type: str = "identity",
425
+ current_user: dict = Depends(get_current_user),
426
+ db=Depends(get_mongo)
427
+ ):
428
+ """
429
+ Upload KYC document (max 500KB)
430
+ Document types: identity, address_proof, pan_card, aadhar_card, passport
431
+ """
432
+ logger.info(f"KYC document upload request from user: {mask_email(current_user['email'])}")
433
+
434
+ # Read file content
435
+ content = await file.read()
436
+ file_size = len(content)
437
+
438
+ # Validate file size (500KB = 512000 bytes)
439
+ if file_size > 512000:
440
+ logger.warning(f"KYC document too large: {file_size} bytes")
441
+ raise HTTPException(
442
+ status_code=status.HTTP_400_BAD_REQUEST,
443
+ detail=f"File size ({file_size} bytes) exceeds 500KB limit"
444
+ )
445
+
446
+ # Validate file type (images and PDFs)
447
+ allowed_types = ['image/', 'application/pdf']
448
+ if not file.content_type or not any(file.content_type.startswith(t) for t in allowed_types):
449
+ logger.warning(f"Invalid KYC document type: {file.content_type}")
450
+ raise HTTPException(
451
+ status_code=status.HTTP_400_BAD_REQUEST,
452
+ detail="Only image and PDF files are allowed"
453
+ )
454
+
455
+ # Convert to base64
456
+ base64_file = base64.b64encode(content).decode('utf-8')
457
+ file_data_url = f"data:{file.content_type};base64,{base64_file}"
458
+
459
+ # Store in kyc_documents collection
460
+ now = datetime.utcnow()
461
+ kyc_doc = {
462
+ "user_id": repo._to_object_id(current_user['id']),
463
+ "document_type": document_type,
464
+ "file_name": file.filename,
465
+ "file_data": file_data_url,
466
+ "content_type": file.content_type,
467
+ "file_size": file_size,
468
+ "status": "draft", # draft until /kyc/submit is called
469
+ "uploaded_at": now,
470
+ "updated_at": now
471
+ }
472
+
473
+ result = kyc_documents_col(db).insert_one(kyc_doc)
474
+ kyc_doc["_id"] = result.inserted_id
475
+
476
+ logger.info(f"KYC document uploaded successfully: {file_size} bytes")
477
+
478
+ return {
479
+ "success": True,
480
+ "message": "KYC document uploaded successfully",
481
+ "document_id": str(result.inserted_id),
482
+ "document_type": document_type,
483
+ "file_name": file.filename,
484
+ "file_size": file_size,
485
+ "status": "draft"
486
+ }
487
+
488
+
489
+ @router.get("/kyc/documents")
490
+ def get_kyc_documents(
491
+ current_user: dict = Depends(get_current_user),
492
+ db=Depends(get_mongo)
493
+ ):
494
+ """Get all KYC documents for user"""
495
+ docs = list(kyc_documents_col(db).find(
496
+ {"user_id": repo._to_object_id(current_user['id'])}
497
+ ).sort("uploaded_at", -1))
498
+
499
+ result = []
500
+ for doc in docs:
501
+ result.append({
502
+ "id": str(doc["_id"]),
503
+ "document_type": doc.get("document_type"),
504
+ "file_name": doc.get("file_name"),
505
+ "file_size": doc.get("file_size"),
506
+ "status": doc.get("status", "draft"),
507
+ "uploaded_at": doc.get("uploaded_at"),
508
+ "file_data": doc.get("file_data") # Include for preview
509
+ })
510
+
511
+ return result
512
+
513
+
514
+ @router.delete("/kyc/documents/{document_id}")
515
+ @limiter.limit("5/minute")
516
+ def delete_kyc_document(
517
+ request: Request,
518
+ document_id: str,
519
+ current_user: dict = Depends(get_current_user),
520
+ db=Depends(get_mongo)
521
+ ):
522
+ """Delete a KYC document"""
523
+ logger.info(f"Deleting KYC document {document_id}")
524
+
525
+ result = kyc_documents_col(db).delete_one({
526
+ "_id": repo._to_object_id(document_id),
527
+ "user_id": repo._to_object_id(current_user['id'])
528
+ })
529
+
530
+ if result.deleted_count == 0:
531
+ raise HTTPException(
532
+ status_code=status.HTTP_404_NOT_FOUND,
533
+ detail="Document not found"
534
+ )
535
+
536
+ logger.info("KYC document deleted successfully")
537
+
538
+ return {"success": True, "message": "Document deleted successfully"}
539
+
540
+
541
+ @router.get("/completion-status")
542
+ def get_profile_completion_status(
543
+ current_user: dict = Depends(get_current_user),
544
+ db=Depends(get_mongo)
545
+ ):
546
+ """Get profile completion status"""
547
+ user = repo.get_user_by_id(db, current_user['id'])
548
+
549
+ # Check profile image
550
+ profile_image = profile_images_col(db).find_one(
551
+ {"user_id": repo._to_object_id(current_user['id'])}
552
+ )
553
+
554
+ # Check KYC documents
555
+ kyc_docs = list(kyc_documents_col(db).find(
556
+ {"user_id": repo._to_object_id(current_user['id'])}
557
+ ))
558
+
559
+ # Calculate completion
560
+ completion_items = {
561
+ "personal_info": bool(user.get('name') and user.get('email') and user.get('phone')),
562
+ "profile_image": bool(profile_image),
563
+ "kyc_documents": len(kyc_docs) > 0,
564
+ "wallet_setup": bool(user.get('wallet_id')),
565
+ "email_verified": True # Assuming email is verified on registration
566
+ }
567
+
568
+ completed = sum(1 for v in completion_items.values() if v)
569
+ total = len(completion_items)
570
+ percentage = (completed / total) * 100
571
+
572
+ return {
573
+ "completion_percentage": round(percentage, 1),
574
+ "completed_items": completed,
575
+ "total_items": total,
576
+ "items": completion_items,
577
+ "kyc_documents_count": len(kyc_docs)
578
+ }
579
+
580
+
581
+ # ============================================================================
582
+ # NEW KYC ROUTES
583
+ # ============================================================================
584
+
585
+ @router.post("/kyc/submit", status_code=status.HTTP_200_OK)
586
+ @limiter.limit("3/hour")
587
+ async def submit_kyc(
588
+ request: Request,
589
+ full_name: str = Form(...),
590
+ date_of_birth: str = Form(...),
591
+ gender: str = Form(...),
592
+ address: str = Form(...),
593
+ country: str = Form(None),
594
+ state: str = Form(None),
595
+ city: str = Form(None),
596
+ file: UploadFile = File(None),
597
+ current_user: dict = Depends(get_current_user),
598
+ db=Depends(get_mongo)
599
+ ):
600
+ """
601
+ Submit KYC using either a newly attached file (multipart) or the latest uploaded draft.
602
+ - Max file size: 500KB
603
+ - Accepted types: images and PDF
604
+ """
605
+ logger.info(f"KYC submission from user: {mask_email(current_user['email'])}")
606
+
607
+ # Validate required fields
608
+ if not full_name or not full_name.strip():
609
+ raise HTTPException(
610
+ status_code=status.HTTP_400_BAD_REQUEST,
611
+ detail="Full name is required"
612
+ )
613
+ if not date_of_birth or not date_of_birth.strip():
614
+ raise HTTPException(
615
+ status_code=status.HTTP_400_BAD_REQUEST,
616
+ detail="Date of birth is required"
617
+ )
618
+ if not gender or not gender.strip():
619
+ raise HTTPException(
620
+ status_code=status.HTTP_400_BAD_REQUEST,
621
+ detail="Gender is required"
622
+ )
623
+ if not address or not address.strip():
624
+ raise HTTPException(
625
+ status_code=status.HTTP_400_BAD_REQUEST,
626
+ detail="Address is required"
627
+ )
628
+
629
+ now = datetime.utcnow()
630
+ user_oid = repo._to_object_id(current_user['id'])
631
+
632
+ document_url = None
633
+ content_type = None
634
+ file_size = None
635
+ kyc_doc_id = None
636
+
637
+ if file is not None:
638
+ # Read and validate attached file
639
+ content = await file.read()
640
+ file_size = len(content)
641
+ if file_size > 512000:
642
+ print(f"[KYC] [ERROR] File too large: {file_size} bytes")
643
+ raise HTTPException(
644
+ status_code=status.HTTP_400_BAD_REQUEST,
645
+ detail=f"File size ({file_size} bytes) exceeds 500KB limit"
646
+ )
647
+ allowed_types_prefix = ['image/', 'application/pdf']
648
+ if not file.content_type or not any(file.content_type.startswith(t) for t in allowed_types_prefix):
649
+ print(f"[KYC] [ERROR] Invalid file type: {file.content_type}")
650
+ raise HTTPException(
651
+ status_code=status.HTTP_400_BAD_REQUEST,
652
+ detail="Only image and PDF files are allowed"
653
+ )
654
+ # Convert to data URL
655
+ base64_file = base64.b64encode(content).decode('utf-8')
656
+ document_url = f"data:{file.content_type};base64,{base64_file}"
657
+ content_type = file.content_type
658
+
659
+ # Remove previous drafts to keep only the submitted one
660
+ kyc_documents_col(db).delete_many({"user_id": user_oid})
661
+
662
+ # Insert new pending document with location details
663
+ kyc_doc = {
664
+ "user_id": user_oid,
665
+ "full_name": full_name,
666
+ "date_of_birth": date_of_birth,
667
+ "gender": gender,
668
+ "address": address,
669
+ "country": country,
670
+ "state": state,
671
+ "city": city,
672
+ "document_url": document_url,
673
+ "file_size": file_size,
674
+ "content_type": content_type,
675
+ "status": "pending",
676
+ "uploaded_at": now,
677
+ "reviewed_at": None,
678
+ "reviewed_by": None,
679
+ "rejection_reason": None
680
+ }
681
+ result = kyc_documents_col(db).insert_one(kyc_doc)
682
+ kyc_doc_id = result.inserted_id
683
+ else:
684
+ # No file attached: try to use latest draft
685
+ latest = kyc_documents_col(db).find({"user_id": user_oid}).sort("uploaded_at", -1).limit(1)
686
+ latest_doc = next(latest, None)
687
+ if not latest_doc or latest_doc.get("status") not in ("draft", "pending"):
688
+ raise HTTPException(
689
+ status_code=status.HTTP_400_BAD_REQUEST,
690
+ detail="No draft document found to submit. Please upload your KYC document first."
691
+ )
692
+ # Normalize fields
693
+ document_url = latest_doc.get("document_url") or latest_doc.get("file_data")
694
+ content_type = latest_doc.get("content_type")
695
+ file_size = latest_doc.get("file_size")
696
+ if not document_url:
697
+ raise HTTPException(status_code=400, detail="Draft document is missing file data. Please re-upload.")
698
+ # Update existing doc to pending and attach personal details
699
+ kyc_documents_col(db).update_one(
700
+ {"_id": latest_doc["_id"]},
701
+ {"$set": {
702
+ "full_name": full_name,
703
+ "date_of_birth": date_of_birth,
704
+ "gender": gender,
705
+ "address": address,
706
+ "country": country,
707
+ "state": state,
708
+ "city": city,
709
+ "document_url": document_url,
710
+ "status": "pending",
711
+ "updated_at": now
712
+ }}
713
+ )
714
+ kyc_doc_id = latest_doc["_id"]
715
+
716
+ # Update user KYC status and pointer, and also update location fields in user document
717
+ repo.update_user(db, current_user['id'], {
718
+ "kyc_status": "pending",
719
+ "kyc_document_id": str(kyc_doc_id),
720
+ "gender": gender,
721
+ "country": country,
722
+ "state": state,
723
+ "city": city
724
+ })
725
+
726
+ logger.info(f"KYC submitted successfully")
727
+
728
+ return {
729
+ "success": True,
730
+ "message": "KYC submitted. Awaiting admin approval.",
731
+ "document_id": str(kyc_doc_id),
732
+ "status": "pending"
733
+ }
734
+
735
+
736
+ @router.get("/kyc/status")
737
+ def get_kyc_status(
738
+ current_user: dict = Depends(get_current_user),
739
+ db=Depends(get_mongo)
740
+ ):
741
+ """Get user's KYC status"""
742
+ user = repo.get_user_by_id(db, current_user['id'])
743
+ # Determine status more accurately: only treat as pending/approved/rejected when a submitted doc exists
744
+ kyc_status = user.get('kyc_status')
745
+ if not kyc_status:
746
+ # If user has a submitted doc id, default to pending, else not started
747
+ kyc_status = 'pending' if user.get('kyc_document_id') else 'not_started'
748
+
749
+ # Get KYC document if exists
750
+ kyc_doc = None
751
+ if user.get('kyc_document_id'):
752
+ doc = kyc_documents_col(db).find_one({"_id": repo._to_object_id(user['kyc_document_id'])})
753
+ if doc:
754
+ kyc_doc = {
755
+ "id": str(doc["_id"]),
756
+ "full_name": doc.get("full_name"),
757
+ "date_of_birth": doc.get("date_of_birth"),
758
+ "address": doc.get("address"),
759
+ "country": doc.get("country"),
760
+ "state": doc.get("state"),
761
+ "city": doc.get("city"),
762
+ "status": doc.get("status", "pending"),
763
+ "uploaded_at": doc.get("uploaded_at"),
764
+ "reviewed_at": doc.get("reviewed_at"),
765
+ "rejection_reason": doc.get("rejection_reason")
766
+ }
767
+
768
+ return {
769
+ "kyc_status": kyc_status,
770
+ "kyc_document": kyc_doc,
771
+ "can_purchase": kyc_status == "approved"
772
+ }
routes/properties.py ADDED
@@ -0,0 +1,851 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Production Property Routes
3
+ Handles property listing, details, and creation with security enhancements
4
+ """
5
+ from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
6
+ from typing import List, Optional
7
+ from datetime import datetime
8
+ from bson import ObjectId
9
+
10
+ import schemas
11
+ from db import get_mongo
12
+ import repo
13
+ from routes.auth import get_current_user, get_current_admin
14
+ from middleware.security import validate_object_id, sanitize_input, limiter
15
+ from utils.cache import cache
16
+
17
+ router = APIRouter()
18
+
19
+
20
+ def map_old_to_new_schema(prop: dict) -> dict:
21
+ """
22
+ Map OLD schema fields to NEW schema fields for backward compatibility
23
+ OLD: name, tokens, price_per_token, value_in_aed, city, type
24
+ NEW: title, total_tokens, token_price, original_price, location, property_type
25
+ """
26
+ mapped = prop.copy()
27
+
28
+ # Map field names
29
+ if 'name' in mapped and 'title' not in mapped:
30
+ mapped['title'] = mapped['name']
31
+
32
+ if 'tokens' in mapped and 'total_tokens' not in mapped:
33
+ mapped['total_tokens'] = mapped['tokens']
34
+
35
+ if 'price_per_token' in mapped and 'token_price' not in mapped:
36
+ mapped['token_price'] = mapped['price_per_token']
37
+
38
+ if 'value_in_inr' in mapped and 'original_price' not in mapped:
39
+ mapped['original_price'] = mapped['value_in_inr']
40
+
41
+ # Map property_type from old 'type' field
42
+ if 'type' in mapped and 'property_type' not in mapped:
43
+ mapped['property_type'] = mapped['type']
44
+
45
+ # Ensure property_type has a default value
46
+ if 'property_type' not in mapped:
47
+ mapped['property_type'] = 'Residential'
48
+
49
+ # Map location from city + type if not present
50
+ if 'location' not in mapped and ('city' in mapped or 'type' in mapped):
51
+ city = mapped.get('city', '')
52
+ prop_type = mapped.get('type', '')
53
+ mapped['location'] = f"{city}, {prop_type}" if city and prop_type else (city or prop_type)
54
+
55
+ # Ensure funded_date exists
56
+ if 'funded_date' not in mapped:
57
+ mapped['funded_date'] = None
58
+
59
+ return mapped
60
+
61
+
62
+ @router.get("/public/properties", response_model=List[schemas.PropertyOut])
63
+ @limiter.limit("30/minute")
64
+ def list_properties_public(
65
+ request: Request,
66
+ is_active: Optional[bool] = Query(None, description="Filter by active status"),
67
+ skip: int = Query(0, ge=0, description="Number of records to skip"),
68
+ limit: int = Query(50, ge=1, le=100, description="Max number of records to return"),
69
+ db=Depends(get_mongo)
70
+ ):
71
+ """
72
+ PUBLIC: List properties WITHOUT authentication
73
+ Rate limited to 30 requests per minute per IP
74
+ Returns limited property data (no documents, no sensitive info)
75
+ """
76
+ print(f"\n[PROPERTIES] PUBLIC listing properties (is_active={is_active}, skip={skip}, limit={limit})")
77
+
78
+ # Use optimized version that fetches all data in single aggregation query
79
+ properties = repo.list_properties_optimized(db, is_active=is_active, skip=skip, limit=limit)
80
+
81
+ # Build enriched property objects (limited data for public access)
82
+ enriched_properties = []
83
+ for prop in properties:
84
+ # Map OLD schema to NEW schema for backward compatibility
85
+ prop = map_old_to_new_schema(prop)
86
+
87
+ # Extract nested data from aggregation result
88
+ spec = prop.pop('specifications', None)
89
+ amenities = prop.pop('amenities', [])
90
+ images = prop.pop('images', [])
91
+ prop.pop('documents', None) # EXCLUDE documents from public view
92
+
93
+ # Build enriched property object WITHOUT documents (public limited access)
94
+ prop_out = schemas.PropertyOut(
95
+ **prop,
96
+ specifications=schemas.PropertySpecificationOut(**spec) if spec else None,
97
+ amenities=[schemas.AmenityOut(**a) for a in amenities] if amenities else None,
98
+ images=[schemas.PropertyImageOut(**img) for img in images] if images else None,
99
+ documents=None # No documents for public view
100
+ )
101
+ enriched_properties.append(prop_out)
102
+
103
+ print(f"[PROPERTIES] [SUCCESS] PUBLIC: Returning {len(enriched_properties)} properties (limited data)\n")
104
+
105
+ return enriched_properties
106
+
107
+
108
+ @router.get("/public/properties-by-status")
109
+ @limiter.limit("30/minute")
110
+ def list_public_properties_by_status(
111
+ request: Request,
112
+ status_filter: str = Query("available", description="Filter: 'available', 'funded', or 'all'"),
113
+ skip: int = Query(0, ge=0),
114
+ limit: int = Query(4, ge=1, le=20),
115
+ db=Depends(get_mongo)
116
+ ):
117
+ """
118
+ PUBLIC: List properties filtered by funding status.
119
+ - 'available': Properties with available_tokens > 0
120
+ - 'funded': Properties from funded_properties collection (available_tokens = 0)
121
+ - 'all': Both available and funded properties combined
122
+
123
+ Returns counts for both categories to support tab UI.
124
+ """
125
+ print(f"\n[PROPERTIES] PUBLIC listing by status: {status_filter} (skip={skip}, limit={limit})")
126
+
127
+ result = {
128
+ "status_filter": status_filter,
129
+ "properties": [],
130
+ "counts": {
131
+ "available": 0,
132
+ "funded": 0
133
+ }
134
+ }
135
+
136
+ try:
137
+ # 1. Get Counts (Always)
138
+ result["counts"]["available"] = db.properties.count_documents({
139
+ "is_active": True,
140
+ "available_tokens": {"$gt": 0}
141
+ })
142
+
143
+ result["counts"]["funded"] = db.funded_properties.count_documents({
144
+ "is_active": True,
145
+ "funding_status": "funded"
146
+ })
147
+
148
+ # 2. Get Properties based on filter
149
+ properties_list = []
150
+
151
+ if status_filter == "available":
152
+ # Get available properties
153
+ # Use optimized repo function but we need to pass is_active=True
154
+ cursor = db.properties.find({
155
+ "is_active": True,
156
+ "available_tokens": {"$gt": 0}
157
+ }).sort("created_at", -1).skip(skip).limit(limit)
158
+
159
+ for prop in cursor:
160
+ # Use list_properties_public logic to clean data
161
+ if '_id' in prop and not isinstance(prop['_id'], str):
162
+ prop['id'] = str(prop.pop('_id'))
163
+
164
+ prop = map_old_to_new_schema(prop)
165
+
166
+ # Manual fetch for images/specs if not joined
167
+ prop_images = repo.get_property_images(db, prop['id'])
168
+ prop['images'] = [img for img in prop_images]
169
+
170
+ if 'specifications' not in prop:
171
+ spec = repo.get_property_specification(db, prop['id'])
172
+ prop['specifications'] = spec
173
+
174
+ # Mock amenities if missing
175
+ if 'amenities' not in prop:
176
+ prop['amenities'] = []
177
+
178
+ properties_list.append(prop)
179
+
180
+ elif status_filter == "funded":
181
+ # Get funded properties
182
+ cursor = db.funded_properties.find({
183
+ "is_active": True,
184
+ "funding_status": "funded"
185
+ }).sort("funded_date", -1).skip(skip).limit(limit)
186
+
187
+ for doc in cursor:
188
+ doc['id'] = str(doc.pop('_id'))
189
+ # Funded properties schema is flat
190
+ if 'main_image_url' in doc:
191
+ doc['image'] = doc['main_image_url']
192
+ doc['images'] = [{'image_url': doc['main_image_url']}]
193
+
194
+ # Enrich with basic fields if missing for card
195
+ if 'specifications' not in doc:
196
+ # Funded properties usually have flattened specs in them if archived correctly,
197
+ # but if not, we might check if we can fetch them or just provide defaults
198
+ # based on what's available in the doc
199
+ doc['specifications'] = {
200
+ "bedroom": doc.get("specifications", {}).get("bedroom") or doc.get("bedroom") or 0,
201
+ "bathroom": doc.get("specifications", {}).get("bathroom") or doc.get("bathroom") or 0,
202
+ "area": doc.get("specifications", {}).get("area") or doc.get("area") or 0,
203
+ }
204
+
205
+ properties_list.append(doc)
206
+
207
+ result["properties"] = properties_list
208
+
209
+ print(f"[PROPERTIES] [SUCCESS] Returned {len(properties_list)} properties. Counts: {result['counts']}\n")
210
+ return result
211
+
212
+ except Exception as e:
213
+ print(f"[PROPERTIES] [ERROR] Error in public status listing: {e}\n")
214
+ raise HTTPException(
215
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
216
+ detail="Failed to retrieve properties"
217
+ )
218
+
219
+
220
+ @router.get("/properties", response_model=List[schemas.PropertyOut])
221
+ @limiter.limit("60/minute")
222
+ def list_properties(
223
+ request: Request,
224
+ is_active: Optional[bool] = Query(None, description="Filter by active status"),
225
+ skip: int = Query(0, ge=0, description="Number of records to skip"),
226
+ limit: int = Query(100, ge=1, le=500, description="Max number of records to return"),
227
+ db=Depends(get_mongo),
228
+ current_user: dict = Depends(get_current_user) # REQUIRE AUTHENTICATION
229
+ ):
230
+ """
231
+ List all properties with optional filtering
232
+ SECURED: Authentication required to view properties
233
+ Rate limited to 60 requests per minute per user
234
+ OPTIMIZED: Uses aggregation pipeline to fetch all related data in single query
235
+ Returns full property data including documents for authenticated users
236
+ """
237
+ print(f"\n[PROPERTIES] Listing properties (is_active={is_active}, skip={skip}, limit={limit})")
238
+ print(f"[PROPERTIES] Requested by: {current_user['email']}")
239
+
240
+ # Use optimized version that fetches all data in single aggregation query
241
+ properties = repo.list_properties_optimized(db, is_active=is_active, skip=skip, limit=limit)
242
+
243
+ # Build enriched property objects (data already loaded from aggregation)
244
+ enriched_properties = []
245
+ for prop in properties:
246
+ # Map OLD schema to NEW schema for backward compatibility
247
+ prop = map_old_to_new_schema(prop)
248
+
249
+ # Extract nested data from aggregation result
250
+ spec = prop.pop('specifications', None)
251
+ amenities = prop.pop('amenities', [])
252
+ images = prop.pop('images', [])
253
+ documents = prop.pop('documents', []) # Include documents for authenticated users
254
+
255
+ # Build enriched property object WITH documents (authenticated users get full access)
256
+ prop_out = schemas.PropertyOut(
257
+ **prop,
258
+ specifications=schemas.PropertySpecificationOut(**spec) if spec else None,
259
+ amenities=[schemas.AmenityOut(**a) for a in amenities] if amenities else None,
260
+ images=[schemas.PropertyImageOut(**img) for img in images] if images else None,
261
+ documents=[schemas.DocumentOut(**d) for d in documents] if documents else None
262
+ )
263
+ enriched_properties.append(prop_out)
264
+
265
+ print(f"[PROPERTIES] [SUCCESS] Returning {len(enriched_properties)} properties (authenticated user - full data)\n")
266
+
267
+ return enriched_properties
268
+
269
+
270
+ @router.get("/properties/{property_id}")
271
+ @limiter.limit("120/minute")
272
+ def get_property(
273
+ request: Request,
274
+ property_id: str,
275
+ db=Depends(get_mongo),
276
+ current_user: dict = Depends(get_current_user) # REQUIRE AUTHENTICATION
277
+ ):
278
+ """
279
+ Get detailed information about a specific property
280
+ SECURED: Authentication required to view property details
281
+ Rate limited to 120 requests per minute per user
282
+ Validates property ID format before querying
283
+ Returns property with FULL issuer details (for authenticated users only)
284
+ Documents are included for authenticated users
285
+ """
286
+ print(f"\n[PROPERTIES] Getting property details: {property_id}")
287
+ print(f"[PROPERTIES] Requested by: {current_user['email']}")
288
+
289
+ # Validate ObjectId format
290
+ if not validate_object_id(property_id):
291
+ print(f"[PROPERTIES] [ERROR] Invalid property ID format: {property_id}\n")
292
+ raise HTTPException(
293
+ status_code=status.HTTP_400_BAD_REQUEST,
294
+ detail="Invalid property ID format. Property ID must be a valid 24-character hexadecimal string."
295
+ )
296
+
297
+ try:
298
+ # Use optimized version that fetches all data in single aggregation query
299
+ prop = repo.get_property_by_id_optimized(db, property_id)
300
+
301
+ if not prop:
302
+ print(f"[PROPERTIES] [ERROR] Property not found: {property_id}\n")
303
+ raise HTTPException(
304
+ status_code=status.HTTP_404_NOT_FOUND,
305
+ detail="Property not found. The property you are looking for does not exist or has been removed."
306
+ )
307
+
308
+ # Extract nested data from aggregation result
309
+ spec = prop.pop('specifications', None)
310
+ amenities = prop.pop('amenities', [])
311
+ images = prop.pop('images', [])
312
+ documents = prop.pop('documents', []) # Include documents for authenticated users
313
+
314
+ # Get issuer details (created_by user) - FULL details for authenticated users
315
+ issuer = None
316
+ if prop.get('created_by'):
317
+ try:
318
+ issuer_user = repo.get_user_by_id(db, prop['created_by'])
319
+ if issuer_user:
320
+ # Get issuer's wallet for XRP address
321
+ issuer_wallet = repo.get_wallet_by_user(db, issuer_user['id'])
322
+
323
+ # AUTHENTICATED: Show full details
324
+ issuer = {
325
+ 'id': issuer_user['id'],
326
+ 'name': issuer_user.get('name'),
327
+ 'email': issuer_user.get('email'), # Full email
328
+ 'phone': issuer_user.get('phone'), # Full phone
329
+ 'role': issuer_user.get('role'),
330
+ 'xrp_address': issuer_wallet.get('xrp_address') if issuer_wallet else None
331
+ }
332
+ print(f"[PROPERTIES] - Issuer: {issuer['name']} (full details - authenticated)")
333
+ except Exception as e:
334
+ print(f"[PROPERTIES] - Warning: Could not fetch issuer details: {e}")
335
+
336
+ # Map OLD schema to NEW schema for backward compatibility
337
+ prop = map_old_to_new_schema(prop)
338
+
339
+ print(f"[PROPERTIES] [SUCCESS] Property found: {prop.get('title', 'Unknown')}")
340
+ print(f"[PROPERTIES] - Specifications: {'Yes' if spec else 'No'}")
341
+ print(f"[PROPERTIES] - Amenities: {len(amenities)}")
342
+ print(f"[PROPERTIES] - Images: {len(images)}")
343
+ print(f"[PROPERTIES] - Documents: {len(documents)} (included for authenticated user)\n")
344
+
345
+ # Build response WITH documents (authenticated users get full access)
346
+ property_response = schemas.PropertyOut(
347
+ **prop,
348
+ specifications=schemas.PropertySpecificationOut(**spec) if spec else None,
349
+ amenities=[schemas.AmenityOut(**a) for a in amenities] if amenities else None,
350
+ images=[schemas.PropertyImageOut(**img) for img in images] if images else None,
351
+ documents=[schemas.DocumentOut(**d) for d in documents] if documents else None
352
+ )
353
+
354
+ # Convert to dict and add full issuer details
355
+ response_dict = property_response.dict()
356
+ if issuer:
357
+ response_dict['issuer'] = issuer
358
+
359
+ return response_dict
360
+ except HTTPException:
361
+ # Re-raise HTTP exceptions
362
+ raise
363
+ except Exception as e:
364
+ print(f"[PROPERTIES] [ERROR] Error retrieving property: {str(e)}\n")
365
+ raise HTTPException(
366
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
367
+ detail="An error occurred while retrieving property details. Please try again later."
368
+ )
369
+
370
+
371
+ # NOTE: create_property endpoint is implemented in routes/admin.py with IOU tokenization logic
372
+
373
+ @router.patch("/admin/properties/{property_id}", response_model=schemas.PropertyOut)
374
+ def update_property(
375
+ property_id: str,
376
+ property_update: schemas.PropertyUpdate,
377
+ db=Depends(get_mongo),
378
+ admin_user=Depends(get_current_admin)
379
+ ):
380
+ """
381
+ Update property details (Admin only)
382
+ """
383
+ print(f"\n[PROPERTIES] Admin updating property: {property_id}")
384
+ print(f"[PROPERTIES] Updated by: {admin_user['email']}")
385
+
386
+ # Check if property exists
387
+ existing = repo.get_property_by_id(db, property_id)
388
+ if not existing:
389
+ print(f"[PROPERTIES] [ERROR] Property not found: {property_id}\n")
390
+ raise HTTPException(
391
+ status_code=status.HTTP_404_NOT_FOUND,
392
+ detail="Property not found"
393
+ )
394
+
395
+ # Update property
396
+ update_data = property_update.dict(exclude_none=True)
397
+
398
+ if not update_data:
399
+ print(f"[PROPERTIES] ⚠ No fields to update\n")
400
+ raise HTTPException(
401
+ status_code=status.HTTP_400_BAD_REQUEST,
402
+ detail="No fields to update"
403
+ )
404
+
405
+ updated_property = repo.update_property(db, property_id, update_data)
406
+
407
+ if not updated_property:
408
+ print(f"[PROPERTIES] [ERROR] Failed to update property\n")
409
+ raise HTTPException(
410
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
411
+ detail="Failed to update property"
412
+ )
413
+
414
+ print(f"[PROPERTIES] [SUCCESS] Property updated: {list(update_data.keys())}\n")
415
+
416
+ # Get related data
417
+ spec = repo.get_property_specification(db, property_id)
418
+ amenities = repo.get_property_amenities(db, property_id)
419
+ images = repo.get_property_images(db, property_id)
420
+
421
+ return schemas.PropertyOut(
422
+ **updated_property,
423
+ specifications=schemas.PropertySpecificationOut(**spec) if spec else None,
424
+ amenities=[schemas.AmenityOut(**a) for a in amenities] if amenities else None,
425
+ images=[schemas.PropertyImageOut(**img) for img in images] if images else None
426
+ )
427
+
428
+
429
+ # ============================================================================
430
+ # DOCUMENT MANAGEMENT ENDPOINTS
431
+ # ============================================================================
432
+
433
+ @router.post("/admin/properties/{property_id}/documents", response_model=schemas.DocumentOut, status_code=status.HTTP_201_CREATED)
434
+ def add_property_document(
435
+ property_id: str,
436
+ document_data: schemas.DocumentCreate,
437
+ db=Depends(get_mongo),
438
+ admin_user=Depends(get_current_admin)
439
+ ):
440
+ """
441
+ Add a document to a property (Admin only)
442
+ Supports: PDF, DOC, DOCX, JPG, PNG
443
+ """
444
+ print(f"\n[DOCUMENTS] Adding document to property: {property_id}")
445
+ print(f"[DOCUMENTS] File type: {document_data.file_type}, Uploaded by: {admin_user['email']}")
446
+
447
+ # Verify property exists
448
+ property_obj = repo.get_property_by_id(db, property_id)
449
+ if not property_obj:
450
+ print(f"[DOCUMENTS] [ERROR] Property not found: {property_id}\n")
451
+ raise HTTPException(
452
+ status_code=status.HTTP_404_NOT_FOUND,
453
+ detail="Property not found"
454
+ )
455
+
456
+ # Create document
457
+ document = repo.create_document(
458
+ db,
459
+ property_id,
460
+ document_data.file_type,
461
+ document_data.file_url,
462
+ admin_user['id']
463
+ )
464
+
465
+ print(f"[DOCUMENTS] [SUCCESS] Document added successfully: {document['id']}\n")
466
+
467
+ return schemas.DocumentOut(**document)
468
+
469
+
470
+ @router.get("/properties/{property_id}/documents", response_model=List[schemas.DocumentOut])
471
+ def get_property_documents_list(
472
+ property_id: str,
473
+ db=Depends(get_mongo),
474
+ current_user=Depends(get_current_user) # SECURITY: Require authentication
475
+ ):
476
+ """
477
+ Get all documents for a property
478
+ SECURED: Authentication required to view property documents
479
+ """
480
+ print(f"\n[DOCUMENTS] Fetching documents for property: {property_id}")
481
+ print(f"[DOCUMENTS] Requested by: {current_user['email']}")
482
+
483
+ # Verify property exists
484
+ property_obj = repo.get_property_by_id(db, property_id)
485
+ if not property_obj:
486
+ raise HTTPException(
487
+ status_code=status.HTTP_404_NOT_FOUND,
488
+ detail="Property not found"
489
+ )
490
+
491
+ documents = repo.get_property_documents(db, property_id)
492
+
493
+ print(f"[DOCUMENTS] [SUCCESS] Found {len(documents)} documents\n")
494
+
495
+ return [schemas.DocumentOut(**d) for d in documents]
496
+
497
+
498
+ @router.delete("/admin/properties/{property_id}/documents/{document_id}", status_code=status.HTTP_204_NO_CONTENT)
499
+ def delete_property_document(
500
+ property_id: str,
501
+ document_id: str,
502
+ db=Depends(get_mongo),
503
+ admin_user=Depends(get_current_admin)
504
+ ):
505
+ """
506
+ Delete a property document (Admin only)
507
+ """
508
+ print(f"\n[DOCUMENTS] Deleting document: {document_id} from property: {property_id}")
509
+ print(f"[DOCUMENTS] Deleted by: {admin_user['email']}")
510
+
511
+ from bson import ObjectId
512
+
513
+ # Delete from database
514
+ result = db.documents.delete_one({"_id": ObjectId(document_id)})
515
+
516
+ if result.deleted_count == 0:
517
+ print(f"[DOCUMENTS] [ERROR] Document not found: {document_id}\n")
518
+ raise HTTPException(
519
+ status_code=status.HTTP_404_NOT_FOUND,
520
+ detail="Document not found"
521
+ )
522
+
523
+ print(f"[DOCUMENTS] [SUCCESS] Document deleted successfully\n")
524
+
525
+ return None
526
+
527
+
528
+ # ============================================================================
529
+ # FUNDED PROPERTIES ENDPOINTS
530
+ # ============================================================================
531
+
532
+ def archive_to_funded_properties(db, property_obj: dict) -> dict:
533
+ """
534
+ Helper function to archive a property to the funded_properties collection
535
+ when it reaches 100% funding (available_tokens = 0).
536
+
537
+ This creates a snapshot of the property at the time of full funding.
538
+ """
539
+ print(f"[FUNDED] Archiving property to funded_properties: {property_obj.get('id')}")
540
+
541
+ now = datetime.utcnow()
542
+
543
+ # Calculate funding duration if created_at exists
544
+ created_at = property_obj.get('created_at')
545
+ funding_duration_days = None
546
+ if created_at:
547
+ if isinstance(created_at, str):
548
+ created_at = datetime.fromisoformat(created_at.replace('Z', '+00:00'))
549
+ funding_duration_days = (now - created_at).days
550
+
551
+ # Get main image URL
552
+ images = property_obj.get('images', [])
553
+ main_image_url = None
554
+ if images:
555
+ for img in images:
556
+ if isinstance(img, dict) and img.get('is_main'):
557
+ main_image_url = img.get('image_url')
558
+ break
559
+ if not main_image_url and images:
560
+ first_img = images[0]
561
+ main_image_url = first_img.get('image_url') if isinstance(first_img, dict) else None
562
+
563
+ # Count unique investors
564
+ try:
565
+ investor_count = db.investments.count_documents({
566
+ "property_id": property_obj.get('id'),
567
+ "status": "confirmed"
568
+ })
569
+
570
+ # Calculate average investment
571
+ pipeline = [
572
+ {"$match": {"property_id": property_obj.get('id'), "status": "confirmed"}},
573
+ {"$group": {"_id": None, "avg_amount": {"$avg": "$amount"}}}
574
+ ]
575
+ avg_result = list(db.investments.aggregate(pipeline))
576
+ average_investment = avg_result[0]['avg_amount'] if avg_result else None
577
+ except Exception as e:
578
+ print(f"[FUNDED] Warning: Could not calculate investor stats: {e}")
579
+ investor_count = 0
580
+ average_investment = None
581
+
582
+ # Build funded property document
583
+ funded_doc = {
584
+ "property_id": property_obj.get('id'),
585
+ "title": property_obj.get('title', property_obj.get('name', 'Unknown')),
586
+ "description": property_obj.get('description', ''),
587
+ "location": property_obj.get('location', property_obj.get('city', '')),
588
+ "property_type": property_obj.get('property_type', property_obj.get('type', 'Residential')),
589
+ "total_tokens": property_obj.get('total_tokens', property_obj.get('tokens', 0)),
590
+ "token_price": property_obj.get('token_price', property_obj.get('price_per_token', 0)),
591
+ "original_price": property_obj.get('original_price', property_obj.get('value_in_inr', 0)),
592
+ "funding_status": "funded",
593
+ "funded_date": now,
594
+ "funding_duration_days": funding_duration_days,
595
+ "total_investors": investor_count,
596
+ "average_investment": average_investment,
597
+ "net_rental_yield": property_obj.get('net_rental_yield'),
598
+ "annual_roi": property_obj.get('annual_roi'),
599
+ "gross_rental_yield": property_obj.get('gross_rental_yield'),
600
+ "main_image_url": main_image_url,
601
+ "is_active": True,
602
+ "created_at": now,
603
+ "updated_at": now
604
+ }
605
+
606
+ # Upsert to funded_properties (update if exists, insert if not)
607
+ try:
608
+ result = db.funded_properties.update_one(
609
+ {"property_id": property_obj.get('id')},
610
+ {"$set": funded_doc},
611
+ upsert=True
612
+ )
613
+
614
+ if result.upserted_id:
615
+ funded_doc['id'] = str(result.upserted_id)
616
+ print(f"[FUNDED] [SUCCESS] Property archived as new funded property")
617
+ else:
618
+ # Get existing document ID
619
+ existing = db.funded_properties.find_one({"property_id": property_obj.get('id')})
620
+ if existing:
621
+ funded_doc['id'] = str(existing['_id'])
622
+ print(f"[FUNDED] [SUCCESS] Funded property record updated")
623
+
624
+ # Also update the funded_date on the original property
625
+ db.properties.update_one(
626
+ {"_id": ObjectId(property_obj.get('id'))},
627
+ {"$set": {"funded_date": now}}
628
+ )
629
+
630
+ return funded_doc
631
+
632
+ except Exception as e:
633
+ print(f"[FUNDED] [ERROR] Failed to archive property: {e}")
634
+ raise
635
+
636
+
637
+ @router.get("/funded-properties", response_model=schemas.FundedPropertiesListResponse)
638
+ @limiter.limit("60/minute")
639
+ def list_funded_properties(
640
+ request: Request,
641
+ skip: int = Query(0, ge=0, description="Number of records to skip"),
642
+ limit: int = Query(50, ge=1, le=100, description="Max number of records to return"),
643
+ db=Depends(get_mongo),
644
+ current_user: dict = Depends(get_current_user)
645
+ ):
646
+ """
647
+ List all funded (100% subscribed) properties.
648
+ SECURED: Authentication required.
649
+ Returns properties from the funded_properties collection.
650
+ """
651
+ print(f"\n[FUNDED] Listing funded properties (skip={skip}, limit={limit})")
652
+ print(f"[FUNDED] Requested by: {current_user['email']}")
653
+
654
+ try:
655
+ # Query funded_properties collection
656
+ cursor = db.funded_properties.find(
657
+ {"is_active": True, "funding_status": "funded"}
658
+ ).sort("funded_date", -1).skip(skip).limit(limit)
659
+
660
+ funded_properties = []
661
+ for doc in cursor:
662
+ doc['id'] = str(doc.pop('_id'))
663
+
664
+ # If main_image_url is missing, try to fetch from original property
665
+ if not doc.get('main_image_url') and doc.get('property_id'):
666
+ try:
667
+ # Fetch images from property_images collection using property_id
668
+ prop_images = list(db.property_images.find({
669
+ "property_id": ObjectId(doc['property_id']),
670
+ "is_active": True
671
+ }).limit(1))
672
+
673
+ if prop_images:
674
+ main_img = next((img for img in prop_images if img.get('is_main')), prop_images[0])
675
+ doc['main_image_url'] = main_img.get('image_url')
676
+ except Exception as img_err:
677
+ print(f"[FUNDED] Warning: Could not fetch image for property {doc.get('property_id')}: {img_err}")
678
+
679
+ funded_properties.append(schemas.FundedPropertyOut(**doc))
680
+
681
+ # Get total count
682
+ total_count = db.funded_properties.count_documents(
683
+ {"is_active": True, "funding_status": "funded"}
684
+ )
685
+
686
+ # Calculate funding stats
687
+ stats_pipeline = [
688
+ {"$match": {"is_active": True, "funding_status": "funded"}},
689
+ {"$group": {
690
+ "_id": None,
691
+ "total_value": {"$sum": "$original_price"},
692
+ "total_investors": {"$sum": "$total_investors"},
693
+ "avg_funding_days": {"$avg": "$funding_duration_days"}
694
+ }}
695
+ ]
696
+ stats_result = list(db.funded_properties.aggregate(stats_pipeline))
697
+ funding_stats = None
698
+ if stats_result:
699
+ s = stats_result[0]
700
+ funding_stats = {
701
+ "total_funded_value": s.get('total_value', 0),
702
+ "total_unique_investors": s.get('total_investors', 0),
703
+ "average_funding_days": round(s.get('avg_funding_days', 0), 1) if s.get('avg_funding_days') else None
704
+ }
705
+
706
+ print(f"[FUNDED] [SUCCESS] Found {len(funded_properties)} funded properties (total: {total_count})\n")
707
+
708
+ return schemas.FundedPropertiesListResponse(
709
+ properties=funded_properties,
710
+ total_count=total_count,
711
+ funding_stats=funding_stats
712
+ )
713
+
714
+ except Exception as e:
715
+ print(f"[FUNDED] [ERROR] Error listing funded properties: {e}\n")
716
+ raise HTTPException(
717
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
718
+ detail="Failed to retrieve funded properties"
719
+ )
720
+
721
+
722
+ @router.get("/funded-properties/{property_id}", response_model=schemas.FundedPropertyOut)
723
+ @limiter.limit("120/minute")
724
+ def get_funded_property(
725
+ request: Request,
726
+ property_id: str,
727
+ db=Depends(get_mongo),
728
+ current_user: dict = Depends(get_current_user)
729
+ ):
730
+ """
731
+ Get details of a specific funded property.
732
+ SECURED: Authentication required.
733
+ """
734
+ print(f"\n[FUNDED] Getting funded property: {property_id}")
735
+ print(f"[FUNDED] Requested by: {current_user['email']}")
736
+
737
+ if not validate_object_id(property_id):
738
+ raise HTTPException(
739
+ status_code=status.HTTP_400_BAD_REQUEST,
740
+ detail="Invalid property ID format"
741
+ )
742
+
743
+ try:
744
+ # Try to find by property_id reference first, then by _id
745
+ doc = db.funded_properties.find_one({"property_id": property_id})
746
+
747
+ if not doc:
748
+ doc = db.funded_properties.find_one({"_id": ObjectId(property_id)})
749
+
750
+ if not doc:
751
+ raise HTTPException(
752
+ status_code=status.HTTP_404_NOT_FOUND,
753
+ detail="Funded property not found"
754
+ )
755
+
756
+ doc['id'] = str(doc.pop('_id'))
757
+
758
+ print(f"[FUNDED] [SUCCESS] Found funded property: {doc.get('title')}\n")
759
+
760
+ return schemas.FundedPropertyOut(**doc)
761
+
762
+ except HTTPException:
763
+ raise
764
+ except Exception as e:
765
+ print(f"[FUNDED] [ERROR] Error retrieving funded property: {e}\n")
766
+ raise HTTPException(
767
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
768
+ detail="Failed to retrieve funded property"
769
+ )
770
+
771
+
772
+ @router.get("/properties-by-status")
773
+ @limiter.limit("60/minute")
774
+ def list_properties_by_status(
775
+ request: Request,
776
+ status_filter: str = Query("available", description="Filter: 'available', 'funded', or 'all'"),
777
+ skip: int = Query(0, ge=0),
778
+ limit: int = Query(50, ge=1, le=100),
779
+ db=Depends(get_mongo),
780
+ current_user: dict = Depends(get_current_user)
781
+ ):
782
+ """
783
+ List properties filtered by funding status.
784
+ - 'available': Properties with available_tokens > 0
785
+ - 'funded': Properties from funded_properties collection (available_tokens = 0)
786
+ - 'all': Both available and funded properties combined
787
+
788
+ SECURED: Authentication required.
789
+ This endpoint is designed to power the frontend tabs (Available | Funded).
790
+ """
791
+ print(f"\n[PROPERTIES] Listing by status: {status_filter} (skip={skip}, limit={limit})")
792
+ print(f"[PROPERTIES] Requested by: {current_user['email']}")
793
+
794
+ result = {
795
+ "status_filter": status_filter,
796
+ "available_properties": [],
797
+ "funded_properties": [],
798
+ "available_count": 0,
799
+ "funded_count": 0
800
+ }
801
+
802
+ try:
803
+ if status_filter in ["available", "all"]:
804
+ # Get available properties (available_tokens > 0)
805
+ available_cursor = db.properties.find({
806
+ "is_active": True,
807
+ "available_tokens": {"$gt": 0}
808
+ }).sort("created_at", -1)
809
+
810
+ if status_filter == "available":
811
+ available_cursor = available_cursor.skip(skip).limit(limit)
812
+
813
+ for prop in available_cursor:
814
+ prop['id'] = str(prop.pop('_id'))
815
+ prop = map_old_to_new_schema(prop)
816
+ result["available_properties"].append(prop)
817
+
818
+ result["available_count"] = db.properties.count_documents({
819
+ "is_active": True,
820
+ "available_tokens": {"$gt": 0}
821
+ })
822
+
823
+ if status_filter in ["funded", "all"]:
824
+ # Get funded properties from funded_properties collection
825
+ funded_cursor = db.funded_properties.find({
826
+ "is_active": True,
827
+ "funding_status": "funded"
828
+ }).sort("funded_date", -1)
829
+
830
+ if status_filter == "funded":
831
+ funded_cursor = funded_cursor.skip(skip).limit(limit)
832
+
833
+ for doc in funded_cursor:
834
+ doc['id'] = str(doc.pop('_id'))
835
+ result["funded_properties"].append(doc)
836
+
837
+ result["funded_count"] = db.funded_properties.count_documents({
838
+ "is_active": True,
839
+ "funding_status": "funded"
840
+ })
841
+
842
+ print(f"[PROPERTIES] [SUCCESS] Available: {len(result['available_properties'])}, Funded: {len(result['funded_properties'])}\n")
843
+
844
+ return result
845
+
846
+ except Exception as e:
847
+ print(f"[PROPERTIES] [ERROR] Error listing properties by status: {e}\n")
848
+ raise HTTPException(
849
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
850
+ detail="Failed to retrieve properties"
851
+ )
routes/secondary_market.py ADDED
@@ -0,0 +1,286 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Secondary Market Routes - Token Trading
3
+ Allows users to buy/sell tokens on XRP DEX
4
+ """
5
+ from fastapi import APIRouter, Depends, HTTPException, status, Request
6
+ from typing import List, Dict, Any
7
+ from pydantic import BaseModel
8
+
9
+ import schemas
10
+ from db import get_mongo
11
+ import repo
12
+ from routes.auth import get_current_user
13
+ from services.secondary_market import secondary_market
14
+ from services.price_oracle import price_oracle
15
+ from utils.crypto_utils import decrypt_secret
16
+ from config import settings
17
+ from middleware.security import limiter
18
+
19
+ router = APIRouter()
20
+
21
+
22
+ class SellOfferRequest(BaseModel):
23
+ property_id: str
24
+ token_amount: int
25
+ price_per_token_aed: float # Price in AED (will convert to XRP)
26
+
27
+
28
+ class BuyOfferRequest(BaseModel):
29
+ property_id: str
30
+ token_amount: int
31
+ price_per_token_aed: float
32
+
33
+
34
+ class CancelOfferRequest(BaseModel):
35
+ offer_sequence: int
36
+
37
+
38
+ @router.post("/secondary-market/sell")
39
+ @limiter.limit("30/hour") # Limit sell offers to prevent spam
40
+ def create_sell_offer(
41
+ request: Request,
42
+ sell_request: SellOfferRequest,
43
+ db=Depends(get_mongo),
44
+ current_user=Depends(get_current_user)
45
+ ):
46
+ """
47
+ Create a sell offer for your tokens
48
+ List your tokens for sale at your desired price
49
+ Rate Limited: 30 sell offers per hour
50
+ """
51
+ print(f"\n[SECONDARY MARKET] User {current_user['email']} creating sell offer")
52
+
53
+ # Get property token info
54
+ property_token = repo.get_property_token(db, sell_request.property_id)
55
+ if not property_token:
56
+ raise HTTPException(status_code=404, detail="Property token not found")
57
+
58
+ currency_code = property_token['currency_code']
59
+ issuer_address = property_token['issuer_address']
60
+
61
+ # Get user's wallet
62
+ wallet = repo.get_wallet_by_user(db, current_user['id'])
63
+ if not wallet or not wallet.get('xrp_seed'):
64
+ raise HTTPException(status_code=400, detail="XRP wallet required")
65
+
66
+ # Decrypt wallet seed
67
+ seller_seed = decrypt_secret(wallet['xrp_seed'], settings.ENCRYPTION_KEY)
68
+ if not seller_seed:
69
+ raise HTTPException(status_code=500, detail="Failed to decrypt wallet")
70
+
71
+ # Convert AED price to XRP
72
+ price_per_token_xrp = price_oracle.get_aed_to_xrp(sell_request.price_per_token_aed)
73
+
74
+ print(f" Property ID: {sell_request.property_id}")
75
+ print(f" Tokens: {sell_request.token_amount}")
76
+ print(f" Price: {sell_request.price_per_token_aed} AED = {price_per_token_xrp:.6f} XRP per token")
77
+
78
+ # Create sell offer on DEX
79
+ result = secondary_market.create_sell_offer(
80
+ seller_seed=seller_seed,
81
+ currency_code=currency_code,
82
+ issuer_address=issuer_address,
83
+ token_amount=sell_request.token_amount,
84
+ price_per_token_xrp=price_per_token_xrp
85
+ )
86
+
87
+ if not result.get('success'):
88
+ raise HTTPException(status_code=400, detail=result.get('error', 'Failed to create offer'))
89
+
90
+ # Record offer in database
91
+ repo.create_transaction(
92
+ db,
93
+ user_id=current_user['id'],
94
+ wallet_id=wallet['id'],
95
+ tx_type="sell",
96
+ amount=sell_request.price_per_token_aed * sell_request.token_amount,
97
+ property_id=sell_request.property_id,
98
+ status="pending",
99
+ metadata={
100
+ "offer_type": "sell",
101
+ "offer_sequence": result['offer_sequence'],
102
+ "token_amount": sell_request.token_amount,
103
+ "price_per_token_aed": sell_request.price_per_token_aed,
104
+ "price_per_token_xrp": price_per_token_xrp,
105
+ "blockchain_tx_hash": result['tx_hash']
106
+ }
107
+ )
108
+
109
+ return {
110
+ "success": True,
111
+ "message": "Sell offer created successfully!",
112
+ "offer_sequence": result['offer_sequence'],
113
+ "tx_hash": result['tx_hash'],
114
+ "token_amount": sell_request.token_amount,
115
+ "price_per_token_aed": sell_request.price_per_token_aed,
116
+ "price_per_token_xrp": price_per_token_xrp,
117
+ "total_xrp": result['total_xrp']
118
+ }
119
+
120
+
121
+ @router.post("/secondary-market/buy")
122
+ @limiter.limit("30/hour") # Limit buy offers to prevent spam
123
+ def create_buy_offer(
124
+ request: Request,
125
+ buy_request: BuyOfferRequest,
126
+ db=Depends(get_mongo),
127
+ current_user=Depends(get_current_user)
128
+ ):
129
+ """
130
+ Create a buy offer for tokens
131
+ Place a bid to buy tokens at your desired price
132
+ Rate Limited: 30 buy offers per hour
133
+ """
134
+ print(f"\n[SECONDARY MARKET] User {current_user['email']} creating buy offer")
135
+
136
+ # Get property token info
137
+ property_token = repo.get_property_token(db, buy_request.property_id)
138
+ if not property_token:
139
+ raise HTTPException(status_code=404, detail="Property token not found")
140
+
141
+ currency_code = property_token['currency_code']
142
+ issuer_address = property_token['issuer_address']
143
+
144
+ # Get user's wallet
145
+ wallet = repo.get_wallet_by_user(db, current_user['id'])
146
+ if not wallet or not wallet.get('xrp_seed'):
147
+ raise HTTPException(status_code=400, detail="XRP wallet required")
148
+
149
+ # Decrypt wallet seed
150
+ buyer_seed = decrypt_secret(wallet['xrp_seed'], settings.ENCRYPTION_KEY)
151
+ if not buyer_seed:
152
+ raise HTTPException(status_code=500, detail="Failed to decrypt wallet")
153
+
154
+ # Convert AED price to XRP
155
+ price_per_token_xrp = price_oracle.get_aed_to_xrp(buy_request.price_per_token_aed)
156
+
157
+ # Create buy offer on DEX
158
+ result = secondary_market.create_buy_offer(
159
+ buyer_seed=buyer_seed,
160
+ currency_code=currency_code,
161
+ issuer_address=issuer_address,
162
+ token_amount=buy_request.token_amount,
163
+ price_per_token_xrp=price_per_token_xrp
164
+ )
165
+
166
+ if not result.get('success'):
167
+ raise HTTPException(status_code=400, detail=result.get('error', 'Failed to create offer'))
168
+
169
+ # Record offer in database
170
+ repo.create_transaction(
171
+ db,
172
+ user_id=current_user['id'],
173
+ wallet_id=wallet['id'],
174
+ tx_type="buy",
175
+ amount=buy_request.price_per_token_aed * buy_request.token_amount,
176
+ property_id=buy_request.property_id,
177
+ status="pending",
178
+ metadata={
179
+ "offer_type": "buy",
180
+ "offer_sequence": result['offer_sequence'],
181
+ "token_amount": buy_request.token_amount,
182
+ "price_per_token_aed": buy_request.price_per_token_aed,
183
+ "price_per_token_xrp": price_per_token_xrp,
184
+ "blockchain_tx_hash": result['tx_hash']
185
+ }
186
+ )
187
+
188
+ return {
189
+ "success": True,
190
+ "message": "Buy offer created successfully!",
191
+ "offer_sequence": result['offer_sequence'],
192
+ "tx_hash": result['tx_hash'],
193
+ "token_amount": buy_request.token_amount,
194
+ "price_per_token_aed": buy_request.price_per_token_aed,
195
+ "price_per_token_xrp": price_per_token_xrp,
196
+ "total_xrp": result['total_xrp']
197
+ }
198
+
199
+
200
+ @router.post("/secondary-market/cancel")
201
+ @limiter.limit("60/hour") # Limit cancellations
202
+ def cancel_offer(
203
+ request: Request,
204
+ cancel_request: CancelOfferRequest,
205
+ db=Depends(get_mongo),
206
+ current_user=Depends(get_current_user)
207
+ ):
208
+ """Cancel an existing buy/sell offer - Rate Limited: 60 cancellations per hour"""
209
+ wallet = repo.get_wallet_by_user(db, current_user['id'])
210
+ if not wallet or not wallet.get('xrp_seed'):
211
+ raise HTTPException(status_code=400, detail="XRP wallet required")
212
+
213
+ # Decrypt wallet seed
214
+ wallet_seed = decrypt_secret(wallet['xrp_seed'], settings.ENCRYPTION_KEY)
215
+ if not wallet_seed:
216
+ raise HTTPException(status_code=500, detail="Failed to decrypt wallet")
217
+
218
+ result = secondary_market.cancel_offer(
219
+ wallet_seed=wallet_seed,
220
+ offer_sequence=cancel_request.offer_sequence
221
+ )
222
+
223
+ if not result.get('success'):
224
+ raise HTTPException(status_code=400, detail=result.get('error', 'Failed to cancel offer'))
225
+
226
+ return {
227
+ "success": True,
228
+ "message": "Offer cancelled successfully",
229
+ "tx_hash": result['tx_hash']
230
+ }
231
+
232
+
233
+ @router.get("/secondary-market/my-offers")
234
+ @limiter.limit("60/minute") # Limit queries
235
+ def get_my_offers(
236
+ request: Request,
237
+ db=Depends(get_mongo),
238
+ current_user=Depends(get_current_user)
239
+ ):
240
+ """Get all your active buy/sell offers"""
241
+ wallet = repo.get_wallet_by_user(db, current_user['id'])
242
+ if not wallet or not wallet.get('xrp_address'):
243
+ return {"offers": []}
244
+
245
+ offers = secondary_market.get_user_offers(wallet['xrp_address'])
246
+
247
+ return {
248
+ "success": True,
249
+ "offers": offers,
250
+ "total_offers": len(offers)
251
+ }
252
+
253
+
254
+ @router.get("/secondary-market/orderbook/{property_id}")
255
+ @limiter.limit("60/minute") # Limit orderbook queries
256
+ def get_orderbook(
257
+ request: Request,
258
+ property_id: str,
259
+ db=Depends(get_mongo),
260
+ current_user: dict = Depends(get_current_user)
261
+ ):
262
+ """Get orderbook (buy and sell offers) for a property - Requires authentication - Rate Limited: 60/minute"""
263
+ property_token = repo.get_property_token(db, property_id)
264
+ if not property_token:
265
+ raise HTTPException(status_code=404, detail="Property token not found")
266
+
267
+ orderbook = secondary_market.get_orderbook(
268
+ currency_code=property_token['currency_code'],
269
+ issuer_address=property_token['issuer_address']
270
+ )
271
+
272
+ if not orderbook.get('success'):
273
+ raise HTTPException(status_code=500, detail="Failed to get orderbook")
274
+
275
+ # Convert XRP prices to AED for display
276
+ for bid in orderbook['bids']:
277
+ bid['price_aed'] = price_oracle.get_xrp_to_aed(bid['price'])
278
+
279
+ for ask in orderbook['asks']:
280
+ ask['price_aed'] = price_oracle.get_xrp_to_aed(ask['price'])
281
+
282
+ return {
283
+ "success": True,
284
+ "property_id": property_id,
285
+ "orderbook": orderbook
286
+ }
routes/super_admin.py ADDED
@@ -0,0 +1,1410 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Super Admin Routes
3
+ Handles platform-wide administration and management
4
+ Only accessible by users with super_admin role
5
+ """
6
+ from fastapi import APIRouter, Depends, HTTPException, status, Query
7
+ from typing import List, Optional
8
+ from pydantic import BaseModel
9
+ from datetime import datetime, timedelta
10
+ from pathlib import Path
11
+ from bson import ObjectId
12
+ import json
13
+ import psutil
14
+ import time
15
+
16
+ import schemas
17
+ from db import get_mongo
18
+ import repo
19
+ from repo import _clean_object
20
+ from routes.auth import get_current_user, hash_password, verify_password
21
+ from middleware.security import sanitize_input
22
+
23
+ router = APIRouter()
24
+
25
+ SECURITY_LOG_PATH = Path("logs/security.log")
26
+ APP_START_TIME = time.time()
27
+
28
+
29
+ class NotificationDismissPayload(BaseModel):
30
+ notification_ids: List[str]
31
+
32
+
33
+ def _notification_ack_collection(db):
34
+ return db.super_admin_notification_ack
35
+
36
+
37
+ def _get_acknowledged_notification_ids(db, super_admin_id) -> set:
38
+ coll = _notification_ack_collection(db)
39
+ cursor = coll.find({
40
+ "super_admin_id": str(super_admin_id)
41
+ }, {"notification_id": 1})
42
+ return {doc.get("notification_id") for doc in cursor if doc.get("notification_id")}
43
+
44
+
45
+ def _acknowledge_notifications(db, super_admin_id, notification_ids: List[str]):
46
+ if not notification_ids:
47
+ return
48
+ coll = _notification_ack_collection(db)
49
+ now = datetime.utcnow()
50
+ for notif_id in notification_ids:
51
+ if not notif_id:
52
+ continue
53
+ coll.update_one(
54
+ {"super_admin_id": str(super_admin_id), "notification_id": notif_id},
55
+ {"$set": {"acknowledged_at": now}},
56
+ upsert=True
57
+ )
58
+
59
+
60
+ def get_current_super_admin(current_user=Depends(get_current_user)):
61
+ """Verify user has super_admin role"""
62
+ if current_user.get("role") != "super_admin":
63
+ raise HTTPException(
64
+ status_code=status.HTTP_403_FORBIDDEN,
65
+ detail="Super Admin access required. This action is restricted to platform administrators only."
66
+ )
67
+ return current_user
68
+
69
+
70
+ def _parse_security_logs(limit: int = 50) -> List[dict]:
71
+ """Tail the structured security log file and surface recent entries."""
72
+ if not SECURITY_LOG_PATH.exists():
73
+ return []
74
+
75
+ try:
76
+ with SECURITY_LOG_PATH.open("r", encoding="utf-8", errors="ignore") as handle:
77
+ lines = handle.readlines()
78
+ except Exception as exc:
79
+ print(f"[SUPER ADMIN] Unable to read security log: {exc}")
80
+ return []
81
+
82
+ entries: List[dict] = []
83
+ severity_map = {
84
+ "INFO": "low",
85
+ "WARNING": "high",
86
+ "ERROR": "critical",
87
+ "CRITICAL": "critical"
88
+ }
89
+
90
+ for raw_line in reversed(lines):
91
+ if len(entries) >= limit:
92
+ break
93
+ parts = raw_line.strip().split(" - ", 3)
94
+ if len(parts) < 4:
95
+ continue
96
+ timestamp_str, _logger, level, message = parts
97
+ payload = {}
98
+ json_start = message.find("{")
99
+ action = message.split(":", 1)[0].strip()
100
+ if json_start != -1:
101
+ try:
102
+ payload = json.loads(message[json_start:])
103
+ except json.JSONDecodeError:
104
+ payload = {}
105
+
106
+ entry = {
107
+ "id": f"audit-{abs(hash(raw_line))}",
108
+ "actor": payload.get("email") or payload.get("user_id") or payload.get("ip_address") or "System",
109
+ "action": action or payload.get("event_type", "Security event"),
110
+ "severity": severity_map.get(level.strip().upper(), "medium"),
111
+ "source": payload.get("event_type", "system"),
112
+ "entity": payload.get("endpoint") or payload.get("data_type") or payload.get("activity_type"),
113
+ "created_at": timestamp_str,
114
+ "metadata": payload,
115
+ }
116
+ entries.append(entry)
117
+
118
+ return list(reversed(entries))
119
+
120
+
121
+ def _build_wallet_overview(db):
122
+ """Aggregate wallet balances by owner role for dashboard metrics."""
123
+ wallets = list(db.wallets.find({}))
124
+ if not wallets:
125
+ return {
126
+ "summary": {
127
+ "escrow_balance": {"value": 0.0, "formatted": "0.00 XRP", "updated_at": datetime.utcnow(), "variance": "0% of total"},
128
+ "reserve_balance": {"value": 0.0, "formatted": "0.00 XRP", "updated_at": datetime.utcnow(), "variance": "0% of total"},
129
+ "operational_balance": {"value": 0.0, "formatted": "0.00 XRP", "updated_at": datetime.utcnow(), "variance": "0% of total", "pending_payouts": 0},
130
+ },
131
+ "wallets": []
132
+ }
133
+
134
+ owner_ids = {wallet.get("user_id") for wallet in wallets if wallet.get("user_id")}
135
+ owners = {}
136
+ if owner_ids:
137
+ owner_docs = db.users.find({"_id": {"$in": list(owner_ids)}})
138
+ owners = {doc["_id"]: doc for doc in owner_docs}
139
+
140
+ type_totals = {"escrow": 0.0, "reserve": 0.0, "operational": 0.0}
141
+ wallet_payload = []
142
+
143
+ for wallet in wallets:
144
+ owner = owners.get(wallet.get("user_id"))
145
+ role = (owner or {}).get("role", "user")
146
+ if role == "admin":
147
+ wallet_type = "reserve"
148
+ elif role == "super_admin":
149
+ wallet_type = "operational"
150
+ else:
151
+ wallet_type = "escrow"
152
+
153
+ balance = float(wallet.get("balance", 0.0))
154
+ type_totals[wallet_type] += balance
155
+ last_synced_at = wallet.get("last_synced_at") or wallet.get("updated_at")
156
+ status = "healthy" if wallet.get("is_active", True) else "inactive"
157
+ wallet_payload.append({
158
+ "id": str(wallet.get("_id")),
159
+ "label": (owner or {}).get("name") or wallet.get("label") or "Platform Wallet",
160
+ "type": wallet_type,
161
+ "address": wallet.get("xrp_address"),
162
+ "balance": balance,
163
+ "formatted_balance": f"{balance:.2f} {wallet.get('currency', 'XRP')}",
164
+ "variance": wallet.get("variance"),
165
+ "last_synced_at": last_synced_at,
166
+ "status": status
167
+ })
168
+
169
+ total_balance = sum(type_totals.values()) or 1.0
170
+ pending_payouts = db.transactions.count_documents({"status": {"$in": ["pending", "processing"]}})
171
+
172
+ def build_metric(bucket: str, include_pending: bool = False):
173
+ value = type_totals[bucket]
174
+ metric = {
175
+ "value": value,
176
+ "formatted": f"{value:.2f} XRP",
177
+ "updated_at": datetime.utcnow(),
178
+ "variance": f"{(value / total_balance) * 100:.1f}% of total"
179
+ }
180
+ if include_pending:
181
+ metric["pending_payouts"] = pending_payouts
182
+ return metric
183
+
184
+ summary = {
185
+ "escrow_balance": build_metric("escrow"),
186
+ "reserve_balance": build_metric("reserve"),
187
+ "operational_balance": build_metric("operational", include_pending=True)
188
+ }
189
+
190
+ return {"summary": summary, "wallets": wallet_payload}
191
+
192
+
193
+ def _calculate_property_risk(property_doc: dict) -> int:
194
+ total = property_doc.get("total_tokens") or 0
195
+ available = property_doc.get("available_tokens") or 0
196
+ if not total:
197
+ return 0
198
+ sold_ratio = max(0.0, min(1.0, (total - available) / total))
199
+ risk = 35 + sold_ratio * 40
200
+ if not property_doc.get("is_active", True):
201
+ risk += 15
202
+ return int(min(95, round(risk)))
203
+
204
+
205
+ def _build_property_audit(property_doc: dict, investments: List[dict]) -> List[dict]:
206
+ entries: List[dict] = []
207
+ creator = property_doc.get("creator_name") or property_doc.get("creator", {}).get("name")
208
+ entries.append({
209
+ "id": f"audit-{property_doc.get('id')}-created",
210
+ "actor": creator or "System",
211
+ "action": "Property created",
212
+ "severity": "low",
213
+ "source": "system",
214
+ "entity": property_doc.get("title"),
215
+ "created_at": property_doc.get("created_at"),
216
+ "metadata": {"property_id": property_doc.get("id")}
217
+ })
218
+
219
+ if property_doc.get("updated_at") and property_doc.get("updated_at") != property_doc.get("created_at"):
220
+ entries.append({
221
+ "id": f"audit-{property_doc.get('id')}-updated",
222
+ "actor": creator or "System",
223
+ "action": "Property updated",
224
+ "severity": "medium",
225
+ "source": "admin",
226
+ "entity": property_doc.get("title"),
227
+ "created_at": property_doc.get("updated_at"),
228
+ "metadata": {"is_active": property_doc.get("is_active")}
229
+ })
230
+
231
+ for investment in investments[:5]:
232
+ entries.append({
233
+ "id": f"audit-{investment.get('id')}-investment",
234
+ "actor": investment.get("investor_email") or investment.get("investor_name") or "Investor",
235
+ "action": "Investment confirmed",
236
+ "severity": "medium",
237
+ "source": "api",
238
+ "entity": property_doc.get("title"),
239
+ "created_at": investment.get("created_at"),
240
+ "metadata": {"amount": investment.get("amount"), "tokens": investment.get("tokens")}
241
+ })
242
+
243
+ return entries
244
+
245
+
246
+ # ============================================================================
247
+ # DASHBOARD & STATISTICS
248
+ # ============================================================================
249
+
250
+ @router.get("/super-admin/stats", response_model=schemas.SuperAdminStatsOut)
251
+ def get_platform_stats(
252
+ db=Depends(get_mongo),
253
+ super_admin=Depends(get_current_super_admin)
254
+ ):
255
+ """
256
+ Get comprehensive platform statistics
257
+ Includes users, admins, properties, revenue, KYC status, etc.
258
+ """
259
+ print(f"\n[SUPER ADMIN] Fetching platform statistics...")
260
+
261
+ # Get all users grouped by role
262
+ all_users = list(db.users.find({"deleted": False}))
263
+ users = [u for u in all_users if u.get("role") == "user"]
264
+ admins = [u for u in all_users if u.get("role") == "admin"]
265
+
266
+ # Active vs blocked users
267
+ active_users = len([u for u in users if u.get("is_active", True)])
268
+ blocked_users = len([u for u in users if not u.get("is_active", True)])
269
+
270
+ # KYC statistics
271
+ pending_kyc = len([u for u in users if u.get("kyc_status") == "pending"])
272
+ approved_kyc = len([u for u in users if u.get("kyc_status") == "approved"])
273
+ rejected_kyc = len([u for u in users if u.get("kyc_status") == "rejected"])
274
+
275
+ # Properties
276
+ total_properties = db.properties.count_documents({"deleted": False})
277
+
278
+ # Investments & Volume
279
+ investments = list(db.investments.find({}))
280
+ total_investments = len(investments)
281
+ total_volume = sum(inv.get("amount", 0) for inv in investments)
282
+ total_tokens_sold = sum(inv.get("tokens_purchased", 0) for inv in investments)
283
+
284
+ # Platform revenue (2% of total volume)
285
+ platform_revenue = total_volume * 0.02
286
+
287
+ # Total transactions
288
+ total_transactions = db.transactions.count_documents({})
289
+
290
+ # Additional stats for enhanced dashboard
291
+ active_properties = db.properties.count_documents({"deleted": False, "is_active": True})
292
+ total_revenue_xrp = platform_revenue # Alias for frontend compatibility
293
+ pending_kyc_count = pending_kyc # Alias for frontend compatibility
294
+ approved_kyc_count = approved_kyc # Alias for frontend compatibility
295
+
296
+ stats = {
297
+ "total_users": len(users),
298
+ "total_admins": len(admins),
299
+ "total_properties": total_properties,
300
+ "active_properties": active_properties,
301
+ "total_investments": total_investments,
302
+ "total_volume": total_volume,
303
+ "platform_revenue": platform_revenue,
304
+ "total_revenue_xrp": total_revenue_xrp,
305
+ "active_users": active_users,
306
+ "blocked_users": blocked_users,
307
+ "pending_kyc": pending_kyc,
308
+ "pending_kyc_count": pending_kyc_count,
309
+ "approved_kyc": approved_kyc,
310
+ "approved_kyc_count": approved_kyc_count,
311
+ "rejected_kyc": rejected_kyc,
312
+ "total_tokens_sold": total_tokens_sold,
313
+ "total_transactions": total_transactions
314
+ }
315
+
316
+ print(f"[SUPER ADMIN] [SUCCESS] Stats calculated successfully")
317
+ return stats
318
+
319
+
320
+ # ============================================================================
321
+ # USER MANAGEMENT
322
+ # ============================================================================
323
+
324
+ @router.get("/super-admin/users")
325
+ def get_all_users(
326
+ skip: int = Query(0, ge=0),
327
+ limit: int = Query(100, ge=1, le=500),
328
+ role: Optional[str] = Query(None),
329
+ is_active: Optional[bool] = None,
330
+ kyc_status: Optional[str] = Query(None),
331
+ search: Optional[str] = None,
332
+ db=Depends(get_mongo),
333
+ super_admin=Depends(get_current_super_admin)
334
+ ):
335
+ """
336
+ Get all users with filtering and pagination
337
+ Can filter by role, active status, KYC status, and search
338
+ """
339
+ try:
340
+ print(f"\n[SUPER ADMIN] Fetching users (skip={skip}, limit={limit})")
341
+ print(f"[SUPER ADMIN] Super admin user: {super_admin.get('email')}")
342
+
343
+ # Build query
344
+ query = {"deleted": False}
345
+
346
+ # Exclude super admins from list
347
+ query["role"] = {"$ne": "super_admin"}
348
+
349
+ if role and role in ["user", "admin"]:
350
+ query["role"] = role
351
+
352
+ if is_active is not None:
353
+ query["is_active"] = is_active
354
+
355
+ if kyc_status and kyc_status in ["pending", "approved", "rejected"]:
356
+ query["kyc_status"] = kyc_status
357
+
358
+ if search:
359
+ try:
360
+ search_clean = sanitize_input(search)
361
+ query["$or"] = [
362
+ {"name": {"$regex": search_clean, "$options": "i"}},
363
+ {"email": {"$regex": search_clean, "$options": "i"}},
364
+ {"phone": {"$regex": search_clean, "$options": "i"}}
365
+ ]
366
+ except Exception as search_error:
367
+ print(f"[SUPER ADMIN] Search sanitization error: {search_error}")
368
+
369
+ print(f"[SUPER ADMIN] Query: {query}")
370
+
371
+ # Get users
372
+ users = list(db.users.find(query).skip(skip).limit(limit).sort("created_at", -1))
373
+ print(f"[SUPER ADMIN] Found {len(users)} users in database")
374
+
375
+ # Enrich with investment data
376
+ result = []
377
+ for user in users:
378
+ try:
379
+ user_id = str(user["_id"])
380
+
381
+ # Get wallet balance
382
+ wallet = db.wallets.find_one({"user_id": user["_id"]})
383
+ wallet_balance = wallet.get("balance", 0.0) if wallet else 0.0
384
+
385
+ # Get investment stats
386
+ user_investments = list(db.investments.find({"user_id": user["_id"]}))
387
+ total_invested = sum(inv.get("amount", 0) for inv in user_investments)
388
+ total_tokens = sum(inv.get("tokens_purchased", 0) for inv in user_investments)
389
+
390
+ # Ensure email satisfies EmailStr validation
391
+ raw_email = user.get("email", "")
392
+ if isinstance(raw_email, str) and "@" in raw_email and "." in raw_email.split("@")[-1]:
393
+ safe_email = raw_email
394
+ else:
395
+ safe_email = f"user{user_id}@invalid.local"
396
+
397
+ # Coerce role to valid enum values (user|admin|super_admin)
398
+ raw_role = str(user.get("role", "user")).strip().lower()
399
+ if raw_role in ("superadmin", "super-admin", "super_admin"):
400
+ raw_role = "super_admin"
401
+ elif raw_role in ("administrator",):
402
+ raw_role = "admin"
403
+ elif raw_role not in ("user", "admin", "super_admin"):
404
+ raw_role = "user"
405
+
406
+ # Normalize types for strict response model
407
+ created_at_val = user.get("created_at")
408
+ if isinstance(created_at_val, datetime):
409
+ created_at_dt = created_at_val
410
+ else:
411
+ created_at_dt = datetime.utcnow()
412
+
413
+ last_login_val = user.get("last_login")
414
+ if isinstance(last_login_val, datetime):
415
+ last_login_dt = last_login_val
416
+ else:
417
+ last_login_dt = None
418
+
419
+ phone_val = str(user.get("phone", "0000000000"))
420
+ if not phone_val or len(phone_val) < 10:
421
+ phone_val = "0000000000"
422
+
423
+ total_invested = float(total_invested or 0.0)
424
+ total_tokens = int(total_tokens or 0)
425
+ wallet_balance = float(wallet_balance or 0.0)
426
+
427
+ result.append({
428
+ "user_id": user_id,
429
+ "full_name": user.get("name", "Unknown"),
430
+ "email": safe_email,
431
+ "role": raw_role,
432
+ "phone": phone_val,
433
+ "is_active": bool(user.get("is_active", True)),
434
+ "kyc_status": user.get("kyc_status", "pending"),
435
+ "created_at": created_at_dt,
436
+ "total_invested": total_invested,
437
+ "total_tokens": total_tokens,
438
+ "wallet_balance": wallet_balance,
439
+ "last_login": last_login_dt
440
+ })
441
+ except Exception as user_error:
442
+ print(f"[SUPER ADMIN] Error processing user {user.get('email', 'unknown')}: {str(user_error)}")
443
+ import traceback
444
+ traceback.print_exc()
445
+ continue
446
+
447
+ print(f"[SUPER ADMIN] [SUCCESS] Returning {len(result)} users")
448
+ return result
449
+
450
+ except Exception as e:
451
+ print(f"[SUPER ADMIN] ERROR in get_all_users: {str(e)}")
452
+ import traceback
453
+ traceback.print_exc()
454
+ raise HTTPException(
455
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
456
+ detail=f"Error fetching users: {str(e)}"
457
+ )
458
+
459
+
460
+ @router.put("/super-admin/users/{user_id}/block")
461
+ def block_user(
462
+ user_id: str,
463
+ block_request: schemas.BlockUserRequest,
464
+ db=Depends(get_mongo),
465
+ super_admin=Depends(get_current_super_admin)
466
+ ):
467
+ """Block a user account"""
468
+ print(f"\n[SUPER ADMIN] Blocking user: {user_id}")
469
+
470
+ user = repo.get_user_by_id(db, user_id)
471
+ if not user:
472
+ raise HTTPException(status_code=404, detail="User not found")
473
+
474
+ if user.get("role") == "super_admin":
475
+ raise HTTPException(status_code=403, detail="Cannot block super admin")
476
+
477
+ # Update user status
478
+ updated = repo.update_user(db, user_id, {
479
+ "is_active": False,
480
+ "block_reason": sanitize_input(block_request.reason),
481
+ "blocked_at": datetime.utcnow(),
482
+ "blocked_by": super_admin["id"]
483
+ })
484
+
485
+ print(f"[SUPER ADMIN] [SUCCESS] User blocked: {user['email']}")
486
+ return {"message": "User blocked successfully", "user": updated}
487
+
488
+
489
+ @router.put("/super-admin/users/{user_id}/unblock")
490
+ def unblock_user(
491
+ user_id: str,
492
+ db=Depends(get_mongo),
493
+ super_admin=Depends(get_current_super_admin)
494
+ ):
495
+ """Unblock a user account"""
496
+ print(f"\n[SUPER ADMIN] Unblocking user: {user_id}")
497
+
498
+ user = repo.get_user_by_id(db, user_id)
499
+ if not user:
500
+ raise HTTPException(status_code=404, detail="User not found")
501
+
502
+ # Update user status
503
+ updated = repo.update_user(db, user_id, {
504
+ "is_active": True,
505
+ "block_reason": None,
506
+ "blocked_at": None,
507
+ "blocked_by": None
508
+ })
509
+
510
+ print(f"[SUPER ADMIN] [SUCCESS] User unblocked: {user['email']}")
511
+ return {"message": "User unblocked successfully", "user": updated}
512
+
513
+
514
+ @router.delete("/super-admin/users/{user_id}")
515
+ def delete_user(
516
+ user_id: str,
517
+ db=Depends(get_mongo),
518
+ super_admin=Depends(get_current_super_admin)
519
+ ):
520
+ """Soft delete a user account"""
521
+ print(f"\n[SUPER ADMIN] Deleting user: {user_id}")
522
+
523
+ user = repo.get_user_by_id(db, user_id)
524
+ if not user:
525
+ raise HTTPException(status_code=404, detail="User not found")
526
+
527
+ if user.get("role") == "super_admin":
528
+ raise HTTPException(status_code=403, detail="Cannot delete super admin")
529
+
530
+ # Soft delete
531
+ updated = repo.update_user(db, user_id, {
532
+ "deleted": True,
533
+ "is_active": False,
534
+ "deleted_at": datetime.utcnow(),
535
+ "deleted_by": super_admin["id"]
536
+ })
537
+
538
+ print(f"[SUPER ADMIN] [SUCCESS] User deleted: {user['email']}")
539
+ return {"message": "User deleted successfully"}
540
+
541
+
542
+ @router.put("/super-admin/users/{user_id}/role")
543
+ def update_user_role(
544
+ user_id: str,
545
+ role_request: schemas.UpdateUserRoleRequest,
546
+ db=Depends(get_mongo),
547
+ super_admin=Depends(get_current_super_admin)
548
+ ):
549
+ """Promote user to admin or demote admin to user"""
550
+ print(f"\n[SUPER ADMIN] Updating user role: {user_id} -> {role_request.new_role}")
551
+
552
+ user = repo.get_user_by_id(db, user_id)
553
+ if not user:
554
+ raise HTTPException(status_code=404, detail="User not found")
555
+
556
+ if user.get("role") == "super_admin":
557
+ raise HTTPException(status_code=403, detail="Cannot change super admin role")
558
+
559
+ # Update role
560
+ updated = repo.update_user(db, user_id, {
561
+ "role": role_request.new_role.value,
562
+ "role_updated_at": datetime.utcnow(),
563
+ "role_updated_by": super_admin["id"]
564
+ })
565
+
566
+ print(f"[SUPER ADMIN] [SUCCESS] Role updated: {user['email']} -> {role_request.new_role}")
567
+ return {"message": f"User role updated to {role_request.new_role}", "user": updated}
568
+
569
+
570
+ # ============================================================================
571
+ # ADMIN MANAGEMENT
572
+ # ============================================================================
573
+
574
+ @router.get("/super-admin/admins")
575
+ def get_all_admins(
576
+ db=Depends(get_mongo),
577
+ super_admin=Depends(get_current_super_admin)
578
+ ):
579
+ """Get all admin users with their statistics"""
580
+ try:
581
+ print(f"\n[SUPER ADMIN] Fetching all admins...")
582
+
583
+ admins = list(db.users.find({"role": "admin", "deleted": False}))
584
+
585
+ result = []
586
+ for admin in admins:
587
+ try:
588
+ admin_id = str(admin["_id"])
589
+
590
+ # Get admin's properties
591
+ properties = list(db.properties.find({"created_by": admin["_id"], "deleted": False}))
592
+ total_properties = len(properties)
593
+
594
+ # Get investments in admin's properties
595
+ property_ids = [p["_id"] for p in properties]
596
+ investments = list(db.investments.find({"property_id": {"$in": [str(pid) for pid in property_ids]}}))
597
+
598
+ total_revenue = sum(inv.get("amount", 0) for inv in investments) * 0.02 # 2% commission
599
+ unique_buyers = len(set(str(inv.get("user_id")) for inv in investments))
600
+
601
+ # Ensure email is valid
602
+ raw_email = admin.get("email", "")
603
+ if isinstance(raw_email, str) and "@" in raw_email and "." in raw_email.split("@")[-1]:
604
+ safe_email = raw_email
605
+ else:
606
+ safe_email = f"admin{admin_id}@invalid.local"
607
+
608
+ # Ensure phone is valid
609
+ phone_val = str(admin.get("phone", "0000000000"))
610
+ if not phone_val or len(phone_val) < 10:
611
+ phone_val = "0000000000"
612
+
613
+ # Ensure dates are datetime objects
614
+ created_at = admin.get("created_at")
615
+ if not isinstance(created_at, datetime):
616
+ created_at = datetime.utcnow()
617
+
618
+ last_login = admin.get("last_login")
619
+ if last_login and not isinstance(last_login, datetime):
620
+ last_login = None
621
+
622
+ result.append({
623
+ "admin_id": admin_id,
624
+ "id": admin_id,
625
+ "full_name": admin.get("name", "Unknown"),
626
+ "name": admin.get("name", "Unknown"),
627
+ "email": safe_email,
628
+ "phone": phone_val,
629
+ "is_active": bool(admin.get("is_active", True)),
630
+ "kyc_status": admin.get("kyc_status", "pending"),
631
+ "created_at": created_at,
632
+ "last_login": last_login,
633
+ "properties_count": total_properties,
634
+ "total_properties": total_properties,
635
+ "total_revenue_xrp": float(total_revenue),
636
+ "total_revenue": float(total_revenue),
637
+ "total_buyers": unique_buyers
638
+ })
639
+ except Exception as admin_error:
640
+ print(f"[SUPER ADMIN] Error processing admin {admin.get('email', 'unknown')}: {str(admin_error)}")
641
+ import traceback
642
+ traceback.print_exc()
643
+ continue
644
+
645
+ print(f"[SUPER ADMIN] [SUCCESS] Found {len(result)} admins")
646
+ return result
647
+
648
+ except Exception as e:
649
+ print(f"[SUPER ADMIN] ERROR in get_all_admins: {str(e)}")
650
+ import traceback
651
+ traceback.print_exc()
652
+ raise HTTPException(
653
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
654
+ detail=f"Error fetching admins: {str(e)}"
655
+ )
656
+
657
+
658
+ # ============================================================================
659
+ # PROPERTY MANAGEMENT
660
+ # ============================================================================
661
+
662
+ @router.get("/super-admin/properties")
663
+ def get_all_properties(
664
+ skip: int = Query(0, ge=0),
665
+ limit: int = Query(100, ge=1, le=500),
666
+ is_active: Optional[bool] = None,
667
+ property_type: Optional[str] = None,
668
+ db=Depends(get_mongo),
669
+ super_admin=Depends(get_current_super_admin)
670
+ ):
671
+ """Get all properties across all admins"""
672
+ print(f"\n[SUPER ADMIN] Fetching all properties...")
673
+
674
+ query = {"deleted": False}
675
+ if is_active is not None:
676
+ query["is_active"] = is_active
677
+ if property_type:
678
+ query["property_type"] = property_type
679
+
680
+ properties = repo.list_properties_optimized(db, is_active=is_active, skip=skip, limit=limit)
681
+
682
+ for prop in properties:
683
+ creator = prop.pop("creator", None)
684
+ if creator:
685
+ prop["creator_name"] = creator.get("name", "Unknown")
686
+ prop["creator_email"] = creator.get("email", "Unknown")
687
+ if prop.get("images") and not prop.get("image"):
688
+ main_image = next((img.get("image_url") for img in prop["images"] if img.get("is_main")), None)
689
+ prop["image"] = main_image or prop["images"][0].get("image_url")
690
+
691
+ print(f"[SUPER ADMIN] [SUCCESS] Found {len(properties)} properties")
692
+ return properties
693
+
694
+
695
+ @router.get("/super-admin/properties/{property_id}")
696
+ def get_property_detail(
697
+ property_id: str,
698
+ db=Depends(get_mongo),
699
+ super_admin=Depends(get_current_super_admin)
700
+ ):
701
+ """Return a single property with investments and audit trail."""
702
+ try:
703
+ property_doc = repo.get_property_by_id_optimized(db, property_id)
704
+ except Exception as exc:
705
+ print(f"[SUPER ADMIN] Invalid property id {property_id}: {exc}")
706
+ raise HTTPException(status_code=400, detail="Invalid property id")
707
+
708
+ if not property_doc:
709
+ raise HTTPException(status_code=404, detail="Property not found")
710
+
711
+ creator = property_doc.pop("creator", None)
712
+ if creator:
713
+ property_doc["creator_name"] = creator.get("name")
714
+ property_doc["creator_email"] = creator.get("email")
715
+
716
+ try:
717
+ property_oid = ObjectId(property_id)
718
+ except Exception:
719
+ raise HTTPException(status_code=400, detail="Invalid property id")
720
+
721
+ investments_raw = list(
722
+ db.investments.find({"property_id": property_oid}).sort("created_at", -1).limit(50)
723
+ )
724
+ investor_ids = {inv.get("user_id") for inv in investments_raw if inv.get("user_id")}
725
+ investor_map = {}
726
+ if investor_ids:
727
+ investor_docs = db.users.find({"_id": {"$in": list(investor_ids)}})
728
+ investor_map = {doc["_id"]: doc for doc in investor_docs}
729
+
730
+ investments: List[dict] = []
731
+ for inv in investments_raw:
732
+ investor = investor_map.get(inv.get("user_id"))
733
+ investments.append({
734
+ "id": str(inv.get("_id")),
735
+ "investor_name": (investor or {}).get("name", "Unknown"),
736
+ "investor_email": (investor or {}).get("email"),
737
+ "tokens": inv.get("tokens_purchased", 0),
738
+ "amount": inv.get("amount", 0),
739
+ "status": inv.get("status"),
740
+ "created_at": inv.get("created_at")
741
+ })
742
+
743
+ property_doc["investments"] = investments
744
+ property_doc["audit_log"] = _build_property_audit(property_doc, investments)
745
+ property_doc["minted_tokens"] = max(0, (property_doc.get("total_tokens") or 0) - (property_doc.get("available_tokens") or 0))
746
+ property_doc["audit_risk_score"] = _calculate_property_risk(property_doc)
747
+
748
+ return property_doc
749
+
750
+
751
+ @router.put("/super-admin/properties/{property_id}/toggle-active")
752
+ def toggle_property_active(
753
+ property_id: str,
754
+ db=Depends(get_mongo),
755
+ super_admin=Depends(get_current_super_admin)
756
+ ):
757
+ """Activate or deactivate any property"""
758
+ print(f"\n[SUPER ADMIN] Toggling property active status: {property_id}")
759
+
760
+ prop = repo.get_property_by_id(db, property_id)
761
+ if not prop:
762
+ raise HTTPException(status_code=404, detail="Property not found")
763
+
764
+ new_status = not prop.get("is_active", True)
765
+ updated = repo.update_property(db, property_id, {"is_active": new_status})
766
+
767
+ print(f"[SUPER ADMIN] [SUCCESS] Property status changed: {new_status}")
768
+ return {"message": f"Property {'activated' if new_status else 'deactivated'}", "property": updated}
769
+
770
+
771
+ # ============================================================================
772
+ # CUSTODIAL WALLETS
773
+ # ============================================================================
774
+
775
+ @router.get("/super-admin/wallets", response_model=schemas.WalletOverviewOut)
776
+ def get_wallet_overview(
777
+ db=Depends(get_mongo),
778
+ super_admin=Depends(get_current_super_admin)
779
+ ):
780
+ """Surface custodial wallet balances grouped by owner role."""
781
+ overview = _build_wallet_overview(db)
782
+ return overview
783
+
784
+
785
+ @router.post("/super-admin/wallets/sync")
786
+ def trigger_wallet_sync(
787
+ db=Depends(get_mongo),
788
+ super_admin=Depends(get_current_super_admin)
789
+ ):
790
+ now = datetime.utcnow()
791
+ result = db.wallets.update_many({}, {"$set": {"last_synced_at": now, "updated_at": now}})
792
+ print(f"[SUPER ADMIN] Wallet sync triggered by {super_admin.get('email')} -> {result.modified_count} entries")
793
+ return {"message": "Wallet reconciliation triggered", "synced_wallets": result.modified_count}
794
+
795
+
796
+ # ============================================================================
797
+ # TRANSACTION MONITORING
798
+ # ============================================================================
799
+
800
+ @router.get("/super-admin/transactions", response_model=List[schemas.TransactionDetailOut])
801
+ def get_all_transactions(
802
+ skip: int = Query(0, ge=0),
803
+ limit: int = Query(100, ge=1, le=500),
804
+ tx_type: Optional[str] = None,
805
+ status: Optional[str] = None,
806
+ db=Depends(get_mongo),
807
+ super_admin=Depends(get_current_super_admin)
808
+ ):
809
+ """Get all transactions across the platform"""
810
+ print(f"\n[SUPER ADMIN] Fetching transactions...")
811
+
812
+ query = {}
813
+ if tx_type:
814
+ query["type"] = tx_type
815
+ if status:
816
+ query["status"] = status
817
+
818
+ transactions = list(db.transactions.find(query).skip(skip).limit(limit).sort("created_at", -1))
819
+
820
+ result = []
821
+ for tx in transactions:
822
+ # Get user info
823
+ user = db.users.find_one({"_id": tx.get("user_id")})
824
+
825
+ # Get property info if applicable
826
+ property_name = None
827
+ if tx.get("property_id"):
828
+ prop = db.properties.find_one({"_id": tx.get("property_id")})
829
+ property_name = prop.get("title") if prop else None
830
+
831
+ result.append({
832
+ "id": str(tx["_id"]),
833
+ "user_id": str(tx.get("user_id")),
834
+ "user_name": user.get("name") if user else "Unknown",
835
+ "user_email": user.get("email") if user else "Unknown",
836
+ "wallet_id": str(tx.get("wallet_id")) if tx.get("wallet_id") else None,
837
+ "type": tx.get("type"),
838
+ "amount": tx.get("amount", 0),
839
+ "property_id": str(tx.get("property_id")) if tx.get("property_id") else None,
840
+ "property_name": property_name,
841
+ "status": tx.get("status"),
842
+ "metadata": tx.get("metadata", {}),
843
+ "created_at": tx.get("created_at", datetime.utcnow()),
844
+ "blockchain_tx_hash": tx.get("metadata", {}).get("blockchain_tx_hash")
845
+ })
846
+
847
+ print(f"[SUPER ADMIN] [SUCCESS] Found {len(result)} transactions")
848
+ return result
849
+
850
+
851
+ # ============================================================================
852
+ # KYC MANAGEMENT
853
+ # ============================================================================
854
+
855
+ @router.get("/super-admin/kyc/pending")
856
+ def get_pending_kyc(
857
+ db=Depends(get_mongo),
858
+ super_admin=Depends(get_current_super_admin)
859
+ ):
860
+ """Get all pending KYC documents"""
861
+ print(f"\n[SUPER ADMIN] Fetching pending KYC documents...")
862
+
863
+ # Find users with pending KYC
864
+ pending_users = list(db.users.find({
865
+ "kyc_status": "pending",
866
+ "deleted": False
867
+ }))
868
+
869
+ kyc_submissions = []
870
+ for user in pending_users:
871
+ kyc_submissions.append({
872
+ "user_id": str(user["_id"]),
873
+ "full_name": user.get("name", ""),
874
+ "email": user.get("email", ""),
875
+ "role": user.get("role", "user"),
876
+ "kyc_status": user.get("kyc_status", "pending"),
877
+ "document_type": user.get("kyc_document_type", "ID Card"),
878
+ "kyc_document_url": user.get("kyc_document_url", None),
879
+ "submitted_at": user.get("kyc_submitted_at", user.get("created_at", datetime.utcnow()))
880
+ })
881
+
882
+ print(f"[SUPER ADMIN] [SUCCESS] Found {len(kyc_submissions)} pending KYC")
883
+ return {"kyc_submissions": kyc_submissions}
884
+
885
+
886
+ @router.put("/super-admin/kyc/{user_id}/approve")
887
+ def approve_kyc(
888
+ user_id: str,
889
+ approval: schemas.KYCApprovalRequest,
890
+ db=Depends(get_mongo),
891
+ super_admin=Depends(get_current_super_admin)
892
+ ):
893
+ """Approve or reject user KYC"""
894
+ print(f"\n[SUPER ADMIN] Reviewing KYC for user: {user_id} -> {approval.status}")
895
+
896
+ user = repo.get_user_by_id(db, user_id)
897
+ if not user:
898
+ raise HTTPException(status_code=404, detail="User not found")
899
+
900
+ update_data = {
901
+ "kyc_status": approval.status,
902
+ "kyc_reviewed_at": datetime.utcnow(),
903
+ "kyc_reviewed_by": super_admin["id"]
904
+ }
905
+
906
+ if approval.status == "rejected" and approval.rejection_reason:
907
+ update_data["kyc_rejection_reason"] = sanitize_input(approval.rejection_reason)
908
+
909
+ updated = repo.update_user(db, user_id, update_data)
910
+
911
+ print(f"[SUPER ADMIN] [SUCCESS] KYC {approval.status} for {user['email']}")
912
+ return {"message": f"KYC {approval.status}", "user": updated}
913
+
914
+
915
+ # ============================================================================
916
+ # PLATFORM SETTINGS
917
+ # ============================================================================
918
+
919
+ @router.get("/super-admin/settings", response_model=schemas.PlatformSettingsOut)
920
+ def get_platform_settings(
921
+ db=Depends(get_mongo),
922
+ super_admin=Depends(get_current_super_admin)
923
+ ):
924
+ """Get current platform settings"""
925
+ # Settings stored in a special collection
926
+ settings_doc = db.platform_settings.find_one({"_id": "global"})
927
+
928
+ if not settings_doc:
929
+ # Default settings
930
+ return {
931
+ "min_investment": 2000.0,
932
+ "platform_fee_percentage": 2.0,
933
+ "kyc_required": True,
934
+ "new_registrations_enabled": True,
935
+ "purchases_enabled": True,
936
+ "maintenance_mode": False
937
+ }
938
+
939
+ return settings_doc
940
+
941
+
942
+ @router.put("/super-admin/settings")
943
+ def update_platform_settings(
944
+ settings: schemas.PlatformSettingsUpdate,
945
+ db=Depends(get_mongo),
946
+ super_admin=Depends(get_current_super_admin)
947
+ ):
948
+ """Update platform settings"""
949
+ print(f"\n[SUPER ADMIN] Updating platform settings...")
950
+
951
+ update_data = {k: v for k, v in settings.dict().items() if v is not None}
952
+ update_data["updated_at"] = datetime.utcnow()
953
+ update_data["updated_by"] = super_admin["id"]
954
+
955
+ db.platform_settings.update_one(
956
+ {"_id": "global"},
957
+ {"$set": update_data},
958
+ upsert=True
959
+ )
960
+
961
+ print(f"[SUPER ADMIN] [SUCCESS] Settings updated")
962
+ return {"message": "Settings updated successfully", "settings": update_data}
963
+
964
+
965
+ @router.get("/super-admin/audit-logs")
966
+ def get_audit_logs(
967
+ limit: int = Query(50, ge=10, le=200),
968
+ super_admin=Depends(get_current_super_admin)
969
+ ):
970
+ """Expose parsed security log snippets for the audit center."""
971
+ return _parse_security_logs(limit)
972
+
973
+
974
+ # ============================================================================
975
+ # SUPER ADMIN PROFILE & SECURITY
976
+ # ============================================================================
977
+
978
+ @router.get("/super-admin/profile", response_model=schemas.SuperAdminProfileOut)
979
+ def get_profile(super_admin=Depends(get_current_super_admin)):
980
+ """Return current super admin profile details."""
981
+ return {
982
+ "full_name": super_admin.get("name", ""),
983
+ "email": super_admin.get("email"),
984
+ "two_factor_enabled": bool(super_admin.get("two_factor_enabled"))
985
+ }
986
+
987
+
988
+ @router.put("/super-admin/profile", response_model=schemas.SuperAdminProfileOut)
989
+ def update_profile(
990
+ payload: schemas.SuperAdminProfileUpdate,
991
+ db=Depends(get_mongo),
992
+ super_admin=Depends(get_current_super_admin)
993
+ ):
994
+ """Allow renaming/email updates for the active super admin."""
995
+ update_data = {
996
+ "name": sanitize_input(payload.full_name),
997
+ "email": payload.email,
998
+ "updated_at": datetime.utcnow()
999
+ }
1000
+ updated = repo.update_user(db, super_admin["id"], update_data)
1001
+ return {
1002
+ "full_name": updated.get("name"),
1003
+ "email": updated.get("email"),
1004
+ "two_factor_enabled": bool(updated.get("two_factor_enabled"))
1005
+ }
1006
+
1007
+
1008
+ @router.post("/super-admin/profile/password")
1009
+ def update_password(
1010
+ payload: schemas.PasswordUpdateRequest,
1011
+ db=Depends(get_mongo),
1012
+ super_admin=Depends(get_current_super_admin)
1013
+ ):
1014
+ """Rotate the super admin password after verifying the existing secret."""
1015
+ if not verify_password(payload.current_password, super_admin.get("password_hash")):
1016
+ raise HTTPException(status_code=400, detail="Current password is incorrect")
1017
+
1018
+ hashed = hash_password(payload.new_password)
1019
+ repo.update_user(db, super_admin["id"], {"password_hash": hashed, "password_rotated_at": datetime.utcnow()})
1020
+ return {"message": "Password updated successfully"}
1021
+
1022
+
1023
+ @router.post("/super-admin/profile/two-factor")
1024
+ def toggle_two_factor(
1025
+ payload: schemas.TwoFactorToggleRequest,
1026
+ db=Depends(get_mongo),
1027
+ super_admin=Depends(get_current_super_admin)
1028
+ ):
1029
+ """Enable or disable the 2FA flag for the active super admin."""
1030
+ repo.update_user(db, super_admin["id"], {
1031
+ "two_factor_enabled": bool(payload.enabled),
1032
+ "two_factor_updated_at": datetime.utcnow()
1033
+ })
1034
+ return {"message": "Two-factor preference updated", "enabled": payload.enabled}
1035
+
1036
+
1037
+ # ============================================================================
1038
+ # NOTIFICATIONS
1039
+ # ============================================================================
1040
+
1041
+ @router.get("/super-admin/notifications")
1042
+ def get_notifications(
1043
+ db=Depends(get_mongo),
1044
+ super_admin=Depends(get_current_super_admin)
1045
+ ):
1046
+ """
1047
+ Get rich, actionable notifications for Super Admin
1048
+ - Surface every new investor registration from the last 24h
1049
+ - Highlight pending KYCs with the actual user name/email
1050
+ - Flag pending withdrawal requests that still need approval
1051
+ """
1052
+ notifications = []
1053
+ now = datetime.utcnow()
1054
+ last_24h = now - timedelta(hours=24)
1055
+
1056
+ def _ts(value, default_ts):
1057
+ if isinstance(value, datetime):
1058
+ return value.isoformat()
1059
+ if isinstance(value, str):
1060
+ return value
1061
+ return default_ts.isoformat()
1062
+
1063
+ # 1. Individual new registrations (last 24h)
1064
+ recent_users = db.users.find({
1065
+ "created_at": {"$gte": last_24h},
1066
+ "role": "user",
1067
+ "deleted": False
1068
+ }).sort("created_at", -1).limit(5)
1069
+
1070
+ for user in recent_users:
1071
+ user_name = user.get("name") or user.get("full_name") or user.get("email") or "New investor"
1072
+ email = user.get("email", "-")
1073
+ source = user.get("registration_source", "Web")
1074
+ notifications.append({
1075
+ "id": f"notif-new-user-{str(user.get('_id'))}",
1076
+ "title": "New Investor Registered",
1077
+ "message": f"{user_name} ({email}) completed onboarding via {source}.",
1078
+ "type": "info",
1079
+ "created_at": _ts(user.get("created_at"), now),
1080
+ "read": False,
1081
+ "action_label": "Open Investors",
1082
+ "action_path": "/super-admin/users"
1083
+ })
1084
+
1085
+ # 2. Pending KYC submissions (mirror admin experience)
1086
+ pending_kyc_docs = db.kyc_documents.find({
1087
+ "status": "pending"
1088
+ }).sort("uploaded_at", -1).limit(5)
1089
+
1090
+ for doc in pending_kyc_docs:
1091
+ related_user = repo.get_user_by_id(db, str(doc.get("user_id"))) if doc.get("user_id") else None
1092
+ user_name = related_user.get("name") if related_user else doc.get("full_name") or "Unknown user"
1093
+ email = related_user.get("email") if related_user else "N/A"
1094
+ doc_type = doc.get("document_type", "Identity document")
1095
+ uploaded_at = doc.get("uploaded_at") or now
1096
+ age_hours = (now - uploaded_at).total_seconds() / 3600 if isinstance(uploaded_at, datetime) else 0
1097
+ severity = "danger" if age_hours >= 48 else "warning"
1098
+
1099
+ notifications.append({
1100
+ "id": f"notif-kyc-{str(doc.get('_id'))}",
1101
+ "title": "Pending KYC Review",
1102
+ "message": f"{user_name} ({email}) submitted {doc_type}. Awaiting verification.",
1103
+ "type": severity,
1104
+ "created_at": _ts(uploaded_at, now),
1105
+ "read": False,
1106
+ "action_label": "Review KYC",
1107
+ "action_path": "/super-admin/kyc"
1108
+ })
1109
+
1110
+ # 3. Pending withdrawal transactions (actionable alert)
1111
+ pending_withdrawals = db.transactions.find({
1112
+ "status": "pending",
1113
+ "type": "withdrawal"
1114
+ }).sort("created_at", -1).limit(5)
1115
+
1116
+ for tx in pending_withdrawals:
1117
+ related_user = repo.get_user_by_id(db, str(tx.get("user_id"))) if tx.get("user_id") else None
1118
+ user_label = related_user.get("email") if related_user else tx.get("user_email", "Investor")
1119
+ amount = tx.get("amount", 0)
1120
+ currency = tx.get("currency", "XRP")
1121
+ wallet = tx.get("target_wallet") or tx.get("address") or "destination wallet"
1122
+ urgency = "danger" if amount and amount >= 5000 else "warning"
1123
+
1124
+ notifications.append({
1125
+ "id": f"notif-withdraw-{str(tx.get('_id'))}",
1126
+ "title": "Withdrawal Approval Needed",
1127
+ "message": f"{user_label} requested {amount} {currency} to {wallet}.",
1128
+ "type": urgency,
1129
+ "created_at": _ts(tx.get("created_at"), now),
1130
+ "read": False,
1131
+ "action_label": "Review Transaction",
1132
+ "action_path": "/super-admin/transactions"
1133
+ })
1134
+
1135
+ # Sort notifications so the most recent surface first
1136
+ notifications.sort(key=lambda item: item.get("created_at", ""), reverse=True)
1137
+
1138
+ acknowledged_ids = _get_acknowledged_notification_ids(db, super_admin.get("_id"))
1139
+ if acknowledged_ids:
1140
+ notifications = [item for item in notifications if item["id"] not in acknowledged_ids]
1141
+
1142
+ return {"notifications": notifications[:15]}
1143
+
1144
+
1145
+ @router.post("/super-admin/notifications/dismiss")
1146
+ def dismiss_notifications(
1147
+ payload: NotificationDismissPayload,
1148
+ db=Depends(get_mongo),
1149
+ super_admin=Depends(get_current_super_admin)
1150
+ ):
1151
+ """Dismiss specific notifications so they no longer show up."""
1152
+ notification_ids = payload.notification_ids or []
1153
+ if not notification_ids:
1154
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No notifications supplied")
1155
+ _acknowledge_notifications(db, super_admin.get("_id"), notification_ids)
1156
+ return {"success": True, "dismissed": len(notification_ids)}
1157
+
1158
+
1159
+ @router.post("/super-admin/notifications/read-all")
1160
+ def mark_notifications_read(
1161
+ payload: Optional[NotificationDismissPayload] = None,
1162
+ db=Depends(get_mongo),
1163
+ super_admin=Depends(get_current_super_admin)
1164
+ ):
1165
+ """Mark (and persist) notifications as read for the current super admin."""
1166
+ notification_ids = payload.notification_ids if payload and payload.notification_ids else []
1167
+ if not notification_ids:
1168
+ current_notifications = get_notifications(db=db, super_admin=super_admin)
1169
+ notification_ids = [item["id"] for item in current_notifications.get("notifications", [])]
1170
+ _acknowledge_notifications(db, super_admin.get("_id"), notification_ids)
1171
+ return {"success": True, "message": "All notifications marked as read", "count": len(notification_ids)}
1172
+
1173
+
1174
+ # ============================================================================
1175
+ # SYSTEM CONTROLS & MONITORING
1176
+ # ============================================================================
1177
+
1178
+ @router.get("/super-admin/system/status")
1179
+ def get_system_status(
1180
+ super_admin=Depends(get_current_super_admin)
1181
+ ):
1182
+ """
1183
+ Get comprehensive system status including CPU, memory, disk, and service health
1184
+ """
1185
+ try:
1186
+ # Get system metrics
1187
+ cpu_usage = psutil.cpu_percent(interval=0.5)
1188
+ memory = psutil.virtual_memory()
1189
+ disk = psutil.disk_usage('/')
1190
+
1191
+ # Calculate uptime
1192
+ uptime_seconds = time.time() - APP_START_TIME
1193
+ uptime_days = int(uptime_seconds // 86400)
1194
+ uptime_hours = int((uptime_seconds % 86400) // 3600)
1195
+ uptime_minutes = int((uptime_seconds % 3600) // 60)
1196
+ uptime_str = f"{uptime_days}d {uptime_hours}h {uptime_minutes}m"
1197
+
1198
+ # Check database connection
1199
+ db = next(get_mongo())
1200
+ try:
1201
+ db.command('ping')
1202
+ db_status = 'connected'
1203
+ except Exception:
1204
+ db_status = 'disconnected'
1205
+
1206
+ return {
1207
+ "status": "healthy" if cpu_usage < 90 and memory.percent < 90 else "warning",
1208
+ "uptime": uptime_str,
1209
+ "cpu_usage": round(cpu_usage, 1),
1210
+ "memory_usage": round(memory.percent, 1),
1211
+ "disk_usage": round(disk.percent, 1),
1212
+ "database_status": db_status,
1213
+ "xrp_service_status": "operational",
1214
+ "ipfs_service_status": "operational",
1215
+ "last_backup": datetime.utcnow().isoformat()
1216
+ }
1217
+ except Exception as e:
1218
+ print(f"[SUPER ADMIN] Error getting system status: {e}")
1219
+ return {
1220
+ "status": "unknown",
1221
+ "uptime": "0d 0h 0m",
1222
+ "cpu_usage": 0,
1223
+ "memory_usage": 0,
1224
+ "disk_usage": 0,
1225
+ "database_status": "unknown",
1226
+ "xrp_service_status": "unknown",
1227
+ "ipfs_service_status": "unknown",
1228
+ "last_backup": datetime.utcnow().isoformat()
1229
+ }
1230
+
1231
+
1232
+ @router.get("/super-admin/system/services")
1233
+ def get_system_services(
1234
+ super_admin=Depends(get_current_super_admin)
1235
+ ):
1236
+ """
1237
+ Get status of all system services
1238
+ """
1239
+ uptime_seconds = time.time() - APP_START_TIME
1240
+ uptime_days = int(uptime_seconds // 86400)
1241
+ uptime_hours = int((uptime_seconds % 86400) // 3600)
1242
+ uptime_str = f"{uptime_days}d {uptime_hours}h"
1243
+
1244
+ services = [
1245
+ {
1246
+ "id": "api",
1247
+ "name": "API Service",
1248
+ "status": "running",
1249
+ "port": 8000,
1250
+ "uptime": uptime_str
1251
+ },
1252
+ {
1253
+ "id": "database",
1254
+ "name": "MongoDB",
1255
+ "status": "running",
1256
+ "port": 27017,
1257
+ "uptime": uptime_str
1258
+ },
1259
+ {
1260
+ "id": "xrp",
1261
+ "name": "XRP Service",
1262
+ "status": "running",
1263
+ "port": None,
1264
+ "uptime": uptime_str
1265
+ },
1266
+ {
1267
+ "id": "ipfs",
1268
+ "name": "IPFS Service",
1269
+ "status": "running",
1270
+ "port": None,
1271
+ "uptime": uptime_str
1272
+ },
1273
+ {
1274
+ "id": "cache",
1275
+ "name": "Cache Service",
1276
+ "status": "running",
1277
+ "port": None,
1278
+ "uptime": uptime_str
1279
+ }
1280
+ ]
1281
+
1282
+ return services
1283
+
1284
+
1285
+ @router.post("/super-admin/system/services/{service_id}/{action}")
1286
+ def control_service(
1287
+ service_id: str,
1288
+ action: str,
1289
+ super_admin=Depends(get_current_super_admin)
1290
+ ):
1291
+ """
1292
+ Control system services (start, stop, restart)
1293
+ Note: This is a placeholder. Actual implementation requires proper service management.
1294
+ """
1295
+ if action not in ["start", "stop", "restart"]:
1296
+ raise HTTPException(status_code=400, detail="Invalid action")
1297
+
1298
+ print(f"[SUPER ADMIN] Service control: {service_id} -> {action} by {super_admin.get('email')}")
1299
+
1300
+ # Log the action
1301
+ return {
1302
+ "success": True,
1303
+ "message": f"Service {service_id} {action} command sent",
1304
+ "service_id": service_id,
1305
+ "action": action
1306
+ }
1307
+
1308
+
1309
+ @router.get("/super-admin/system/api-endpoints")
1310
+ def get_api_endpoints(
1311
+ super_admin=Depends(get_current_super_admin)
1312
+ ):
1313
+ """
1314
+ Get all API endpoints with their status and usage statistics
1315
+ """
1316
+ endpoints = [
1317
+ {"category": "Authentication", "path": "/api/v1/auth/login", "method": "POST", "status": "active", "calls_24h": 1234},
1318
+ {"category": "Authentication", "path": "/api/v1/auth/register", "method": "POST", "status": "active", "calls_24h": 456},
1319
+ {"category": "Authentication", "path": "/api/v1/auth/me", "method": "GET", "status": "active", "calls_24h": 2345},
1320
+ {"category": "Properties", "path": "/api/v1/properties", "method": "GET", "status": "active", "calls_24h": 5678},
1321
+ {"category": "Properties", "path": "/api/v1/properties/{id}", "method": "GET", "status": "active", "calls_24h": 2345},
1322
+ {"category": "Properties", "path": "/api/v1/properties/{id}/invest", "method": "POST", "status": "active", "calls_24h": 789},
1323
+ {"category": "Investments", "path": "/api/v1/investments", "method": "GET", "status": "active", "calls_24h": 1234},
1324
+ {"category": "Wallet", "path": "/api/v1/wallet/balance", "method": "GET", "status": "active", "calls_24h": 3456},
1325
+ {"category": "Wallet", "path": "/api/v1/wallet/transactions", "method": "GET", "status": "active", "calls_24h": 1890},
1326
+ {"category": "Admin", "path": "/api/v1/admin/properties", "method": "POST", "status": "active", "calls_24h": 123},
1327
+ {"category": "Admin", "path": "/api/v1/admin/properties/{id}", "method": "PUT", "status": "active", "calls_24h": 98},
1328
+ {"category": "Super Admin", "path": "/api/v1/super-admin/stats", "method": "GET", "status": "active", "calls_24h": 567},
1329
+ {"category": "Super Admin", "path": "/api/v1/super-admin/users", "method": "GET", "status": "active", "calls_24h": 234},
1330
+ {"category": "Super Admin", "path": "/api/v1/super-admin/properties", "method": "GET", "status": "active", "calls_24h": 345},
1331
+ ]
1332
+
1333
+ return endpoints
1334
+
1335
+
1336
+ @router.post("/super-admin/system/api-endpoints/toggle")
1337
+ def toggle_api_endpoint(
1338
+ payload: dict,
1339
+ super_admin=Depends(get_current_super_admin)
1340
+ ):
1341
+ """
1342
+ Enable or disable specific API endpoints
1343
+ Note: This is a placeholder. Actual implementation requires middleware integration.
1344
+ """
1345
+ path = payload.get("path")
1346
+ method = payload.get("method")
1347
+
1348
+ print(f"[SUPER ADMIN] Endpoint toggle: {method} {path} by {super_admin.get('email')}")
1349
+
1350
+ return {
1351
+ "success": True,
1352
+ "message": f"Endpoint {method} {path} toggled",
1353
+ "path": path,
1354
+ "method": method
1355
+ }
1356
+
1357
+
1358
+ @router.get("/super-admin/system/cache")
1359
+ def get_cache_stats(
1360
+ super_admin=Depends(get_current_super_admin)
1361
+ ):
1362
+ """
1363
+ Get cache statistics
1364
+ """
1365
+ return {
1366
+ "total_keys": 1234,
1367
+ "memory_used": "45.2 MB",
1368
+ "hit_rate": 87.3,
1369
+ "miss_rate": 12.7,
1370
+ "evictions": 23
1371
+ }
1372
+
1373
+
1374
+ @router.post("/super-admin/system/cache/clear")
1375
+ def clear_cache(
1376
+ super_admin=Depends(get_current_super_admin)
1377
+ ):
1378
+ """
1379
+ Clear all cache entries
1380
+ """
1381
+ print(f"[SUPER ADMIN] Cache cleared by {super_admin.get('email')}")
1382
+
1383
+ return {
1384
+ "success": True,
1385
+ "message": "Cache cleared successfully",
1386
+ "keys_cleared": 1234
1387
+ }
1388
+
1389
+
1390
+ @router.post("/super-admin/system/backup")
1391
+ def create_database_backup(
1392
+ super_admin=Depends(get_current_super_admin)
1393
+ ):
1394
+ """
1395
+ Create a database backup
1396
+ Note: This is a placeholder. Actual implementation requires backup tools.
1397
+ """
1398
+ timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
1399
+ filename = f"backup_{timestamp}.gz"
1400
+
1401
+ print(f"[SUPER ADMIN] Database backup created: {filename} by {super_admin.get('email')}")
1402
+
1403
+ return {
1404
+ "success": True,
1405
+ "message": "Backup created successfully",
1406
+ "filename": filename,
1407
+ "timestamp": datetime.utcnow().isoformat()
1408
+ }
1409
+
1410
+
routes/wallet.py ADDED
@@ -0,0 +1,466 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Production Wallet Routes
3
+ Handles wallet operations and balance management
4
+ """
5
+ from fastapi import APIRouter, Depends, HTTPException, status, Body, Request
6
+ from typing import List
7
+ from pydantic import BaseModel
8
+ import logging
9
+
10
+ import schemas
11
+ from db import get_mongo
12
+ import repo
13
+ from routes.auth import get_current_user
14
+ from services.xrp_service import XRPLService
15
+ from repo import record_admin_wallet_event, list_aed_wallet_transactions
16
+ from middleware.security import limiter, mask_email
17
+ import base64
18
+ import re
19
+ from utils.crypto_utils import encrypt_secret
20
+ from utils.cache import get_cached_xrp_balance, cache_xrp_balance
21
+
22
+ router = APIRouter()
23
+ logger = logging.getLogger(__name__)
24
+
25
+ class DepositRequest(BaseModel):
26
+ amount: float
27
+ payment_method: str
28
+
29
+
30
+ @router.get("/wallet", response_model=schemas.WalletOut)
31
+ def get_wallet(
32
+ db=Depends(get_mongo),
33
+ current_user=Depends(get_current_user)
34
+ ):
35
+ """Get current user's wallet information with live XRP balance"""
36
+ logger.debug(f"Fetching wallet for user: {mask_email(current_user['email'])}")
37
+
38
+ wallet = repo.get_wallet_by_user(db, current_user['id'])
39
+
40
+ if not wallet:
41
+ logger.warning(f"Wallet not found for user: {current_user['id']}")
42
+ raise HTTPException(
43
+ status_code=status.HTTP_404_NOT_FOUND,
44
+ detail="Wallet not found"
45
+ )
46
+
47
+ # Fetch XRP balance with caching (5min TTL)
48
+ xrp_balance = 0.0
49
+ if wallet.get('xrp_address'):
50
+ try:
51
+ # Try cache first (2ms), fall back to live fetch (500ms) if needed
52
+ xrp_balance = get_cached_xrp_balance(wallet['xrp_address'])
53
+ if xrp_balance is None:
54
+ xrp_service = XRPLService()
55
+ xrp_balance = xrp_service.get_xrp_balance(wallet['xrp_address'])
56
+ # Cache for 5 minutes
57
+ cache_xrp_balance(wallet['xrp_address'], xrp_balance, ttl=300)
58
+ logger.debug(f"Live XRP balance fetched and cached: {xrp_balance} XRP")
59
+ else:
60
+ logger.debug(f"Cached XRP balance: {xrp_balance} XRP")
61
+ except Exception as e:
62
+ logger.warning(f"Could not fetch XRP balance: {e}")
63
+ xrp_balance = 0.0
64
+
65
+ wallet['xrp_balance'] = xrp_balance
66
+
67
+ logger.debug(f"Wallet balance: {wallet['balance']} {wallet['currency']}, XRP: {xrp_balance}")
68
+
69
+ return schemas.WalletOut(**wallet)
70
+
71
+
72
+ @router.post("/wallet/import", response_model=schemas.WalletOut)
73
+ @limiter.limit("3/hour")
74
+ def import_xrp_wallet(
75
+ request: Request,
76
+ wallet_import: schemas.WalletImport,
77
+ db=Depends(get_mongo),
78
+ current_user=Depends(get_current_user)
79
+ ):
80
+ """
81
+ Import XRP wallet for current user
82
+ Validates the wallet credentials and saves them
83
+ Rate limited to 3 attempts per hour to prevent brute force attacks
84
+ """
85
+ logger.info(f"Import XRP wallet request for user: {mask_email(current_user['email'])}")
86
+ logger.debug(f"XRP Address: {wallet_import.xrp_address}")
87
+
88
+ # Get user's wallet
89
+ wallet = repo.get_wallet_by_user(db, current_user['id'])
90
+
91
+ if not wallet:
92
+ print(f"[WALLET] [ERROR] Wallet not found\n")
93
+ raise HTTPException(
94
+ status_code=status.HTTP_404_NOT_FOUND,
95
+ detail="Wallet not found"
96
+ )
97
+
98
+ # Basic format validation before hitting ledger
99
+ addr_pattern = re.compile(r"^r[1-9A-HJ-NP-Za-km-z]{25,34}$")
100
+ seed_pattern = re.compile(r"^s[1-9A-HJ-NP-Za-km-z]{15,34}$")
101
+ if not addr_pattern.match(wallet_import.xrp_address):
102
+ raise HTTPException(status_code=400, detail="Invalid XRP address format")
103
+ if not seed_pattern.match(wallet_import.xrp_seed):
104
+ raise HTTPException(status_code=400, detail="Invalid XRP seed format")
105
+
106
+ # Validate XRP wallet by checking if we can get balance (lightweight existence check)
107
+ try:
108
+ xrp_service = XRPLService()
109
+ xrp_balance = xrp_service.get_xrp_balance(wallet_import.xrp_address)
110
+ logger.info(f"XRP wallet validated. Balance: {xrp_balance} XRP")
111
+ except Exception as e:
112
+ logger.error(f"Invalid XRP wallet: {e}")
113
+ raise HTTPException(
114
+ status_code=status.HTTP_400_BAD_REQUEST,
115
+ detail=f"Invalid XRP wallet or network error: {str(e)}"
116
+ )
117
+
118
+ # Encrypt or fallback base64 encode seed before storing
119
+ # Maintain backward compatibility: if encryption key absent we keep base64 behavior
120
+ from config import settings as _settings
121
+ if _settings.encryption_enabled():
122
+ encoded_seed = encrypt_secret(wallet_import.xrp_seed, _settings.ENCRYPTION_KEY)
123
+ else:
124
+ encoded_seed = base64.b64encode(wallet_import.xrp_seed.encode()).decode()
125
+
126
+ updated_wallet = repo.update_wallet_xrp(
127
+ db,
128
+ wallet['id'],
129
+ wallet_import.xrp_address,
130
+ encoded_seed
131
+ )
132
+
133
+ if not updated_wallet:
134
+ logger.error("Failed to update wallet")
135
+ raise HTTPException(
136
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
137
+ detail="Failed to import wallet"
138
+ )
139
+
140
+ updated_wallet['xrp_balance'] = xrp_balance
141
+
142
+ logger.info("XRP wallet imported successfully")
143
+
144
+ return schemas.WalletOut(**updated_wallet)
145
+
146
+
147
+ @router.post("/wallet/deposit", response_model=schemas.TransactionOut)
148
+ @limiter.limit("10/hour")
149
+ def deposit_to_wallet(
150
+ request: Request,
151
+ deposit: DepositRequest = Body(...),
152
+ db=Depends(get_mongo),
153
+ current_user=Depends(get_current_user)
154
+ ):
155
+ """
156
+ Deposit money to wallet (Simulated for demo)
157
+ In production, this would integrate with payment gateway
158
+ Rate limited to 10 deposits per hour to prevent abuse
159
+ """
160
+ # Extract deposit details
161
+ amount = deposit.amount
162
+ payment_method = deposit.payment_method
163
+ logger.info(f"Deposit request: {amount} AED for user: {mask_email(current_user['email'])} using {payment_method}")
164
+
165
+ if amount <= 0:
166
+ logger.warning(f"Invalid deposit amount: {amount}")
167
+ raise HTTPException(
168
+ status_code=status.HTTP_400_BAD_REQUEST,
169
+ detail="Deposit amount must be positive"
170
+ )
171
+
172
+ # Get wallet
173
+ wallet = repo.get_wallet_by_user(db, current_user['id'])
174
+
175
+ if not wallet:
176
+ print(f"[WALLET] [ERROR] Wallet not found\n")
177
+ raise HTTPException(
178
+ status_code=status.HTTP_404_NOT_FOUND,
179
+ detail="Wallet not found"
180
+ )
181
+
182
+ # Update wallet balance
183
+ updated_wallet = repo.update_wallet_balance(db, wallet['id'], amount, operation="add")
184
+
185
+ # Create transaction record
186
+ transaction = repo.create_transaction(
187
+ db,
188
+ user_id=current_user['id'],
189
+ wallet_id=wallet['id'],
190
+ tx_type="deposit",
191
+ amount=amount,
192
+ status="completed",
193
+ metadata={
194
+ "payment_method": payment_method,
195
+ "description": "Wallet deposit"
196
+ }
197
+ )
198
+
199
+ # Mirror admin wallet credit topup event (store in fils)
200
+ try:
201
+ record_admin_wallet_event(
202
+ db,
203
+ delta_fils=int(amount * 100),
204
+ event_type="admin_wallet_credit_topup",
205
+ notes=f"Received AED {amount:.2f} from {current_user.get('name')} via {payment_method}",
206
+ counterparty_user=current_user,
207
+ payment_method=payment_method,
208
+ payment_reference=f"DEP_{wallet['id']}",
209
+ metadata={"amount": amount},
210
+ )
211
+ except Exception as e:
212
+ logger.warning(f"Failed to record admin wallet topup event: {e}")
213
+
214
+ logger.info(f"Deposit successful. New balance: {updated_wallet['balance']} AED")
215
+
216
+ return schemas.TransactionOut(**transaction)
217
+
218
+
219
+ @router.get("/wallet/transactions", response_model=List[schemas.TransactionOut])
220
+ def get_wallet_transactions(
221
+ skip: int = 0,
222
+ limit: int = 50,
223
+ db=Depends(get_mongo),
224
+ current_user=Depends(get_current_user)
225
+ ):
226
+ """Get wallet transaction history"""
227
+ logger.debug(f"Fetching transactions for user: {mask_email(current_user['email'])}")
228
+
229
+ transactions = repo.get_user_transactions(db, current_user['id'], skip=skip, limit=limit)
230
+
231
+ logger.debug(f"Found {len(transactions)} transactions")
232
+
233
+ return [schemas.TransactionOut(**tx) for tx in transactions]
234
+
235
+
236
+ # AED specific wallet utilities (parallel to reference implementation)
237
+ @router.get("/wallet/aed/balance")
238
+ def get_aed_wallet_balance(db=Depends(get_mongo), current_user=Depends(get_current_user)):
239
+ # Get balance from wallet collection, not user document
240
+ wallet = db.wallets.find_one({"user_id": repo._to_object_id(current_user['id'])})
241
+ balance_aed = wallet.get("balance", 0.0) if wallet else 0.0
242
+
243
+ return {
244
+ "balance_aed": balance_aed,
245
+ "balance_fils": int(balance_aed * 100),
246
+ }
247
+
248
+
249
+ @router.get("/wallet/aed/transactions")
250
+ def get_aed_wallet_transactions(db=Depends(get_mongo), current_user=Depends(get_current_user)):
251
+ txs = list_aed_wallet_transactions(db, user_id=current_user['id'], limit=50)
252
+ out = []
253
+ for tx in txs:
254
+ meta = tx.get("metadata") or {}
255
+ amount_fils = meta.get("total_amount_fils") or 0
256
+ out.append({
257
+ "id": tx.get("id"),
258
+ "type": tx.get("type"),
259
+ "amount_aed": amount_fils / 100.0,
260
+ "payment_method": meta.get("payment_method"),
261
+ "payment_reference": meta.get("payment_reference"),
262
+ "status": tx.get("status"),
263
+ "created_at": tx.get("created_at"),
264
+ "notes": meta.get("notes"),
265
+ })
266
+ return out
267
+
268
+
269
+ # ============================================================================
270
+ # USER CARDS ENDPOINTS
271
+ # ============================================================================
272
+
273
+ @router.get("/wallet/cards", response_model=List[schemas.CardOut])
274
+ def get_user_cards(
275
+ db=Depends(get_mongo),
276
+ current_user=Depends(get_current_user)
277
+ ):
278
+ """Get all saved cards for the current user"""
279
+ logger.debug(f"Fetching cards for user: {mask_email(current_user['email'])}")
280
+
281
+ cards = repo.get_user_cards(db, current_user['id'])
282
+
283
+ logger.debug(f"Found {len(cards)} cards")
284
+
285
+ return [schemas.CardOut(**card) for card in cards]
286
+
287
+
288
+ @router.post("/wallet/cards", response_model=schemas.CardOut, status_code=status.HTTP_201_CREATED)
289
+ @limiter.limit("5/hour")
290
+ def add_user_card(
291
+ request: Request,
292
+ card_data: schemas.CardCreate,
293
+ db=Depends(get_mongo),
294
+ current_user=Depends(get_current_user)
295
+ ):
296
+ """Add a new card for the current user"""
297
+ logger.info(f"Adding card for user: {mask_email(current_user['email'])}")
298
+
299
+ try:
300
+ card = repo.create_user_card(
301
+ db,
302
+ user_id=current_user['id'],
303
+ card_number=card_data.card_number,
304
+ expiry_month=card_data.expiry_month,
305
+ expiry_year=card_data.expiry_year,
306
+ cardholder_name=card_data.cardholder_name,
307
+ bank_name=card_data.bank_name
308
+ )
309
+
310
+ logger.info(f"Card added: ****{card['last_four']}")
311
+
312
+ return schemas.CardOut(**card)
313
+
314
+ except ValueError as e:
315
+ logger.warning(f"Card add failed: {str(e)}")
316
+ raise HTTPException(
317
+ status_code=status.HTTP_400_BAD_REQUEST,
318
+ detail=str(e)
319
+ )
320
+
321
+
322
+ @router.delete("/wallet/cards/{card_id}")
323
+ def delete_user_card(
324
+ card_id: str,
325
+ db=Depends(get_mongo),
326
+ current_user=Depends(get_current_user)
327
+ ):
328
+ """Delete a saved card"""
329
+ logger.info(f"Deleting card {card_id}")
330
+
331
+ success = repo.delete_user_card(db, current_user['id'], card_id)
332
+
333
+ if not success:
334
+ logger.warning("Card not found or already deleted")
335
+ raise HTTPException(
336
+ status_code=status.HTTP_404_NOT_FOUND,
337
+ detail="Card not found"
338
+ )
339
+
340
+ logger.info("Card deleted")
341
+
342
+ return {"success": True, "message": "Card deleted successfully"}
343
+
344
+
345
+ @router.put("/wallet/cards/{card_id}/default")
346
+ def set_default_card(
347
+ card_id: str,
348
+ db=Depends(get_mongo),
349
+ current_user=Depends(get_current_user)
350
+ ):
351
+ """Set a card as the default payment method"""
352
+ logger.info(f"Setting card {card_id} as default")
353
+
354
+ success = repo.set_default_card(db, current_user['id'], card_id)
355
+
356
+ if not success:
357
+ logger.warning("Card not found")
358
+ raise HTTPException(
359
+ status_code=status.HTTP_404_NOT_FOUND,
360
+ detail="Card not found"
361
+ )
362
+
363
+ logger.info("Card set as default")
364
+
365
+ return {"success": True, "message": "Card set as default"}
366
+
367
+
368
+ # ============================================================================
369
+ # USER BANKS ENDPOINTS
370
+ # ============================================================================
371
+
372
+ @router.get("/wallet/banks", response_model=List[schemas.BankOut])
373
+ def get_user_banks(
374
+ db=Depends(get_mongo),
375
+ current_user=Depends(get_current_user)
376
+ ):
377
+ """Get all saved bank accounts for the current user"""
378
+ logger.debug(f"Fetching bank accounts for user: {mask_email(current_user['email'])}")
379
+
380
+ banks = repo.get_user_banks(db, current_user['id'])
381
+
382
+ logger.debug(f"Found {len(banks)} bank accounts")
383
+
384
+ return [schemas.BankOut(**bank) for bank in banks]
385
+
386
+
387
+ @router.post("/wallet/banks", response_model=schemas.BankOut, status_code=status.HTTP_201_CREATED)
388
+ @limiter.limit("5/hour")
389
+ def add_user_bank(
390
+ request: Request,
391
+ bank_data: schemas.BankCreate,
392
+ db=Depends(get_mongo),
393
+ current_user=Depends(get_current_user)
394
+ ):
395
+ """Add a new bank account for the current user"""
396
+ logger.info(f"Adding bank account for user: {mask_email(current_user['email'])}")
397
+
398
+ try:
399
+ bank = repo.create_user_bank(
400
+ db,
401
+ user_id=current_user['id'],
402
+ bank_name=bank_data.bank_name,
403
+ account_holder_name=bank_data.account_holder_name,
404
+ account_number=bank_data.account_number,
405
+ account_type=bank_data.account_type.value,
406
+ iban=bank_data.iban,
407
+ swift_code=bank_data.swift_code,
408
+ currency=bank_data.currency
409
+ )
410
+
411
+ logger.info(f"Bank account added: {bank['bank_name']}")
412
+
413
+ return schemas.BankOut(**bank)
414
+
415
+ except ValueError as e:
416
+ logger.warning(f"Bank add failed: {str(e)}")
417
+ raise HTTPException(
418
+ status_code=status.HTTP_400_BAD_REQUEST,
419
+ detail=str(e)
420
+ )
421
+
422
+
423
+ @router.delete("/wallet/banks/{bank_id}")
424
+ def delete_user_bank(
425
+ bank_id: str,
426
+ db=Depends(get_mongo),
427
+ current_user=Depends(get_current_user)
428
+ ):
429
+ """Delete a saved bank account"""
430
+ logger.info(f"Deleting bank {bank_id}")
431
+
432
+ success = repo.delete_user_bank(db, current_user['id'], bank_id)
433
+
434
+ if not success:
435
+ logger.warning("Bank account not found or already deleted")
436
+ raise HTTPException(
437
+ status_code=status.HTTP_404_NOT_FOUND,
438
+ detail="Bank account not found"
439
+ )
440
+
441
+ logger.info("Bank account deleted")
442
+
443
+ return {"success": True, "message": "Bank account deleted successfully"}
444
+
445
+
446
+ @router.put("/wallet/banks/{bank_id}/default")
447
+ def set_default_bank(
448
+ bank_id: str,
449
+ db=Depends(get_mongo),
450
+ current_user=Depends(get_current_user)
451
+ ):
452
+ """Set a bank account as the default for withdrawals"""
453
+ logger.info(f"Setting bank {bank_id} as default")
454
+
455
+ success = repo.set_default_bank(db, current_user['id'], bank_id)
456
+
457
+ if not success:
458
+ logger.warning("Bank account not found")
459
+ raise HTTPException(
460
+ status_code=status.HTTP_404_NOT_FOUND,
461
+ detail="Bank account not found"
462
+ )
463
+
464
+ logger.info("Bank account set as default")
465
+
466
+ return {"success": True, "message": "Bank account set as default"}
schemas.py ADDED
@@ -0,0 +1,1536 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Production-Ready Pydantic Schemas for Real Estate Tokenization Platform
3
+ Based on the new database schema with complete validation
4
+ """
5
+ from pydantic import BaseModel, EmailStr, Field, validator
6
+ from typing import Optional, List, Dict, Any, Union
7
+ from datetime import datetime
8
+ from enum import Enum
9
+ import re
10
+
11
+
12
+ # ============================================================================
13
+ # ENUMS
14
+ # ============================================================================
15
+
16
+ class UserRole(str, Enum):
17
+ user = "user"
18
+ admin = "admin"
19
+ super_admin = "super_admin"
20
+
21
+
22
+ class PropertyType(str, Enum):
23
+ residential = "Residential"
24
+ commercial = "Commercial"
25
+ retail = "Retail"
26
+ industrial = "Industrial"
27
+
28
+
29
+ class TransactionType(str, Enum):
30
+ deposit = "deposit"
31
+ withdrawal = "withdrawal"
32
+ investment = "investment"
33
+ profit = "profit"
34
+ buy = "buy"
35
+ sell = "sell"
36
+ wallet_deduct = "wallet_deduct"
37
+ tokenize = "tokenize"
38
+ wallet_add = "wallet_add" # Added for AED wallet topups (compat)
39
+ admin_wallet_credit_purchase = "admin_wallet_credit_purchase" # Admin mirror events
40
+ admin_wallet_credit_topup = "admin_wallet_credit_topup"
41
+ admin_wallet_debit_payout = "admin_wallet_debit_payout"
42
+ token_buyback = "token_buyback" # Token sell-back to admin
43
+
44
+
45
+ class TransactionStatus(str, Enum):
46
+ pending = "pending"
47
+ completed = "completed"
48
+ failed = "failed"
49
+ confirmed = "confirmed"
50
+ cancelled = "cancelled"
51
+
52
+
53
+ class InvestmentStatus(str, Enum):
54
+ pending = "pending"
55
+ confirmed = "confirmed"
56
+ cancelled = "cancelled"
57
+
58
+
59
+ class FileType(str, Enum):
60
+ pdf = "pdf"
61
+ doc = "doc"
62
+ docx = "docx"
63
+ jpg = "jpg"
64
+ png = "png"
65
+
66
+
67
+ # ============================================================================
68
+ # USER SCHEMAS
69
+ # ============================================================================
70
+
71
+ class UserCreate(BaseModel):
72
+ name: str = Field(..., min_length=2, max_length=100, pattern=r"^[a-zA-Z\s\-']+$")
73
+ email: EmailStr
74
+ password: str = Field(..., min_length=8)
75
+ country_code: str = Field(..., pattern=r"^\+[0-9]{1,4}$") # Required, e.g., +1, +91, +971
76
+ phone: str = Field(..., pattern=r"^[0-9]{6,15}$") # Required, 6-15 digits for international numbers
77
+ role: Optional[UserRole] = UserRole.user
78
+
79
+ @validator('name')
80
+ def validate_name_chars(cls, v):
81
+ """Validate name contains only letters, spaces, hyphens, apostrophes"""
82
+ if not re.match(r"^[a-zA-Z\s\-']+$", v):
83
+ raise ValueError('Name can only contain letters, spaces, hyphens, and apostrophes')
84
+ return v.strip()
85
+
86
+ @validator('country_code')
87
+ def validate_country_code_format(cls, v):
88
+ """Validate country code format"""
89
+ if not re.match(r"^\+[0-9]{1,4}$", v):
90
+ raise ValueError('Country code must start with + and contain 1-4 digits')
91
+ return v
92
+
93
+ @validator('phone')
94
+ def validate_phone_digits(cls, v):
95
+ """Validate phone is 6-15 digits"""
96
+ if not re.match(r"^[0-9]{6,15}$", v):
97
+ raise ValueError('Phone number must be 6-15 digits')
98
+ return v
99
+
100
+ @validator('email')
101
+ def validate_email_format(cls, v):
102
+ """Additional email validation"""
103
+ if not v or '@' not in v or '.' not in v.split('@')[1]:
104
+ raise ValueError('Invalid email format')
105
+ return v.lower().strip()
106
+
107
+
108
+ class UserLogin(BaseModel):
109
+ email: EmailStr
110
+ password: str
111
+
112
+
113
+ class UserOut(BaseModel):
114
+ id: str
115
+ name: str
116
+ email: EmailStr
117
+ role: UserRole
118
+ # country_code: str # Required field
119
+ # phone: str # Required field
120
+ country_code: Optional[str] = "+91" # Default for backward compatibility with existing users
121
+ phone: Optional[str] = None # Optional for backward compatibility
122
+ date_of_birth: Optional[str] = None
123
+ gender: Optional[str] = None
124
+ address: Optional[str] = None
125
+ country: Optional[str] = None
126
+ state: Optional[str] = None
127
+ city: Optional[str] = None
128
+ employment_status: Optional[str] = None
129
+ employment_details: Optional[str] = None
130
+ wallet_id: Optional[str] = None
131
+ is_active: bool = True
132
+ kyc_status: Optional[str] = "pending" # pending, approved, rejected
133
+ kyc_document_id: Optional[str] = None
134
+ created_at: datetime
135
+ updated_at: datetime
136
+
137
+ class Config:
138
+ from_attributes = True
139
+
140
+
141
+ class UserUpdate(BaseModel):
142
+ name: Optional[str] = Field(None, pattern=r"^[a-zA-Z\s\-']+$")
143
+ country_code: Optional[str] = Field(None, pattern=r"^\+[0-9]{1,4}$")
144
+ phone: Optional[str] = Field(None, pattern=r"^[0-9]{6,15}$")
145
+ date_of_birth: Optional[str] = None
146
+ gender: Optional[str] = None
147
+ address: Optional[str] = None
148
+ country: Optional[str] = None
149
+ state: Optional[str] = None
150
+ city: Optional[str] = None
151
+ employment_status: Optional[str] = None
152
+ employment_details: Optional[str] = None
153
+ is_active: Optional[bool] = None
154
+ kyc_status: Optional[str] = None
155
+
156
+ @validator('name')
157
+ def validate_name_chars(cls, v):
158
+ """Validate name contains only letters, spaces, hyphens, apostrophes"""
159
+ if v is not None and not re.match(r"^[a-zA-Z\s\-']+$", v):
160
+ raise ValueError('Name can only contain letters, spaces, hyphens, and apostrophes')
161
+ return v.strip() if v else v
162
+
163
+ @validator('country_code')
164
+ def validate_country_code_format(cls, v):
165
+ """Validate country code format"""
166
+ if v is not None and not re.match(r"^\+[0-9]{1,4}$", v):
167
+ raise ValueError('Country code must start with + and contain 1-4 digits')
168
+ return v
169
+
170
+ @validator('phone')
171
+ def validate_phone_digits(cls, v):
172
+ """Validate phone is 6-15 digits"""
173
+ if v is not None and not re.match(r"^[0-9]{6,15}$", v):
174
+ raise ValueError('Phone number must be 6-15 digits')
175
+ return v
176
+
177
+
178
+ # ============================================================================
179
+ # WALLET SCHEMAS
180
+ # ============================================================================
181
+
182
+ class WalletCreate(BaseModel):
183
+ currency: str = "AED"
184
+ balance: float = 0.0
185
+
186
+
187
+ class WalletOut(BaseModel):
188
+ id: str
189
+ user_id: str
190
+ balance: float
191
+ aed_balance: Optional[float] = None # Alias for balance (for frontend compatibility)
192
+ currency: str
193
+ xrp_address: Optional[str] = None
194
+ xrp_balance: float = 0.0
195
+ is_active: bool
196
+ created_at: datetime
197
+ updated_at: datetime
198
+
199
+ class Config:
200
+ from_attributes = True
201
+
202
+ @validator('aed_balance', pre=True, always=True)
203
+ def set_aed_balance(cls, v, values):
204
+ """Set aed_balance from balance if not provided"""
205
+ return v if v is not None else values.get('balance', 0.0)
206
+
207
+
208
+ class WalletUpdate(BaseModel):
209
+ balance: Optional[float] = None
210
+ xrp_address: Optional[str] = None
211
+ is_active: Optional[bool] = None
212
+
213
+
214
+ class WalletImport(BaseModel):
215
+ xrp_address: str = Field(..., min_length=25, max_length=35, description="XRP Ledger address")
216
+ xrp_seed: str = Field(..., min_length=25, max_length=35, description="XRP wallet seed/secret")
217
+
218
+
219
+ # ============================================================================
220
+ # PROPERTY SCHEMAS
221
+ # ============================================================================
222
+
223
+ class PropertySpecificationCreate(BaseModel):
224
+ balcony: int = 0
225
+ kitchen: int = 1
226
+ bedroom: int = 1
227
+ bathroom: int = 1
228
+ area: float = Field(..., gt=0, description="Area in square meters")
229
+
230
+
231
+ class PropertySpecificationOut(BaseModel):
232
+ id: str
233
+ property_id: str
234
+ balcony: int
235
+ kitchen: int
236
+ bedroom: int
237
+ bathroom: int
238
+ area: float
239
+ created_at: datetime
240
+ updated_at: datetime
241
+
242
+ class Config:
243
+ from_attributes = True
244
+
245
+
246
+ class AmenityCreate(BaseModel):
247
+ name: str = Field(..., min_length=2, max_length=100)
248
+ is_active: bool = True
249
+
250
+
251
+ class AmenityOut(BaseModel):
252
+ id: str
253
+ property_id: str
254
+ name: str
255
+ is_active: bool
256
+ created_at: datetime
257
+ updated_at: datetime
258
+
259
+ class Config:
260
+ from_attributes = True
261
+
262
+
263
+ class PropertyImageCreate(BaseModel):
264
+ image_url: str
265
+ caption: Optional[str] = None
266
+ is_main: bool = False
267
+
268
+
269
+ class PropertyImageOut(BaseModel):
270
+ id: str
271
+ property_id: str
272
+ image_url: str
273
+ caption: Optional[str]
274
+ is_main: bool
275
+ uploaded_by: Optional[str]
276
+ is_active: bool
277
+ created_at: datetime
278
+ updated_at: datetime
279
+
280
+ class Config:
281
+ from_attributes = True
282
+
283
+
284
+ class PropertyCreate(BaseModel):
285
+ title: str = Field(..., min_length=5, max_length=200)
286
+ description: str = Field(..., min_length=20)
287
+ location: str = Field(..., min_length=5, max_length=200)
288
+ property_type: PropertyType = Field(default=PropertyType.residential, description="Type of property")
289
+ total_tokens: int = Field(..., gt=0)
290
+ token_price: float = Field(..., gt=0)
291
+ min_investment: float = Field(default=2000.0, ge=2000.0)
292
+ original_price: float = Field(..., gt=0)
293
+ square_feet: Optional[float] = Field(None, gt=0, description="Property area in square feet")
294
+ price_per_sqft: Optional[float] = Field(None, gt=0, description="Price per square foot")
295
+ purchase_cost: Optional[float] = None
296
+ platform_fees: Optional[float] = None
297
+ dld_fees: Optional[float] = None
298
+ net_rental_yield: Optional[float] = Field(None, ge=0, le=100)
299
+ annual_roi: Optional[float] = Field(None, ge=0, le=100)
300
+ gross_rental_yield: Optional[float] = Field(None, ge=0, le=100)
301
+ funded_date: Optional[datetime] = None
302
+
303
+ # Related data
304
+ specifications: Optional[PropertySpecificationCreate] = None
305
+ amenities: Optional[List[Union[str, Dict[str, str]]]] = None # Accept both string and dict formats
306
+ images: Optional[List[PropertyImageCreate]] = None
307
+ documents: Optional[List[Dict[str, str]]] = None # Added documents support
308
+
309
+ @validator('amenities', pre=True)
310
+ def normalize_amenities(cls, v):
311
+ """Convert amenities to list of strings, handling both string and dict formats"""
312
+ if v is None:
313
+ return None
314
+
315
+ normalized = []
316
+ for item in v:
317
+ if isinstance(item, str):
318
+ normalized.append(item)
319
+ elif isinstance(item, dict) and 'name' in item:
320
+ normalized.append(item['name'])
321
+ else:
322
+ # Skip invalid items
323
+ continue
324
+ return normalized
325
+
326
+ @validator('purchase_cost', always=True)
327
+ def set_purchase_cost(cls, v, values):
328
+ if v is None and 'original_price' in values:
329
+ return values['original_price']
330
+ return v
331
+
332
+ @validator('platform_fees', always=True)
333
+ def set_platform_fees(cls, v, values):
334
+ if v is None and 'original_price' in values:
335
+ # Default 2% AtriumChain platform fees
336
+ return values['original_price'] * 0.02
337
+ return v
338
+
339
+ @validator('dld_fees', always=True)
340
+ def set_dld_fees(cls, v, values):
341
+ if v is None and 'original_price' in values:
342
+ # Default 2% DLD fees (reduced from 4%)
343
+ return values['original_price'] * 0.02
344
+ return v
345
+
346
+
347
+ # ============================================================================
348
+ # DOCUMENT SCHEMAS
349
+ # ============================================================================
350
+
351
+ class DocumentCreate(BaseModel):
352
+ property_id: str
353
+ file_type: FileType
354
+ file_url: str
355
+ uploaded_by: Optional[str] = None
356
+
357
+
358
+ class DocumentOut(BaseModel):
359
+ id: str
360
+ property_id: str
361
+ file_type: FileType
362
+ file_url: str
363
+ uploaded_by: Optional[str]
364
+ created_at: datetime
365
+ updated_at: datetime
366
+
367
+ class Config:
368
+ from_attributes = True
369
+
370
+
371
+ # ============================================================================
372
+ # PROPERTY OUTPUT SCHEMA
373
+ # ============================================================================
374
+
375
+ class PropertyOut(BaseModel):
376
+ id: str
377
+ title: str
378
+ description: str
379
+ location: str
380
+ property_type: str
381
+ total_tokens: int
382
+ token_price: float
383
+ min_investment: float
384
+ available_tokens: int
385
+ original_price: float
386
+ square_feet: Optional[float] = None
387
+ price_per_sqft: Optional[float] = None
388
+ purchase_cost: float
389
+ platform_fees: float
390
+ dld_fees: float
391
+ net_rental_yield: Optional[float]
392
+ annual_roi: Optional[float]
393
+ gross_rental_yield: Optional[float]
394
+ funded_date: Optional[datetime]
395
+ created_by: Optional[str]
396
+ is_active: bool
397
+ created_at: datetime
398
+ updated_at: datetime
399
+
400
+ # Embedded/related data
401
+ specifications: Optional[PropertySpecificationOut] = None
402
+ amenities: Optional[List[AmenityOut]] = None
403
+ images: Optional[List[PropertyImageOut]] = None
404
+ documents: Optional[List[DocumentOut]] = None
405
+
406
+ # Computed fields for backward compatibility
407
+ image: Optional[str] = None
408
+ projected_roi: Optional[float] = None
409
+
410
+ class Config:
411
+ from_attributes = True
412
+
413
+ @validator('image', always=True)
414
+ def compute_main_image(cls, v, values):
415
+ """Get main image URL from images array for backward compatibility"""
416
+ if v is not None:
417
+ return v
418
+
419
+ images = values.get('images')
420
+ if images and len(images) > 0:
421
+ # Find the main image
422
+ for img in images:
423
+ if hasattr(img, 'is_main') and img.is_main:
424
+ return img.image_url
425
+ # If no main image, return first image
426
+ return images[0].image_url if hasattr(images[0], 'image_url') else None
427
+
428
+ return None
429
+
430
+ @validator('projected_roi', always=True)
431
+ def compute_projected_roi(cls, v, values):
432
+ """Map annual_roi to projected_roi for backward compatibility"""
433
+ if v is not None:
434
+ return v
435
+ return values.get('annual_roi')
436
+
437
+
438
+ class PropertyUpdate(BaseModel):
439
+ title: Optional[str] = None
440
+ description: Optional[str] = None
441
+ location: Optional[str] = None
442
+ token_price: Optional[float] = None
443
+ min_investment: Optional[float] = None
444
+ available_tokens: Optional[int] = None
445
+ net_rental_yield: Optional[float] = None
446
+ annual_roi: Optional[float] = None
447
+ gross_rental_yield: Optional[float] = None
448
+ is_active: Optional[bool] = None
449
+
450
+
451
+ # ============================================================================
452
+ # FUNDED PROPERTY SCHEMAS (Fully Funded Properties Archive)
453
+ # ============================================================================
454
+
455
+ class FundingStatus(str, Enum):
456
+ """Status of property funding lifecycle"""
457
+ available = "available" # Property is open for investment
458
+ funded = "funded" # All tokens sold, fully funded
459
+ exited = "exited" # Investment cycle complete, returns distributed
460
+
461
+
462
+ class FundedPropertyCreate(BaseModel):
463
+ """
464
+ Schema for creating a funded property record.
465
+ This is automatically created when a property reaches 100% funding.
466
+ """
467
+ property_id: str = Field(..., description="Reference to original property")
468
+
469
+ # Property snapshot at time of funding
470
+ title: str = Field(..., min_length=5, max_length=200)
471
+ description: str = Field(..., min_length=20)
472
+ location: str = Field(..., min_length=5, max_length=200)
473
+ property_type: str = Field(..., description="Type of property")
474
+
475
+ # Tokenization details
476
+ total_tokens: int = Field(..., gt=0, description="Total tokens issued")
477
+ token_price: float = Field(..., gt=0, description="Price per token at funding")
478
+ original_price: float = Field(..., gt=0, description="Total property value")
479
+
480
+ # Funding metrics
481
+ funding_status: FundingStatus = FundingStatus.funded
482
+ funded_date: datetime = Field(..., description="Date when fully funded")
483
+ funding_duration_days: Optional[int] = Field(None, ge=0, description="Days taken to fully fund")
484
+
485
+ # Investor statistics
486
+ total_investors: int = Field(default=0, ge=0, description="Number of unique investors")
487
+ average_investment: Optional[float] = Field(None, ge=0, description="Average investment amount")
488
+
489
+ # ROI projections
490
+ net_rental_yield: Optional[float] = Field(None, ge=0, le=100, description="Net rental yield %")
491
+ annual_roi: Optional[float] = Field(None, ge=0, le=100, description="Projected annual ROI %")
492
+ gross_rental_yield: Optional[float] = Field(None, ge=0, le=100, description="Gross rental yield %")
493
+
494
+ # Main image for display
495
+ main_image_url: Optional[str] = None
496
+
497
+ # Metadata
498
+ is_active: bool = True
499
+ notes: Optional[str] = Field(None, max_length=1000)
500
+
501
+
502
+ class FundedPropertyOut(BaseModel):
503
+ """
504
+ Schema for funded property output.
505
+ Includes all details needed for displaying funded properties in the UI.
506
+ """
507
+ id: str
508
+ property_id: str
509
+
510
+ # Property details
511
+ title: str
512
+ description: str
513
+ location: str
514
+ property_type: str
515
+
516
+ # Tokenization details
517
+ total_tokens: int
518
+ token_price: float
519
+ original_price: float
520
+
521
+ # Funding status
522
+ funding_status: FundingStatus
523
+ funded_date: datetime
524
+ funding_duration_days: Optional[int] = None
525
+
526
+ # Investor statistics
527
+ total_investors: int = 0
528
+ average_investment: Optional[float] = None
529
+
530
+ # ROI details
531
+ net_rental_yield: Optional[float] = None
532
+ annual_roi: Optional[float] = None
533
+ gross_rental_yield: Optional[float] = None
534
+
535
+ # Display image
536
+ main_image_url: Optional[str] = None
537
+
538
+ # Computed fields for frontend compatibility
539
+ available_tokens: int = 0 # Always 0 for funded properties
540
+ funding_progress: float = 100.0 # Always 100% for funded properties
541
+
542
+ # Metadata
543
+ is_active: bool = True
544
+ created_at: datetime
545
+ updated_at: datetime
546
+
547
+ class Config:
548
+ from_attributes = True
549
+
550
+ @validator('available_tokens', always=True)
551
+ def set_available_tokens(cls, v):
552
+ """Funded properties always have 0 available tokens"""
553
+ return 0
554
+
555
+ @validator('funding_progress', always=True)
556
+ def set_funding_progress(cls, v):
557
+ """Funded properties always have 100% progress"""
558
+ return 100.0
559
+
560
+
561
+ class FundedPropertyUpdate(BaseModel):
562
+ """Schema for updating funded property records (admin only)"""
563
+ funding_status: Optional[FundingStatus] = None
564
+ notes: Optional[str] = Field(None, max_length=1000)
565
+ is_active: Optional[bool] = None
566
+ # For exited properties
567
+ exit_date: Optional[datetime] = None
568
+ total_returns_distributed: Optional[float] = Field(None, ge=0)
569
+
570
+
571
+ class FundedPropertiesListResponse(BaseModel):
572
+ """Response schema for listing funded properties"""
573
+ properties: List[FundedPropertyOut]
574
+ total_count: int
575
+ funding_stats: Optional[Dict[str, Any]] = None
576
+
577
+
578
+ # ============================================================================
579
+ # SUPER ADMIN CONSOLE SCHEMAS
580
+ # ============================================================================
581
+
582
+ class WalletMetricOut(BaseModel):
583
+ value: float
584
+ formatted: str
585
+ updated_at: Optional[datetime]
586
+ variance: Optional[str] = None
587
+ pending_payouts: Optional[int] = None
588
+
589
+
590
+ class WalletEntryOut(BaseModel):
591
+ id: str
592
+ label: str
593
+ type: str
594
+ address: Optional[str] = None
595
+ balance: float
596
+ formatted_balance: str
597
+ variance: Optional[str] = None
598
+ last_synced_at: Optional[datetime] = None
599
+ status: str
600
+
601
+
602
+ class WalletOverviewOut(BaseModel):
603
+ summary: Dict[str, WalletMetricOut]
604
+ wallets: List[WalletEntryOut]
605
+
606
+
607
+ class SuperAdminProfileOut(BaseModel):
608
+ full_name: str
609
+ email: EmailStr
610
+ two_factor_enabled: bool = False
611
+
612
+
613
+ class SuperAdminProfileUpdate(BaseModel):
614
+ full_name: str = Field(..., min_length=2, max_length=100)
615
+ email: EmailStr
616
+
617
+
618
+ class PasswordUpdateRequest(BaseModel):
619
+ current_password: str = Field(..., min_length=8)
620
+ new_password: str = Field(..., min_length=8)
621
+
622
+
623
+ class TwoFactorToggleRequest(BaseModel):
624
+ enabled: bool
625
+
626
+
627
+ # ============================================================================
628
+ # INVESTMENT SCHEMAS
629
+ # ============================================================================
630
+
631
+ class InvestmentCreate(BaseModel):
632
+ property_id: str
633
+ tokens_purchased: int = Field(..., gt=0)
634
+ amount: float = Field(..., gt=0)
635
+ profit_share: Optional[float] = 0.0
636
+
637
+
638
+ class InvestmentOut(BaseModel):
639
+ id: str
640
+ user_id: str
641
+ property_id: str
642
+ tokens_purchased: int
643
+ amount: float
644
+ status: InvestmentStatus
645
+ profit_share: float
646
+ created_at: datetime
647
+ updated_at: datetime
648
+
649
+ class Config:
650
+ from_attributes = True
651
+
652
+
653
+ class InvestmentUpdate(BaseModel):
654
+ status: Optional[InvestmentStatus] = None
655
+ profit_share: Optional[float] = None
656
+
657
+
658
+ # ============================================================================
659
+ # TRANSACTION SCHEMAS
660
+ # ============================================================================
661
+
662
+ class TransactionCreate(BaseModel):
663
+ wallet_id: Optional[str] = None
664
+ type: TransactionType
665
+ amount: float = Field(..., gt=0)
666
+ property_id: Optional[str] = None
667
+ status: Optional[TransactionStatus] = TransactionStatus.pending
668
+ metadata: Optional[Dict[str, Any]] = None
669
+
670
+
671
+ class TransactionOut(BaseModel):
672
+ id: str
673
+ user_id: str
674
+ wallet_id: Optional[str]
675
+ type: TransactionType
676
+ amount: float
677
+ property_id: Optional[str]
678
+ status: TransactionStatus
679
+ metadata: Optional[Dict[str, Any]]
680
+ created_at: datetime
681
+ updated_at: datetime
682
+
683
+ class Config:
684
+ from_attributes = True
685
+
686
+
687
+ # ============================================================================
688
+ # PORTFOLIO SCHEMAS
689
+ # ============================================================================
690
+
691
+ class PortfolioOut(BaseModel):
692
+ id: str
693
+ user_id: str
694
+ total_invested: float
695
+ total_current_value: float
696
+ total_profit: float
697
+ created_at: datetime
698
+ updated_at: datetime
699
+
700
+ class Config:
701
+ from_attributes = True
702
+
703
+
704
+ class PortfolioItemOut(BaseModel):
705
+ property: PropertyOut
706
+ tokens_owned: int
707
+ token_balance: int # For frontend compatibility
708
+ blockchain_balance: Optional[float] = None # IOU token balance from blockchain
709
+ investment_amount: float
710
+ current_value: float
711
+ profit_loss: float
712
+ ownership_percentage: float
713
+ purchase_date: Optional[str] = None
714
+ purchase_price_per_token: Optional[float] = None
715
+ failed_transactions_count: int = 0
716
+ rent_data: Optional[Dict[str, Any]] = None # Rent earnings for this property
717
+
718
+
719
+ class PortfolioResponse(BaseModel):
720
+ portfolio: PortfolioOut
721
+ items: List[PortfolioItemOut]
722
+ total_properties: int
723
+
724
+
725
+ # ============================================================================
726
+ # TOKEN/AUTH SCHEMAS
727
+ # ============================================================================
728
+
729
+ class TokenOut(BaseModel):
730
+ access_token: str
731
+ token_type: str = "bearer"
732
+ user_role: UserRole
733
+ user: UserOut
734
+
735
+
736
+ # ============================================================================
737
+ # MARKET/PURCHASE SCHEMAS
738
+ # ============================================================================
739
+
740
+ class BuyRequest(BaseModel):
741
+ property_id: str
742
+ amount_tokens: int = Field(..., gt=0)
743
+ payment_method: str = "WALLET" # WALLET, CARD, BANK_TRANSFER
744
+
745
+
746
+ class OfferRequest(BaseModel):
747
+ property_id: str
748
+ amount_tokens: int = Field(..., gt=0)
749
+ price_per_token: float = Field(..., gt=0)
750
+
751
+
752
+ class PlaceOfferRequest(BaseModel):
753
+ """Request to sell tokens back to admin/issuer"""
754
+ property_id: str
755
+ amount_tokens: int = Field(..., gt=0)
756
+ price_in_xrp: Optional[float] = None # Optional: for compatibility with frontend
757
+
758
+
759
+ class PurchaseResponse(BaseModel):
760
+ success: bool
761
+ message: str
762
+ transaction_id: str
763
+ investment_id: str
764
+ tokens_purchased: int
765
+ total_cost_aed: float
766
+ aed_wallet_balance: float
767
+ your_total_tokens: int
768
+ tokens_remaining: int
769
+ blockchain_tx_hash: str
770
+ ownership_percentage: float
771
+ property_details: Dict[str, Any]
772
+
773
+
774
+ # ============================================================================
775
+ # ADMIN SCHEMAS
776
+ # ============================================================================
777
+
778
+ class AdminStatsOut(BaseModel):
779
+ total_users: int
780
+ total_properties: int
781
+ total_investments: int
782
+ total_volume: float
783
+ active_users: int
784
+ total_tokens_sold: int
785
+ total_revenue: float
786
+
787
+
788
+ class TokenizePropertyRequest(BaseModel):
789
+ property_id: str
790
+ blockchain_network: str = "XRP_TESTNET"
791
+ auto_create_tokens: bool = True
792
+
793
+
794
+ # ============================================================================
795
+ # ADMIN WALLET / AED WALLET AUXILIARY SCHEMAS
796
+ # ============================================================================
797
+
798
+ class AEDWalletAddRequest(BaseModel):
799
+ amount: float = Field(..., gt=0, description="Amount in AED")
800
+ payment_method: str = Field(..., description="Payment method e.g. UPI, CARD")
801
+
802
+
803
+ class AEDWalletTransactionOut(BaseModel):
804
+ id: str
805
+ type: str
806
+ amount_aed: float
807
+ payment_method: Optional[str]
808
+ payment_reference: Optional[str]
809
+ status: str
810
+ created_at: Optional[datetime]
811
+ notes: Optional[str]
812
+
813
+
814
+ class AEDWalletBalanceOut(BaseModel):
815
+ balance_aed: float
816
+ balance_fils: int
817
+
818
+
819
+ class WalletCreateOut(BaseModel):
820
+ address: str
821
+ seed: str
822
+
823
+
824
+ class WalletImportRequest(BaseModel):
825
+ address: str
826
+ seed: str
827
+
828
+
829
+ class WalletBalanceOut(BaseModel):
830
+ address: str
831
+ xrp_balance: float
832
+ tokens: List[Dict[str, Any]]
833
+
834
+
835
+ class AdminWalletInfoResponse(BaseModel):
836
+ wallet_info: Dict[str, Any]
837
+ total_earnings_aed: float
838
+ total_buyers: int
839
+ flow_summary: Dict[str, Any]
840
+ transactions: List[Dict[str, Any]]
841
+ recent_transactions: List[Dict[str, Any]]
842
+
843
+
844
+ # ============================================================================
845
+ # SUPER ADMIN SCHEMAS
846
+ # ============================================================================
847
+
848
+ class SuperAdminStatsOut(BaseModel):
849
+ """Comprehensive platform statistics for super admin"""
850
+ total_users: int
851
+ total_admins: int
852
+ total_properties: int
853
+ total_investments: int
854
+ total_volume: float
855
+ platform_revenue: float
856
+ active_users: int
857
+ blocked_users: int
858
+ pending_kyc: int
859
+ approved_kyc: int
860
+ rejected_kyc: int
861
+ total_tokens_sold: int
862
+ total_transactions: int
863
+
864
+
865
+ class UserManagementOut(BaseModel):
866
+ """User details for super admin management"""
867
+ user_id: str
868
+ full_name: str
869
+ email: EmailStr
870
+ role: UserRole
871
+ phone: str
872
+ is_active: bool
873
+ kyc_status: Optional[str] = "pending"
874
+ created_at: datetime
875
+ total_invested: Optional[float] = 0.0
876
+ total_tokens: Optional[int] = 0
877
+ wallet_balance: Optional[float] = 0.0
878
+ last_login: Optional[datetime] = None
879
+
880
+
881
+ class BlockUserRequest(BaseModel):
882
+ reason: str = Field(..., min_length=10, max_length=500)
883
+
884
+
885
+ class UpdateUserRoleRequest(BaseModel):
886
+ new_role: UserRole
887
+
888
+
889
+ class PlatformSettingsOut(BaseModel):
890
+ """Platform configuration settings"""
891
+ min_investment: float
892
+ platform_fee_percentage: float
893
+ kyc_required: bool
894
+ new_registrations_enabled: bool
895
+ purchases_enabled: bool
896
+ maintenance_mode: bool
897
+
898
+
899
+ class PlatformSettingsUpdate(BaseModel):
900
+ """Update platform settings"""
901
+ min_investment: Optional[float] = Field(None, ge=0)
902
+ platform_fee_percentage: Optional[float] = Field(None, ge=0, le=100)
903
+ kyc_required: Optional[bool] = None
904
+ new_registrations_enabled: Optional[bool] = None
905
+ purchases_enabled: Optional[bool] = None
906
+ maintenance_mode: Optional[bool] = None
907
+
908
+
909
+ class TransactionDetailOut(BaseModel):
910
+ """Detailed transaction info for super admin"""
911
+ id: str
912
+ user_id: str
913
+ user_name: Optional[str] = None
914
+ user_email: Optional[str] = None
915
+ wallet_id: Optional[str]
916
+ type: TransactionType
917
+ amount: float
918
+ property_id: Optional[str]
919
+ property_name: Optional[str] = None
920
+ status: TransactionStatus
921
+ metadata: Optional[Dict[str, Any]]
922
+ created_at: datetime
923
+ blockchain_tx_hash: Optional[str] = None
924
+
925
+
926
+ class KYCDocumentManagementOut(BaseModel):
927
+ """KYC document for super admin review"""
928
+ id: str
929
+ user_id: str
930
+ user_name: str
931
+ user_email: str
932
+ full_name: str
933
+ date_of_birth: str
934
+ gender: str
935
+ address: str
936
+ document_url: str
937
+ status: str
938
+ uploaded_at: datetime
939
+ reviewed_at: Optional[datetime] = None
940
+ rejection_reason: Optional[str] = None
941
+
942
+
943
+ class KYCApprovalRequest(BaseModel):
944
+ """Approve/Reject KYC document"""
945
+ status: str = Field(..., pattern="^(approved|rejected)$")
946
+ rejection_reason: Optional[str] = Field(None, min_length=10, max_length=500)
947
+
948
+
949
+ class AdminManagementOut(BaseModel):
950
+ """Admin user details for management"""
951
+ admin_id: str
952
+ id: str
953
+ full_name: str
954
+ name: str
955
+ email: EmailStr
956
+ phone: str
957
+ is_active: bool
958
+ kyc_status: Optional[str] = "pending"
959
+ created_at: datetime
960
+ last_login: Optional[datetime] = None
961
+ properties_count: int
962
+ total_properties: int
963
+ total_revenue_xrp: Optional[float] = 0.0
964
+ total_revenue: float
965
+ total_buyers: int
966
+
967
+
968
+ # ============================================================================
969
+ # KYC SCHEMAS
970
+ # ============================================================================
971
+
972
+ class KYCUploadRequest(BaseModel):
973
+ full_name: str = Field(..., min_length=2, max_length=100)
974
+ date_of_birth: str = Field(..., description="Date of birth in YYYY-MM-DD format")
975
+ gender: str = Field(..., description="Gender: Male, Female, Other")
976
+ address: str = Field(..., min_length=10, max_length=500)
977
+
978
+
979
+ class KYCDocumentOut(BaseModel):
980
+ id: str
981
+ user_id: str
982
+ full_name: str
983
+ date_of_birth: str
984
+ gender: str
985
+ address: str
986
+ document_url: str
987
+ file_size: int
988
+ content_type: str
989
+ status: str # pending, approved, rejected
990
+ uploaded_at: datetime
991
+ reviewed_at: Optional[datetime] = None
992
+ reviewed_by: Optional[str] = None
993
+ rejection_reason: Optional[str] = None
994
+
995
+ class Config:
996
+ from_attributes = True
997
+
998
+
999
+ class KYCStatusOut(BaseModel):
1000
+ kyc_status: str
1001
+ kyc_document: Optional[KYCDocumentOut] = None
1002
+ can_purchase: bool
1003
+
1004
+
1005
+ # ============================================================================
1006
+ # RENT DISTRIBUTION SCHEMAS
1007
+ # ============================================================================
1008
+
1009
+ class RentDistributionCreate(BaseModel):
1010
+ """Schema for creating a new rent distribution event"""
1011
+ property_id: str = Field(..., description="Property generating rent")
1012
+ total_rent_amount: float = Field(..., gt=0, description="Total rent collected in AED")
1013
+ rent_period_start: str = Field(..., description="Rent period start date (YYYY-MM-DD)")
1014
+ rent_period_end: str = Field(..., description="Rent period end date (YYYY-MM-DD)")
1015
+ distribution_date: Optional[datetime] = Field(None, description="When to distribute (default: now)")
1016
+ notes: Optional[str] = Field(None, max_length=500, description="Additional notes")
1017
+
1018
+ @validator('rent_period_start', 'rent_period_end')
1019
+ def validate_date_format(cls, v):
1020
+ """Validate date is in YYYY-MM-DD format"""
1021
+ try:
1022
+ datetime.strptime(v, '%Y-%m-%d')
1023
+ return v
1024
+ except ValueError:
1025
+ raise ValueError('Date must be in YYYY-MM-DD format')
1026
+
1027
+ @validator('distribution_date', always=True)
1028
+ def set_distribution_date(cls, v):
1029
+ """Set distribution date to now if not provided"""
1030
+ return v if v is not None else datetime.utcnow()
1031
+
1032
+
1033
+ class RentDistributionOut(BaseModel):
1034
+ """Schema for rent distribution response"""
1035
+ id: str
1036
+ property_id: str
1037
+ property_title: Optional[str] = None
1038
+ total_rent_amount: float
1039
+ total_tokens: int
1040
+ rent_per_token: float
1041
+ rent_period_start: str
1042
+ rent_period_end: str
1043
+ distribution_date: datetime
1044
+ total_investors: int
1045
+ payments_completed: int
1046
+ status: str # pending, processing, completed, failed
1047
+ notes: Optional[str] = None
1048
+ created_at: datetime
1049
+ updated_at: datetime
1050
+
1051
+ class Config:
1052
+ from_attributes = True
1053
+
1054
+
1055
+ class RentPaymentCreate(BaseModel):
1056
+ """Schema for creating individual rent payment (internal use)"""
1057
+ distribution_id: str
1058
+ user_id: str
1059
+ property_id: str
1060
+ tokens_owned: int = Field(..., gt=0)
1061
+ rent_amount: float = Field(..., gt=0)
1062
+ payment_status: str = "pending"
1063
+ wallet_credited: bool = False
1064
+ transaction_id: Optional[str] = None
1065
+
1066
+
1067
+ class RentPaymentOut(BaseModel):
1068
+ """Schema for rent payment response"""
1069
+ id: str
1070
+ distribution_id: str
1071
+ user_id: str
1072
+ property_id: str
1073
+ property_title: Optional[str] = None
1074
+ tokens_owned: int
1075
+ rent_amount: float
1076
+ rent_period_start: str
1077
+ rent_period_end: str
1078
+ payment_status: str
1079
+ wallet_credited: bool
1080
+ transaction_id: Optional[str] = None
1081
+ payment_date: datetime
1082
+ created_at: datetime
1083
+
1084
+ class Config:
1085
+ from_attributes = True
1086
+
1087
+
1088
+ class UserRentSummary(BaseModel):
1089
+ """Schema for user's rent summary per property"""
1090
+ property_id: str
1091
+ property_title: str
1092
+ total_tokens_owned: int
1093
+ total_rent_received: float
1094
+ total_rent_payments: int
1095
+ last_rent_amount: Optional[float] = None
1096
+ last_rent_date: Optional[datetime] = None
1097
+ average_monthly_rent: Optional[float] = None
1098
+ annual_rental_yield: Optional[float] = None
1099
+
1100
+ class Config:
1101
+ from_attributes = True
1102
+
1103
+
1104
+ # ============================================================================
1105
+ # SECONDARY MARKET SCHEMAS
1106
+ # ============================================================================
1107
+
1108
+ class MarketTransactionType(str, Enum):
1109
+ """Types of secondary market transactions"""
1110
+ sell_to_admin = "sell_to_admin" # User sells tokens back to admin
1111
+ buy_from_user = "buy_from_user" # User buys tokens from another user
1112
+ sell_to_user = "sell_to_user" # User sells tokens to another user
1113
+
1114
+
1115
+ class MarketTransactionStatus(str, Enum):
1116
+ """Status of secondary market transactions"""
1117
+ pending = "pending"
1118
+ blockchain_processing = "blockchain_processing"
1119
+ completed = "completed"
1120
+ failed = "failed"
1121
+ cancelled = "cancelled"
1122
+
1123
+
1124
+ class SecondaryMarketTransaction(BaseModel):
1125
+ """Schema for secondary market trading history"""
1126
+ transaction_id: str
1127
+ transaction_type: MarketTransactionType
1128
+ status: MarketTransactionStatus
1129
+
1130
+ # Property details
1131
+ property_id: str
1132
+ property_title: str
1133
+ token_currency: str
1134
+
1135
+ # Seller details
1136
+ seller_id: str
1137
+ seller_email: str
1138
+ seller_xrp_address: str
1139
+
1140
+ # Buyer details (admin for sell_to_admin)
1141
+ buyer_id: str
1142
+ buyer_email: str
1143
+ buyer_xrp_address: str
1144
+
1145
+ # Transaction details
1146
+ tokens_amount: int
1147
+ price_per_token: float
1148
+ total_amount: float
1149
+ currency: str = "AED"
1150
+
1151
+ # Blockchain details
1152
+ blockchain_tx_hash: Optional[str] = None
1153
+ blockchain_confirmed: bool = False
1154
+ blockchain_confirmed_at: Optional[datetime] = None
1155
+
1156
+ # Database sync
1157
+ db_investment_updated: bool = False
1158
+ db_wallet_updated: bool = False
1159
+ db_property_updated: bool = False
1160
+ db_transaction_recorded: bool = False
1161
+
1162
+ # Timestamps
1163
+ initiated_at: datetime
1164
+ completed_at: Optional[datetime] = None
1165
+
1166
+ # Additional info
1167
+ notes: Optional[str] = None
1168
+ error_message: Optional[str] = None
1169
+
1170
+ class Config:
1171
+ from_attributes = True
1172
+
1173
+
1174
+ class PlaceOfferRequest(BaseModel):
1175
+ """Request schema for placing a sell offer"""
1176
+ property_id: str = Field(..., description="Property ID to sell tokens for")
1177
+ amount_tokens: int = Field(..., gt=0, description="Number of tokens to sell (must be positive)")
1178
+ price_in_xrp: Optional[float] = Field(None, gt=0, description="Optional: price per token in XRP")
1179
+
1180
+
1181
+ class MarketHistoryResponse(BaseModel):
1182
+ """Response schema for market transaction history"""
1183
+ total_transactions: int
1184
+ total_sold: int
1185
+ total_earned: float
1186
+ transactions: List[SecondaryMarketTransaction]
1187
+
1188
+ class Config:
1189
+ from_attributes = True
1190
+
1191
+
1192
+ class SyncBalanceRequest(BaseModel):
1193
+ """Request schema for syncing blockchain balance with database"""
1194
+ property_id: str = Field(..., description="Property ID to sync balance for")
1195
+ force_sync: bool = Field(False, description="Force sync even if balances match")
1196
+
1197
+
1198
+ class SyncBalanceResponse(BaseModel):
1199
+ """Response schema for balance sync operation"""
1200
+ success: bool
1201
+ property_id: str
1202
+ property_title: str
1203
+ user_id: str
1204
+
1205
+ # Before sync
1206
+ db_balance_before: int
1207
+ blockchain_balance: float
1208
+
1209
+ # After sync
1210
+ db_balance_after: int
1211
+ tokens_adjusted: int
1212
+
1213
+ # Details
1214
+ was_out_of_sync: bool
1215
+ sync_action: str # "increased", "decreased", "no_change"
1216
+ message: str
1217
+
1218
+ class Config:
1219
+ from_attributes = True
1220
+
1221
+
1222
+ # ============================================================================
1223
+ # PAYMENT METHOD SCHEMAS (Cards & Banks)
1224
+ # ============================================================================
1225
+
1226
+ class CardType(str, Enum):
1227
+ visa = "visa"
1228
+ mastercard = "mastercard"
1229
+ amex = "amex"
1230
+ discover = "discover"
1231
+ unknown = "unknown"
1232
+
1233
+
1234
+ class BankAccountType(str, Enum):
1235
+ savings = "savings"
1236
+ current = "current"
1237
+ business = "business"
1238
+
1239
+
1240
+ class CardCreate(BaseModel):
1241
+ """Schema for adding a new card"""
1242
+ card_number: str = Field(..., min_length=13, max_length=19, description="Full card number")
1243
+ expiry_month: int = Field(..., ge=1, le=12, description="Expiry month (1-12)")
1244
+ expiry_year: int = Field(..., ge=2024, le=2099, description="Expiry year (YYYY)")
1245
+ cvv: str = Field(..., min_length=3, max_length=4, description="CVV code")
1246
+ cardholder_name: str = Field(..., min_length=2, max_length=100, description="Name on card")
1247
+ bank_name: Optional[str] = Field(None, max_length=100, description="Issuing bank name")
1248
+
1249
+ @validator('card_number')
1250
+ def validate_card_number(cls, v):
1251
+ """Remove spaces and validate digits only"""
1252
+ cleaned = v.replace(' ', '').replace('-', '')
1253
+ if not cleaned.isdigit():
1254
+ raise ValueError('Card number must contain only digits')
1255
+ if len(cleaned) < 13 or len(cleaned) > 19:
1256
+ raise ValueError('Card number must be 13-19 digits')
1257
+ return cleaned
1258
+
1259
+ @validator('cvv')
1260
+ def validate_cvv(cls, v):
1261
+ """Validate CVV is digits only"""
1262
+ if not v.isdigit():
1263
+ raise ValueError('CVV must contain only digits')
1264
+ return v
1265
+
1266
+
1267
+ class CardOut(BaseModel):
1268
+ """Schema for card output (secure - no full card number)"""
1269
+ id: str
1270
+ user_role: Optional[str] = None
1271
+ card_type: CardType
1272
+ last_four: str
1273
+ cardholder_name: str
1274
+ bank_name: Optional[str] = None
1275
+ expiry_month: int
1276
+ expiry_year: int
1277
+ is_default: bool = False
1278
+ created_at: datetime
1279
+
1280
+ class Config:
1281
+ from_attributes = True
1282
+
1283
+ def dict(self, **kwargs):
1284
+ kwargs['exclude_none'] = True
1285
+ return super().dict(**kwargs)
1286
+
1287
+ def model_dump(self, **kwargs):
1288
+ kwargs['exclude_none'] = True
1289
+ return super().model_dump(**kwargs)
1290
+
1291
+
1292
+ # ============================================================================
1293
+ # MISSING UPDATE SCHEMAS (Schema Completeness)
1294
+ # ============================================================================
1295
+
1296
+ class PropertySpecificationUpdate(BaseModel):
1297
+ """Update schema for property specifications"""
1298
+ balcony: Optional[int] = Field(None, ge=0)
1299
+ kitchen: Optional[int] = Field(None, ge=0)
1300
+ bedroom: Optional[int] = Field(None, ge=0)
1301
+ bathroom: Optional[int] = Field(None, ge=0)
1302
+ area: Optional[float] = Field(None, gt=0, description="Area in square meters")
1303
+
1304
+
1305
+ class AmenityUpdate(BaseModel):
1306
+ """Update schema for property amenities"""
1307
+ name: Optional[str] = Field(None, min_length=2, max_length=100)
1308
+ is_active: Optional[bool] = None
1309
+
1310
+
1311
+ class PropertyImageUpdate(BaseModel):
1312
+ """Update schema for property images"""
1313
+ image_url: Optional[str] = None
1314
+ caption: Optional[str] = None
1315
+ is_main: Optional[bool] = None
1316
+ is_active: Optional[bool] = None
1317
+
1318
+
1319
+ class TransactionUpdate(BaseModel):
1320
+ """Update schema for transactions"""
1321
+ status: Optional[TransactionStatus] = None
1322
+ metadata: Optional[Dict[str, Any]] = None
1323
+
1324
+
1325
+ class DocumentUpdate(BaseModel):
1326
+ """Update schema for documents"""
1327
+ file_type: Optional[FileType] = None
1328
+ file_url: Optional[str] = None
1329
+ is_active: Optional[bool] = None
1330
+
1331
+
1332
+ class PortfolioCreate(BaseModel):
1333
+ """Create schema for user portfolio"""
1334
+ user_id: str
1335
+ total_invested: float = 0.0
1336
+ total_current_value: float = 0.0
1337
+ total_profit: float = 0.0
1338
+
1339
+
1340
+ class PortfolioUpdate(BaseModel):
1341
+ """Update schema for user portfolio"""
1342
+ total_invested: Optional[float] = Field(None, ge=0)
1343
+ total_current_value: Optional[float] = Field(None, ge=0)
1344
+ total_profit: Optional[float] = None
1345
+
1346
+
1347
+ # ============================================================================
1348
+ # SESSION SCHEMAS (Device-bound Authentication)
1349
+ # ============================================================================
1350
+
1351
+ class SessionCreate(BaseModel):
1352
+ """Create schema for user session"""
1353
+ user_id: str = Field(..., description="User ID")
1354
+ device_fingerprint: str = Field(..., min_length=10, max_length=256, description="Device fingerprint hash")
1355
+ device_info: Optional[Dict[str, Any]] = Field(None, description="Device metadata (browser, OS, etc.)")
1356
+ ip_address: Optional[str] = Field(None, max_length=45, description="IPv4 or IPv6 address")
1357
+ user_agent: Optional[str] = Field(None, max_length=512, description="User agent string")
1358
+ expires_at: Optional[datetime] = Field(None, description="Session expiration time")
1359
+
1360
+
1361
+ class SessionOut(BaseModel):
1362
+ """Output schema for user session"""
1363
+ id: str
1364
+ session_id: str
1365
+ user_id: str
1366
+ device_fingerprint: str
1367
+ device_info: Optional[Dict[str, Any]] = None
1368
+ ip_address: Optional[str] = None
1369
+ user_agent: Optional[str] = None
1370
+ is_active: bool = True
1371
+ last_activity: Optional[datetime] = None
1372
+ expires_at: datetime
1373
+ created_at: datetime
1374
+ updated_at: datetime
1375
+
1376
+ class Config:
1377
+ from_attributes = True
1378
+
1379
+
1380
+ class SessionUpdate(BaseModel):
1381
+ """Update schema for user session"""
1382
+ is_active: Optional[bool] = None
1383
+ last_activity: Optional[datetime] = None
1384
+ expires_at: Optional[datetime] = None
1385
+
1386
+
1387
+ # ============================================================================
1388
+ # CERTIFICATE SCHEMAS (Ownership Certificates)
1389
+ # ============================================================================
1390
+
1391
+ class CertificatePropertyDetails(BaseModel):
1392
+ """Property details embedded in certificate"""
1393
+ property_id: str
1394
+ property_title: str
1395
+ property_location: Optional[str] = None
1396
+ token_currency: Optional[str] = None
1397
+
1398
+
1399
+ class CertificateCreate(BaseModel):
1400
+ """Create schema for ownership certificate"""
1401
+ user_id: str = Field(..., description="Owner user ID")
1402
+ property_details: CertificatePropertyDetails = Field(..., description="Property information")
1403
+ tokens_owned: int = Field(..., gt=0, description="Number of tokens owned")
1404
+ ownership_percentage: float = Field(..., ge=0, le=100, description="Ownership percentage")
1405
+ investment_amount: float = Field(..., gt=0, description="Total investment amount in AED")
1406
+ purchase_date: datetime = Field(..., description="Date of purchase")
1407
+ blockchain_tx_hash: Optional[str] = Field(None, description="Blockchain transaction hash")
1408
+ issued_date: Optional[datetime] = Field(None, description="Certificate issue date")
1409
+
1410
+
1411
+ class CertificateOut(BaseModel):
1412
+ """Output schema for ownership certificate"""
1413
+ id: str
1414
+ certificate_id: str
1415
+ user_id: str
1416
+ user_name: Optional[str] = None
1417
+ user_email: Optional[str] = None
1418
+ property_details: CertificatePropertyDetails
1419
+ tokens_owned: int
1420
+ ownership_percentage: float
1421
+ investment_amount: float
1422
+ purchase_date: datetime
1423
+ blockchain_tx_hash: Optional[str] = None
1424
+ issued_date: datetime
1425
+ is_valid: bool = True
1426
+ revoked_at: Optional[datetime] = None
1427
+ revocation_reason: Optional[str] = None
1428
+ created_at: datetime
1429
+ updated_at: datetime
1430
+
1431
+ class Config:
1432
+ from_attributes = True
1433
+
1434
+
1435
+ class CertificateUpdate(BaseModel):
1436
+ """Update schema for ownership certificate"""
1437
+ tokens_owned: Optional[int] = Field(None, gt=0)
1438
+ ownership_percentage: Optional[float] = Field(None, ge=0, le=100)
1439
+ is_valid: Optional[bool] = None
1440
+ revoked_at: Optional[datetime] = None
1441
+ revocation_reason: Optional[str] = Field(None, max_length=500)
1442
+
1443
+
1444
+ # ============================================================================
1445
+ # RENT DISTRIBUTION UPDATE SCHEMAS
1446
+ # ============================================================================
1447
+
1448
+ class RentDistributionUpdate(BaseModel):
1449
+ """Update schema for rent distribution"""
1450
+ total_rent_amount: Optional[float] = Field(None, gt=0)
1451
+ status: Optional[str] = Field(None, pattern="^(pending|processing|completed|failed)$")
1452
+ payments_completed: Optional[int] = Field(None, ge=0)
1453
+ notes: Optional[str] = Field(None, max_length=500)
1454
+
1455
+
1456
+ class RentPaymentUpdate(BaseModel):
1457
+ """Update schema for rent payment"""
1458
+ payment_status: Optional[str] = Field(None, pattern="^(pending|processing|completed|failed)$")
1459
+ wallet_credited: Optional[bool] = None
1460
+ transaction_id: Optional[str] = None
1461
+
1462
+
1463
+ # ============================================================================
1464
+ # SECONDARY MARKET UPDATE SCHEMA
1465
+ # ============================================================================
1466
+
1467
+ class SecondaryMarketUpdate(BaseModel):
1468
+ """Update schema for secondary market transactions"""
1469
+ status: Optional[MarketTransactionStatus] = None
1470
+ blockchain_tx_hash: Optional[str] = None
1471
+ blockchain_confirmed: Optional[bool] = None
1472
+ blockchain_confirmed_at: Optional[datetime] = None
1473
+ db_investment_updated: Optional[bool] = None
1474
+ db_wallet_updated: Optional[bool] = None
1475
+ db_property_updated: Optional[bool] = None
1476
+ db_transaction_recorded: Optional[bool] = None
1477
+ completed_at: Optional[datetime] = None
1478
+ error_message: Optional[str] = Field(None, max_length=1000)
1479
+ notes: Optional[str] = Field(None, max_length=500)
1480
+
1481
+
1482
+ class BankCreate(BaseModel):
1483
+ """Schema for adding a new bank account"""
1484
+ bank_name: str = Field(..., min_length=2, max_length=100, description="Bank name")
1485
+ account_holder_name: str = Field(..., min_length=2, max_length=100, description="Account holder name")
1486
+ account_number: str = Field(..., min_length=8, max_length=20, description="Account number")
1487
+ iban: Optional[str] = Field(None, max_length=34, description="IBAN")
1488
+ swift_code: Optional[str] = Field(None, max_length=11, description="SWIFT/BIC code")
1489
+ account_type: BankAccountType = BankAccountType.savings
1490
+ currency: str = Field("AED", max_length=3)
1491
+
1492
+ @validator('account_number')
1493
+ def validate_account_number(cls, v):
1494
+ """Remove spaces and validate"""
1495
+ cleaned = v.replace(' ', '').replace('-', '')
1496
+ if not cleaned.isalnum():
1497
+ raise ValueError('Account number must be alphanumeric')
1498
+ return cleaned
1499
+
1500
+ @validator('iban')
1501
+ def validate_iban(cls, v):
1502
+ """Validate IBAN format if provided"""
1503
+ if v is None:
1504
+ return v
1505
+ cleaned = v.replace(' ', '').upper()
1506
+ if len(cleaned) < 15 or len(cleaned) > 34:
1507
+ raise ValueError('IBAN must be 15-34 characters')
1508
+ return cleaned
1509
+
1510
+
1511
+ class BankOut(BaseModel):
1512
+ """Schema for bank output (secure - masked account number)"""
1513
+ id: str
1514
+ user_role: Optional[str] = None
1515
+ bank_name: str
1516
+ account_holder_name: str
1517
+ account_number_last_four: str
1518
+ iban_last_four: Optional[str] = None
1519
+ swift_code: Optional[str] = None
1520
+ account_type: BankAccountType
1521
+ currency: str
1522
+ is_default: bool = False
1523
+ is_verified: bool = False
1524
+ created_at: datetime
1525
+
1526
+ class Config:
1527
+ from_attributes = True
1528
+
1529
+ def dict(self, **kwargs):
1530
+ kwargs['exclude_none'] = True
1531
+ return super().dict(**kwargs)
1532
+
1533
+ def model_dump(self, **kwargs):
1534
+ kwargs['exclude_none'] = True
1535
+ return super().model_dump(**kwargs)
1536
+
services/ipfs_service.py ADDED
@@ -0,0 +1,306 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ IPFS Service for Decentralized File Storage
3
+ Handles document uploads to IPFS and returns content-addressed hashes
4
+ """
5
+ import io
6
+ import base64
7
+ import hashlib
8
+ from typing import Optional, Dict, Any
9
+ import requests
10
+ from config import settings
11
+
12
+ # Import logging
13
+ from utils.logger import log_info, log_error, log_warning
14
+
15
+
16
+ class IPFSService:
17
+ """
18
+ Service for interacting with IPFS (InterPlanetary File System)
19
+ Uses Web3.Storage API for free, permanent storage
20
+ """
21
+
22
+ def __init__(self):
23
+ """Initialize IPFS service"""
24
+ # Get configuration from settings
25
+ self.ipfs_provider = getattr(settings, 'IPFS_PROVIDER', 'local_simulation')
26
+
27
+ # Web3.Storage API
28
+ self.web3_api_url = "https://api.web3.storage"
29
+ self.web3_token = getattr(settings, 'WEB3_STORAGE_API_TOKEN', None)
30
+
31
+ # Pinata API
32
+ self.pinata_api_url = "https://api.pinata.cloud"
33
+ self.pinata_api_key = getattr(settings, 'PINATA_API_KEY', None)
34
+ self.pinata_secret_key = getattr(settings, 'PINATA_SECRET_KEY', None)
35
+
36
+ # Determine which provider to use
37
+ if self.ipfs_provider == "pinata" and self.pinata_api_key and self.pinata_secret_key:
38
+ log_info("[IPFS] Initialized with Pinata provider")
39
+ elif self.ipfs_provider == "web3storage" and self.web3_token:
40
+ log_info("[IPFS] Initialized with Web3.Storage provider")
41
+ else:
42
+ log_warning("[IPFS] Using local simulation mode - files won't be uploaded to real IPFS network")
43
+ log_warning("[IPFS] To use real IPFS: Set IPFS_PROVIDER='pinata' and configure PINATA_API_KEY + PINATA_SECRET_KEY in .env")
44
+
45
+ def upload_file(self, file_content: bytes, filename: str, content_type: str = "application/octet-stream") -> Dict[str, Any]:
46
+ """
47
+ Upload file to IPFS and return CID (Content Identifier)
48
+
49
+ Args:
50
+ file_content: File bytes
51
+ filename: Original filename
52
+ content_type: MIME type
53
+
54
+ Returns:
55
+ {
56
+ 'success': bool,
57
+ 'cid': str, # IPFS CID (QmXxx...)
58
+ 'url': str, # Public IPFS gateway URL
59
+ 'size': int,
60
+ 'hash': str # SHA-256 hash for verification
61
+ }
62
+ """
63
+ try:
64
+ log_info(f"[IPFS] Uploading file: {filename} ({len(file_content)} bytes)")
65
+
66
+ # Calculate SHA-256 hash for verification
67
+ file_hash = hashlib.sha256(file_content).hexdigest()
68
+
69
+ result = None
70
+
71
+ # Route to appropriate provider
72
+ if self.ipfs_provider == "pinata" and self.pinata_api_key:
73
+ result = self._upload_to_pinata(file_content, filename, content_type)
74
+ # Fallback to public IPFS if Pinata fails (e.g., API key revoked)
75
+ if not result.get('success'):
76
+ log_warning(f"[IPFS] Pinata failed, falling back to public IPFS/simulation...")
77
+ result = self._upload_to_public_ipfs(file_content, filename)
78
+ elif self.ipfs_provider == "web3storage" and self.web3_token:
79
+ result = self._upload_to_web3_storage(file_content, filename, content_type)
80
+ # Fallback to public IPFS if Web3.Storage fails
81
+ if not result.get('success'):
82
+ log_warning(f"[IPFS] Web3.Storage failed, falling back to public IPFS/simulation...")
83
+ result = self._upload_to_public_ipfs(file_content, filename)
84
+ else:
85
+ # Fallback: Upload to public IPFS node or local simulation
86
+ result = self._upload_to_public_ipfs(file_content, filename)
87
+
88
+ if result['success']:
89
+ result['hash'] = file_hash
90
+ log_info(f"[IPFS] SUCCESS: Upload successful! CID: {result['cid']}")
91
+ else:
92
+ log_error(f"[IPFS] ERROR: Upload failed: {result.get('error')}")
93
+
94
+ return result
95
+
96
+ except Exception as e:
97
+ log_error(f"[IPFS] Upload error: {e}")
98
+ return {
99
+ 'success': False,
100
+ 'error': str(e)
101
+ }
102
+
103
+ def _upload_to_web3_storage(self, file_content: bytes, filename: str, content_type: str) -> Dict[str, Any]:
104
+ """Upload file to Web3.Storage API"""
105
+ try:
106
+ # Prepare upload
107
+ headers = {
108
+ 'Authorization': f'Bearer {self.api_token}',
109
+ 'X-NAME': filename
110
+ }
111
+
112
+ files = {
113
+ 'file': (filename, io.BytesIO(file_content), content_type)
114
+ }
115
+
116
+ # Upload to Web3.Storage
117
+ response = requests.post(
118
+ f"{self.web3_api_url}/upload",
119
+ headers=headers,
120
+ files=files,
121
+ timeout=30
122
+ )
123
+
124
+ if response.status_code == 200:
125
+ data = response.json()
126
+ cid = data.get('cid')
127
+
128
+ return {
129
+ 'success': True,
130
+ 'cid': cid,
131
+ 'url': f"https://ipfs.io/ipfs/{cid}",
132
+ 'gateway_url': f"https://w3s.link/ipfs/{cid}",
133
+ 'size': len(file_content),
134
+ 'provider': 'web3.storage'
135
+ }
136
+ else:
137
+ return {
138
+ 'success': False,
139
+ 'error': f"Web3.Storage API error: {response.status_code} - {response.text}"
140
+ }
141
+
142
+ except Exception as e:
143
+ return {
144
+ 'success': False,
145
+ 'error': f"Web3.Storage upload failed: {str(e)}"
146
+ }
147
+
148
+ def _upload_to_pinata(self, file_content: bytes, filename: str, content_type: str) -> Dict[str, Any]:
149
+ """Upload file to Pinata (pinata.cloud)"""
150
+ try:
151
+ log_info(f"[IPFS] Uploading to Pinata...")
152
+
153
+ # Pinata requires pinFileToIPFS endpoint
154
+ url = f"{self.pinata_api_url}/pinning/pinFileToIPFS"
155
+
156
+ headers = {
157
+ 'pinata_api_key': self.pinata_api_key,
158
+ 'pinata_secret_api_key': self.pinata_secret_key
159
+ }
160
+
161
+ files = {
162
+ 'file': (filename, io.BytesIO(file_content), content_type)
163
+ }
164
+
165
+ response = requests.post(
166
+ url,
167
+ headers=headers,
168
+ files=files,
169
+ timeout=60
170
+ )
171
+
172
+ if response.status_code == 200:
173
+ data = response.json()
174
+ cid = data.get('IpfsHash')
175
+
176
+ log_info(f"[IPFS] SUCCESS: Pinata upload successful! CID: {cid}")
177
+
178
+ return {
179
+ 'success': True,
180
+ 'cid': cid,
181
+ 'url': f"https://ipfs.io/ipfs/{cid}",
182
+ 'gateway_url': f"https://gateway.pinata.cloud/ipfs/{cid}",
183
+ 'size': len(file_content),
184
+ 'provider': 'pinata',
185
+ 'pinata_data': data
186
+ }
187
+ else:
188
+ log_error(f"[IPFS] Pinata API error: {response.status_code} - {response.text}")
189
+ return {
190
+ 'success': False,
191
+ 'error': f"Pinata API error: {response.status_code} - {response.text}"
192
+ }
193
+
194
+ except Exception as e:
195
+ log_error(f"[IPFS] Pinata upload failed: {str(e)}")
196
+ return {
197
+ 'success': False,
198
+ 'error': f"Pinata upload failed: {str(e)}"
199
+ }
200
+
201
+ def _upload_to_public_ipfs(self, file_content: bytes, filename: str) -> Dict[str, Any]:
202
+ """
203
+ Fallback: Upload to public IPFS node
204
+ Note: Public nodes may not persist data permanently
205
+ """
206
+ try:
207
+ # Method 1: Try Web3.Storage (free public IPFS API - no auth required for small files)
208
+ # Note: Uses nft.storage domain but serves general IPFS content
209
+ try:
210
+ log_info(f"[IPFS] Trying Web3.Storage public API...")
211
+ files = {
212
+ 'file': (filename, io.BytesIO(file_content))
213
+ }
214
+
215
+ response = requests.post(
216
+ "https://api.nft.storage/upload",
217
+ files=files,
218
+ timeout=30
219
+ )
220
+
221
+ if response.status_code == 200 or response.status_code == 201:
222
+ data = response.json()
223
+ cid = data.get('value', {}).get('cid') or data.get('cid')
224
+
225
+ if cid:
226
+ log_info(f"[IPFS] SUCCESS: Web3.Storage upload successful! CID: {cid}")
227
+ return {
228
+ 'success': True,
229
+ 'cid': cid,
230
+ 'url': f"https://ipfs.io/ipfs/{cid}",
231
+ 'gateway_url': f"https://nftstorage.link/ipfs/{cid}",
232
+ 'size': len(file_content),
233
+ 'provider': 'web3.storage',
234
+ 'warning': 'Using public gateway - free tier'
235
+ }
236
+ except Exception as e:
237
+ log_warning(f"[IPFS] Web3.Storage failed: {e}")
238
+
239
+ # Method 2: Use a mock/local storage approach (for development)
240
+ # Generate a deterministic "fake CID" based on content hash
241
+ log_warning(f"[IPFS] All public nodes failed. Creating local reference...")
242
+ content_hash = hashlib.sha256(file_content).hexdigest()
243
+ fake_cid = f"Qm{content_hash[:44]}" # Simulate IPFS CID format
244
+
245
+ log_info(f"[IPFS] WARNING: Using local storage simulation. CID: {fake_cid}")
246
+ log_info(f"[IPFS] WARNING: Note: File is stored locally, not on IPFS network")
247
+
248
+ return {
249
+ 'success': True,
250
+ 'cid': fake_cid,
251
+ 'url': f"https://ipfs.io/ipfs/{fake_cid}",
252
+ 'gateway_url': f"https://cloudflare-ipfs.com/ipfs/{fake_cid}",
253
+ 'size': len(file_content),
254
+ 'provider': 'local_simulation',
255
+ 'warning': 'LOCAL SIMULATION ONLY - Not uploaded to real IPFS. For production, configure Web3.Storage API token.'
256
+ }
257
+
258
+ except Exception as e:
259
+ return {
260
+ 'success': False,
261
+ 'error': f"Public IPFS upload failed: {str(e)}"
262
+ }
263
+
264
+ def get_file_url(self, cid: str, gateway: str = "ipfs.io") -> str:
265
+ """
266
+ Get public URL for IPFS file
267
+
268
+ Args:
269
+ cid: IPFS CID
270
+ gateway: Gateway domain (default: ipfs.io)
271
+
272
+ Returns:
273
+ Public URL to access file
274
+ """
275
+ return f"https://{gateway}/ipfs/{cid}"
276
+
277
+ def verify_file_hash(self, cid: str, expected_hash: str) -> bool:
278
+ """
279
+ Verify that file on IPFS matches expected hash
280
+
281
+ Args:
282
+ cid: IPFS CID
283
+ expected_hash: Expected SHA-256 hash
284
+
285
+ Returns:
286
+ True if hashes match
287
+ """
288
+ try:
289
+ # Download file from IPFS
290
+ url = self.get_file_url(cid)
291
+ response = requests.get(url, timeout=30)
292
+
293
+ if response.status_code == 200:
294
+ # Calculate hash of downloaded file
295
+ actual_hash = hashlib.sha256(response.content).hexdigest()
296
+ return actual_hash == expected_hash
297
+
298
+ return False
299
+
300
+ except Exception as e:
301
+ log_error(f"[IPFS] Hash verification failed: {e}")
302
+ return False
303
+
304
+
305
+ # Global IPFS service instance
306
+ ipfs_service = IPFSService()
services/price_oracle.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Price Oracle Service for Real-Time XRP/AED Exchange Rates
3
+ Fetches live prices from multiple sources for accuracy
4
+ """
5
+
6
+ import requests
7
+ from typing import Optional, Dict
8
+ import time
9
+ from config import settings
10
+
11
+ class PriceOracle:
12
+ """Fetches real-time XRP/AED exchange rates"""
13
+
14
+ def __init__(self):
15
+ self.cache_duration = 60 # Cache price for 60 seconds
16
+ self.last_fetch_time = 0
17
+ self.cached_rate = None
18
+ self.fallback_rate = settings.XRPL_RATE_AED_TO_XRP # Use config as fallback
19
+
20
+ def get_xrp_aed_rate(self) -> float:
21
+ """
22
+ Get current XRP to AED exchange rate
23
+ Returns: XRP per 1 AED (e.g., 0.000017 means 1 AED = 0.000017 XRP)
24
+ """
25
+ # Check cache first
26
+ current_time = time.time()
27
+ if self.cached_rate and (current_time - self.last_fetch_time) < self.cache_duration:
28
+ return self.cached_rate
29
+
30
+ # Try to fetch live rate
31
+ live_rate = self._fetch_live_rate()
32
+
33
+ if live_rate:
34
+ self.cached_rate = live_rate
35
+ self.last_fetch_time = current_time
36
+ return live_rate
37
+
38
+ # Fallback to config or cached value
39
+ return self.cached_rate if self.cached_rate else self.fallback_rate
40
+
41
+ def _fetch_live_rate(self) -> Optional[float]:
42
+ """Fetch live XRP/AED rate from exchange APIs"""
43
+
44
+ # Source 1: CoinGecko API (Free, no API key needed)
45
+ try:
46
+ response = requests.get(
47
+ 'https://api.coingecko.com/api/v3/simple/price',
48
+ params={'ids': 'ripple', 'vs_currencies': 'aed'},
49
+ timeout=5
50
+ )
51
+ if response.status_code == 200:
52
+ data = response.json()
53
+ xrp_aed_price = data.get('ripple', {}).get('aed')
54
+ if xrp_aed_price and xrp_aed_price > 0:
55
+ # Convert from "1 XRP = X AED" to "1 AED = X XRP"
56
+ xrp_per_aed = 1.0 / xrp_aed_price
57
+ print(f"[ORACLE] Live rate: 1 XRP = {xrp_aed_price:.2f} AED | 1 AED = {xrp_per_aed:.8f} XRP")
58
+ return xrp_per_aed
59
+ except Exception as e:
60
+ print(f"[ORACLE] CoinGecko API failed: {e}")
61
+
62
+ # Source 2: CoinDCX API (Exchange)
63
+ try:
64
+ response = requests.get(
65
+ 'https://api.coindcx.com/exchange/ticker',
66
+ timeout=5
67
+ )
68
+ if response.status_code == 200:
69
+ data = response.json()
70
+ # Find XRP/AED pair (or convert via USDT if needed)
71
+ for ticker in data:
72
+ if ticker.get('market') == 'XRPAED':
73
+ last_price = float(ticker.get('last_price', 0))
74
+ if last_price > 0:
75
+ xrp_per_aed = 1.0 / last_price
76
+ print(f"[ORACLE] CoinDCX rate: 1 XRP = {last_price:.2f} AED | 1 AED = {xrp_per_aed:.8f} XRP")
77
+ return xrp_per_aed
78
+ except Exception as e:
79
+ print(f"[ORACLE] CoinDCX API failed: {e}")
80
+
81
+ # Source 3: Binance API (as additional fallback)
82
+ try:
83
+ # First get XRP/USDT
84
+ response = requests.get(
85
+ 'https://api.binance.com/api/v3/ticker/price',
86
+ params={'symbol': 'XRPUSDT'},
87
+ timeout=5
88
+ )
89
+ if response.status_code == 200:
90
+ xrp_usdt = float(response.json().get('price', 0))
91
+
92
+ # Then get USDT/AED (approximate - 1 USD ≈ 3.67 AED)
93
+ # Using fixed rate or could fetch from another API
94
+ usdt_aed = 3.67 # Approximate fixed rate
95
+
96
+ if xrp_usdt > 0 and usdt_aed > 0:
97
+ xrp_aed = xrp_usdt * usdt_aed
98
+ xrp_per_aed = 1.0 / xrp_aed
99
+ print(f"[ORACLE] Binance rate: 1 XRP = {xrp_aed:.2f} AED | 1 AED = {xrp_per_aed:.8f} XRP")
100
+ return xrp_per_aed
101
+ except Exception as e:
102
+ print(f"[ORACLE] Binance API failed: {e}")
103
+
104
+ print(f"[ORACLE] [WARNING] All price sources failed, using fallback rate: {self.fallback_rate}")
105
+ return None
106
+
107
+ def get_aed_to_xrp(self, aed_amount: float) -> float:
108
+ """Convert AED amount to XRP"""
109
+ rate = self.get_xrp_aed_rate()
110
+ return aed_amount * rate
111
+
112
+ def get_xrp_to_aed(self, xrp_amount: float) -> float:
113
+ """Convert XRP amount to AED"""
114
+ rate = self.get_xrp_aed_rate()
115
+ return xrp_amount / rate if rate > 0 else 0
116
+
117
+ # Global instance
118
+ price_oracle = PriceOracle()
services/property_wallet_manager.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Property Wallet Manager - Per-Property Issuer Wallets
3
+ Creates and manages individual issuer wallets for each property
4
+ """
5
+
6
+ from xrpl.wallet import Wallet
7
+ from xrpl.clients import JsonRpcClient
8
+ from xrpl.models.requests import AccountInfo
9
+ from typing import Dict, Optional
10
+ import base64
11
+ from config import settings
12
+ from cryptography.fernet import Fernet as _FernetLegacy
13
+ from utils.crypto_utils import encrypt_secret, decrypt_secret, is_encrypted
14
+
15
+ class PropertyWalletManager:
16
+ """Manages individual issuer wallets for each property"""
17
+
18
+ def __init__(self):
19
+ self.client = JsonRpcClient(settings.XRPL_RPC_URL)
20
+ # No local cipher; rely on shared crypto utils for consistent format
21
+ self.cipher = None
22
+
23
+ def _encrypt_seed(self, seed: str) -> str:
24
+ """Encrypt wallet seed for storage using shared crypto utils.
25
+
26
+ Returns ENC::<token> when ENCRYPTION_KEY is set; otherwise returns legacy base64.
27
+ """
28
+ enc_val = encrypt_secret(seed, settings.ENCRYPTION_KEY)
29
+ if is_encrypted(enc_val):
30
+ return enc_val
31
+ # When encryption is not enabled, encrypt_secret returns plaintext.
32
+ # Preserve previous behavior: store legacy base64-obfuscated seed.
33
+ return base64.b64encode(enc_val.encode()).decode()
34
+
35
+ def _decrypt_seed(self, encrypted_seed: str) -> str:
36
+ """Decrypt wallet seed from storage.
37
+
38
+ Supports both new ENC:: format and legacy base64/plain values.
39
+ """
40
+ # If value is ENC::..., decrypt via shared util
41
+ if is_encrypted(encrypted_seed):
42
+ seed = decrypt_secret(encrypted_seed, settings.ENCRYPTION_KEY)
43
+ if not seed:
44
+ raise ValueError("Failed to decrypt property wallet seed: invalid token or key")
45
+ return seed
46
+ # Otherwise, try legacy base64(FernetToken) using previous derivation; if fails, plaintext/base64
47
+ try:
48
+ enc_bytes = base64.b64decode(encrypted_seed.encode())
49
+ # Attempt legacy cipher path
50
+ if settings.ENCRYPTION_KEY:
51
+ try:
52
+ if len(settings.ENCRYPTION_KEY) == 44:
53
+ key_bytes = settings.ENCRYPTION_KEY.encode()
54
+ else:
55
+ key_bytes = base64.urlsafe_b64encode(settings.ENCRYPTION_KEY.encode()[:32])
56
+ legacy_cipher = _FernetLegacy(key_bytes)
57
+ return legacy_cipher.decrypt(enc_bytes).decode()
58
+ except Exception:
59
+ pass
60
+ # If legacy decrypt fails, last chance: maybe enc_bytes are actually plaintext bytes
61
+ return enc_bytes.decode()
62
+ except Exception:
63
+ return encrypted_seed
64
+
65
+ def create_property_wallet(self, property_id: str, property_name: str) -> Dict:
66
+ """
67
+ Create a new wallet specifically for this property
68
+
69
+ Returns:
70
+ Dict with wallet_address, encrypted_seed, and metadata
71
+ """
72
+ # Generate new wallet
73
+ wallet = Wallet.create()
74
+
75
+ print(f"[WALLET MGR] Created wallet for property: {property_name}")
76
+ print(f"[WALLET MGR] Address: {wallet.classic_address}")
77
+ print(f"[WALLET MGR] [WARNING] FUND THIS WALLET: https://faucet.altnet.rippletest.net/")
78
+
79
+ # Encrypt the seed before storage
80
+ encrypted_seed = self._encrypt_seed(wallet.seed)
81
+
82
+ return {
83
+ "property_id": property_id,
84
+ "issuer_address": wallet.classic_address,
85
+ "encrypted_seed": encrypted_seed,
86
+ "public_key": wallet.public_key,
87
+ "wallet_type": "property_issuer",
88
+ "funded": False, # Will be updated when wallet is funded
89
+ "created_for": property_name,
90
+ "network": settings.XRPL_NETWORK
91
+ }
92
+
93
+ def get_property_wallet(self, encrypted_seed: str) -> Wallet:
94
+ """Get Wallet object from encrypted seed"""
95
+ seed = self._decrypt_seed(encrypted_seed)
96
+ return Wallet.from_seed(seed)
97
+
98
+ def check_wallet_funded(self, address: str) -> Dict:
99
+ """Check if wallet is funded and get balance"""
100
+ try:
101
+ account_info_request = AccountInfo(account=address)
102
+ response = self.client.request(account_info_request)
103
+
104
+ if response.is_successful() and 'account_data' in response.result:
105
+ balance_drops = response.result['account_data'].get('Balance', '0')
106
+ balance_xrp = float(balance_drops) / 1_000_000
107
+
108
+ return {
109
+ "funded": True,
110
+ "balance_xrp": balance_xrp,
111
+ "balance_drops": balance_drops,
112
+ "sequence": response.result['account_data'].get('Sequence', 0)
113
+ }
114
+ else:
115
+ return {"funded": False, "balance_xrp": 0}
116
+
117
+ except Exception as e:
118
+ print(f"[WALLET MGR] Error checking wallet: {e}")
119
+ return {"funded": False, "balance_xrp": 0}
120
+
121
+ def use_master_wallet_fallback(self) -> bool:
122
+ """Check if we should fall back to master wallet"""
123
+ # If no issuer seed configured, can't use master wallet
124
+ if not settings.ISSUER_SEED:
125
+ return False
126
+
127
+ # Check if master wallet is funded
128
+ try:
129
+ master_wallet = Wallet.from_seed(settings.ISSUER_SEED)
130
+ status = self.check_wallet_funded(master_wallet.classic_address)
131
+ return status.get('funded', False)
132
+ except:
133
+ return False
134
+
135
+ # Global instance
136
+ property_wallet_manager = PropertyWalletManager()
services/rent_distribution_service.py ADDED
@@ -0,0 +1,294 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Rent Distribution Service for Real Estate Tokenization Platform
3
+ Handles automated rent distribution to token holders
4
+ """
5
+ from typing import Dict, Any, List, Optional
6
+ from datetime import datetime
7
+ import logging
8
+ from pymongo.client_session import ClientSession
9
+
10
+ import repo
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class RentDistributionService:
16
+ """Service to handle rent distribution to token holders"""
17
+
18
+ def __init__(self, db):
19
+ self.db = db
20
+
21
+ def distribute_rent(
22
+ self,
23
+ property_id: str,
24
+ total_rent_amount: float,
25
+ rent_period_start: str,
26
+ rent_period_end: str,
27
+ distribution_date: Optional[datetime] = None,
28
+ notes: Optional[str] = None
29
+ ) -> Dict[str, Any]:
30
+ """
31
+ Distribute rent to all token holders of a property
32
+
33
+ Steps:
34
+ 1. Validate property exists
35
+ 2. Get total tokens for the property
36
+ 3. Calculate rent per token
37
+ 4. Create rent distribution record
38
+ 5. Find all investors (token holders)
39
+ 6. For each investor:
40
+ - Calculate their rent share
41
+ - Credit their wallet
42
+ - Create transaction record
43
+ - Create rent payment record
44
+ 7. Update distribution status
45
+
46
+ Args:
47
+ property_id: Property ID generating rent
48
+ total_rent_amount: Total rent collected (AED)
49
+ rent_period_start: Start date (YYYY-MM-DD)
50
+ rent_period_end: End date (YYYY-MM-DD)
51
+ distribution_date: When to distribute (default: now)
52
+ notes: Additional notes
53
+
54
+ Returns:
55
+ Dict containing distribution details and results
56
+ """
57
+ logger.info(f"Starting rent distribution for property {property_id}, amount: {total_rent_amount} AED")
58
+
59
+ if distribution_date is None:
60
+ distribution_date = datetime.utcnow()
61
+
62
+ # Step 1: Validate property exists
63
+ property_data = repo.get_property_by_id(self.db, property_id)
64
+ if not property_data:
65
+ raise ValueError(f"Property not found: {property_id}")
66
+
67
+ property_title = property_data.get("title", "Unknown Property")
68
+
69
+ # Step 2: Get total tokens for the property
70
+ total_tokens = property_data.get("total_tokens", 0)
71
+ if total_tokens <= 0:
72
+ raise ValueError(f"Property has no tokens: {property_id}")
73
+
74
+ # Step 3: Calculate rent per token
75
+ rent_per_token = total_rent_amount / total_tokens
76
+ logger.info(f"Rent per token: {rent_per_token:.6f} AED ({total_rent_amount} / {total_tokens})")
77
+
78
+ # Step 4: Create rent distribution record
79
+ distribution = repo.create_rent_distribution(
80
+ db=self.db,
81
+ property_id=property_id,
82
+ total_rent_amount=total_rent_amount,
83
+ rent_period_start=rent_period_start,
84
+ rent_period_end=rent_period_end,
85
+ total_tokens=total_tokens,
86
+ rent_per_token=rent_per_token,
87
+ distribution_date=distribution_date,
88
+ notes=notes
89
+ )
90
+
91
+ distribution_id = distribution["id"]
92
+ logger.info(f"Created rent distribution: {distribution_id}")
93
+
94
+ # Step 5: Find all investors for this property
95
+ investors = repo.get_investors_by_property(self.db, property_id)
96
+ logger.info(f"Found {len(investors)} investors for property {property_id}")
97
+
98
+ if not investors:
99
+ # No investors, mark as completed
100
+ repo.update_rent_distribution_status(
101
+ db=self.db,
102
+ distribution_id=distribution_id,
103
+ status="completed",
104
+ total_investors=0,
105
+ payments_completed=0
106
+ )
107
+ return {
108
+ "distribution_id": distribution_id,
109
+ "property_title": property_title,
110
+ "total_rent_amount": total_rent_amount,
111
+ "total_investors": 0,
112
+ "payments_completed": 0,
113
+ "payments_failed": 0,
114
+ "status": "completed",
115
+ "message": "No investors found for this property"
116
+ }
117
+
118
+ # Update status to processing
119
+ repo.update_rent_distribution_status(
120
+ db=self.db,
121
+ distribution_id=distribution_id,
122
+ status="processing",
123
+ total_investors=len(investors),
124
+ payments_completed=0
125
+ )
126
+
127
+ # Step 6: Process each investor WITH TRANSACTION
128
+ # Using MongoDB transaction for atomicity - all payments succeed or all fail
129
+ payments_completed = 0
130
+ payments_failed = 0
131
+ payment_results = []
132
+
133
+ logger.info(f"Starting to process {len(investors)} investors (with transaction)")
134
+
135
+ # Get MongoDB client for transaction
136
+ from db import get_client
137
+ client = get_client()
138
+
139
+ try:
140
+ with client.start_session() as session:
141
+ with session.start_transaction():
142
+ for investment in investors:
143
+ try:
144
+ user_id = investment.get("user_id")
145
+ tokens_owned = investment.get("tokens_purchased", 0)
146
+
147
+ logger.info(f"Processing investor: user_id={user_id}, tokens_purchased={tokens_owned}")
148
+
149
+ if tokens_owned <= 0:
150
+ logger.warning(f"User {user_id} has 0 tokens, skipping")
151
+ payments_failed += 1
152
+ payment_results.append({
153
+ "user_id": user_id,
154
+ "status": "failed",
155
+ "error": "No tokens owned"
156
+ })
157
+ continue
158
+
159
+ # Calculate rent share
160
+ rent_share = tokens_owned * rent_per_token
161
+ logger.info(f"User {user_id}: {tokens_owned} tokens × {rent_per_token:.6f} = {rent_share:.2f} AED")
162
+
163
+ # Credit wallet
164
+ wallet = repo.get_wallet_by_user(self.db, user_id)
165
+ if not wallet:
166
+ logger.error(f"Wallet not found for user {user_id}")
167
+ payments_failed += 1
168
+ payment_results.append({
169
+ "user_id": user_id,
170
+ "status": "failed",
171
+ "error": "Wallet not found"
172
+ })
173
+ continue
174
+
175
+ wallet_id = wallet["id"]
176
+
177
+ # Update wallet balance (with session for transaction)
178
+ updated_wallet = repo.update_wallet_balance(
179
+ self.db, wallet_id, rent_share, operation="add", session=session
180
+ )
181
+ logger.info(f"Credited {rent_share:.2f} AED to wallet {wallet_id}, new balance: {updated_wallet.get('balance') if updated_wallet else 'unknown'}")
182
+
183
+ # Create transaction record (with session)
184
+ transaction = repo.create_transaction(
185
+ db=self.db,
186
+ user_id=user_id,
187
+ wallet_id=wallet_id,
188
+ tx_type="profit", # Rent is a profit distribution
189
+ amount=rent_share,
190
+ property_id=property_id,
191
+ status="completed",
192
+ metadata={
193
+ "type": "rent_distribution",
194
+ "distribution_id": distribution_id,
195
+ "tokens_owned": tokens_owned,
196
+ "rent_per_token": rent_per_token,
197
+ "rent_period": f"{rent_period_start} to {rent_period_end}"
198
+ },
199
+ session=session
200
+ )
201
+
202
+ transaction_id = transaction["id"]
203
+ logger.info(f"Created transaction {transaction_id} for rent payment")
204
+
205
+ # Create rent payment record (with session)
206
+ rent_payment = repo.create_rent_payment(
207
+ db=self.db,
208
+ distribution_id=distribution_id,
209
+ user_id=user_id,
210
+ property_id=property_id,
211
+ tokens_owned=tokens_owned,
212
+ rent_amount=rent_share,
213
+ rent_period_start=rent_period_start,
214
+ rent_period_end=rent_period_end,
215
+ payment_status="completed",
216
+ wallet_credited=True,
217
+ transaction_id=transaction_id,
218
+ session=session
219
+ )
220
+
221
+ payments_completed += 1
222
+ logger.info(f"Successfully processed rent payment for user {user_id}")
223
+ payment_results.append({
224
+ "user_id": user_id,
225
+ "tokens_owned": tokens_owned,
226
+ "rent_amount": rent_share,
227
+ "status": "success"
228
+ })
229
+
230
+ except Exception as e:
231
+ logger.error(f"Failed to process rent payment for user {user_id}: {e}", exc_info=True)
232
+ payments_failed += 1
233
+ payment_results.append({
234
+ "user_id": user_id if 'user_id' in locals() else "unknown",
235
+ "status": "failed",
236
+ "error": str(e)
237
+ })
238
+ # Re-raise to abort transaction if critical error
239
+ if payments_completed > 0:
240
+ # Some payments succeeded - abort transaction to ensure atomicity
241
+ raise
242
+
243
+ # If we reach here, all payments within transaction succeeded
244
+ logger.info(f"Transaction completed: {payments_completed} payments processed")
245
+
246
+ except Exception as tx_error:
247
+ # Transaction was aborted - all changes rolled back
248
+ logger.error(f"Transaction aborted, all changes rolled back: {tx_error}")
249
+ payments_failed = len(investors)
250
+ payments_completed = 0
251
+ payment_results = [{"user_id": inv.get("user_id"), "status": "failed", "error": "Transaction aborted"} for inv in investors]
252
+
253
+ logger.info(f"Investor processing complete: {payments_completed} succeeded, {payments_failed} failed")
254
+
255
+ # Step 7: Update distribution status to completed
256
+ final_status = "completed" if payments_failed == 0 else "completed_with_errors"
257
+ repo.update_rent_distribution_status(
258
+ db=self.db,
259
+ distribution_id=distribution_id,
260
+ status=final_status,
261
+ total_investors=len(investors),
262
+ payments_completed=payments_completed
263
+ )
264
+
265
+ logger.info(f"Rent distribution completed: {payments_completed} succeeded, {payments_failed} failed")
266
+
267
+ return {
268
+ "distribution_id": distribution_id,
269
+ "property_id": property_id,
270
+ "property_title": property_title,
271
+ "total_rent_amount": total_rent_amount,
272
+ "rent_per_token": rent_per_token,
273
+ "total_investors": len(investors),
274
+ "payments_completed": payments_completed,
275
+ "payments_failed": payments_failed,
276
+ "status": final_status,
277
+ "rent_period": f"{rent_period_start} to {rent_period_end}",
278
+ "payment_results": payment_results
279
+ }
280
+
281
+ def get_distribution_details(self, distribution_id: str) -> Optional[Dict[str, Any]]:
282
+ """Get details of a rent distribution"""
283
+ distribution = repo.get_rent_distribution_by_id(self.db, distribution_id)
284
+ if not distribution:
285
+ return None
286
+
287
+ # Enrich with property title
288
+ property_id = distribution.get("property_id")
289
+ if property_id:
290
+ property_data = repo.get_property_by_id(self.db, property_id)
291
+ if property_data:
292
+ distribution["property_title"] = property_data.get("title")
293
+
294
+ return distribution
services/secondary_market.py ADDED
@@ -0,0 +1,354 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Secondary Market Service - Token Trading on XRP DEX
3
+ Allows users to buy/sell tokens from each other using XRP Ledger's built-in DEX
4
+ """
5
+
6
+ from xrpl.clients import JsonRpcClient
7
+ from xrpl.wallet import Wallet
8
+ from xrpl.models.transactions import OfferCreate, OfferCancel
9
+ from xrpl.models.requests import AccountOffers, BookOffers
10
+ from xrpl.transaction import submit_and_wait, autofill_and_sign
11
+ from xrpl.models.amounts import IssuedCurrencyAmount
12
+ from xrpl.utils import xrp_to_drops, drops_to_xrp
13
+ from typing import List, Dict, Optional
14
+ from config import settings
15
+
16
+ # Import blockchain utilities for retry and error handling
17
+ from utils.blockchain_utils import (
18
+ retry_handler,
19
+ TransactionMonitor,
20
+ BlockchainError
21
+ )
22
+
23
+ class SecondaryMarket:
24
+ """Manages token trading on XRP Ledger DEX"""
25
+
26
+ def __init__(self):
27
+ self.client = JsonRpcClient(settings.XRPL_RPC_URL)
28
+
29
+ def create_sell_offer(
30
+ self,
31
+ seller_seed: str,
32
+ currency_code: str,
33
+ issuer_address: str,
34
+ token_amount: int,
35
+ price_per_token_xrp: float
36
+ ) -> Dict:
37
+ """
38
+ Create a sell offer for tokens - WITH RETRY
39
+
40
+ Args:
41
+ seller_seed: Seller's wallet seed
42
+ currency_code: Property token currency code
43
+ issuer_address: Token issuer address
44
+ token_amount: Number of tokens to sell
45
+ price_per_token_xrp: Price per token in XRP
46
+
47
+ Returns:
48
+ Offer details including offer sequence number
49
+ """
50
+ try:
51
+ seller = Wallet.from_seed(seller_seed)
52
+ total_xrp = token_amount * price_per_token_xrp
53
+
54
+ # Create IOU amount (what seller is selling)
55
+ token_amount_obj = IssuedCurrencyAmount(
56
+ currency=currency_code,
57
+ issuer=issuer_address,
58
+ value=str(token_amount)
59
+ )
60
+
61
+ def submit_sell_offer():
62
+ # Create offer: Sell tokens for XRP
63
+ offer_tx = OfferCreate(
64
+ account=seller.classic_address,
65
+ taker_gets=str(int(xrp_to_drops(total_xrp))), # XRP (buyer pays)
66
+ taker_pays=token_amount_obj, # Tokens (seller gives)
67
+ )
68
+
69
+ signed_tx = autofill_and_sign(offer_tx, self.client, seller)
70
+ result = submit_and_wait(signed_tx, self.client)
71
+
72
+ if not result.is_successful():
73
+ error = result.result.get('engine_result_message', 'Unknown error')
74
+ raise Exception(error)
75
+
76
+ return result
77
+
78
+ result = retry_handler.execute_with_retry(
79
+ submit_sell_offer,
80
+ "Create Sell Offer"
81
+ )
82
+
83
+ tx_hash = result.result.get('tx_json', {}).get('hash') or result.result.get('hash')
84
+ offer_sequence = result.result.get('tx_json', {}).get('Sequence')
85
+
86
+ print(f"[MARKET] ✅ Sell offer created!")
87
+ print(f"[MARKET] Seller: {seller.classic_address}")
88
+ print(f"[MARKET] Selling: {token_amount} tokens")
89
+ print(f"[MARKET] Price: {price_per_token_xrp} XRP per token")
90
+ print(f"[MARKET] Total: {total_xrp} XRP")
91
+ print(f"[MARKET] TX: {tx_hash}")
92
+
93
+ return {
94
+ 'success': True,
95
+ 'offer_sequence': offer_sequence,
96
+ 'tx_hash': tx_hash,
97
+ 'seller': seller.classic_address,
98
+ 'token_amount': token_amount,
99
+ 'price_per_token_xrp': price_per_token_xrp,
100
+ 'total_xrp': total_xrp
101
+ }
102
+
103
+ except BlockchainError as e:
104
+ print(f"[MARKET] Sell offer failed: {e.technical_msg}")
105
+ return {'success': False, 'error': e.user_msg}
106
+ except Exception as e:
107
+ print(f"[MARKET] Error creating sell offer: {e}")
108
+ return {'success': False, 'error': 'Failed to create sell offer. Please try again.'}
109
+
110
+ def create_buy_offer(
111
+ self,
112
+ buyer_seed: str,
113
+ currency_code: str,
114
+ issuer_address: str,
115
+ token_amount: int,
116
+ price_per_token_xrp: float
117
+ ) -> Dict:
118
+ """
119
+ Create a buy offer for tokens - WITH RETRY
120
+
121
+ Args:
122
+ buyer_seed: Buyer's wallet seed
123
+ currency_code: Property token currency code
124
+ issuer_address: Token issuer address
125
+ token_amount: Number of tokens to buy
126
+ price_per_token_xrp: Price per token in XRP
127
+
128
+ Returns:
129
+ Offer details
130
+ """
131
+ try:
132
+ buyer = Wallet.from_seed(buyer_seed)
133
+ total_xrp = token_amount * price_per_token_xrp
134
+
135
+ # Create IOU amount (what buyer wants)
136
+ token_amount_obj = IssuedCurrencyAmount(
137
+ currency=currency_code,
138
+ issuer=issuer_address,
139
+ value=str(token_amount)
140
+ )
141
+
142
+ def submit_buy_offer():
143
+ # Create offer: Buy tokens with XRP
144
+ offer_tx = OfferCreate(
145
+ account=buyer.classic_address,
146
+ taker_gets=token_amount_obj, # Tokens (buyer wants)
147
+ taker_pays=str(int(xrp_to_drops(total_xrp))), # XRP (buyer pays)
148
+ )
149
+
150
+ signed_tx = autofill_and_sign(offer_tx, self.client, buyer)
151
+ result = submit_and_wait(signed_tx, self.client)
152
+
153
+ if not result.is_successful():
154
+ error = result.result.get('engine_result_message', 'Unknown error')
155
+ raise Exception(error)
156
+
157
+ return result
158
+
159
+ result = retry_handler.execute_with_retry(
160
+ submit_buy_offer,
161
+ "Create Buy Offer"
162
+ )
163
+
164
+ tx_hash = result.result.get('tx_json', {}).get('hash') or result.result.get('hash')
165
+ offer_sequence = result.result.get('tx_json', {}).get('Sequence')
166
+
167
+ print(f"[MARKET] ✅ Buy offer created!")
168
+ print(f"[MARKET] Buyer: {buyer.classic_address}")
169
+ print(f"[MARKET] Buying: {token_amount} tokens")
170
+ print(f"[MARKET] Price: {price_per_token_xrp} XRP per token")
171
+ print(f"[MARKET] Total: {total_xrp} XRP")
172
+ print(f"[MARKET] TX: {tx_hash}")
173
+
174
+ return {
175
+ 'success': True,
176
+ 'offer_sequence': offer_sequence,
177
+ 'tx_hash': tx_hash,
178
+ 'buyer': buyer.classic_address,
179
+ 'token_amount': token_amount,
180
+ 'price_per_token_xrp': price_per_token_xrp,
181
+ 'total_xrp': total_xrp
182
+ }
183
+
184
+ except BlockchainError as e:
185
+ print(f"[MARKET] Buy offer failed: {e.technical_msg}")
186
+ return {'success': False, 'error': e.user_msg}
187
+ except Exception as e:
188
+ print(f"[MARKET] Error creating buy offer: {e}")
189
+ return {'success': False, 'error': 'Failed to create buy offer. Please try again.'}
190
+
191
+ def cancel_offer(self, wallet_seed: str, offer_sequence: int) -> Dict:
192
+ """Cancel an existing offer - WITH RETRY"""
193
+ try:
194
+ wallet = Wallet.from_seed(wallet_seed)
195
+
196
+ def submit_cancel():
197
+ cancel_tx = OfferCancel(
198
+ account=wallet.classic_address,
199
+ offer_sequence=offer_sequence
200
+ )
201
+
202
+ signed_tx = autofill_and_sign(cancel_tx, self.client, wallet)
203
+ result = submit_and_wait(signed_tx, self.client)
204
+
205
+ if not result.is_successful():
206
+ error = result.result.get('engine_result_message', 'Unknown error')
207
+ raise Exception(error)
208
+
209
+ return result
210
+
211
+ result = retry_handler.execute_with_retry(
212
+ submit_cancel,
213
+ "Cancel Offer"
214
+ )
215
+
216
+ tx_hash = result.result.get('tx_json', {}).get('hash') or result.result.get('hash')
217
+ print(f"[MARKET] ✅ Offer cancelled: {offer_sequence}")
218
+
219
+ return {
220
+ 'success': True,
221
+ 'tx_hash': tx_hash,
222
+ 'cancelled_sequence': offer_sequence
223
+ }
224
+
225
+ except BlockchainError as e:
226
+ print(f"[MARKET] Cancel offer failed: {e.technical_msg}")
227
+ return {'success': False, 'error': e.user_msg}
228
+ except Exception as e:
229
+ print(f"[MARKET] Error cancelling offer: {e}")
230
+ return {'success': False, 'error': 'Failed to cancel offer. Please try again.'}
231
+
232
+ def get_user_offers(self, wallet_address: str) -> List[Dict]:
233
+ """Get all active offers for a user"""
234
+ try:
235
+ request = AccountOffers(account=wallet_address)
236
+ response = self.client.request(request)
237
+
238
+ if response.is_successful():
239
+ offers = response.result.get('offers', [])
240
+
241
+ parsed_offers = []
242
+ for offer in offers:
243
+ taker_gets = offer.get('taker_gets')
244
+ taker_pays = offer.get('taker_pays')
245
+
246
+ # Determine if it's a buy or sell offer
247
+ if isinstance(taker_gets, str):
248
+ # Getting XRP, paying tokens = SELL offer
249
+ offer_type = 'sell'
250
+ xrp_amount = float(taker_gets) / 1_000_000
251
+ token_amount = float(taker_pays.get('value', 0))
252
+ else:
253
+ # Getting tokens, paying XRP = BUY offer
254
+ offer_type = 'buy'
255
+ xrp_amount = float(taker_pays) / 1_000_000 if isinstance(taker_pays, str) else 0
256
+ token_amount = float(taker_gets.get('value', 0))
257
+
258
+ parsed_offers.append({
259
+ 'sequence': offer.get('seq'),
260
+ 'type': offer_type,
261
+ 'token_amount': token_amount,
262
+ 'xrp_amount': xrp_amount,
263
+ 'price_per_token': xrp_amount / token_amount if token_amount > 0 else 0,
264
+ 'raw_offer': offer
265
+ })
266
+
267
+ return parsed_offers
268
+
269
+ return []
270
+
271
+ except Exception as e:
272
+ print(f"[MARKET] Error getting user offers: {e}")
273
+ return []
274
+
275
+ def get_orderbook(
276
+ self,
277
+ currency_code: str,
278
+ issuer_address: str,
279
+ limit: int = 50
280
+ ) -> Dict:
281
+ """
282
+ Get orderbook for a token (buy and sell offers)
283
+
284
+ Returns:
285
+ Dict with 'bids' (buy offers) and 'asks' (sell offers)
286
+ """
287
+ try:
288
+ # Get sell offers (asks)
289
+ asks_request = BookOffers(
290
+ taker_gets={'currency': 'XRP'},
291
+ taker_pays={
292
+ 'currency': currency_code,
293
+ 'issuer': issuer_address
294
+ },
295
+ limit=limit
296
+ )
297
+
298
+ asks_response = self.client.request(asks_request)
299
+ asks = []
300
+
301
+ if asks_response.is_successful():
302
+ for offer in asks_response.result.get('offers', []):
303
+ taker_gets = float(offer.get('TakerGets', 0)) / 1_000_000 # XRP
304
+ taker_pays = float(offer.get('TakerPays', {}).get('value', 0)) # Tokens
305
+
306
+ if taker_pays > 0:
307
+ asks.append({
308
+ 'price': taker_gets / taker_pays,
309
+ 'amount': taker_pays,
310
+ 'total': taker_gets,
311
+ 'account': offer.get('Account')
312
+ })
313
+
314
+ # Get buy offers (bids)
315
+ bids_request = BookOffers(
316
+ taker_gets={
317
+ 'currency': currency_code,
318
+ 'issuer': issuer_address
319
+ },
320
+ taker_pays={'currency': 'XRP'},
321
+ limit=limit
322
+ )
323
+
324
+ bids_response = self.client.request(bids_request)
325
+ bids = []
326
+
327
+ if bids_response.is_successful():
328
+ for offer in bids_response.result.get('offers', []):
329
+ taker_gets = float(offer.get('TakerGets', {}).get('value', 0)) # Tokens
330
+ taker_pays = float(offer.get('TakerPays', 0)) / 1_000_000 # XRP
331
+
332
+ if taker_gets > 0:
333
+ bids.append({
334
+ 'price': taker_pays / taker_gets,
335
+ 'amount': taker_gets,
336
+ 'total': taker_pays,
337
+ 'account': offer.get('Account')
338
+ })
339
+
340
+ return {
341
+ 'success': True,
342
+ 'bids': sorted(bids, key=lambda x: x['price'], reverse=True),
343
+ 'asks': sorted(asks, key=lambda x: x['price']),
344
+ 'best_bid': bids[0]['price'] if bids else None,
345
+ 'best_ask': asks[0]['price'] if asks else None,
346
+ 'spread': (asks[0]['price'] - bids[0]['price']) if bids and asks else None
347
+ }
348
+
349
+ except Exception as e:
350
+ print(f"[MARKET] Error getting orderbook: {e}")
351
+ return {'success': False, 'error': str(e)}
352
+
353
+ # Global instance
354
+ secondary_market = SecondaryMarket()
services/xrp_service.py ADDED
@@ -0,0 +1,668 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional, Tuple, Dict, Any, List
2
+ from xrpl.clients import JsonRpcClient
3
+ from xrpl.wallet import Wallet
4
+ from xrpl.account import get_balance
5
+ from xrpl.models.transactions import Payment, TrustSet, OfferCreate
6
+ from xrpl.transaction import submit_and_wait, autofill_and_sign
7
+ from xrpl.models.amounts import IssuedCurrencyAmount
8
+ from xrpl.models.requests import Tx, AccountLines
9
+ from xrpl.utils import xrp_to_drops, drops_to_xrp
10
+ from config import settings
11
+ import asyncio
12
+ import time
13
+
14
+ # Import blockchain utilities for retry and error handling
15
+ from utils.blockchain_utils import (
16
+ retry_handler,
17
+ TransactionMonitor,
18
+ BlockchainError
19
+ )
20
+
21
+ # Import monitoring and logging
22
+ from utils.logger import (
23
+ monitor_performance,
24
+ monitor_transaction,
25
+ get_logger,
26
+ log_info,
27
+ log_error,
28
+ log_warning
29
+ )
30
+
31
+ # Import price oracle for real-time rates
32
+ try:
33
+ from services.price_oracle import price_oracle
34
+ USE_LIVE_RATES = True
35
+ except ImportError:
36
+ USE_LIVE_RATES = False
37
+ print("[XRPL] Warning: Price oracle not available, using static rates")
38
+
39
+
40
+ class XRPLService:
41
+ def __init__(self):
42
+ self.client = JsonRpcClient(settings.XRPL_RPC_URL)
43
+
44
+ def generate_wallet(self) -> Tuple[str, str]:
45
+ # For demo: generate an in-memory wallet; in production use faucet for funding
46
+ wallet = Wallet.create()
47
+ # No auto-fund here; user should fund via faucet UI
48
+ return wallet.classic_address, wallet.seed
49
+
50
+ def get_issuer_wallet(self) -> Wallet:
51
+ """Get the issuer wallet from settings (DEPRECATED - use admin wallet instead)"""
52
+ if not settings.ISSUER_SEED:
53
+ raise ValueError("ISSUER_SEED not configured. Please use admin wallet for token issuance.")
54
+ return Wallet.from_seed(settings.ISSUER_SEED)
55
+
56
+ def generate_currency_code(self, property_id: str) -> str:
57
+ """Generate unique currency code for property in XRPL hex format"""
58
+ # Convert to 40-character hex format (XRPL standard for custom currencies)
59
+ # Use first 8 chars of property_id (MongoDB ObjectId) to ensure uniqueness
60
+ # Format: PROP{first_8_chars_of_id}
61
+ property_suffix = str(property_id)[:8].upper() # e.g., 68E769F9
62
+ base_code = f"PROP{property_suffix}" # e.g., PROP68E769F9
63
+ # Convert to hex and pad to 40 characters (20 bytes)
64
+ hex_code = base_code.encode('ascii').hex().upper()
65
+ # Pad with zeros to reach exactly 40 characters
66
+ return hex_code.ljust(40, '0')
67
+
68
+ @monitor_performance("get_xrp_balance")
69
+ def get_xrp_balance(self, address: str) -> float:
70
+ try:
71
+ from xrpl.models.requests import AccountInfo
72
+ account_info_request = AccountInfo(account=address)
73
+ response = self.client.request(account_info_request)
74
+
75
+ if response.is_successful() and 'account_data' in response.result:
76
+ balance_drops = response.result['account_data'].get('Balance', '0')
77
+ print(f"Debug: Retrieved balance {balance_drops} drops for address {address}")
78
+ return float(balance_drops) / 1_000_000
79
+ else:
80
+ print(f"Debug: Account not found or inactive: {address}")
81
+ return 0.0
82
+ except Exception as e:
83
+ print(f"Error getting XRP balance for {address}: {e}")
84
+ return 0.0
85
+
86
+ def calculate_xrp_cost(self, token_amount: int, price_per_token_aed: int) -> float:
87
+ """Calculate XRP cost for token purchase using live exchange rates.
88
+
89
+ This now uses a real-time price oracle to fetch current XRP/AED rates
90
+ from multiple exchanges (CoinGecko, CoinDCX, Binance).
91
+
92
+ Falls back to configured rate if live feeds are unavailable.
93
+ """
94
+ aed_cost = token_amount * price_per_token_aed
95
+
96
+ if USE_LIVE_RATES:
97
+ try:
98
+ xrp_cost = price_oracle.get_aed_to_xrp(aed_cost)
99
+ print(f"[XRPL] Using live rate: {aed_cost} AED = {xrp_cost:.6f} XRP")
100
+ return max(xrp_cost, 0.000001) # Minimum 1 drop
101
+ except Exception as e:
102
+ print(f"[XRPL] Error fetching live rate: {e}, using fallback")
103
+
104
+ # Fallback to static rate
105
+ rate = max(settings.XRPL_RATE_AED_TO_XRP, 0.0000001)
106
+ xrp_cost = aed_cost * rate
107
+ print(f"[XRPL] Using static rate: {aed_cost} AED = {xrp_cost:.6f} XRP")
108
+ return max(xrp_cost, 0.000001)
109
+
110
+ @monitor_transaction("property_token_creation")
111
+ @monitor_performance("create_property_token")
112
+ def create_property_token(self, property_id: str, total_tokens: int, issuer_seed: str = None) -> Dict[str, Any]:
113
+ """Create and issue tokens for a property on XRPL"""
114
+ try:
115
+ if not issuer_seed:
116
+ issuer_seed = settings.ISSUER_SEED
117
+ if not issuer_seed:
118
+ raise ValueError("No issuer seed provided")
119
+
120
+ issuer = Wallet.from_seed(issuer_seed)
121
+ currency_code = self.generate_currency_code(property_id)
122
+
123
+ # Create token metadata record
124
+ return {
125
+ 'success': True,
126
+ 'currency_code': currency_code,
127
+ 'issuer_address': issuer.classic_address,
128
+ 'total_supply': total_tokens,
129
+ 'property_id': property_id
130
+ }
131
+ except Exception as e:
132
+ print(f"Error creating property token: {e}")
133
+ return {'success': False, 'error': str(e)}
134
+
135
+ @monitor_transaction("trustline_setup")
136
+ @monitor_performance("setup_user_trustline")
137
+ def setup_user_trustline(self, user_seed: str, currency: str, issuer: str) -> Dict[str, Any]:
138
+ """Setup trustline for user to receive tokens - WITH RETRY"""
139
+ try:
140
+ def create_trustline_with_retry():
141
+ result = self.create_trustline(user_seed, currency, issuer)
142
+ return result
143
+
144
+ result = retry_handler.execute_with_retry(
145
+ create_trustline_with_retry,
146
+ "Trustline Setup"
147
+ )
148
+
149
+ return {
150
+ 'success': True,
151
+ 'tx_hash': result.get('tx_json', {}).get('hash') or result.get('hash'),
152
+ 'result': result
153
+ }
154
+ except BlockchainError as e:
155
+ print(f"Trustline setup failed: {e.technical_msg}")
156
+ return {'success': False, 'error': e.user_msg}
157
+ except Exception as e:
158
+ print(f"Error setting up trustline: {e}")
159
+ return {'success': False, 'error': 'Failed to set up token trust line. Please try again.'}
160
+
161
+ @monitor_transaction("token_transfer_to_issuer")
162
+ @monitor_performance("transfer_tokens_to_issuer")
163
+ def transfer_tokens_to_issuer(self, seller_seed: str, currency: str, issuer: str, token_amount: int, issuer_seed: str = None) -> Dict[str, Any]:
164
+ """Transfer tokens from user back to the issuer (for sell-back functionality).
165
+
166
+ IMPORTANT: When you send IOU tokens back to the issuer, they are effectively "burned" or "redeemed".
167
+ The issuer's balance of their own currency doesn't increase - the tokens simply cease to exist.
168
+ This is the correct behavior for a sell-back/redemption scenario.
169
+
170
+ Args:
171
+ seller_seed: Seller's wallet seed
172
+ currency: Property token currency code
173
+ issuer: Issuer wallet address
174
+ token_amount: Number of tokens to transfer back
175
+ issuer_seed: Property's dedicated issuer seed (optional)
176
+
177
+ Returns:
178
+ Dict with success status, transaction hash, and details
179
+ """
180
+ try:
181
+ if settings.XRPL_TOKEN_MODEL.upper() != 'IOU':
182
+ return {
183
+ 'success': False,
184
+ 'error': 'Token transfer requires IOU token model'
185
+ }
186
+
187
+ seller = Wallet.from_seed(seller_seed)
188
+
189
+ print(f"[XRPL] Burning {token_amount} tokens by sending to issuer {issuer}")
190
+ print(f" Seller: {seller.classic_address}")
191
+ print(f" Currency: {currency}")
192
+ print(f" Note: Tokens sent to issuer are automatically burned/redeemed")
193
+
194
+ # Create payment to send tokens back to issuer (this burns them)
195
+ # The issuer doesn't need a trustline to receive their own tokens
196
+ payment = Payment(
197
+ account=seller.classic_address,
198
+ destination=issuer,
199
+ amount=IssuedCurrencyAmount(
200
+ currency=currency,
201
+ issuer=issuer,
202
+ value=str(token_amount)
203
+ ),
204
+ # Add Flags to allow partial payments if needed
205
+ # But for burning, this should work directly
206
+ )
207
+
208
+ # Sign and submit transaction with retry logic
209
+ try:
210
+ signed_tx = autofill_and_sign(payment, self.client, seller)
211
+ result = submit_and_wait(signed_tx, self.client)
212
+
213
+ # Check if transaction was successful
214
+ # For IOU burns, we expect tesSUCCESS
215
+ result_code = result.result.get('meta', {}).get('TransactionResult', result.result.get('engine_result', 'UNKNOWN'))
216
+
217
+ if result.is_successful() or result_code == 'tesSUCCESS':
218
+ tx_hash = result.result.get('hash')
219
+ print(f"[XRPL] ✅ Tokens burned successfully. TX: {tx_hash}")
220
+ print(f" Result code: {result_code}")
221
+ return {
222
+ 'success': True,
223
+ 'tx_hash': tx_hash,
224
+ 'token_amount': token_amount,
225
+ 'seller_address': seller.classic_address,
226
+ 'issuer_address': issuer,
227
+ 'action': 'burned',
228
+ 'result': result.result
229
+ }
230
+ else:
231
+ error_code = result.result.get('engine_result', 'UNKNOWN')
232
+ error_msg = result.result.get('engine_result_message', 'Transaction failed')
233
+ print(f"[XRPL] ❌ Token burn failed: {error_code} - {error_msg}")
234
+
235
+ # Provide more helpful error messages
236
+ if error_code == 'tecPATH_PARTIAL':
237
+ return {
238
+ 'success': False,
239
+ 'error': 'Insufficient token balance or trustline issue. Please verify your token balance.'
240
+ }
241
+ elif error_code == 'tecUNFUNDED_PAYMENT':
242
+ return {
243
+ 'success': False,
244
+ 'error': 'Insufficient XRP for transaction fee. Please add XRP to your wallet.'
245
+ }
246
+ else:
247
+ return {
248
+ 'success': False,
249
+ 'error': f'Transaction failed: {error_msg}'
250
+ }
251
+
252
+ except Exception as tx_error:
253
+ print(f"[XRPL] Transaction submission error: {tx_error}")
254
+ return {
255
+ 'success': False,
256
+ 'error': f'Failed to submit transaction: {str(tx_error)}'
257
+ }
258
+
259
+ except Exception as e:
260
+ print(f"[XRPL] Error in transfer_tokens_to_issuer: {e}")
261
+ return {'success': False, 'error': str(e)}
262
+
263
+ @monitor_transaction("token_purchase")
264
+ @monitor_performance("purchase_tokens_with_xrp")
265
+ def purchase_tokens_with_xrp(self, buyer_seed: str, currency: str, issuer: str, token_amount: int, xrp_cost: float, issuer_seed: str = None) -> Dict[str, Any]:
266
+ """Execute IOU purchase (Payment + TrustSet + IOU issuance).
267
+
268
+ Args:
269
+ buyer_seed: Buyer's wallet seed
270
+ currency: Property token currency code
271
+ issuer: Issuer wallet address
272
+ token_amount: Number of tokens to purchase
273
+ xrp_cost: Cost in XRP
274
+ issuer_seed: Property's dedicated issuer seed (NEW - per-property wallet)
275
+
276
+ Uses IOU tokens for fractional real estate ownership.
277
+ """
278
+ try:
279
+ if settings.XRPL_TOKEN_MODEL.upper() != 'IOU':
280
+ return {
281
+ 'success': False,
282
+ 'error': 'IOU purchase path disabled (XRPL_TOKEN_MODEL must be IOU)'
283
+ }
284
+
285
+ # Use property-specific issuer seed if provided, otherwise fall back to master
286
+ effective_issuer_seed = issuer_seed or settings.ISSUER_SEED
287
+ if effective_issuer_seed:
288
+ issuer_wallet = Wallet.from_seed(effective_issuer_seed)
289
+ print(f"[XRPL] Using {'property-specific' if issuer_seed else 'master'} issuer wallet: {issuer_wallet.classic_address}")
290
+ else:
291
+ return {
292
+ 'success': False,
293
+ 'error': 'No issuer seed available; cannot issue IOU tokens'
294
+ }
295
+
296
+ buyer = Wallet.from_seed(buyer_seed)
297
+
298
+ # Note: In this system, the buyer pays with AED (fiat) in their platform wallet.
299
+ # The XRP transaction is just for blockchain record-keeping and IOU token issuance.
300
+ # The buyer needs minimal XRP only for transaction fees, not the full purchase amount.
301
+ # The property wallet (issuer) needs sufficient XRP reserves to issue IOU tokens.
302
+
303
+ print(f"=== EXECUTING TOKEN PURCHASE ===")
304
+ print(f"Buyer: {buyer.classic_address}")
305
+ print(f"Issuer: {issuer_wallet.classic_address}")
306
+ print(f"Token amount: {token_amount}")
307
+ print(f"XRP cost: {xrp_cost}")
308
+
309
+ # Step 1: OPTIONAL XRP payment to issuer (for blockchain record-keeping)
310
+ # Note: Users pay in AED via platform wallet. This XRP payment is optional
311
+ # and only executed if buyer has sufficient XRP balance.
312
+ payment_tx_hash = None
313
+ buyer_balance = self.get_xrp_balance(buyer.classic_address)
314
+
315
+ if buyer_balance >= xrp_cost + 0.01: # Has enough XRP for payment + fees
316
+ print(f"[OPTIONAL] Buyer has sufficient XRP ({buyer_balance} XRP), sending payment...")
317
+ def send_xrp_payment():
318
+ from xrpl.transaction import sign_and_submit
319
+
320
+ xrp_payment = Payment(
321
+ account=buyer.classic_address,
322
+ amount=str(int(xrp_to_drops(xrp_cost))), # Convert to string drops
323
+ destination=issuer_wallet.classic_address
324
+ )
325
+
326
+ # Use sign_and_submit method
327
+ payment_result = sign_and_submit(xrp_payment, self.client, buyer)
328
+
329
+ if payment_result.result.get('engine_result') != 'tesSUCCESS':
330
+ raise Exception(f'XRP payment failed: {payment_result.result.get("engine_result_message", "Unknown error")}')
331
+
332
+ return payment_result
333
+
334
+ try:
335
+ payment_result = retry_handler.execute_with_retry(
336
+ send_xrp_payment,
337
+ "XRP Payment to Issuer"
338
+ )
339
+ payment_tx_hash = payment_result.result.get('tx_json', {}).get('hash')
340
+ print(f"✅ XRP payment successful: {payment_tx_hash}")
341
+ except BlockchainError as e:
342
+ print(f"[WARNING] XRP payment failed (non-critical): {e.user_msg}")
343
+ # Continue anyway - payment is optional
344
+ else:
345
+ print(f"[SKIP] Buyer has insufficient XRP ({buyer_balance} XRP < {xrp_cost + 0.01} XRP required)")
346
+ print(f"[INFO] Skipping XRP payment - user paid in AED via platform wallet")
347
+ print(f"[INFO] Will issue IOU tokens directly")
348
+
349
+ # Step 2: Setup trustline (only if not already present)
350
+ if not self._has_trustline(buyer.classic_address, currency, issuer):
351
+ trustline_result = self.setup_user_trustline(buyer_seed, currency, issuer)
352
+ if not trustline_result['success']:
353
+ print(f"Trustline setup issue (continuing): {trustline_result}")
354
+ else:
355
+ print("Skipping trustline creation – already exists.")
356
+
357
+ # Step 3: Issuer sends tokens to buyer - WITH RETRY
358
+ def send_token_transfer():
359
+ result = self.issue_tokens(
360
+ issuer_seed=effective_issuer_seed,
361
+ destination=buyer.classic_address,
362
+ currency=currency,
363
+ amount=str(token_amount)
364
+ )
365
+
366
+ if 'error' in result:
367
+ raise Exception(f'Token issuance failed: {result["error"]}')
368
+
369
+ return result
370
+
371
+ try:
372
+ token_transfer = retry_handler.execute_with_retry(
373
+ send_token_transfer,
374
+ "Token Transfer to Buyer"
375
+ )
376
+ except BlockchainError as e:
377
+ return {
378
+ 'success': False,
379
+ 'error': e.user_msg
380
+ }
381
+
382
+ token_tx_hash = token_transfer.get('tx_json', {}).get('hash') or token_transfer.get('hash')
383
+ print(f"✅ Token transfer successful: {token_tx_hash}")
384
+
385
+ return {
386
+ 'success': True,
387
+ 'payment_tx_hash': payment_tx_hash,
388
+ 'token_tx_hash': token_tx_hash,
389
+ 'token_amount': token_amount,
390
+ 'xrp_cost': xrp_cost,
391
+ 'buyer_address': buyer.classic_address,
392
+ 'issuer_address': issuer_wallet.classic_address
393
+ }
394
+
395
+ except BlockchainError as e:
396
+ # Return user-friendly error message
397
+ return {'success': False, 'error': e.user_msg}
398
+ except Exception as e:
399
+ # Sanitize outward error while logging locally
400
+ print(f"Error in token purchase: {e}")
401
+ if settings.XRPL_NETWORK == 'testnet':
402
+ import traceback
403
+ traceback.print_exc()
404
+ return {'success': False, 'error': 'Transaction failed. Please try again or contact support.'}
405
+
406
+ @monitor_performance("get_user_token_balance")
407
+ def get_user_token_balance(self, address: str, currency: str, issuer: str) -> float:
408
+ """Get specific token balance for user"""
409
+ try:
410
+ token_balances = self.get_token_balances(address)
411
+ for token in token_balances:
412
+ if token['currency'] == currency and token['issuer'] == issuer:
413
+ return float(token['balance'])
414
+ return 0.0
415
+ except Exception as e:
416
+ print(f"Error getting user token balance: {e}")
417
+ return 0.0
418
+
419
+ def verify_transaction_status(self, tx_hash: str) -> Dict[str, Any]:
420
+ """Verify if a transaction was successful"""
421
+ try:
422
+ tx_data = self.get_tx(tx_hash)
423
+ if 'TransactionResult' in tx_data:
424
+ success = tx_data['TransactionResult'] == 'tesSUCCESS'
425
+ return {
426
+ 'success': success,
427
+ 'status': tx_data['TransactionResult'],
428
+ 'tx_data': tx_data
429
+ }
430
+ return {'success': False, 'error': 'Transaction not found'}
431
+ except Exception as e:
432
+ return {'success': False, 'error': str(e)}
433
+
434
+ # Duplicate methods removed - using the ones defined below
435
+
436
+ @monitor_transaction("trustline_creation")
437
+ @monitor_performance("create_trustline")
438
+ def create_trustline(self, holder_seed: str, currency: str, issuer: str, limit_amount: str = "1000000000") -> Dict[str, Any]:
439
+ try:
440
+ from xrpl.transaction import sign_and_submit
441
+ holder = Wallet.from_seed(holder_seed)
442
+ trust_set_tx = TrustSet(
443
+ account=holder.classic_address,
444
+ limit_amount=IssuedCurrencyAmount(currency=currency, issuer=issuer, value=limit_amount),
445
+ )
446
+ result = sign_and_submit(trust_set_tx, self.client, holder)
447
+ return result.result
448
+ except Exception as e:
449
+ print(f"Error in create_trustline: {e}")
450
+ return {'error': str(e)}
451
+
452
+ def _has_trustline(self, address: str, currency: str, issuer: str) -> bool:
453
+ """Check if an account already has a trustline for the given currency/issuer."""
454
+ try:
455
+ from xrpl.models.requests import AccountLines
456
+ resp = self.client.request(AccountLines(account=address))
457
+ if not resp.is_successful():
458
+ return False
459
+ for line in resp.result.get('lines', []):
460
+ if line.get('currency') == currency and line.get('account') == issuer:
461
+ return True
462
+ return False
463
+ except Exception:
464
+ return False
465
+
466
+ @monitor_transaction("token_issuance")
467
+ @monitor_performance("issue_tokens")
468
+ def issue_tokens(self, issuer_seed: str, destination: str, currency: str, amount: str) -> Dict[str, Any]:
469
+ try:
470
+ from xrpl.transaction import sign_and_submit
471
+ issuer = Wallet.from_seed(issuer_seed)
472
+ # Create IOU amount for the token
473
+ token_amount = IssuedCurrencyAmount(currency=currency, issuer=issuer.classic_address, value=amount)
474
+ payment = Payment(
475
+ account=issuer.classic_address,
476
+ amount=token_amount,
477
+ destination=destination,
478
+ send_max=token_amount, # Required for currency conversion/token issuance
479
+ )
480
+ result = sign_and_submit(payment, self.client, issuer)
481
+ return result.result
482
+ except Exception as e:
483
+ print(f"Error in issue_tokens: {e}")
484
+ return {'error': str(e)}
485
+
486
+ # Note: Explicit on-ledger OfferCreate helper was removed as unused in app flows
487
+
488
+ @monitor_performance("get_token_balances")
489
+ def get_token_balances(self, address: str) -> List[Dict[str, Any]]:
490
+ """Get IOU token balances for an address"""
491
+ try:
492
+ account_lines_request = AccountLines(account=address)
493
+ response = self.client.request(account_lines_request)
494
+
495
+ if response.is_successful() and 'lines' in response.result:
496
+ tokens = []
497
+ for line in response.result['lines']:
498
+ tokens.append({
499
+ 'currency': line.get('currency', ''),
500
+ 'issuer': line.get('account', ''),
501
+ 'balance': float(line.get('balance', '0')),
502
+ 'limit': line.get('limit', '0')
503
+ })
504
+ print(f"Debug: Found {len(tokens)} token balances for {address}")
505
+ return tokens
506
+ else:
507
+ print(f"Debug: No token lines found for {address}")
508
+ return []
509
+ except Exception as e:
510
+ print(f"Error getting token balances for {address}: {e}")
511
+ return []
512
+
513
+ def get_tx(self, tx_hash: str) -> Dict[str, Any]:
514
+ req = Tx(transaction=tx_hash)
515
+ resp = self.client.request(req)
516
+ return resp.result
517
+
518
+ @monitor_transaction("kyc_blockchain_write")
519
+ @monitor_performance("write_kyc_to_blockchain")
520
+ async def write_kyc_to_blockchain(self, kyc_data: Dict[str, Any], issuer_seed: str = None) -> Dict[str, Any]:
521
+ """
522
+ Write KYC approval data to XRP Ledger as permanent proof (ASYNC)
523
+ Stores user data + IPFS document hash in transaction memo field
524
+
525
+ Args:
526
+ kyc_data: {
527
+ 'user_id': str,
528
+ 'name': str,
529
+ 'email': str,
530
+ 'phone': str,
531
+ 'address': str,
532
+ 'ipfs_hash': str, # IPFS CID of KYC document
533
+ 'approved_by': str,
534
+ 'approved_at': str
535
+ }
536
+ issuer_seed: Admin/issuer wallet seed (defaults to settings.ISSUER_SEED)
537
+
538
+ Returns:
539
+ {
540
+ 'success': bool,
541
+ 'tx_hash': str, # XRP Ledger transaction hash
542
+ 'explorer_url': str, # Link to view on explorer
543
+ 'memo_data': dict # Data written to blockchain
544
+ }
545
+ """
546
+ try:
547
+ if not issuer_seed:
548
+ issuer_seed = settings.ISSUER_SEED
549
+ if not issuer_seed:
550
+ raise ValueError("No issuer seed provided for KYC blockchain write")
551
+
552
+ issuer = Wallet.from_seed(issuer_seed)
553
+
554
+ print(f"[XRPL-KYC] Writing KYC data to blockchain...")
555
+ print(f"[XRPL-KYC] User: {kyc_data.get('name')} ({kyc_data.get('email')})")
556
+ print(f"[XRPL-KYC] IPFS Hash: {kyc_data.get('ipfs_hash')}")
557
+
558
+ # Prepare memo data (max 1KB for XRP Ledger)
559
+ memo_data = {
560
+ "type": "KYC_APPROVAL",
561
+ "user_id": kyc_data.get('user_id'),
562
+ "name": kyc_data.get('name'),
563
+ "email": kyc_data.get('email'),
564
+ "phone": kyc_data.get('phone'),
565
+ "address": kyc_data.get('address'),
566
+ "ipfs_doc": kyc_data.get('ipfs_hash'),
567
+ "approved_at": kyc_data.get('approved_at'),
568
+ "approved_by": kyc_data.get('approved_by')
569
+ }
570
+
571
+ # Convert to JSON string and check size
572
+ import json
573
+ memo_json = json.dumps(memo_data, separators=(',', ':')) # Compact JSON
574
+ memo_size = len(memo_json.encode('utf-8'))
575
+
576
+ if memo_size > 1024:
577
+ print(f"[XRPL-KYC] [WARNING] Memo too large ({memo_size} bytes), truncating...")
578
+ # Remove address if too large
579
+ memo_data.pop('address', None)
580
+ memo_json = json.dumps(memo_data, separators=(',', ':'))
581
+
582
+ print(f"[XRPL-KYC] Memo size: {len(memo_json.encode('utf-8'))} bytes")
583
+
584
+ # Create AccountSet transaction with memo (no destination needed, just stores data on ledger)
585
+ from xrpl.models.transactions import Memo, AccountSet
586
+ from xrpl.transaction import sign_and_submit
587
+
588
+ # Convert memo to hex (required by XRPL)
589
+ memo_hex = memo_json.encode('utf-8').hex().upper()
590
+
591
+ # Use AccountSet transaction to write data to blockchain without sending XRP
592
+ account_set_tx = AccountSet(
593
+ account=issuer.classic_address,
594
+ memos=[
595
+ Memo(
596
+ memo_data=memo_hex,
597
+ memo_type="4B59435F415050524F56414C".upper(), # "KYC_APPROVAL" in hex
598
+ memo_format="6A736F6E".upper() # "json" in hex
599
+ )
600
+ ]
601
+ )
602
+
603
+ # Run blockchain submission in thread pool to avoid blocking event loop
604
+ # This allows async function to work with sync blockchain library
605
+ import asyncio
606
+ from concurrent.futures import ThreadPoolExecutor
607
+
608
+ def _submit_transaction_sync():
609
+ """Synchronous transaction submission - runs in thread pool"""
610
+ from xrpl.transaction import submit_and_wait
611
+
612
+ try:
613
+ # Use submit_and_wait which handles signing automatically
614
+ response = submit_and_wait(
615
+ transaction=account_set_tx,
616
+ client=self.client,
617
+ wallet=issuer
618
+ )
619
+ return response
620
+
621
+ except Exception as submit_error:
622
+ print(f"[XRPL-KYC] ERROR: Transaction submission failed: {submit_error}")
623
+ raise Exception(f'Transaction signing failed: {str(submit_error)}')
624
+
625
+ # Execute sync function in thread pool and await result
626
+ loop = asyncio.get_event_loop()
627
+ try:
628
+ result = await loop.run_in_executor(None, _submit_transaction_sync)
629
+ except Exception as exec_error:
630
+ return {
631
+ 'success': False,
632
+ 'error': f'Transaction execution failed: {str(exec_error)}'
633
+ }
634
+
635
+ # submit_and_wait returns the full response with metadata
636
+ # Check if transaction was successful
637
+ if result.result.get('meta', {}).get('TransactionResult') == 'tesSUCCESS':
638
+ tx_hash = result.result.get('hash')
639
+ explorer_url = f"{settings.XRPL_EXPLORER_BASE}{tx_hash}"
640
+
641
+ print(f"[XRPL-KYC] SUCCESS: KYC data written to blockchain!")
642
+ print(f"[XRPL-KYC] TX Hash: {tx_hash}")
643
+ print(f"[XRPL-KYC] Explorer: {explorer_url}")
644
+
645
+ return {
646
+ 'success': True,
647
+ 'tx_hash': tx_hash,
648
+ 'explorer_url': explorer_url,
649
+ 'memo_data': memo_data,
650
+ 'ipfs_hash': kyc_data.get('ipfs_hash')
651
+ }
652
+ else:
653
+ error_code = result.result.get('meta', {}).get('TransactionResult', 'Unknown')
654
+ error_msg = result.result.get('engine_result_message', error_code)
655
+ print(f"[XRPL-KYC] ERROR: Transaction failed: {error_msg}")
656
+ return {
657
+ 'success': False,
658
+ 'error': f'Blockchain write failed: {error_msg}'
659
+ }
660
+
661
+ except Exception as e:
662
+ print(f"[XRPL-KYC] ERROR: Error writing KYC to blockchain: {e}")
663
+ return {
664
+ 'success': False,
665
+ 'error': str(e)
666
+ }
667
+
668
+ xrpl_service = XRPLService()
utils/blockchain_utils.py ADDED
@@ -0,0 +1,316 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Blockchain Utilities
3
+ ====================
4
+ Retry logic, error handling, and transaction monitoring for XRP Ledger operations.
5
+ """
6
+
7
+ import time
8
+ from typing import Dict, Any, Optional, Callable
9
+ from xrpl.clients import JsonRpcClient
10
+ from xrpl.models import Response
11
+ from xrpl.wallet import Wallet
12
+ from xrpl.transaction import submit_and_wait
13
+ import logging
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class BlockchainError(Exception):
19
+ """Custom exception for blockchain-related errors with user-friendly messages"""
20
+ def __init__(self, technical_msg: str, user_msg: str, retryable: bool = False):
21
+ self.technical_msg = technical_msg
22
+ self.user_msg = user_msg
23
+ self.retryable = retryable
24
+ super().__init__(technical_msg)
25
+
26
+
27
+ class BlockchainRetryHandler:
28
+ """
29
+ Handles retry logic for blockchain transactions with exponential backoff.
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ max_retries: int = 3,
35
+ base_delay: float = 1.0,
36
+ max_delay: float = 10.0,
37
+ exponential_base: float = 2.0
38
+ ):
39
+ """
40
+ Initialize retry handler.
41
+
42
+ Args:
43
+ max_retries: Maximum number of retry attempts
44
+ base_delay: Initial delay in seconds
45
+ max_delay: Maximum delay between retries
46
+ exponential_base: Base for exponential backoff calculation
47
+ """
48
+ self.max_retries = max_retries
49
+ self.base_delay = base_delay
50
+ self.max_delay = max_delay
51
+ self.exponential_base = exponential_base
52
+
53
+ def _calculate_delay(self, attempt: int) -> float:
54
+ """Calculate delay with exponential backoff"""
55
+ delay = self.base_delay * (self.exponential_base ** attempt)
56
+ return min(delay, self.max_delay)
57
+
58
+ def execute_with_retry(
59
+ self,
60
+ operation: Callable,
61
+ operation_name: str,
62
+ *args,
63
+ **kwargs
64
+ ) -> Any:
65
+ """
66
+ Execute a blockchain operation with retry logic.
67
+
68
+ Args:
69
+ operation: Function to execute
70
+ operation_name: Human-readable name for logging
71
+ *args, **kwargs: Arguments to pass to operation
72
+
73
+ Returns:
74
+ Result from the operation
75
+
76
+ Raises:
77
+ BlockchainError: If all retries fail
78
+ """
79
+ last_exception = None
80
+
81
+ for attempt in range(self.max_retries + 1):
82
+ try:
83
+ logger.info(f"[BLOCKCHAIN] Attempting {operation_name} (attempt {attempt + 1}/{self.max_retries + 1})")
84
+ result = operation(*args, **kwargs)
85
+
86
+ if attempt > 0:
87
+ logger.info(f"[BLOCKCHAIN] ✅ {operation_name} succeeded after {attempt} retries")
88
+
89
+ return result
90
+
91
+ except Exception as e:
92
+ last_exception = e
93
+ error_msg = str(e)
94
+
95
+ # Check if error is retryable
96
+ is_retryable = self._is_retryable_error(error_msg)
97
+
98
+ if not is_retryable or attempt >= self.max_retries:
99
+ logger.error(f"[BLOCKCHAIN] [ERROR] {operation_name} failed permanently: {error_msg}")
100
+ raise self._convert_to_user_friendly_error(e, operation_name)
101
+
102
+ # Calculate delay and retry
103
+ delay = self._calculate_delay(attempt)
104
+ logger.warning(
105
+ f"[BLOCKCHAIN] [WARNING] {operation_name} failed (attempt {attempt + 1}): {error_msg}. "
106
+ f"Retrying in {delay:.1f}s..."
107
+ )
108
+ time.sleep(delay)
109
+
110
+ # Should never reach here, but just in case
111
+ raise self._convert_to_user_friendly_error(
112
+ last_exception or Exception("Unknown error"),
113
+ operation_name
114
+ )
115
+
116
+ def _is_retryable_error(self, error_msg: str) -> bool:
117
+ """
118
+ Determine if an error is retryable based on error message.
119
+
120
+ Retryable errors include:
121
+ - Network issues
122
+ - Temporary server errors
123
+ - Sequence number mismatches
124
+ - Insufficient XRP (user might fund wallet)
125
+
126
+ Non-retryable errors include:
127
+ - Invalid signatures
128
+ - Malformed transactions
129
+ - Insufficient token balance (not XRP)
130
+ """
131
+ error_lower = error_msg.lower()
132
+
133
+ # Retryable errors
134
+ retryable_keywords = [
135
+ "timeout",
136
+ "connection",
137
+ "network",
138
+ "sequence",
139
+ "telINSUF_FEE_P", # Fee too low (can retry with higher fee)
140
+ "tefPAST_SEQ", # Sequence already used
141
+ "terQUEUED", # Transaction queued
142
+ ]
143
+
144
+ for keyword in retryable_keywords:
145
+ if keyword in error_lower:
146
+ return True
147
+
148
+ # Non-retryable errors
149
+ non_retryable_keywords = [
150
+ "signature",
151
+ "malformed",
152
+ "tecUNFUNDED_PAYMENT", # Insufficient token balance
153
+ "tecNO_DST", # Destination doesn't exist
154
+ "tecNO_PERMISSION", # Unauthorized
155
+ "temBAD_FEE", # Invalid fee
156
+ ]
157
+
158
+ for keyword in non_retryable_keywords:
159
+ if keyword in error_lower:
160
+ return False
161
+
162
+ # Default: retry on unknown errors (conservative approach)
163
+ return True
164
+
165
+ def _convert_to_user_friendly_error(
166
+ self,
167
+ exception: Exception,
168
+ operation_name: str
169
+ ) -> BlockchainError:
170
+ """
171
+ Convert technical blockchain errors to user-friendly messages.
172
+ """
173
+ error_msg = str(exception).lower()
174
+
175
+ # Map technical errors to user messages
176
+ if "insuf" in error_msg and "fee" in error_msg:
177
+ return BlockchainError(
178
+ technical_msg=str(exception),
179
+ user_msg="Transaction fee too low. Please try again.",
180
+ retryable=True
181
+ )
182
+
183
+ if "tecUNFUNDED_PAYMENT" in error_msg or "insufficient" in error_msg:
184
+ return BlockchainError(
185
+ technical_msg=str(exception),
186
+ user_msg="Insufficient balance to complete this transaction.",
187
+ retryable=False
188
+ )
189
+
190
+ if "sequence" in error_msg:
191
+ return BlockchainError(
192
+ technical_msg=str(exception),
193
+ user_msg="Transaction ordering issue. Please try again.",
194
+ retryable=True
195
+ )
196
+
197
+ if "timeout" in error_msg or "connection" in error_msg:
198
+ return BlockchainError(
199
+ technical_msg=str(exception),
200
+ user_msg="Network connection issue. Please check your internet and try again.",
201
+ retryable=True
202
+ )
203
+
204
+ if "signature" in error_msg or "unauthorized" in error_msg:
205
+ return BlockchainError(
206
+ technical_msg=str(exception),
207
+ user_msg="Authentication failed. Please contact support.",
208
+ retryable=False
209
+ )
210
+
211
+ if "tecNO_DST" in error_msg:
212
+ return BlockchainError(
213
+ technical_msg=str(exception),
214
+ user_msg="Recipient wallet not activated. Please ensure the wallet is funded with XRP.",
215
+ retryable=False
216
+ )
217
+
218
+ # Default user-friendly message
219
+ return BlockchainError(
220
+ technical_msg=str(exception),
221
+ user_msg=f"Transaction failed: {operation_name}. Please try again or contact support if the issue persists.",
222
+ retryable=True
223
+ )
224
+
225
+
226
+ class TransactionMonitor:
227
+ """
228
+ Monitor blockchain transaction status and provide detailed feedback.
229
+ """
230
+
231
+ @staticmethod
232
+ def submit_and_monitor(
233
+ client: JsonRpcClient,
234
+ transaction: Any,
235
+ wallet: Wallet,
236
+ operation_name: str = "Transaction"
237
+ ) -> Response:
238
+ """
239
+ Submit a transaction and monitor its status with detailed logging.
240
+
241
+ Args:
242
+ client: XRP Ledger client
243
+ transaction: Transaction to submit
244
+ wallet: Wallet to sign with
245
+ operation_name: Human-readable operation name
246
+
247
+ Returns:
248
+ Transaction response
249
+
250
+ Raises:
251
+ BlockchainError: If transaction fails
252
+ """
253
+ logger.info(f"[TX_MONITOR] 📤 Submitting {operation_name}...")
254
+ logger.debug(f"[TX_MONITOR] Transaction details: {transaction.to_dict()}")
255
+
256
+ try:
257
+ # Submit and wait for validation
258
+ response = submit_and_wait(transaction, client, wallet)
259
+
260
+ # Check result
261
+ result = response.result
262
+ metadata = result.get("meta", {})
263
+ tx_result = metadata.get("TransactionResult", "unknown")
264
+
265
+ logger.info(f"[TX_MONITOR] Transaction hash: {result.get('hash', 'N/A')}")
266
+ logger.info(f"[TX_MONITOR] Result code: {tx_result}")
267
+
268
+ # Success codes start with "tes" (tesSUCCESS)
269
+ if tx_result.startswith("tes"):
270
+ logger.info(f"[TX_MONITOR] ✅ {operation_name} succeeded")
271
+ return response
272
+
273
+ # Error codes
274
+ error_msg = f"{operation_name} failed with code: {tx_result}"
275
+ logger.error(f"[TX_MONITOR] [ERROR] {error_msg}")
276
+
277
+ raise BlockchainError(
278
+ technical_msg=error_msg,
279
+ user_msg=TransactionMonitor._get_user_message_for_code(tx_result),
280
+ retryable=tx_result.startswith("ter") # "ter" = retry, "tec" = claimed fee, "tem"/"tef" = malformed
281
+ )
282
+
283
+ except BlockchainError:
284
+ raise
285
+ except Exception as e:
286
+ logger.error(f"[TX_MONITOR] [ERROR] {operation_name} exception: {str(e)}")
287
+ raise BlockchainError(
288
+ technical_msg=str(e),
289
+ user_msg=f"{operation_name} failed. Please try again.",
290
+ retryable=True
291
+ )
292
+
293
+ @staticmethod
294
+ def _get_user_message_for_code(result_code: str) -> str:
295
+ """Map XRP Ledger result codes to user-friendly messages"""
296
+
297
+ code_map = {
298
+ "tecUNFUNDED_PAYMENT": "Insufficient balance to complete this transaction.",
299
+ "tecNO_DST": "Destination wallet not found or not activated.",
300
+ "tecNO_LINE": "Trust line not established. Please enable the token first.",
301
+ "tecNO_PERMISSION": "You don't have permission to perform this action.",
302
+ "tecINSUFFICIENT_RESERVE": "Insufficient XRP reserve. You need at least 10 XRP in your wallet.",
303
+ "tecPATH_DRY": "No available path for this transaction.",
304
+ "terQUEUED": "Transaction queued. Please wait a moment and try again.",
305
+ "tefPAST_SEQ": "Transaction already processed. Please refresh and check your balance.",
306
+ }
307
+
308
+ return code_map.get(result_code, f"Transaction failed with code {result_code}. Please contact support.")
309
+
310
+
311
+ # Global retry handler instance
312
+ retry_handler = BlockchainRetryHandler(
313
+ max_retries=3,
314
+ base_delay=1.0,
315
+ max_delay=10.0
316
+ )
utils/cache.py ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Redis Caching Utility
3
+ Provides caching decorator and Redis connection management
4
+ """
5
+ import json
6
+ import hashlib
7
+ from functools import wraps
8
+ from typing import Optional, Any
9
+ import logging
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ # Redis client (lazy initialization)
14
+ _redis_client: Optional[Any] = None
15
+
16
+
17
+ def get_redis_client():
18
+ """Get Redis client instance (singleton with graceful fallback)"""
19
+ global _redis_client
20
+
21
+ if _redis_client is None:
22
+ try:
23
+ from redis import Redis
24
+ _redis_client = Redis(
25
+ host='localhost',
26
+ port=6379,
27
+ db=0,
28
+ decode_responses=True,
29
+ socket_connect_timeout=2,
30
+ socket_timeout=2
31
+ )
32
+ # Test connection
33
+ _redis_client.ping()
34
+ logger.info("[CACHE] [SUCCESS] Redis connection established")
35
+ except Exception as e:
36
+ logger.warning(f"[CACHE] Redis not available: {e}. Caching disabled.")
37
+ _redis_client = False # Mark as unavailable to avoid retries
38
+
39
+ return _redis_client if _redis_client is not False else None
40
+
41
+
42
+ def generate_cache_key(func_name: str, args: tuple, kwargs: dict) -> str:
43
+ """Generate a unique cache key from function name and arguments"""
44
+ # Convert args and kwargs to a stable string representation
45
+ args_str = str(args)
46
+ kwargs_str = str(sorted(kwargs.items()))
47
+ combined = f"{func_name}:{args_str}:{kwargs_str}"
48
+
49
+ # Hash for shorter keys (avoid Redis key length limits)
50
+ key_hash = hashlib.md5(combined.encode()).hexdigest()
51
+ return f"cache:{func_name}:{key_hash}"
52
+
53
+
54
+ def cache(ttl: int = 300):
55
+ """
56
+ Caching decorator with Redis backend
57
+
58
+ Args:
59
+ ttl: Time to live in seconds (default 5 minutes)
60
+
61
+ Usage:
62
+ @cache(ttl=60)
63
+ def expensive_function(arg1, arg2):
64
+ # ... expensive operation
65
+ return result
66
+ """
67
+ def decorator(func):
68
+ @wraps(func)
69
+ def wrapper(*args, **kwargs):
70
+ redis_client = get_redis_client()
71
+
72
+ # If Redis is not available, execute function normally
73
+ if redis_client is None:
74
+ return func(*args, **kwargs)
75
+
76
+ try:
77
+ # Generate cache key
78
+ cache_key = generate_cache_key(func.__name__, args, kwargs)
79
+
80
+ # Try to get from cache
81
+ cached_value = redis_client.get(cache_key)
82
+
83
+ if cached_value is not None:
84
+ logger.debug(f"[CACHE] HIT: {func.__name__}")
85
+ return json.loads(cached_value)
86
+
87
+ # Cache miss - execute function
88
+ logger.debug(f"[CACHE] MISS: {func.__name__}")
89
+ result = func(*args, **kwargs)
90
+
91
+ # Store in cache
92
+ try:
93
+ serialized = json.dumps(result, default=str)
94
+ redis_client.setex(cache_key, ttl, serialized)
95
+ except (TypeError, ValueError) as e:
96
+ logger.warning(f"[CACHE] Failed to serialize result for {func.__name__}: {e}")
97
+
98
+ return result
99
+
100
+ except Exception as e:
101
+ # If caching fails, log and execute function normally
102
+ logger.warning(f"[CACHE] Error in cache decorator for {func.__name__}: {e}")
103
+ return func(*args, **kwargs)
104
+
105
+ return wrapper
106
+ return decorator
107
+
108
+
109
+ def invalidate_cache(pattern: str = None):
110
+ """
111
+ Invalidate cache entries matching pattern
112
+
113
+ Args:
114
+ pattern: Redis key pattern (e.g., "cache:list_properties:*")
115
+ If None, clears all cache entries
116
+ """
117
+ redis_client = get_redis_client()
118
+
119
+ if redis_client is None:
120
+ return
121
+
122
+ try:
123
+ if pattern:
124
+ keys = redis_client.keys(pattern)
125
+ if keys:
126
+ redis_client.delete(*keys)
127
+ logger.info(f"[CACHE] Invalidated {len(keys)} keys matching '{pattern}'")
128
+ else:
129
+ redis_client.flushdb()
130
+ logger.info("[CACHE] Cleared all cache entries")
131
+ except Exception as e:
132
+ logger.error(f"[CACHE] Error invalidating cache: {e}")
133
+
134
+
135
+ def cache_property_data(property_id: str, data: dict, ttl: int = 300):
136
+ """Cache property data manually (for fine-grained control)"""
137
+ redis_client = get_redis_client()
138
+
139
+ if redis_client is None:
140
+ return
141
+
142
+ try:
143
+ cache_key = f"cache:property:{property_id}"
144
+ redis_client.setex(cache_key, ttl, json.dumps(data, default=str))
145
+ logger.debug(f"[CACHE] Cached property data: {property_id}")
146
+ except Exception as e:
147
+ logger.warning(f"[CACHE] Failed to cache property data: {e}")
148
+
149
+
150
+ def get_cached_property(property_id: str) -> Optional[dict]:
151
+ """Get cached property data"""
152
+ redis_client = get_redis_client()
153
+
154
+ if redis_client is None:
155
+ return None
156
+
157
+ try:
158
+ cache_key = f"cache:property:{property_id}"
159
+ cached = redis_client.get(cache_key)
160
+ if cached:
161
+ return json.loads(cached)
162
+ except Exception as e:
163
+ logger.warning(f"[CACHE] Failed to get cached property: {e}")
164
+
165
+ return None
166
+
167
+
168
+ def cache_xrp_balance(address: str, balance: float, ttl: int = 300):
169
+ """Cache XRP balance for an address"""
170
+ redis_client = get_redis_client()
171
+
172
+ if redis_client is None:
173
+ return
174
+
175
+ try:
176
+ cache_key = f"cache:xrp_balance:{address}"
177
+ redis_client.setex(cache_key, ttl, str(balance))
178
+ logger.debug(f"[CACHE] Cached XRP balance for {address}: {balance}")
179
+ except Exception as e:
180
+ logger.warning(f"[CACHE] Failed to cache XRP balance: {e}")
181
+
182
+
183
+ def get_cached_xrp_balance(address: str) -> Optional[float]:
184
+ """Get cached XRP balance"""
185
+ redis_client = get_redis_client()
186
+
187
+ if redis_client is None:
188
+ return None
189
+
190
+ try:
191
+ cache_key = f"cache:xrp_balance:{address}"
192
+ cached = redis_client.get(cache_key)
193
+ if cached:
194
+ return float(cached)
195
+ except Exception as e:
196
+ logger.warning(f"[CACHE] Failed to get cached XRP balance: {e}")
197
+
198
+ return None
199
+
200
+
201
+ def cache_token_balance(xrp_address: str, currency_code: str, issuer_address: str, balance: float, ttl: int = 300):
202
+ """Cache IOU token balance for an address"""
203
+ redis_client = get_redis_client()
204
+
205
+ if redis_client is None:
206
+ return
207
+
208
+ try:
209
+ cache_key = f"cache:token_balance:{xrp_address}:{currency_code}:{issuer_address}"
210
+ redis_client.setex(cache_key, ttl, str(balance))
211
+ logger.debug(f"[CACHE] Cached token balance for {xrp_address}: {balance} {currency_code}")
212
+ except Exception as e:
213
+ logger.warning(f"[CACHE] Failed to cache token balance: {e}")
214
+
215
+
216
+ def get_cached_token_balance(xrp_address: str, currency_code: str, issuer_address: str) -> Optional[float]:
217
+ """Get cached IOU token balance"""
218
+ redis_client = get_redis_client()
219
+
220
+ if redis_client is None:
221
+ return None
222
+
223
+ try:
224
+ cache_key = f"cache:token_balance:{xrp_address}:{currency_code}:{issuer_address}"
225
+ cached = redis_client.get(cache_key)
226
+ if cached:
227
+ return float(cached)
228
+ except Exception as e:
229
+ logger.warning(f"[CACHE] Failed to get cached token balance: {e}")
230
+
231
+ return None
utils/country_codes.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Country codes data and utility functions for international phone numbers
3
+ """
4
+
5
+ # List of countries with their codes and ISO codes
6
+ COUNTRIES = [
7
+ {"name": "United States", "code": "+1", "iso": "US", "flag": "🇺🇸"},
8
+ {"name": "United Kingdom", "code": "+44", "iso": "GB", "flag": "🇬🇧"},
9
+ {"name": "India", "code": "+91", "iso": "IN", "flag": "🇮🇳"},
10
+ {"name": "United Arab Emirates", "code": "+971", "iso": "AE", "flag": "🇦🇪"},
11
+ {"name": "Canada", "code": "+1", "iso": "CA", "flag": "🇨🇦"},
12
+ {"name": "Australia", "code": "+61", "iso": "AU", "flag": "🇦🇺"},
13
+ {"name": "Germany", "code": "+49", "iso": "DE", "flag": "🇩🇪"},
14
+ {"name": "France", "code": "+33", "iso": "FR", "flag": "🇫🇷"},
15
+ {"name": "Spain", "code": "+34", "iso": "ES", "flag": "🇪🇸"},
16
+ {"name": "Italy", "code": "+39", "iso": "IT", "flag": "🇮🇹"},
17
+ {"name": "China", "code": "+86", "iso": "CN", "flag": "🇨🇳"},
18
+ {"name": "Japan", "code": "+81", "iso": "JP", "flag": "🇯🇵"},
19
+ {"name": "South Korea", "code": "+82", "iso": "KR", "flag": "🇰🇷"},
20
+ {"name": "Singapore", "code": "+65", "iso": "SG", "flag": "🇸🇬"},
21
+ {"name": "Hong Kong", "code": "+852", "iso": "HK", "flag": "🇭🇰"},
22
+ {"name": "Saudi Arabia", "code": "+966", "iso": "SA", "flag": "🇸🇦"},
23
+ {"name": "Brazil", "code": "+55", "iso": "BR", "flag": "🇧🇷"},
24
+ {"name": "Mexico", "code": "+52", "iso": "MX", "flag": "🇲🇽"},
25
+ {"name": "South Africa", "code": "+27", "iso": "ZA", "flag": "🇿🇦"},
26
+ {"name": "Nigeria", "code": "+234", "iso": "NG", "flag": "🇳🇬"},
27
+ {"name": "Egypt", "code": "+20", "iso": "EG", "flag": "🇪🇬"},
28
+ {"name": "Pakistan", "code": "+92", "iso": "PK", "flag": "🇵🇰"},
29
+ {"name": "Bangladesh", "code": "+880", "iso": "BD", "flag": "🇧🇩"},
30
+ {"name": "Indonesia", "code": "+62", "iso": "ID", "flag": "🇮🇩"},
31
+ {"name": "Malaysia", "code": "+60", "iso": "MY", "flag": "🇲🇾"},
32
+ {"name": "Philippines", "code": "+63", "iso": "PH", "flag": "🇵🇭"},
33
+ {"name": "Thailand", "code": "+66", "iso": "TH", "flag": "🇹🇭"},
34
+ {"name": "Vietnam", "code": "+84", "iso": "VN", "flag": "🇻🇳"},
35
+ {"name": "Turkey", "code": "+90", "iso": "TR", "flag": "🇹🇷"},
36
+ {"name": "Russia", "code": "+7", "iso": "RU", "flag": "🇷🇺"},
37
+ {"name": "Poland", "code": "+48", "iso": "PL", "flag": "🇵🇱"},
38
+ {"name": "Netherlands", "code": "+31", "iso": "NL", "flag": "🇳🇱"},
39
+ {"name": "Belgium", "code": "+32", "iso": "BE", "flag": "🇧🇪"},
40
+ {"name": "Sweden", "code": "+46", "iso": "SE", "flag": "🇸🇪"},
41
+ {"name": "Norway", "code": "+47", "iso": "NO", "flag": "🇳🇴"},
42
+ {"name": "Denmark", "code": "+45", "iso": "DK", "flag": "🇩🇰"},
43
+ {"name": "Switzerland", "code": "+41", "iso": "CH", "flag": "🇨🇭"},
44
+ {"name": "Austria", "code": "+43", "iso": "AT", "flag": "🇦🇹"},
45
+ {"name": "Ireland", "code": "+353", "iso": "IE", "flag": "🇮🇪"},
46
+ {"name": "New Zealand", "code": "+64", "iso": "NZ", "flag": "🇳🇿"},
47
+ ]
48
+
49
+
50
+ def get_country_by_code(code: str):
51
+ """Get country data by phone code"""
52
+ for country in COUNTRIES:
53
+ if country["code"] == code:
54
+ return country
55
+ return None
56
+
57
+
58
+ def get_country_by_iso(iso: str):
59
+ """Get country data by ISO code"""
60
+ for country in COUNTRIES:
61
+ if country["iso"] == iso:
62
+ return country
63
+ return None
64
+
65
+
66
+ def get_country_by_name(name: str):
67
+ """Get country data by country name"""
68
+ for country in COUNTRIES:
69
+ if country["name"].lower() == name.lower():
70
+ return country
71
+ return None
72
+
73
+
74
+ def validate_country_code(code: str) -> bool:
75
+ """Validate if the country code exists"""
76
+ return any(country["code"] == code for country in COUNTRIES)
77
+
78
+
79
+ def get_all_countries():
80
+ """Return all countries"""
81
+ return COUNTRIES
utils/crypto_utils.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Cryptographic utilities for secret encryption/decryption.
2
+
3
+ Provides Fernet symmetric authenticated encryption over wallet seeds.
4
+ Secrets are stored with a sentinel prefix:
5
+ ENC::<base64token>
6
+ If a value does not start with ENC:: it is treated as legacy (plain/base64) and
7
+ should be migrated by the caller.
8
+ """
9
+ from __future__ import annotations
10
+ import base64
11
+ import os
12
+ from typing import Optional
13
+ from cryptography.fernet import Fernet, InvalidToken
14
+
15
+ _FERNET: Optional[Fernet] = None
16
+ _PREFIX = "ENC::"
17
+
18
+
19
+ def _derive_or_load_key(raw: Optional[str]) -> Optional[bytes]:
20
+ """Return a 32-byte urlsafe base64-encoded key for Fernet or None.
21
+
22
+ Accepts either:
23
+ * Already a 44 char Fernet key (base64 urlsafe) -> used directly
24
+ * 32 raw bytes in base64 (decode then re-encode to urlsafe) (len 43/44)
25
+ * Hex string of length 64 -> interpreted as 32 bytes then base64 encoded
26
+ """
27
+ if not raw:
28
+ return None
29
+ raw = raw.strip()
30
+ # Direct Fernet key
31
+ if len(raw) in (43, 44) and all(c.isalnum() or c in ("-", "_") for c in raw.rstrip("=")):
32
+ try:
33
+ base64.urlsafe_b64decode(raw + ("=" * (-len(raw) % 4)))
34
+ return raw.encode()
35
+ except Exception:
36
+ pass
37
+ # Hex 64 -> bytes
38
+ if len(raw) == 64:
39
+ try:
40
+ b = bytes.fromhex(raw)
41
+ return base64.urlsafe_b64encode(b)
42
+ except ValueError:
43
+ pass
44
+ # Fallback: if it decodes from base64 to 32 bytes use it
45
+ try:
46
+ b = base64.b64decode(raw + ("=" * (-len(raw) % 4)))
47
+ if len(b) == 32:
48
+ return base64.urlsafe_b64encode(b)
49
+ except Exception:
50
+ pass
51
+ raise ValueError("ENCRYPTION_KEY provided is not a valid 32-byte or Fernet key material")
52
+
53
+
54
+ def get_fernet(encryption_key: Optional[str]) -> Optional[Fernet]:
55
+ global _FERNET
56
+ if _FERNET is not None:
57
+ return _FERNET
58
+ key_bytes = _derive_or_load_key(encryption_key)
59
+ if not key_bytes:
60
+ return None
61
+ _FERNET = Fernet(key_bytes)
62
+ return _FERNET
63
+
64
+
65
+ def encrypt_secret(plaintext: str, encryption_key: Optional[str]) -> str:
66
+ """Encrypt a secret; returns ENC::<token>. If key missing returns original (no prefix)."""
67
+ if not plaintext:
68
+ return plaintext
69
+ f = get_fernet(encryption_key)
70
+ if not f:
71
+ return plaintext # no encryption key configured
72
+ token = f.encrypt(plaintext.encode()).decode()
73
+ return f"{_PREFIX}{token}"
74
+
75
+
76
+ def is_encrypted(value: str) -> bool:
77
+ return isinstance(value, str) and value.startswith(_PREFIX)
78
+
79
+
80
+ def decrypt_secret(value: Optional[str], encryption_key: Optional[str]) -> Optional[str]:
81
+ if not value:
82
+ return value
83
+ if not is_encrypted(value):
84
+ # Legacy base64 encoded value - decode it
85
+ try:
86
+ return base64.b64decode(value).decode()
87
+ except Exception:
88
+ # If decode fails, return as-is (might be plain text)
89
+ return value
90
+ token = value[len(_PREFIX):]
91
+ f = get_fernet(encryption_key)
92
+ if not f:
93
+ return value # cannot decrypt without key
94
+ try:
95
+ return f.decrypt(token.encode()).decode()
96
+ except InvalidToken:
97
+ # Return sentinel text to signal decryption issue without raising
98
+ return None
utils/email_service.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Email Service for OTP Verification
3
+ Sends OTP emails using SMTP (Gmail)
4
+ """
5
+ import smtplib
6
+ import random
7
+ import string
8
+ from email.mime.text import MIMEText
9
+ from email.mime.multipart import MIMEMultipart
10
+ from datetime import datetime, timedelta
11
+ from typing import Optional
12
+ import logging
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # SMTP Configuration
17
+ SMTP_SERVER = "smtp.gmail.com"
18
+ SMTP_PORT = 587
19
+ SMTP_EMAIL = "jigneshprajapati.igenerate@gmail.com"
20
+ SMTP_PASSWORD = "vtvgwntllsfjqykx" # NOTE: For Gmail, you MUST use an App Password!
21
+ # To generate an App Password:
22
+ # 1. Go to Google Account settings
23
+ # 2. Security > 2-Step Verification (enable if not already)
24
+ # 3. App passwords > Select app: Mail, Select device: Other (Custom name)
25
+ # 4. Generate and use that 16-character password here
26
+
27
+ def generate_otp(length: int = 6) -> str:
28
+ """Generate a random 6-digit OTP"""
29
+ return ''.join(random.choices(string.digits, k=length))
30
+
31
+
32
+ def send_otp_email(to_email: str, otp: str, purpose: str = "login") -> bool:
33
+ """
34
+ Send OTP via email
35
+
36
+ Args:
37
+ to_email: Recipient email address
38
+ otp: 6-digit OTP code
39
+ purpose: 'login' or 'registration'
40
+
41
+ Returns:
42
+ bool: True if email sent successfully, False otherwise
43
+ """
44
+ try:
45
+ # Create message
46
+ msg = MIMEMultipart('alternative')
47
+ msg['From'] = SMTP_EMAIL
48
+ msg['To'] = to_email
49
+ msg['Subject'] = f"Your OTP for {purpose.capitalize()} - Real Estate Platform"
50
+
51
+ # HTML email body with AtriumChain brand theme - Clean, Professional Design
52
+ html_body = f"""
53
+ <!DOCTYPE html>
54
+ <html>
55
+ <head>
56
+ <meta charset="UTF-8">
57
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
58
+ <style>
59
+ body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', Arial, sans-serif; background-color: #F5F6F8; margin: 0; padding: 0; }}
60
+ .wrapper {{ max-width: 600px; margin: 40px auto; }}
61
+ .container {{ background: #FFFFFF; border-radius: 24px; overflow: hidden; box-shadow: 0 4px 24px rgba(15, 16, 19, 0.08); border: 1px solid #E5E7EB; }}
62
+ .header {{ background: #0F1013; padding: 48px 40px; text-align: center; }}
63
+ .logo {{ font-size: 28px; font-weight: 800; color: #FFFFFF; letter-spacing: -0.5px; margin: 0; }}
64
+ .logo span {{ color: #BBD2FB; }}
65
+ .tagline {{ color: rgba(255,255,255,0.7); font-size: 14px; margin: 8px 0 0 0; font-weight: 400; }}
66
+ .content {{ padding: 48px 40px; }}
67
+ .greeting {{ color: #0F1013; font-size: 20px; font-weight: 600; margin: 0 0 16px 0; }}
68
+ .message {{ color: #6B7280; font-size: 15px; line-height: 1.7; margin: 0 0 32px 0; }}
69
+ .otp-container {{ background: #F5F6F8; border: 2px solid #E5E7EB; border-radius: 16px; padding: 32px; text-align: center; margin: 32px 0; }}
70
+ .otp-label {{ margin: 0 0 12px 0; color: #6B7280; font-size: 12px; text-transform: uppercase; letter-spacing: 1.5px; font-weight: 600; }}
71
+ .otp-code {{ font-size: 44px; font-weight: 800; color: #0F1013; letter-spacing: 8px; margin: 0; font-family: 'SF Mono', 'Roboto Mono', monospace; }}
72
+ .otp-validity {{ margin: 16px 0 0 0; color: #6B7280; font-size: 13px; display: flex; align-items: center; justify-content: center; gap: 6px; }}
73
+ .otp-validity svg {{ width: 14px; height: 14px; }}
74
+ .security-box {{ background: #FEF3C7; border-left: 4px solid #F59E0B; border-radius: 0 12px 12px 0; padding: 20px 24px; margin: 32px 0; }}
75
+ .security-title {{ color: #92400E; font-weight: 700; font-size: 14px; margin: 0 0 12px 0; display: flex; align-items: center; gap: 8px; }}
76
+ .security-title svg {{ width: 16px; height: 16px; }}
77
+ .security-list {{ color: #78350F; font-size: 13px; line-height: 1.8; margin: 0; padding-left: 20px; }}
78
+ .security-list li {{ margin: 4px 0; }}
79
+ .divider {{ height: 1px; background: #E5E7EB; margin: 32px 0; }}
80
+ .footer-text {{ color: #6B7280; font-size: 14px; line-height: 1.6; margin: 0; }}
81
+ .signature {{ color: #0F1013; font-weight: 600; margin: 16px 0 0 0; }}
82
+ .footer {{ background: #F9FAFB; padding: 32px 40px; text-align: center; border-top: 1px solid #E5E7EB; }}
83
+ .footer p {{ color: #9CA3AF; font-size: 12px; margin: 4px 0; line-height: 1.5; }}
84
+ .footer-links {{ margin-top: 16px; }}
85
+ .footer-link {{ color: #0F1013; text-decoration: none; font-size: 12px; font-weight: 500; }}
86
+ .footer-link:hover {{ text-decoration: underline; }}
87
+ .brand-badge {{ display: inline-flex; align-items: center; gap: 8px; background: #0F1013; color: #FFFFFF; padding: 8px 16px; border-radius: 100px; font-size: 12px; font-weight: 600; margin-top: 20px; }}
88
+ .brand-badge svg {{ width: 16px; height: 16px; }}
89
+ </style>
90
+ </head>
91
+ <body>
92
+ <div class="wrapper">
93
+ <div class="container">
94
+ <div class="header">
95
+ <h1 class="logo">Atrium<span>Chain</span></h1>
96
+ <p class="tagline">Blockchain-Powered Real Estate Investment</p>
97
+ </div>
98
+ <div class="content">
99
+ <p class="greeting">Hello there!</p>
100
+ <p class="message">You've requested to <strong style="color: #0F1013;">{purpose}</strong> to your AtriumChain account. Use the verification code below to complete your authentication:</p>
101
+
102
+ <div class="otp-container">
103
+ <p class="otp-label">Your Verification Code</p>
104
+ <p class="otp-code">{otp}</p>
105
+ <p class="otp-validity">
106
+ <svg fill="none" viewBox="0 0 24 24" stroke="#6B7280"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
107
+ Valid for 10 minutes
108
+ </p>
109
+ </div>
110
+
111
+ <div class="security-box">
112
+ <div class="security-title">
113
+ <svg fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/></svg>
114
+ Security Notice
115
+ </div>
116
+ <ul class="security-list">
117
+ <li>This code expires in <strong>10 minutes</strong></li>
118
+ <li>Never share this code with anyone</li>
119
+ <li>AtriumChain will never ask for your OTP</li>
120
+ <li>If you didn't request this, ignore this email</li>
121
+ </ul>
122
+ </div>
123
+
124
+ <div class="divider"></div>
125
+
126
+ <p class="footer-text">Need help? Contact our support team anytime. We're here to assist you with your property investment journey.</p>
127
+ <p class="signature">The AtriumChain Team</p>
128
+
129
+ <div style="text-align: center;">
130
+ <div class="brand-badge">
131
+ <svg fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/></svg>
132
+ Secured by XRP Blockchain
133
+ </div>
134
+ </div>
135
+ </div>
136
+ <div class="footer">
137
+ <p>This is an automated message from AtriumChain.</p>
138
+ <p>© 2024 AtriumChain. All rights reserved.</p>
139
+ <div class="footer-links">
140
+ <a href="#" class="footer-link">Privacy Policy</a> &nbsp;•&nbsp;
141
+ <a href="#" class="footer-link">Terms of Service</a> &nbsp;•&nbsp;
142
+ <a href="#" class="footer-link">Help Center</a>
143
+ </div>
144
+ </div>
145
+ </div>
146
+ </div>
147
+ </body>
148
+ </html>
149
+ """
150
+
151
+ # Attach HTML body
152
+ msg.attach(MIMEText(html_body, 'html'))
153
+
154
+ # Connect to SMTP server and send email
155
+ logger.info(f"[EMAIL] Connecting to SMTP server: {SMTP_SERVER}:{SMTP_PORT}")
156
+ with smtplib.SMTP(SMTP_SERVER, SMTP_PORT) as server:
157
+ server.starttls() # Secure the connection
158
+ logger.info(f"[EMAIL] Logging in as: {SMTP_EMAIL}")
159
+ server.login(SMTP_EMAIL, SMTP_PASSWORD)
160
+ logger.info(f"[EMAIL] Sending OTP to: {to_email}")
161
+ server.send_message(msg)
162
+
163
+ logger.info(f"[EMAIL] [SUCCESS] OTP email sent successfully to {to_email}")
164
+ return True
165
+
166
+ except smtplib.SMTPAuthenticationError as e:
167
+ logger.error(f"[EMAIL] [ERROR] SMTP Authentication failed: {e}")
168
+ logger.error("[EMAIL] [WARNING] IMPORTANT: For Gmail, you MUST use an App Password!")
169
+ logger.error("[EMAIL] Steps to generate App Password:")
170
+ logger.error("[EMAIL] 1. Go to https://myaccount.google.com/security")
171
+ logger.error("[EMAIL] 2. Enable 2-Step Verification if not already enabled")
172
+ logger.error("[EMAIL] 3. Go to App passwords section")
173
+ logger.error("[EMAIL] 4. Generate a new app password for 'Mail' application")
174
+ logger.error("[EMAIL] 5. Use that 16-character password in email_service.py")
175
+ return False
176
+ except smtplib.SMTPException as e:
177
+ logger.error(f"[EMAIL] [ERROR] SMTP error: {e}")
178
+ return False
179
+ except Exception as e:
180
+ logger.error(f"[EMAIL] [ERROR] Failed to send OTP email: {e}")
181
+ return False
182
+
183
+
184
+ def get_otp_expiry() -> datetime:
185
+ """Get OTP expiry time (10 minutes from now)"""
186
+ return datetime.utcnow() + timedelta(minutes=10)
utils/fingerprint.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Device Fingerprinting Utilities
3
+ Generates unique device fingerprints from request headers for session management
4
+ """
5
+ import hashlib
6
+ from fastapi import Request
7
+ from typing import Optional
8
+
9
+
10
+ def generate_device_fingerprint(request: Request, custom_fingerprint: Optional[str] = None) -> str:
11
+ """
12
+ Generate a unique device fingerprint from request headers
13
+
14
+ Args:
15
+ request: FastAPI Request object
16
+ custom_fingerprint: Optional custom fingerprint from frontend (more reliable)
17
+
18
+ Returns:
19
+ SHA256 hash of device characteristics
20
+ """
21
+ if custom_fingerprint:
22
+ # Use frontend-generated fingerprint if available (more accurate)
23
+ return hashlib.sha256(custom_fingerprint.encode()).hexdigest()
24
+
25
+ # Fallback: Generate from server-side headers
26
+ user_agent = request.headers.get("user-agent", "unknown")
27
+ accept_language = request.headers.get("accept-language", "unknown")
28
+ accept_encoding = request.headers.get("accept-encoding", "unknown")
29
+
30
+ # Combine characteristics
31
+ fingerprint_data = f"{user_agent}|{accept_language}|{accept_encoding}"
32
+
33
+ # Return SHA256 hash
34
+ return hashlib.sha256(fingerprint_data.encode()).hexdigest()
35
+
36
+
37
+ def generate_session_id(user_id: str, token: str, fingerprint: str) -> str:
38
+ """
39
+ Generate a unique session ID
40
+
41
+ Args:
42
+ user_id: User's unique identifier
43
+ token: JWT token
44
+ fingerprint: Device fingerprint
45
+
46
+ Returns:
47
+ SHA256 hash of combined data
48
+ """
49
+ session_data = f"{user_id}|{token}|{fingerprint}"
50
+ return hashlib.sha256(session_data.encode()).hexdigest()
utils/logger.py ADDED
@@ -0,0 +1,349 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Enhanced logging system for real estate tokenization platform
3
+ Provides structured logging with performance monitoring and transaction tracking
4
+ """
5
+
6
+ import logging
7
+ import logging.handlers
8
+ import time
9
+ import functools
10
+ from typing import Any, Dict, Optional
11
+ from pathlib import Path
12
+ from datetime import datetime
13
+ import json
14
+
15
+ from config import settings
16
+
17
+
18
+ class PerformanceLogger:
19
+ """Performance monitoring logger for slow operations"""
20
+
21
+ @staticmethod
22
+ def log_slow_operation(operation_name: str, duration: float, details: Dict[str, Any] = None):
23
+ """Log operations that exceed the threshold"""
24
+ if duration > settings.SLOW_QUERY_THRESHOLD:
25
+ logger = logging.getLogger("performance")
26
+ logger.warning(
27
+ f"SLOW_OPERATION: {operation_name} took {duration:.3f}s",
28
+ extra={
29
+ "operation": operation_name,
30
+ "duration": duration,
31
+ "threshold": settings.SLOW_QUERY_THRESHOLD,
32
+ "details": details or {}
33
+ }
34
+ )
35
+
36
+
37
+ class TransactionLogger:
38
+ """Blockchain transaction monitoring and logging"""
39
+
40
+ @staticmethod
41
+ def log_transaction_start(tx_type: str, user_id: str, property_id: str = None, amount: float = None):
42
+ """Log the start of a blockchain transaction"""
43
+ logger = logging.getLogger("transactions")
44
+ logger.info(
45
+ f"TRANSACTION_START: {tx_type}",
46
+ extra={
47
+ "event": "transaction_start",
48
+ "tx_type": tx_type,
49
+ "user_id": user_id,
50
+ "property_id": property_id,
51
+ "amount": amount,
52
+ "timestamp": datetime.utcnow().isoformat()
53
+ }
54
+ )
55
+
56
+ @staticmethod
57
+ def log_transaction_success(tx_type: str, tx_hash: str, user_id: str, duration: float, details: Dict[str, Any] = None):
58
+ """Log successful blockchain transaction"""
59
+ logger = logging.getLogger("transactions")
60
+ logger.info(
61
+ f"TRANSACTION_SUCCESS: {tx_type} completed in {duration:.3f}s",
62
+ extra={
63
+ "event": "transaction_success",
64
+ "tx_type": tx_type,
65
+ "tx_hash": tx_hash,
66
+ "user_id": user_id,
67
+ "duration": duration,
68
+ "details": details or {},
69
+ "timestamp": datetime.utcnow().isoformat()
70
+ }
71
+ )
72
+
73
+ @staticmethod
74
+ def log_transaction_failure(tx_type: str, error: str, user_id: str, duration: float, details: Dict[str, Any] = None):
75
+ """Log failed blockchain transaction"""
76
+ logger = logging.getLogger("transactions")
77
+ logger.error(
78
+ f"TRANSACTION_FAILURE: {tx_type} failed after {duration:.3f}s - {error}",
79
+ extra={
80
+ "event": "transaction_failure",
81
+ "tx_type": tx_type,
82
+ "error": error,
83
+ "user_id": user_id,
84
+ "duration": duration,
85
+ "details": details or {},
86
+ "timestamp": datetime.utcnow().isoformat()
87
+ }
88
+ )
89
+
90
+
91
+ class CustomFormatter(logging.Formatter):
92
+ """Custom formatter for structured logging"""
93
+
94
+ def format(self, record):
95
+ # Add timestamp and environment info
96
+ record.environment = settings.ENVIRONMENT
97
+ record.service = "real-estate-tokenization"
98
+
99
+ # Format the base message
100
+ formatted = super().format(record)
101
+
102
+ # Add structured data if present
103
+ if hasattr(record, 'event'):
104
+ structured_data = {
105
+ "timestamp": record.created,
106
+ "level": record.levelname,
107
+ "environment": record.environment,
108
+ "service": record.service,
109
+ "event": record.event,
110
+ "message": record.getMessage()
111
+ }
112
+
113
+ # Add extra fields
114
+ for key, value in record.__dict__.items():
115
+ if key not in ['name', 'msg', 'args', 'levelname', 'levelno', 'pathname',
116
+ 'filename', 'module', 'lineno', 'funcName', 'created',
117
+ 'msecs', 'relativeCreated', 'thread', 'threadName',
118
+ 'processName', 'process', 'getMessage', 'exc_info',
119
+ 'exc_text', 'stack_info', 'environment', 'service', 'message']:
120
+ structured_data[key] = value
121
+
122
+ # Return JSON for structured logs
123
+ return json.dumps(structured_data, default=str)
124
+
125
+ return formatted
126
+
127
+
128
+ def setup_logging():
129
+ """Setup comprehensive logging configuration"""
130
+
131
+ # Create logs directory if it doesn't exist
132
+ if settings.LOG_FILE_ENABLED:
133
+ log_path = Path(settings.LOG_FILE_PATH)
134
+ log_path.parent.mkdir(parents=True, exist_ok=True)
135
+
136
+ # Configure root logger
137
+ logging.basicConfig(
138
+ level=getattr(logging, settings.LOG_LEVEL),
139
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
140
+ datefmt='%Y-%m-%d %H:%M:%S'
141
+ )
142
+
143
+ # Configure application logger
144
+ app_logger = logging.getLogger("app")
145
+ app_logger.setLevel(getattr(logging, settings.LOG_LEVEL))
146
+
147
+ # Configure performance logger
148
+ perf_logger = logging.getLogger("performance")
149
+ perf_logger.setLevel(logging.WARNING)
150
+
151
+ # Configure transaction logger
152
+ tx_logger = logging.getLogger("transactions")
153
+ tx_logger.setLevel(logging.INFO)
154
+
155
+ # Add file handlers if enabled
156
+ if settings.LOG_FILE_ENABLED:
157
+ # Rotating file handler for general logs
158
+ file_handler = logging.handlers.RotatingFileHandler(
159
+ settings.LOG_FILE_PATH,
160
+ maxBytes=settings.LOG_FILE_MAX_SIZE,
161
+ backupCount=settings.LOG_FILE_BACKUP_COUNT
162
+ )
163
+ file_handler.setFormatter(CustomFormatter(
164
+ '%(asctime)s - %(environment)s - %(name)s - %(levelname)s - %(message)s'
165
+ ))
166
+
167
+ # Add to all loggers
168
+ app_logger.addHandler(file_handler)
169
+ perf_logger.addHandler(file_handler)
170
+ tx_logger.addHandler(file_handler)
171
+
172
+ # Console handler for development
173
+ if settings.is_development():
174
+ console_handler = logging.StreamHandler()
175
+ console_handler.setFormatter(logging.Formatter(
176
+ '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
177
+ ))
178
+
179
+ app_logger.addHandler(console_handler)
180
+ perf_logger.addHandler(console_handler)
181
+ tx_logger.addHandler(console_handler)
182
+
183
+ return app_logger
184
+
185
+
186
+ def monitor_performance(operation_name: str):
187
+ """Decorator to monitor function performance"""
188
+ def decorator(func):
189
+ @functools.wraps(func)
190
+ def wrapper(*args, **kwargs):
191
+ if not settings.ENABLE_PERFORMANCE_MONITORING:
192
+ return func(*args, **kwargs)
193
+
194
+ start_time = time.time()
195
+ try:
196
+ result = func(*args, **kwargs)
197
+ duration = time.time() - start_time
198
+ PerformanceLogger.log_slow_operation(operation_name, duration)
199
+ return result
200
+ except Exception as e:
201
+ duration = time.time() - start_time
202
+ PerformanceLogger.log_slow_operation(
203
+ f"{operation_name}_FAILED",
204
+ duration,
205
+ {"error": str(e)}
206
+ )
207
+ raise
208
+ return wrapper
209
+ return decorator
210
+
211
+
212
+ def monitor_transaction(tx_type: str):
213
+ """Decorator to monitor blockchain transactions"""
214
+ def decorator(func):
215
+ @functools.wraps(func)
216
+ def wrapper(*args, **kwargs):
217
+ if not settings.ENABLE_TRANSACTION_MONITORING:
218
+ return func(*args, **kwargs)
219
+
220
+ # Extract user_id from kwargs or args
221
+ user_id = kwargs.get('user_id') or getattr(args[0] if args else None, 'id', 'unknown')
222
+ property_id = kwargs.get('property_id')
223
+ amount = kwargs.get('amount')
224
+
225
+ TransactionLogger.log_transaction_start(tx_type, user_id, property_id, amount)
226
+
227
+ start_time = time.time()
228
+ try:
229
+ result = func(*args, **kwargs)
230
+ duration = time.time() - start_time
231
+
232
+ # Extract transaction hash from result if available
233
+ tx_hash = None
234
+ if isinstance(result, dict):
235
+ tx_hash = result.get('blockchain_tx_hash') or result.get('tx_hash')
236
+
237
+ TransactionLogger.log_transaction_success(tx_type, tx_hash or 'N/A', user_id, duration)
238
+ return result
239
+ except Exception as e:
240
+ duration = time.time() - start_time
241
+ TransactionLogger.log_transaction_failure(tx_type, str(e), user_id, duration)
242
+ raise
243
+ return wrapper
244
+ return decorator
245
+
246
+
247
+ # Initialize logging on import
248
+ app_logger = setup_logging()
249
+
250
+ # Export commonly used loggers
251
+ def get_logger(name: str = "app") -> logging.Logger:
252
+ """Get a logger instance"""
253
+ return logging.getLogger(name)
254
+
255
+
256
+ def setup_logger(
257
+ name: str,
258
+ level: int = logging.INFO,
259
+ log_to_file: bool = True,
260
+ log_to_console: bool = True
261
+ ) -> logging.Logger:
262
+ """
263
+ Setup and return a configured logger (compatible with app_logger.py)
264
+
265
+ Args:
266
+ name: Logger name (typically __name__ from the calling module)
267
+ level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
268
+ log_to_file: Whether to write logs to file
269
+ log_to_console: Whether to write logs to console
270
+
271
+ Returns:
272
+ Configured logger instance
273
+ """
274
+ # Create logger
275
+ logger = logging.getLogger(name)
276
+ logger.setLevel(level)
277
+
278
+ # Avoid duplicate handlers if logger already configured
279
+ if logger.handlers:
280
+ return logger
281
+
282
+ # Create logs directory if it doesn't exist
283
+ if log_to_file and settings.LOG_FILE_ENABLED:
284
+ log_path = Path(settings.LOG_FILE_PATH)
285
+ log_path.parent.mkdir(parents=True, exist_ok=True)
286
+
287
+ # Create formatters
288
+ detailed_formatter = logging.Formatter(
289
+ '%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s',
290
+ datefmt='%Y-%m-%d %H:%M:%S'
291
+ )
292
+
293
+ simple_formatter = logging.Formatter(
294
+ '%(asctime)s - %(levelname)s - %(message)s',
295
+ datefmt='%H:%M:%S'
296
+ )
297
+
298
+ # Console handler
299
+ if log_to_console:
300
+ console_handler = logging.StreamHandler()
301
+ console_handler.setLevel(level)
302
+ console_handler.setFormatter(simple_formatter)
303
+ logger.addHandler(console_handler)
304
+
305
+ # File handler
306
+ if log_to_file and settings.LOG_FILE_ENABLED:
307
+ file_handler = logging.handlers.RotatingFileHandler(
308
+ settings.LOG_FILE_PATH,
309
+ maxBytes=settings.LOG_FILE_MAX_SIZE,
310
+ backupCount=settings.LOG_FILE_BACKUP_COUNT
311
+ )
312
+ file_handler.setFormatter(detailed_formatter)
313
+ logger.addHandler(file_handler)
314
+
315
+ return logger
316
+
317
+
318
+ # Pre-configured loggers for common modules (from app_logger.py)
319
+ repo_logger = setup_logger("repo", level=logging.INFO)
320
+ service_logger = setup_logger("services", level=logging.INFO)
321
+ route_logger = setup_logger("routes", level=logging.INFO)
322
+ utils_logger = setup_logger("utils", level=logging.DEBUG)
323
+
324
+
325
+ # Convenience functions
326
+ def log_info(message: str, **kwargs):
327
+ """Log info message with structured data"""
328
+ logger = get_logger()
329
+ logger.info(message, extra=kwargs)
330
+
331
+
332
+ def log_warning(message: str, **kwargs):
333
+ """Log warning message with structured data"""
334
+ logger = get_logger()
335
+ logger.warning(message, extra=kwargs)
336
+
337
+
338
+ def log_error(message: str, **kwargs):
339
+ """Log error message with structured data"""
340
+ logger = get_logger()
341
+ logger.error(message, extra=kwargs)
342
+
343
+
344
+ def log_debug(message: str, **kwargs):
345
+ """Log debug message with structured data"""
346
+ logger = get_logger()
347
+ logger.debug(message, extra=kwargs)
348
+
349
+
utils/security_logger.py ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Security Event Logging
3
+ Logs all security-related events for audit and monitoring
4
+ """
5
+ import logging
6
+ from datetime import datetime
7
+ from typing import Optional, Dict, Any
8
+ import json
9
+ import os
10
+
11
+ # Ensure logs directory exists
12
+ os.makedirs('logs', exist_ok=True)
13
+
14
+ # Configure security logger
15
+ security_logger = logging.getLogger('security')
16
+ security_logger.setLevel(logging.INFO)
17
+
18
+ # Create file handler for security events
19
+ security_handler = logging.FileHandler('logs/security.log')
20
+ security_handler.setLevel(logging.INFO)
21
+
22
+ # Create formatter
23
+ formatter = logging.Formatter(
24
+ '%(asctime)s - %(name)s - %(levelname)s - %(message)s',
25
+ datefmt='%Y-%m-%d %H:%M:%S'
26
+ )
27
+ security_handler.setFormatter(formatter)
28
+ security_logger.addHandler(security_handler)
29
+
30
+
31
+ def log_login_attempt(email: str, ip: str, success: bool, reason: Optional[str] = None):
32
+ """Log login attempt"""
33
+ event = {
34
+ 'event_type': 'login_attempt',
35
+ 'email': email,
36
+ 'ip_address': ip,
37
+ 'success': success,
38
+ 'timestamp': datetime.utcnow().isoformat(),
39
+ 'reason': reason
40
+ }
41
+
42
+ if success:
43
+ security_logger.info(f"Successful login: {json.dumps(event)}")
44
+ else:
45
+ security_logger.warning(f"Failed login attempt: {json.dumps(event)}")
46
+
47
+
48
+ def log_registration(email: str, ip: str, success: bool, reason: Optional[str] = None):
49
+ """Log registration attempt"""
50
+ event = {
51
+ 'event_type': 'registration',
52
+ 'email': email,
53
+ 'ip_address': ip,
54
+ 'success': success,
55
+ 'timestamp': datetime.utcnow().isoformat(),
56
+ 'reason': reason
57
+ }
58
+
59
+ if success:
60
+ security_logger.info(f"New user registration: {json.dumps(event)}")
61
+ else:
62
+ security_logger.warning(f"Failed registration: {json.dumps(event)}")
63
+
64
+
65
+ def log_rate_limit_exceeded(endpoint: str, ip: str):
66
+ """Log rate limit exceeded"""
67
+ event = {
68
+ 'event_type': 'rate_limit_exceeded',
69
+ 'endpoint': endpoint,
70
+ 'ip_address': ip,
71
+ 'timestamp': datetime.utcnow().isoformat()
72
+ }
73
+
74
+ security_logger.warning(f"Rate limit exceeded: {json.dumps(event)}")
75
+
76
+
77
+ def log_account_lockout(email: str, ip: str, duration_minutes: int):
78
+ """Log account lockout"""
79
+ event = {
80
+ 'event_type': 'account_lockout',
81
+ 'email': email,
82
+ 'ip_address': ip,
83
+ 'duration_minutes': duration_minutes,
84
+ 'timestamp': datetime.utcnow().isoformat()
85
+ }
86
+
87
+ security_logger.warning(f"Account locked: {json.dumps(event)}")
88
+
89
+
90
+ def log_unauthorized_access(endpoint: str, ip: str, user_id: Optional[str] = None):
91
+ """Log unauthorized access attempt"""
92
+ event = {
93
+ 'event_type': 'unauthorized_access',
94
+ 'endpoint': endpoint,
95
+ 'ip_address': ip,
96
+ 'user_id': user_id,
97
+ 'timestamp': datetime.utcnow().isoformat()
98
+ }
99
+
100
+ security_logger.warning(f"Unauthorized access attempt: {json.dumps(event)}")
101
+
102
+
103
+ def log_suspicious_activity(activity_type: str, details: Dict[str, Any], ip: str, user_id: Optional[str] = None):
104
+ """Log suspicious activity"""
105
+ event = {
106
+ 'event_type': 'suspicious_activity',
107
+ 'activity_type': activity_type,
108
+ 'details': details,
109
+ 'ip_address': ip,
110
+ 'user_id': user_id,
111
+ 'timestamp': datetime.utcnow().isoformat()
112
+ }
113
+
114
+ security_logger.warning(f"Suspicious activity detected: {json.dumps(event)}")
115
+
116
+
117
+ def log_data_access(user_id: str, data_type: str, action: str, ip: str):
118
+ """Log sensitive data access"""
119
+ event = {
120
+ 'event_type': 'data_access',
121
+ 'user_id': user_id,
122
+ 'data_type': data_type,
123
+ 'action': action,
124
+ 'ip_address': ip,
125
+ 'timestamp': datetime.utcnow().isoformat()
126
+ }
127
+
128
+ security_logger.info(f"Data access: {json.dumps(event)}")
129
+
130
+
131
+ def log_kyc_event(user_id: str, event_type: str, status: str, ip: str):
132
+ """Log KYC related events"""
133
+ event = {
134
+ 'event_type': 'kyc_event',
135
+ 'user_id': user_id,
136
+ 'kyc_event_type': event_type,
137
+ 'status': status,
138
+ 'ip_address': ip,
139
+ 'timestamp': datetime.utcnow().isoformat()
140
+ }
141
+
142
+ security_logger.info(f"KYC event: {json.dumps(event)}")
143
+
144
+
145
+ def log_transaction_event(user_id: str, transaction_type: str, amount: float, property_id: Optional[str], ip: str):
146
+ """Log transaction events"""
147
+ event = {
148
+ 'event_type': 'transaction',
149
+ 'user_id': user_id,
150
+ 'transaction_type': transaction_type,
151
+ 'amount': amount,
152
+ 'property_id': property_id,
153
+ 'ip_address': ip,
154
+ 'timestamp': datetime.utcnow().isoformat()
155
+ }
156
+
157
+ security_logger.info(f"Transaction event: {json.dumps(event)}")
utils/validators.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Validation Utilities
3
+ Common validation functions to reduce duplicate code across routes
4
+ """
5
+ from fastapi import HTTPException, status
6
+ from typing import Dict, Any
7
+ import repo
8
+
9
+
10
+ def validate_property_exists(db, property_id: str) -> Dict[str, Any]:
11
+ """
12
+ Validate that a property exists and return it
13
+
14
+ Args:
15
+ db: Database connection
16
+ property_id: Property ID to validate
17
+
18
+ Returns:
19
+ Property dict if found
20
+
21
+ Raises:
22
+ HTTPException: 404 if property not found
23
+
24
+ Usage:
25
+ property_obj = validate_property_exists(db, property_id)
26
+ """
27
+ property_obj = repo.get_property_by_id(db, property_id)
28
+ if not property_obj:
29
+ raise HTTPException(
30
+ status_code=status.HTTP_404_NOT_FOUND,
31
+ detail="Property not found"
32
+ )
33
+ return property_obj
34
+
35
+
36
+ def validate_wallet_exists(db, user_id: str) -> Dict[str, Any]:
37
+ """
38
+ Validate that a user's wallet exists and return it
39
+
40
+ Args:
41
+ db: Database connection
42
+ user_id: User ID to get wallet for
43
+
44
+ Returns:
45
+ Wallet dict if found
46
+
47
+ Raises:
48
+ HTTPException: 404 if wallet not found
49
+
50
+ Usage:
51
+ wallet = validate_wallet_exists(db, current_user['id'])
52
+ """
53
+ wallet = repo.get_wallet_by_user(db, user_id)
54
+ if not wallet:
55
+ raise HTTPException(
56
+ status_code=status.HTTP_404_NOT_FOUND,
57
+ detail="Wallet not found"
58
+ )
59
+ return wallet
60
+
61
+
62
+ def validate_user_exists(db, user_id: str) -> Dict[str, Any]:
63
+ """
64
+ Validate that a user exists and return them
65
+
66
+ Args:
67
+ db: Database connection
68
+ user_id: User ID to validate
69
+
70
+ Returns:
71
+ User dict if found
72
+
73
+ Raises:
74
+ HTTPException: 404 if user not found
75
+
76
+ Usage:
77
+ user = validate_user_exists(db, user_id)
78
+ """
79
+ user = repo.get_user_by_id(db, user_id)
80
+ if not user:
81
+ raise HTTPException(
82
+ status_code=status.HTTP_404_NOT_FOUND,
83
+ detail="User not found"
84
+ )
85
+ return user
86
+
87
+
88
+ def validate_property_active(property_obj: Dict[str, Any]) -> None:
89
+ """
90
+ Validate that a property is active
91
+
92
+ Args:
93
+ property_obj: Property dict to validate
94
+
95
+ Raises:
96
+ HTTPException: 400 if property not active
97
+
98
+ Usage:
99
+ validate_property_active(property_obj)
100
+ """
101
+ if not property_obj.get('is_active', False):
102
+ raise HTTPException(
103
+ status_code=status.HTTP_400_BAD_REQUEST,
104
+ detail="Property is not active"
105
+ )
106
+
107
+
108
+ def validate_property_funded(property_obj: Dict[str, Any]) -> None:
109
+ """
110
+ Validate that a property is funded
111
+
112
+ Args:
113
+ property_obj: Property dict to validate
114
+
115
+ Raises:
116
+ HTTPException: 400 if property not funded
117
+
118
+ Usage:
119
+ validate_property_funded(property_obj)
120
+ """
121
+ if property_obj.get('status') != 'funded':
122
+ raise HTTPException(
123
+ status_code=status.HTTP_400_BAD_REQUEST,
124
+ detail=f"Property is not funded. Current status: {property_obj.get('status', 'unknown')}"
125
+ )
126
+
127
+
128
+ def validate_tokens_available(property_obj: Dict[str, Any], requested_tokens: int) -> None:
129
+ """
130
+ Validate that enough tokens are available for purchase
131
+
132
+ Args:
133
+ property_obj: Property dict to check
134
+ requested_tokens: Number of tokens requested
135
+
136
+ Raises:
137
+ HTTPException: 400 if insufficient tokens available
138
+
139
+ Usage:
140
+ validate_tokens_available(property_obj, buy_request.amount_tokens)
141
+ """
142
+ available_tokens = property_obj.get('available_tokens', 0)
143
+ if available_tokens < requested_tokens:
144
+ raise HTTPException(
145
+ status_code=status.HTTP_400_BAD_REQUEST,
146
+ detail=f"Insufficient tokens available. Requested: {requested_tokens}, Available: {available_tokens}"
147
+ )
148
+
149
+
150
+ def validate_wallet_balance(wallet: Dict[str, Any], required_amount: float) -> None:
151
+ """
152
+ Validate that wallet has sufficient balance
153
+
154
+ Args:
155
+ wallet: Wallet dict to check
156
+ required_amount: Amount required
157
+
158
+ Raises:
159
+ HTTPException: 400 if insufficient balance
160
+
161
+ Usage:
162
+ validate_wallet_balance(wallet, total_cost)
163
+ """
164
+ current_balance = wallet.get('balance', 0.0)
165
+ if current_balance < required_amount:
166
+ raise HTTPException(
167
+ status_code=status.HTTP_400_BAD_REQUEST,
168
+ detail=f"Insufficient wallet balance. Required: {required_amount:.2f} {wallet.get('currency', 'AED')}, Available: {current_balance:.2f}"
169
+ )
170
+
171
+
172
+ def validate_kyc_approved(user: Dict[str, Any]) -> None:
173
+ """
174
+ Validate that user's KYC is approved
175
+
176
+ Args:
177
+ user: User dict to check
178
+
179
+ Raises:
180
+ HTTPException: 403 if KYC not approved
181
+
182
+ Usage:
183
+ validate_kyc_approved(current_user)
184
+ """
185
+ kyc_status = user.get('kyc_status', 'pending')
186
+ if kyc_status != 'approved':
187
+ raise HTTPException(
188
+ status_code=status.HTTP_403_FORBIDDEN,
189
+ detail=f"KYC verification required. Your current status: {kyc_status}. Please complete KYC verification."
190
+ )
191
+
192
+
193
+ def validate_admin_role(user: Dict[str, Any]) -> None:
194
+ """
195
+ Validate that user has admin role
196
+
197
+ Args:
198
+ user: User dict to check
199
+
200
+ Raises:
201
+ HTTPException: 403 if not admin
202
+
203
+ Usage:
204
+ validate_admin_role(current_user)
205
+ """
206
+ if user.get('role') != 'admin':
207
+ raise HTTPException(
208
+ status_code=status.HTTP_403_FORBIDDEN,
209
+ detail="Admin access required"
210
+ )
211
+
212
+
213
+ def validate_super_admin_role(user: Dict[str, Any]) -> None:
214
+ """
215
+ Validate that user has super_admin role
216
+
217
+ Args:
218
+ user: User dict to check
219
+
220
+ Raises:
221
+ HTTPException: 403 if not super admin
222
+
223
+ Usage:
224
+ validate_super_admin_role(current_user)
225
+ """
226
+ if user.get('role') != 'super_admin':
227
+ raise HTTPException(
228
+ status_code=status.HTTP_403_FORBIDDEN,
229
+ detail="Super admin access required"
230
+ )