From 14c9779267b7c1439abc12166d1201e955943767 Mon Sep 17 00:00:00 2001 From: tonycletus Date: Mon, 2 Nov 2026 20:46:06 +0100 Subject: [PATCH] Fix tenant revenue dashboard calculations --- backend/app/api/v1/dashboard.py | 26 +++- backend/app/core/database_pool.py | 11 +- backend/app/services/cache.py | 20 ++- backend/app/services/reservations.py | 206 ++++++++++++++------------- backend/requirements.txt | 2 +- 5 files changed, 153 insertions(+), 112 deletions(-) diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index 1ec352d7e3..6af6d5714c 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -1,6 +1,7 @@ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query, status from typing import Dict, Any from app.services.cache import get_revenue_summary +from app.services.reservations import calculate_monthly_summary from app.core.auth import authenticate_request as get_current_user router = APIRouter() @@ -8,12 +9,29 @@ @router.get("/dashboard/summary") async def get_dashboard_summary( property_id: str, + month: int | None = Query(default=None, ge=1, le=12), + year: int | None = Query(default=None, ge=2000, le=2100), 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) + tenant_id = getattr(current_user, "tenant_id", None) + if not tenant_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Tenant context is required") + + if (month is None) != (year is None): + raise HTTPException(status_code=400, detail="month and year must be provided together") + + try: + if month is not None and year is not None: + revenue_data = await calculate_monthly_summary(property_id, month, year, tenant_id) + revenue_data.update({"property_id": property_id, "tenant_id": tenant_id, "currency": "USD"}) + else: + revenue_data = await get_revenue_summary(property_id, tenant_id) + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Revenue data is temporarily unavailable", + ) from exc total_revenue_float = float(revenue_data['total']) diff --git a/backend/app/core/database_pool.py b/backend/app/core/database_pool.py index d638dfcfe3..26a33f6dd7 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 import logging from ..config import settings @@ -15,11 +14,14 @@ 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}" + database_url = settings.database_url + if database_url.startswith("postgresql://"): + database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1) + elif database_url.startswith("postgres://"): + database_url = database_url.replace("postgres://", "postgresql+asyncpg://", 1) 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 @@ -56,5 +58,6 @@ async def get_session(self) -> AsyncSession: async def get_db_session() -> AsyncSession: """Dependency to get database session""" - async with db_pool.get_session() as session: + session_context = await db_pool.get_session() + async with session_context as session: yield session diff --git a/backend/app/services/cache.py b/backend/app/services/cache.py index b81474957f..4721c1e2e1 100644 --- a/backend/app/services/cache.py +++ b/backend/app/services/cache.py @@ -10,12 +10,19 @@ async def get_revenue_summary(property_id: str, tenant_id: str) -> Dict[str, Any """ Fetches revenue summary, utilizing caching to improve performance. """ - cache_key = f"revenue:{property_id}" + # Property IDs are only unique within a tenant (the database uses a + # composite property_id/tenant_id key). Never share cached results across + # tenants. + cache_key = f"revenue:{tenant_id}:{property_id}" # Try to get from cache - cached = await redis_client.get(cache_key) - if cached: - return json.loads(cached) + try: + cached = await redis_client.get(cache_key) + if cached: + return json.loads(cached) + except Exception: + # Revenue remains available when Redis is down; Redis is an optimization. + pass # Revenue calculation is delegated to the reservation service. from app.services.reservations import calculate_total_revenue @@ -24,6 +31,9 @@ async def get_revenue_summary(property_id: str, tenant_id: str) -> Dict[str, Any result = await calculate_total_revenue(property_id, tenant_id) # Cache the result for 5 minutes - await redis_client.setex(cache_key, 300, json.dumps(result)) + try: + await redis_client.setex(cache_key, 300, json.dumps(result)) + except Exception: + pass return result diff --git a/backend/app/services/reservations.py b/backend/app/services/reservations.py index 384bd00abe..a3e39e491e 100644 --- a/backend/app/services/reservations.py +++ b/backend/app/services/reservations.py @@ -1,109 +1,119 @@ from datetime import datetime -from decimal import Decimal -from typing import Dict, Any, List +from decimal import Decimal, ROUND_HALF_UP +from typing import Any, Dict +import pytz -async def calculate_monthly_revenue(property_id: str, month: int, year: int, db_session=None) -> Decimal: - """ - Calculates revenue for a specific month. - """ +from sqlalchemy import text - start_date = datetime(year, month, 1) - if month < 12: - end_date = datetime(year, month + 1, 1) + +CENT = Decimal("0.01") + + +def _money(value: Any) -> 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, + tenant_id: str, + db_session=None, +) -> Decimal: + """Sum reservations whose check-in falls in the property's local month.""" + summary = await calculate_monthly_summary( + property_id, month, year, tenant_id, db_session=db_session + ) + return Decimal(summary["total"]) + + +async def calculate_monthly_summary( + property_id: str, + month: int, + year: int, + tenant_id: str, + db_session=None, +) -> Dict[str, Any]: + """Return a tenant-scoped monthly revenue and reservation count.""" + if not 1 <= month <= 12: + raise ValueError("month must be between 1 and 12") + + from app.core.database_pool import db_pool + + if db_session is None: + if db_pool.session_factory is None: + await db_pool.initialize() + session_context = await db_pool.get_session() else: - end_date = datetime(year + 1, 1, 1) - - print(f"DEBUG: Querying revenue for {property_id} from {start_date} to {end_date}") - - # 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 + session_context = None -async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, Any]: - """ - Aggregates revenue from database. - """ - 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 - } + async def query(session): + property_row = await session.execute( + text("SELECT timezone FROM properties WHERE id = :property_id AND tenant_id = :tenant_id"), + {"property_id": property_id, "tenant_id": tenant_id}, + ) + property_data = property_row.fetchone() + if not property_data: + return {"total": "0.00", "count": 0} + + timezone = pytz.timezone(property_data.timezone or "UTC") + start_local = timezone.localize(datetime(year, month, 1), is_dst=None) + if month == 12: + end_local = timezone.localize(datetime(year + 1, 1, 1), is_dst=None) 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} + end_local = timezone.localize(datetime(year, month + 1, 1), is_dst=None) + + result = await session.execute( + text(""" + SELECT COALESCE(SUM(total_amount), 0) AS total, COUNT(*) AS reservation_count + FROM reservations + WHERE property_id = :property_id AND tenant_id = :tenant_id + AND check_in_date >= :start_date AND check_in_date < :end_date + """), + { + "property_id": property_id, + "tenant_id": tenant_id, + "start_date": start_local, + "end_date": end_local, + }, + ) + row = result.fetchone() + return { + "total": format(_money(row.total), ".2f"), + "count": row.reservation_count, } - - mock_property_data = mock_data.get(property_id, {'total': '0.00', 'count': 0}) - + + if db_session is not None: + return await query(db_session) + async with session_context as session: + return await query(session) + + +async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, Any]: + """Aggregate exact reservation amounts for one tenant's property.""" + from app.core.database_pool import db_pool + + if db_pool.session_factory is None: + await db_pool.initialize() + if db_pool.session_factory is None: + raise RuntimeError("Database is unavailable") + + session_context = await db_pool.get_session() + async with session_context as session: + result = await session.execute( + text(""" + SELECT COALESCE(SUM(total_amount), 0) AS total_revenue, COUNT(*) AS reservation_count + FROM reservations + WHERE property_id = :property_id AND tenant_id = :tenant_id + """), + {"property_id": property_id, "tenant_id": tenant_id}, + ) + row = result.fetchone() + total = _money(row.total_revenue if row else 0) return { "property_id": property_id, - "tenant_id": tenant_id, - "total": mock_property_data['total'], + "tenant_id": tenant_id, + "total": format(total, ".2f"), "currency": "USD", - "count": mock_property_data['count'] + "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]