diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index 1ec352d7e..4568241b4 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -1,4 +1,5 @@ -from fastapi import APIRouter, Depends, HTTPException +from decimal import Decimal, ROUND_HALF_UP +from fastapi import APIRouter, Depends from typing import Dict, Any from app.services.cache import get_revenue_summary from app.core.auth import authenticate_request as get_current_user @@ -12,14 +13,18 @@ async def get_dashboard_summary( ) -> 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']) - + + # Round to cents with Decimal before serialising; going straight through a + # binary float is what caused the reported few-cents drift. + total_revenue = Decimal(str(revenue_data["total"])).quantize( + Decimal("0.01"), rounding=ROUND_HALF_UP + ) + return { "property_id": revenue_data['property_id'], - "total_revenue": total_revenue_float, + "total_revenue": float(total_revenue), "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 d638dfcfe..e7c2f6198 100644 --- a/backend/app/core/database_pool.py +++ b/backend/app/core/database_pool.py @@ -1,60 +1,64 @@ -import asyncio -from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker -from sqlalchemy.pool import QueuePool import logging + +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker + from ..config import settings logger = logging.getLogger(__name__) + class DatabasePool: def __init__(self): self.engine = None self.session_factory = None - + + @staticmethod + def _build_async_url() -> str: + """Normalise the configured DB URL to use the asyncpg driver.""" + url = settings.database_url + for prefix in ("postgresql://", "postgres://"): + if url.startswith(prefix): + return url.replace(prefix, "postgresql+asyncpg://", 1) + return url + async def initialize(self): - """Initialize database connection pool""" + """Build the connection pool once; safe to call repeatedly.""" + if self.session_factory is not None: + return + 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._build_async_url(), + pool_size=20, + max_overflow=30, + pool_pre_ping=True, + pool_recycle=3600, ) - self.session_factory = async_sessionmaker( bind=self.engine, class_=AsyncSession, - expire_on_commit=False + expire_on_commit=False, ) - - logger.info("✅ Database connection pool initialized") - + logger.info("Database connection pool initialized") except Exception as e: - logger.error(f"❌ Database pool initialization failed: {e}") + logger.error("Database pool initialization failed: %s", e) self.engine = None self.session_factory = None - + async def close(self): - """Close database connections""" if self.engine: await self.engine.dispose() - - async def get_session(self) -> AsyncSession: - """Get database session from pool""" + + def get_session(self) -> AsyncSession: + """Return a session; use as ``async with db_pool.get_session() as session:``.""" if not self.session_factory: - raise Exception("Database pool not initialized") + raise RuntimeError("Database pool not initialized") return self.session_factory() -# Global database pool instance + db_pool = DatabasePool() + async def get_db_session() -> AsyncSession: - """Dependency to get database session""" async with db_pool.get_session() as session: yield session diff --git a/backend/app/services/cache.py b/backend/app/services/cache.py index b81474957..8000c70d9 100644 --- a/backend/app/services/cache.py +++ b/backend/app/services/cache.py @@ -1,29 +1,26 @@ import json -import redis.asyncio as redis -from typing import Dict, Any import os +from typing import Dict, Any + +import redis.asyncio as redis -# Initialize Redis client (typically configured centrally). redis_client = redis.Redis.from_url(os.getenv("REDIS_URL", "redis://localhost:6379/0")) +CACHE_TTL_SECONDS = 300 + + 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}" - - # Try to get from cache + """Return the revenue summary for a property, cached per tenant.""" + # Property IDs are only unique within a tenant (prop-001 exists for both + # tenant-a and tenant-b), so the key has to include the tenant. + cache_key = f"revenue:{tenant_id}:{property_id}" + cached = await redis_client.get(cache_key) if cached: return json.loads(cached) - - # Revenue calculation is delegated to the reservation service. + from app.services.reservations import calculate_total_revenue - - # Calculate revenue + 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)) - + await redis_client.setex(cache_key, CACHE_TTL_SECONDS, json.dumps(result)) return result diff --git a/backend/app/services/reservations.py b/backend/app/services/reservations.py index 384bd00ab..8858a92cc 100644 --- a/backend/app/services/reservations.py +++ b/backend/app/services/reservations.py @@ -1,109 +1,132 @@ +import logging 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 +from zoneinfo import ZoneInfo + +from sqlalchemy import text + +from app.core.database_pool import db_pool + +logger = logging.getLogger(__name__) + +_CENTS = Decimal("0.01") + + +def _to_currency(amount: Decimal) -> Decimal: + """Round a monetary value to whole cents (half-up).""" + return amount.quantize(_CENTS, rounding=ROUND_HALF_UP) -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}") - - # 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 +async def _get_property_timezone(session, property_id: str, tenant_id: str) -> ZoneInfo: + result = 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}, + ) + row = result.fetchone() + tz_name = row.timezone if row and row.timezone else "UTC" + try: + return ZoneInfo(tz_name) + except Exception: + logger.warning("Unknown timezone %r for property %s, using UTC", tz_name, property_id) + return ZoneInfo("UTC") + + +async def calculate_monthly_revenue(property_id: str, tenant_id: str, month: int, year: int) -> Decimal: + """Revenue for a calendar month, bucketed in the property's local timezone. + + check_in_date is stored in UTC. A 23:30 UTC check-in on Feb 29 is March 1st + in Europe/Paris, so the month boundaries are built in the property's timezone + rather than naive UTC; otherwise the booking lands in the wrong month. """ - - # 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 + await db_pool.initialize() + if not db_pool.session_factory: + raise RuntimeError("Database pool not available") + + async with db_pool.get_session() as session: + tz = await _get_property_timezone(session, property_id, tenant_id) + + start_date = datetime(year, month, 1, tzinfo=tz) + if month < 12: + end_date = datetime(year, month + 1, 1, tzinfo=tz) + else: + end_date = datetime(year + 1, 1, 1, tzinfo=tz) + + result = await session.execute( + text( + """ + SELECT COALESCE(SUM(total_amount), 0) AS total + 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_date, + "end_date": end_date, + }, + ) + row = result.fetchone() + total = row.total if row else 0 + return _to_currency(Decimal(str(total))) + async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, Any]: - """ - Aggregates revenue from database. - """ + """Total revenue and reservation count for a property within a tenant.""" 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: + if not db_pool.session_factory: raise Exception("Database pool not available") - + + async with db_pool.get_session() as session: + result = await session.execute( + text( + """ + SELECT SUM(total_amount) 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_amount = row.total_revenue if row else None + count = row.reservation_count if row else 0 + total = _to_currency(Decimal(str(total_amount))) if total_amount is not None else Decimal("0.00") + + return { + "property_id": property_id, + "tenant_id": tenant_id, + "total": str(total), + "currency": "USD", + "count": count or 0, + } + 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 + logger.warning("Database error for %s (tenant: %s): %s", property_id, tenant_id, e) + + # Property-specific mock data used when the database is unavailable. 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} + "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}) - + mock = mock_data.get(property_id, {"total": "0.00", "count": 0}) + return { "property_id": property_id, - "tenant_id": tenant_id, - "total": mock_property_data['total'], + "tenant_id": tenant_id, + "total": mock["total"], "currency": "USD", - "count": mock_property_data['count'] + "count": mock["count"], }