diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000000..ed8ebf583f --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1 @@ +__pycache__ \ No newline at end of file diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index 1ec352d7e3..30770e79c9 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 fastapi import APIRouter, Depends, HTTPException, Query 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 @@ -8,18 +9,28 @@ @router.get("/dashboard/summary") async def get_dashboard_summary( property_id: str, - current_user: dict = Depends(get_current_user) + current_user: dict = Depends(get_current_user), + month: int | None = Query(default=None, ge=1, le=12), + year: int | None = Query(default=None, ge=1, le=9998), ) -> 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) + if (month is None) != (year is None): + raise HTTPException(status_code=422, detail="Month and year must be supplied together") + try: + revenue_data = await get_revenue_summary(property_id, tenant_id, month, year) + except Exception: + raise HTTPException(status_code=503, detail="Revenue temporarily unavailable") - total_revenue_float = float(revenue_data['total']) + # Sum at full precision, then round once for the two-decimal USD report. + total_revenue = Decimal(revenue_data['total']).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + if total_revenue == 0: + total_revenue = abs(total_revenue) # Avoid displaying negative zero. return { "property_id": revenue_data['property_id'], - "total_revenue": total_revenue_float, + "total_revenue": format(total_revenue, ".2f"), "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..cc247988ce 100644 --- a/backend/app/core/database_pool.py +++ b/backend/app/core/database_pool.py @@ -1,6 +1,6 @@ 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,13 +13,14 @@ def __init__(self): async def initialize(self): """Initialize database connection pool""" + 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}" + database_url = make_url(settings.database_url).set(drivername="postgresql+asyncpg") 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 @@ -45,7 +46,7 @@ async def close(self): 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..ca4958e685 100644 --- a/backend/app/services/cache.py +++ b/backend/app/services/cache.py @@ -6,23 +6,31 @@ # 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: int = None, year: int = None) -> Dict[str, Any]: """ Fetches revenue summary, utilizing caching to improve performance. """ - cache_key = f"revenue:{property_id}" - + if not tenant_id or not tenant_id.strip(): + raise ValueError("Tenant is required for revenue summaries") + # Include tenant and reporting period; skip all legacy shared cache entries. + cache_key = "revenue:v2:" + json.dumps([tenant_id, property_id, year, month]) + # Try to get from cache cached = await redis_client.get(cache_key) if cached: - return json.loads(cached) + data = json.loads(cached) + if data.get("tenant_id") == tenant_id and data.get("property_id") == property_id: + return data # 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, year) + if result.get("tenant_id") != tenant_id or result.get("property_id") != property_id: + raise ValueError("Revenue ownership mismatch") + # 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..f3482c2ce9 100644 --- a/backend/app/services/reservations.py +++ b/backend/app/services/reservations.py @@ -2,45 +2,24 @@ from decimal import Decimal from typing import Dict, Any, List -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}") +async def calculate_monthly_revenue(property_id: str, month: int, year: int, tenant_id: str) -> Decimal: + """Calculate revenue for the month in the property's local time zone.""" + result = await calculate_total_revenue(property_id, tenant_id, month, year) + return Decimal(result["total"]) - # 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 calculate_total_revenue(property_id: str, tenant_id: str, month: int = None, year: int = None) -> Dict[str, Any]: """ Aggregates revenue from database. """ + if (month is None) != (year is None): + raise ValueError("Month and year must be supplied together") + start_date = datetime(year, month, 1) if month is not None else None + end_date = (datetime(year + 1, 1, 1) if month == 12 else datetime(year, month + 1, 1)) if month is not None else None try: # Import database pool - from app.core.database_pool import DatabasePool + from app.core.database_pool import db_pool # Initialize pool if needed - db_pool = DatabasePool() await db_pool.initialize() if db_pool.session_factory: @@ -48,19 +27,25 @@ async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, # Use SQLAlchemy text for raw SQL from sqlalchemy import text + date_filter = "" if month is None else """ + AND r.check_in_date >= (CAST(:start_date AS timestamp) AT TIME ZONE p.timezone) + AND r.check_in_date < (CAST(:end_date AS timestamp) AT TIME ZONE p.timezone) + """ query = text(""" SELECT - property_id, + r.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 - """) + 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 + """ + date_filter + " GROUP BY r.property_id") result = await session.execute(query, { "property_id": property_id, - "tenant_id": tenant_id + "tenant_id": tenant_id, + "start_date": start_date, + "end_date": end_date, }) row = result.fetchone() @@ -88,22 +73,5 @@ async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, 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 serve or cache tenant-independent mock amounts on database failure. + raise 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..d5103354bd 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -11,6 +11,11 @@ const PROPERTIES = [ const Dashboard: React.FC = () => { const [selectedProperty, setSelectedProperty] = useState('prop-001'); + const [period, setPeriod] = useState(() => { + const now = new Date(); + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; + }); + const [year, month] = period.split('-').map(Number); return (
@@ -27,6 +32,13 @@ const Dashboard: React.FC = () => {

+
+ + { if (e.target.validity.valid && e.target.value) setPeriod(e.target.value); }} + className="border border-gray-300 rounded-md px-3 py-2 text-sm" /> +
{/* Property Selector */}
@@ -46,7 +58,7 @@ const Dashboard: React.FC = () => {
- +
diff --git a/frontend/src/components/RevenueSummary.tsx b/frontend/src/components/RevenueSummary.tsx index dbb6d0629d..82d294328e 100644 --- a/frontend/src/components/RevenueSummary.tsx +++ b/frontend/src/components/RevenueSummary.tsx @@ -3,18 +3,20 @@ import { SecureAPI } from '../lib/secureApi'; interface RevenueData { property_id: string; - total_revenue: number; + total_revenue: string; currency: string; reservations_count: number; } interface RevenueSummaryProps { propertyId?: string; + month: number; + year: number; debugTenant?: string; showRaw?: boolean; } -export const RevenueSummary: React.FC = ({ propertyId = 'prop-001', debugTenant, showRaw }) => { +export const RevenueSummary: React.FC = ({ propertyId = 'prop-001', month, year, debugTenant, showRaw }) => { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); @@ -22,26 +24,31 @@ export const RevenueSummary: React.FC = ({ propertyId = 'pr const activeTenant = debugTenant || 'candidate'; useEffect(() => { + let cancelled = false; const fetchRevenue = async () => { setLoading(true); + setError(''); try { // 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); + if (!cancelled) setData(response); } catch (err) { - setError('Failed to load revenue data'); + if (!cancelled) setError('Failed to load revenue data'); console.error(err); } finally { - setLoading(false); + if (!cancelled) setLoading(false); } }; fetchRevenue(); - }, [propertyId, activeTenant]); + return () => { cancelled = true; }; + }, [propertyId, activeTenant, month, year]); if (loading) { return ( @@ -61,7 +68,9 @@ export const RevenueSummary: React.FC = ({ propertyId = 'pr if (error) return
{error}
; if (!data) return null; - const displayTotal = Math.round(data.total_revenue * 100) / 100; + // Format the server-rounded decimal string without floating-point conversion. + const [whole, cents] = data.total_revenue.split('.'); + const displayTotal = `${whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}.${cents}`; return (
@@ -75,10 +84,10 @@ export const RevenueSummary: React.FC = ({ propertyId = 'pr
-

Total Revenue

+

Monthly Revenue

- {data.currency} {displayTotal.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + {data.currency} {displayTotal} {/* Fake trend indicator for premium feel */} @@ -102,17 +111,7 @@ export const RevenueSummary: React.FC = ({ propertyId = 'pr
- {/* Precision Warning Area */} -
- {Math.abs(data.total_revenue - displayTotal) > 0.000001 && showRaw && ( -
- - - - Precision Mismatch Detected -
- )} -
+
); diff --git a/frontend/src/lib/secureApi.ts b/frontend/src/lib/secureApi.ts index f85f04c90a..85059239e2 100644 --- a/frontend/src/lib/secureApi.ts +++ b/frontend/src/lib/secureApi.ts @@ -1452,8 +1452,12 @@ 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', String(options.month)); + queryParams.set('year', String(options.year)); + } if (options?.timestamp) { queryParams.append('_t', options.timestamp.toString()); }