diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index 1ec352d7e3..87a1c749f3 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -1,4 +1,7 @@ +from decimal import Decimal +from typing import Optional from fastapi import APIRouter, Depends, HTTPException +from fastapi import Query from typing import Dict, Any from app.services.cache import get_revenue_summary from app.core.auth import authenticate_request as get_current_user @@ -8,18 +11,25 @@ @router.get("/dashboard/summary") async def get_dashboard_summary( property_id: str, + month: Optional[int] = Query(default=None, ge=1, le=12), + year: Optional[int] = Query(default=None, ge=1), current_user: dict = Depends(get_current_user) ) -> Dict[str, Any]: - - tenant_id = getattr(current_user, "tenant_id", "default_tenant") or "default_tenant" - - revenue_data = await get_revenue_summary(property_id, tenant_id) - - total_revenue_float = float(revenue_data['total']) - + if (month is None) != (year is None): + raise HTTPException(status_code=422, detail="Both month and year are required") + + tenant_id = getattr(current_user, "tenant_id", None) + if not tenant_id: + raise HTTPException(status_code=403, detail="No tenant is associated with this user") + + try: + revenue_data = await get_revenue_summary(property_id, tenant_id, month, year) + except ValueError as error: + raise HTTPException(status_code=422, detail=str(error)) from error + return { "property_id": revenue_data['property_id'], - "total_revenue": total_revenue_float, + "total_revenue": float(Decimal(revenue_data['total'])), "currency": revenue_data['currency'], "reservations_count": revenue_data['count'] } diff --git a/backend/app/core/database_pool.py b/backend/app/core/database_pool.py index d638dfcfe3..393883268c 100644 --- a/backend/app/core/database_pool.py +++ b/backend/app/core/database_pool.py @@ -1,6 +1,5 @@ -import asyncio from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker -from sqlalchemy.pool import QueuePool +from sqlalchemy.engine import make_url import logging from ..config import settings @@ -13,39 +12,33 @@ def __init__(self): async def initialize(self): """Initialize database connection pool""" - try: - # Create async engine with connection pooling - database_url = f"postgresql+asyncpg://{settings.supabase_db_user}:{settings.supabase_db_password}@{settings.supabase_db_host}:{settings.supabase_db_port}/{settings.supabase_db_name}" - - self.engine = create_async_engine( - database_url, - poolclass=QueuePool, - pool_size=20, # Number of connections to maintain - max_overflow=30, # Additional connections when needed - pool_pre_ping=True, # Validate connections - pool_recycle=3600, # Recycle connections every hour - echo=False # Set to True for SQL debugging - ) - - self.session_factory = async_sessionmaker( - bind=self.engine, - class_=AsyncSession, - expire_on_commit=False - ) - - logger.info("✅ Database connection pool initialized") - - except Exception as e: - logger.error(f"❌ Database pool initialization failed: {e}") - self.engine = None - self.session_factory = None + if self.session_factory is not None: + return + + database_url = make_url(settings.database_url).set( + drivername="postgresql+asyncpg" + ) + self.engine = create_async_engine( + database_url, + pool_size=settings.database_pool_size, + max_overflow=settings.database_max_overflow, + pool_pre_ping=True, + pool_recycle=settings.database_pool_recycle, + echo=False, + ) + self.session_factory = async_sessionmaker( + bind=self.engine, + class_=AsyncSession, + expire_on_commit=False, + ) + logger.info("Database connection pool initialized") async def close(self): """Close database connections""" if self.engine: await self.engine.dispose() - async def get_session(self) -> AsyncSession: + def get_session(self) -> AsyncSession: """Get database session from pool""" if not self.session_factory: raise Exception("Database pool not initialized") diff --git a/backend/app/services/cache.py b/backend/app/services/cache.py index b81474957f..59bf6a5755 100644 --- a/backend/app/services/cache.py +++ b/backend/app/services/cache.py @@ -1,16 +1,22 @@ import json import redis.asyncio as redis -from typing import Dict, Any +from typing import Dict, Any, Optional import os # Initialize Redis client (typically configured centrally). redis_client = redis.Redis.from_url(os.getenv("REDIS_URL", "redis://localhost:6379/0")) -async def get_revenue_summary(property_id: str, tenant_id: str) -> Dict[str, Any]: +async def get_revenue_summary( + property_id: str, + tenant_id: str, + month: Optional[int] = None, + year: Optional[int] = None, +) -> Dict[str, Any]: """ Fetches revenue summary, utilizing caching to improve performance. """ - cache_key = f"revenue:{property_id}" + period = f"{year}-{month:02d}" if month is not None and year is not None else "all" + cache_key = f"revenue:{tenant_id}:{property_id}:{period}" # Try to get from cache cached = await redis_client.get(cache_key) @@ -21,7 +27,7 @@ async def get_revenue_summary(property_id: str, tenant_id: str) -> Dict[str, Any from app.services.reservations import calculate_total_revenue # Calculate revenue - result = await calculate_total_revenue(property_id, tenant_id) + result = await calculate_total_revenue(property_id, tenant_id, month, year) # Cache the result for 5 minutes await redis_client.setex(cache_key, 300, json.dumps(result)) diff --git a/backend/app/services/reservations.py b/backend/app/services/reservations.py index 384bd00abe..8e5e9f4040 100644 --- a/backend/app/services/reservations.py +++ b/backend/app/services/reservations.py @@ -1,109 +1,110 @@ from datetime import datetime -from decimal import Decimal -from typing import Dict, Any, List +from decimal import Decimal, ROUND_HALF_UP +from typing import Dict, Any, Optional +from app.core.database_pool import db_pool -async def calculate_monthly_revenue(property_id: str, month: int, year: int, db_session=None) -> Decimal: - """ - Calculates revenue for a specific month. - """ - start_date = datetime(year, month, 1) - if month < 12: - end_date = datetime(year, month + 1, 1) - else: - end_date = datetime(year + 1, 1, 1) - - print(f"DEBUG: Querying revenue for {property_id} from {start_date} to {end_date}") +CENT = Decimal("0.01") - # SQL Simulation (This would be executed against the actual DB) - query = """ - SELECT SUM(total_amount) as total - FROM reservations - WHERE property_id = $1 - AND tenant_id = $2 - AND check_in_date >= $3 - AND check_in_date < $4 - """ - - # In production this query executes against a database session. - # result = await db.fetch_val(query, property_id, tenant_id, start_date, end_date) - # return result or Decimal('0') - - return Decimal('0') # Placeholder for now until DB connection is finalized -async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, Any]: +async def _fetch_revenue_row( + property_id: str, + tenant_id: str, + month: Optional[int] = None, + year: Optional[int] = None, + db_session=None, +): + if not tenant_id: + raise ValueError("A tenant_id is required to calculate revenue") + if (month is None) != (year is None): + raise ValueError("Both month and year are required for monthly revenue") + + date_filter = "" + params: Dict[str, Any] = { + "property_id": property_id, + "tenant_id": tenant_id, + } + if month is not None and year is not None: + start_date = datetime(year, month, 1) + end_date = ( + datetime(year + 1, 1, 1) + if month == 12 + else datetime(year, month + 1, 1) + ) + date_filter = """ + AND timezone(p.timezone, r.check_in_date) >= :start_date + AND timezone(p.timezone, r.check_in_date) < :end_date + """ + params.update(start_date=start_date, end_date=end_date) + + from sqlalchemy import text + + query = text(""" + SELECT + SUM(r.total_amount) AS total_revenue, + COUNT(r.id) AS reservation_count, + COUNT(DISTINCT r.currency) AS currency_count, + MIN(r.currency) AS currency + FROM reservations AS r + JOIN properties AS p + ON p.id = r.property_id + AND p.tenant_id = r.tenant_id + WHERE r.property_id = :property_id + AND r.tenant_id = :tenant_id + """ + date_filter) + await db_pool.initialize() + if db_session is not None: + result = await db_session.execute(query, params) + return result.fetchone() + + async with db_pool.get_session() as session: + result = await session.execute(query, params) + return result.fetchone() + + +def _rounded_total(value: Optional[Decimal]) -> Decimal: + return Decimal(str(value or 0)).quantize(CENT, rounding=ROUND_HALF_UP) + + +async def calculate_monthly_revenue( + property_id: str, + month: int, + year: int, + db_session=None, + tenant_id: Optional[str] = None, +) -> Decimal: + """Calculates revenue for a property-local calendar month.""" + row = await _fetch_revenue_row( + property_id, + tenant_id, + month=month, + year=year, + db_session=db_session, + ) + return _rounded_total(row.total_revenue if row else None) + + +async def calculate_total_revenue( + property_id: str, + tenant_id: str, + month: Optional[int] = None, + year: Optional[int] = None, +) -> Dict[str, Any]: """ - Aggregates revenue from database. + Aggregates revenue for a property and tenant, optionally for a local month. """ - try: - # Import database pool - from app.core.database_pool import DatabasePool - - # Initialize pool if needed - db_pool = DatabasePool() - await db_pool.initialize() - - if db_pool.session_factory: - async with db_pool.get_session() as session: - # Use SQLAlchemy text for raw SQL - from sqlalchemy import text - - query = text(""" - SELECT - property_id, - SUM(total_amount) as total_revenue, - COUNT(*) as reservation_count - FROM reservations - WHERE property_id = :property_id AND tenant_id = :tenant_id - GROUP BY property_id - """) - - result = await session.execute(query, { - "property_id": property_id, - "tenant_id": tenant_id - }) - row = result.fetchone() - - if row: - total_revenue = Decimal(str(row.total_revenue)) - return { - "property_id": property_id, - "tenant_id": tenant_id, - "total": str(total_revenue), - "currency": "USD", - "count": row.reservation_count - } - else: - # No reservations found for this property - return { - "property_id": property_id, - "tenant_id": tenant_id, - "total": "0.00", - "currency": "USD", - "count": 0 - } - else: - raise Exception("Database pool not available") - - except Exception as e: - print(f"Database error for {property_id} (tenant: {tenant_id}): {e}") - - # Create property-specific mock data for testing when DB is unavailable - # This ensures each property shows different figures - mock_data = { - 'prop-001': {'total': '1000.00', 'count': 3}, - 'prop-002': {'total': '4975.50', 'count': 4}, - 'prop-003': {'total': '6100.50', 'count': 2}, - 'prop-004': {'total': '1776.50', 'count': 4}, - 'prop-005': {'total': '3256.00', 'count': 3} - } - - mock_property_data = mock_data.get(property_id, {'total': '0.00', 'count': 0}) - - return { - "property_id": property_id, - "tenant_id": tenant_id, - "total": mock_property_data['total'], - "currency": "USD", - "count": mock_property_data['count'] - } + row = await _fetch_revenue_row(property_id, tenant_id, month=month, year=year) + if row and row.currency_count > 1: + raise ValueError( + f"Property {property_id} has reservations in multiple currencies; " + "revenue cannot be combined without an exchange rate" + ) + + total_revenue = _rounded_total(row.total_revenue if row else None) + return { + "property_id": property_id, + "tenant_id": tenant_id, + "total": f"{total_revenue:.2f}", + "currency": row.currency if row and row.currency else "USD", + "count": row.reservation_count if row else 0, + } diff --git a/backend/requirements.txt b/backend/requirements.txt index 6b777d2aaa..06cb8bd4a6 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -18,7 +18,7 @@ pytz redis>=5.0.0 requests sendgrid -sqlalchemy>=2.0.0 +sqlalchemy[asyncio]>=2.0.0 supabase tenacity>=8.0.0 uvicorn[standard] diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index a21bba404e..6c71b4212d 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -11,6 +11,10 @@ const PROPERTIES = [ const Dashboard: React.FC = () => { const [selectedProperty, setSelectedProperty] = useState('prop-001'); + const [selectedMonth, setSelectedMonth] = useState(() => { + const now = new Date(); + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; + }); return (
@@ -42,11 +46,24 @@ const Dashboard: React.FC = () => { ))}
+ +
+ + { + if (e.target.value) setSelectedMonth(e.target.value); + }} + className="block w-full sm:w-auto min-w-[200px] px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 text-sm" + /> +
- +
diff --git a/frontend/src/components/RevenueSummary.tsx b/frontend/src/components/RevenueSummary.tsx index dbb6d0629d..bf78f8e73e 100644 --- a/frontend/src/components/RevenueSummary.tsx +++ b/frontend/src/components/RevenueSummary.tsx @@ -10,11 +10,12 @@ interface RevenueData { interface RevenueSummaryProps { propertyId?: string; + reportMonth: string; debugTenant?: string; showRaw?: boolean; } -export const RevenueSummary: React.FC = ({ propertyId = 'prop-001', debugTenant, showRaw }) => { +export const RevenueSummary: React.FC = ({ propertyId = 'prop-001', reportMonth, debugTenant, showRaw }) => { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); @@ -25,13 +26,17 @@ export const RevenueSummary: React.FC = ({ propertyId = 'pr const fetchRevenue = async () => { setLoading(true); try { + const [year, month] = reportMonth.split('-').map(Number); // Use SecureAPI to handle authentication automatically // We pass the simulatedTenant option which SecureAPI will attach as a header const response = await SecureAPI.getDashboardSummary(propertyId, { simulatedTenant: activeTenant, - timestamp: Date.now() + timestamp: Date.now(), + month, + year }); setData(response); + setError(''); } catch (err) { setError('Failed to load revenue data'); console.error(err); @@ -41,7 +46,7 @@ export const RevenueSummary: React.FC = ({ propertyId = 'pr }; fetchRevenue(); - }, [propertyId, activeTenant]); + }, [propertyId, activeTenant, reportMonth]); if (loading) { return ( diff --git a/frontend/src/lib/secureApi.ts b/frontend/src/lib/secureApi.ts index f85f04c90a..1a8610bd17 100644 --- a/frontend/src/lib/secureApi.ts +++ b/frontend/src/lib/secureApi.ts @@ -1452,8 +1452,17 @@ export class SecureAPIClient { /** * Get dashboard summary with optional simulation header */ - async getDashboardSummary(propertyId: string, options?: { simulatedTenant?: string, timestamp?: number }) { + async getDashboardSummary(propertyId: string, options?: { + simulatedTenant?: string; + timestamp?: number; + month?: number; + year?: number; + }) { const queryParams = new URLSearchParams({ property_id: propertyId }); + if (options?.month !== undefined && options?.year !== undefined) { + queryParams.set('month', options.month.toString()); + queryParams.set('year', options.year.toString()); + } if (options?.timestamp) { queryParams.append('_t', options.timestamp.toString()); }