diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index 1ec352d7e..febe51b1d 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -1,6 +1,9 @@ +import re +from decimal import Decimal, ROUND_HALF_UP from fastapi import APIRouter, Depends, HTTPException -from typing import Dict, Any +from typing import Dict, Any, Optional from app.services.cache import get_revenue_summary +from app.services.reservations import MixedCurrencyError from app.core.auth import authenticate_request as get_current_user router = APIRouter() @@ -8,18 +11,29 @@ @router.get("/dashboard/summary") async def get_dashboard_summary( property_id: str, + month: Optional[str] = None, 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']) - + + tenant_id = getattr(current_user, "tenant_id", None) + if not tenant_id: + raise HTTPException(status_code=403, detail="No tenant associated with user") + + if month is not None and not re.fullmatch(r"\d{4}-(0[1-9]|1[0-2])", month): + raise HTTPException(status_code=400, detail="month must be in YYYY-MM format") + + try: + revenue_data = await get_revenue_summary(property_id, tenant_id, month) + except MixedCurrencyError as e: + raise HTTPException(status_code=409, detail=str(e)) + + # Quantize once, at presentation. Explicit ROUND_HALF_UP: finance expects + # half-up; Python's Decimal default is ROUND_HALF_EVEN (banker's rounding). + total_revenue = Decimal(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": str(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..250af75bb 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,10 @@ 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.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..fa16c17f1 100644 --- a/backend/app/services/cache.py +++ b/backend/app/services/cache.py @@ -1,27 +1,33 @@ 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[str] = None) -> Dict[str, Any]: """ Fetches revenue summary, utilizing caching to improve performance. """ - cache_key = f"revenue:{property_id}" - + # Tenant segment isolates clients; period segment keeps months and all-time apart + period = month or "all" + cache_key = f"revenue:{tenant_id}:{property_id}:{period}" + # Try to get from cache cached = await redis_client.get(cache_key) if cached: - return json.loads(cached) - + result = json.loads(cached) + # Tripwire: a mis-keyed entry must fail closed as a miss, never cross scopes + if result.get("tenant_id") == tenant_id and result.get("period") == period: + return result + print(f"Cache scope mismatch for {cache_key}: expected {tenant_id}/{period}, got {result.get('tenant_id')}/{result.get('period')} - treating as miss") + # 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) + result = await calculate_total_revenue(property_id, tenant_id, month) # 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 384bd00ab..af254b214 100644 --- a/backend/app/services/reservations.py +++ b/backend/app/services/reservations.py @@ -1,40 +1,38 @@ -from datetime import datetime +from datetime import datetime, timezone from decimal import Decimal -from typing import Dict, Any, List +from typing import Dict, Any, List, Optional, Tuple +from zoneinfo import ZoneInfo -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}") +class MixedCurrencyError(Exception): + """Raised when a total would silently sum amounts in different currencies.""" - # 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 + +def month_bounds_utc(month: str, tz_name: str) -> Tuple[datetime, datetime]: + """Half-open UTC interval [start, end) covering the property-local month. + + Each bound is derived from its own local wall time, so a month containing + a DST transition gets different UTC offsets at each edge. + Unrecognised/empty timezones fall back to UTC bucketing rather than failing. """ - - # 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 + year, mon = int(month[:4]), int(month[5:7]) + try: + tz = ZoneInfo(tz_name) if tz_name else timezone.utc + except Exception: + print(f"Unrecognised property timezone '{tz_name}' - falling back to UTC bucketing") + tz = timezone.utc + start_local = datetime(year, mon, 1, tzinfo=tz) + if mon < 12: + end_local = datetime(year, mon + 1, 1, tzinfo=tz) + else: + end_local = datetime(year + 1, 1, 1, tzinfo=tz) + return start_local.astimezone(timezone.utc), end_local.astimezone(timezone.utc) -async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, Any]: +async def calculate_total_revenue(property_id: str, tenant_id: str, month: Optional[str] = None) -> Dict[str, Any]: """ - Aggregates revenue from database. + Aggregates revenue from database, optionally scoped to a property-local month (YYYY-MM). """ + period = month or "all" try: # Import database pool from app.core.database_pool import DatabasePool @@ -44,40 +42,64 @@ 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 - - query = text(""" - SELECT + + params = {"property_id": property_id, "tenant_id": tenant_id} + date_filter = "" + if month: + # Bucket by the property's local calendar month, comparing in UTC + # so the check_in_date column stays index-friendly + tz_result = await session.execute(text(""" + SELECT timezone FROM properties + WHERE id = :property_id AND tenant_id = :tenant_id + """), params) + tz_row = tz_result.fetchone() + tz_name = tz_row.timezone if tz_row else None + start_utc, end_utc = month_bounds_utc(month, tz_name) + date_filter = " AND check_in_date >= :start_utc AND check_in_date < :end_utc" + params.update({"start_utc": start_utc, "end_utc": end_utc}) + + query = text(f""" + 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 + COUNT(*) as reservation_count, + COUNT(DISTINCT COALESCE(NULLIF(currency, ''), 'USD')) as currency_count, + MIN(COALESCE(NULLIF(currency, ''), 'USD')) as currency + FROM reservations + WHERE property_id = :property_id AND tenant_id = :tenant_id{date_filter} GROUP BY property_id """) - - result = await session.execute(query, { - "property_id": property_id, - "tenant_id": tenant_id - }) + + result = await session.execute(query, params) row = result.fetchone() if row: + if row.currency_count > 1: + # 100 EUR + 100 USD is not 200 of anything without a + # conversion rate and the date that rate applied + raise MixedCurrencyError( + f"Property {property_id} has reservations in {row.currency_count} " + f"currencies for period {period}; refusing to sum without conversion" + ) total_revenue = Decimal(str(row.total_revenue)) return { "property_id": property_id, "tenant_id": tenant_id, + "period": period, "total": str(total_revenue), - "currency": "USD", + "currency": row.currency, "count": row.reservation_count } else: - # No reservations found for this property + # No reservations found for this property; currency falls back + # to the schema default since there is nothing to read it from return { "property_id": property_id, "tenant_id": tenant_id, + "period": period, "total": "0.00", "currency": "USD", "count": 0 @@ -85,25 +107,9 @@ async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, else: raise Exception("Database pool not available") + except MixedCurrencyError: + raise 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'] - } + # Never substitute fabricated figures for a failed query - propagate instead + raise diff --git a/frontend/src/components/RevenueSummary.tsx b/frontend/src/components/RevenueSummary.tsx index dbb6d0629..f3fea39e0 100644 --- a/frontend/src/components/RevenueSummary.tsx +++ b/frontend/src/components/RevenueSummary.tsx @@ -3,7 +3,8 @@ import { SecureAPI } from '../lib/secureApi'; interface RevenueData { property_id: string; - total_revenue: number; + // Decimal string quantized to 2dp server-side; never do float math on it + total_revenue: string; currency: string; reservations_count: number; } @@ -14,6 +15,12 @@ interface RevenueSummaryProps { showRaw?: boolean; } +// String-safe thousands grouping; money must never round-trip through float +const fmt = (v: string) => { + const [i, d] = v.split('.'); + return i.replace(/\B(?=(\d{3})+(?!\d))/g, ',') + (d ? '.' + d : ''); +}; + export const RevenueSummary: React.FC = ({ propertyId = 'prop-001', debugTenant, showRaw }) => { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); @@ -61,7 +68,7 @@ export const RevenueSummary: React.FC = ({ propertyId = 'pr if (error) return
{error}
; if (!data) return null; - const displayTotal = Math.round(data.total_revenue * 100) / 100; + const displayTotal = data.total_revenue; return (
@@ -78,7 +85,7 @@ export const RevenueSummary: React.FC = ({ propertyId = 'pr

Total Revenue

- {data.currency} {displayTotal.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + {data.currency} {fmt(displayTotal)} {/* Fake trend indicator for premium feel */} @@ -102,17 +109,9 @@ export const RevenueSummary: React.FC = ({ propertyId = 'pr
- {/* Precision Warning Area */} -
- {Math.abs(data.total_revenue - displayTotal) > 0.000001 && showRaw && ( -
- - - - Precision Mismatch Detected -
- )} -
+ {/* Precision warning removed: the API now sends an exact 2dp decimal + string, so display can no longer diverge from the payload */} +
);