Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- .gitignore +28 -0
- Dockerfile +35 -0
- README.md +39 -5
- config.py +145 -0
- create_super_admin.py +138 -0
- db.py +429 -0
- main.py +214 -0
- middleware/__init__.py +4 -0
- middleware/security.py +488 -0
- repo.py +2316 -0
- repo_otp.py +185 -0
- requirements.txt +73 -0
- routes/admin.py +60 -0
- routes/admin_analytics.py +191 -0
- routes/admin_kyc.py +276 -0
- routes/admin_properties.py +336 -0
- routes/admin_rent.py +195 -0
- routes/admin_tokenization.py +70 -0
- routes/admin_users.py +290 -0
- routes/admin_wallet.py +277 -0
- routes/auth.py +497 -0
- routes/market.py +1293 -0
- routes/otp.py +203 -0
- routes/portfolio.py +243 -0
- routes/profile.py +772 -0
- routes/properties.py +851 -0
- routes/secondary_market.py +286 -0
- routes/super_admin.py +1410 -0
- routes/wallet.py +466 -0
- schemas.py +1536 -0
- services/ipfs_service.py +306 -0
- services/price_oracle.py +118 -0
- services/property_wallet_manager.py +136 -0
- services/rent_distribution_service.py +294 -0
- services/secondary_market.py +354 -0
- services/xrp_service.py +668 -0
- utils/blockchain_utils.py +316 -0
- utils/cache.py +231 -0
- utils/country_codes.py +81 -0
- utils/crypto_utils.py +98 -0
- utils/email_service.py +186 -0
- utils/fingerprint.py +50 -0
- utils/logger.py +349 -0
- utils/security_logger.py +157 -0
- utils/validators.py +230 -0
.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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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> •
|
| 141 |
+
<a href="#" class="footer-link">Terms of Service</a> •
|
| 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 |
+
)
|