diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index 1ec352d7e..5bbb58006 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -1,5 +1,6 @@ from fastapi import APIRouter, Depends, HTTPException from typing import Dict, Any +from decimal import Decimal, ROUND_HALF_UP from app.services.cache import get_revenue_summary from app.core.auth import authenticate_request as get_current_user @@ -15,11 +16,14 @@ async def get_dashboard_summary( revenue_data = await get_revenue_summary(property_id, tenant_id) - total_revenue_float = float(revenue_data['total']) - + # Round money with Decimal (half-up) — never let binary float decide the cents. + total_revenue = float( + 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": 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..fa824a861 100644 --- a/backend/app/core/database_pool.py +++ b/backend/app/core/database_pool.py @@ -14,12 +14,12 @@ 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}" - + # Create async engine with connection pooling. + # Use the configured database_url and run it through the asyncpg driver. + database_url = settings.database_url.replace("postgresql://", "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 diff --git a/backend/app/services/cache.py b/backend/app/services/cache.py index b81474957..4805b4763 100644 --- a/backend/app/services/cache.py +++ b/backend/app/services/cache.py @@ -10,20 +10,30 @@ 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}" - + # tenant_id must be a part of the cache key + # same property_id can belong to different tenant, so a + # property-only key serves one tenant's revenue to another. + 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) - + # 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) - + + # never return data computed for a another tenant + if result.get("tenant_id") != tenant_id: + raise ValueError( + f"Tenant mismatch in revenue result: expected {tenant_id}, " + f"got {result.get('tenant_id')}" + ) + # Cache the result for 5 minutes await redis_client.setex(cache_key, 300, json.dumps(result)) - + return result diff --git a/backend/app/services/reservations.py b/backend/app/services/reservations.py index 384bd00ab..9b8e9749b 100644 --- a/backend/app/services/reservations.py +++ b/backend/app/services/reservations.py @@ -1,35 +1,44 @@ -from datetime import datetime from decimal import Decimal -from typing import Dict, Any, List +from typing import Dict, Any -async def calculate_monthly_revenue(property_id: str, month: int, year: int, db_session=None) -> Decimal: + +async def calculate_monthly_revenue(property_id: str, tenant_id: str, month: int, year: int) -> Decimal: """ - Calculates revenue for a specific month. + Calculates revenue for a specific month, bucketed in the property's LOCAL timezone. + + The month boundaries are derived from the property's timezone directly in SQL: + `make_timestamp(year, month, 1, 0, 0, 0) AT TIME ZONE p.timezone` turns local + midnight on the 1st into a concrete UTC instant, which is then compared against + the (indexed) check_in_date column. So a late-night check-in near a boundary is + counted in the correct local month (e.g. 23:30 UTC on Feb 29 is 00:30 on Mar 1 + in Europe/Paris, and belongs to March). `+ interval '1 month'` rolls Dec into Jan. """ + from app.core.database_pool import DatabasePool + from sqlalchemy import text - 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}") + db_pool = DatabasePool() + await db_pool.initialize() - # 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 + if not db_pool.session_factory: + raise Exception("Database pool not available") + + async with await db_pool.get_session() as session: + row = (await session.execute( + text(""" + SELECT COALESCE(SUM(r.total_amount), 0) AS total + FROM reservations r + JOIN properties 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 + AND r.check_in_date >= (make_timestamp(:year, :month, 1, 0, 0, 0) AT TIME ZONE p.timezone) + AND r.check_in_date < ((make_timestamp(:year, :month, 1, 0, 0, 0) + interval '1 month') AT TIME ZONE p.timezone) + """), + {"property_id": property_id, "tenant_id": tenant_id, "year": year, "month": month}, + )).fetchone() + + # Keep money as Decimal — never cast through binary float. + return Decimal(str(row.total)) async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, Any]: """ @@ -44,7 +53,7 @@ async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, await db_pool.initialize() if db_pool.session_factory: - async with db_pool.get_session() as session: + async with await db_pool.get_session() as session: # Use SQLAlchemy text for raw SQL from sqlalchemy import text