diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index 1ec352d7e..f8d9e4824 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -1,9 +1,13 @@ +import logging from fastapi import APIRouter, Depends, HTTPException +from decimal import Decimal from typing import Dict, Any from app.services.cache import get_revenue_summary +from app.services.reservations import to_cents, PropertyNotFoundError, list_tenant_properties from app.core.auth import authenticate_request as get_current_user router = APIRouter() +logger = logging.getLogger(__name__) @router.get("/dashboard/summary") async def get_dashboard_summary( @@ -11,15 +15,45 @@ async def get_dashboard_summary( current_user: dict = Depends(get_current_user) ) -> Dict[str, Any]: - tenant_id = getattr(current_user, "tenant_id", "default_tenant") or "default_tenant" + # Never fall back to a shared/default tenant - that would mix clients' data. + tenant_id = getattr(current_user, "tenant_id", None) + if not tenant_id: + raise HTTPException(status_code=403, detail="No tenant associated with this user") - revenue_data = await get_revenue_summary(property_id, tenant_id) + try: + revenue_data = await get_revenue_summary(property_id, tenant_id) + except HTTPException: + raise + except PropertyNotFoundError: + raise HTTPException(status_code=404, detail="Property not found") + except Exception: + logger.exception("Failed to load revenue summary for property %s (tenant %s)", property_id, tenant_id) + raise HTTPException(status_code=503, detail="Revenue data temporarily unavailable") - total_revenue_float = float(revenue_data['total']) + # Total is an exact 2-decimal Decimal string; convert once for JSON (a 2dp value is float-safe). + total_revenue = float(to_cents(Decimal(str(revenue_data['total'])))) 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'] } + + +@router.get("/dashboard/properties") +async def get_dashboard_properties( + current_user: dict = Depends(get_current_user) +) -> Dict[str, Any]: + """Properties belonging to the logged-in tenant (drives the dashboard dropdown).""" + tenant_id = getattr(current_user, "tenant_id", None) + if not tenant_id: + raise HTTPException(status_code=403, detail="No tenant associated with this user") + + try: + properties = await list_tenant_properties(tenant_id) + except Exception: + logger.exception("Failed to list properties for tenant %s", tenant_id) + raise HTTPException(status_code=503, detail="Properties temporarily unavailable") + + return {"properties": properties} diff --git a/backend/app/core/database_pool.py b/backend/app/core/database_pool.py index d638dfcfe..f33337ee3 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 @@ -13,13 +12,21 @@ def __init__(self): async def initialize(self): """Initialize database connection pool""" + if self.session_factory: + # Already initialized - avoid creating a new engine on every call + 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}" - + # Create async engine with connection pooling. + # DATABASE_URL is a plain postgresql:// URL; switch it to the asyncpg driver. + 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) + + # Default pool for async engines is AsyncAdaptedQueuePool (QueuePool is not valid here) 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 +52,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 b81474957..639633ee7 100644 --- a/backend/app/services/cache.py +++ b/backend/app/services/cache.py @@ -10,7 +10,8 @@ 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 MUST be part of the key: property ids (e.g. prop-001) are only unique per tenant. + cache_key = f"revenue:{tenant_id}:{property_id}" # Try to get from cache cached = await redis_client.get(cache_key) diff --git a/backend/app/services/reservations.py b/backend/app/services/reservations.py index 384bd00ab..cd61028bf 100644 --- a/backend/app/services/reservations.py +++ b/backend/app/services/reservations.py @@ -1,109 +1,151 @@ 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, List, Optional -async def calculate_monthly_revenue(property_id: str, month: int, year: int, db_session=None) -> Decimal: +CENT = Decimal("0.01") + + +class PropertyNotFoundError(Exception): + """Raised when a property does not exist for the requesting tenant.""" + + def __init__(self, property_id: str, tenant_id: str): + super().__init__(f"Property {property_id} not found for tenant {tenant_id}") + self.property_id = property_id + self.tenant_id = tenant_id + + +def to_cents(value) -> Decimal: + """Round a monetary amount to 2 decimals (half-up). Done once, at the very end.""" + return Decimal(str(value)).quantize(CENT, rounding=ROUND_HALF_UP) + + +async def calculate_monthly_revenue( + property_id: str, + month: int, + year: int, + db_session=None, + tenant_id: Optional[str] = None, +) -> Decimal: """ Calculates revenue for a specific month. + + Month boundaries are computed in the PROPERTY'S local time zone (properties.timezone), + not UTC, so a reservation checking in at 2024-02-29 23:30 UTC is counted in March + for a property in Europe/Paris. """ + if not tenant_id: + raise ValueError("tenant_id is required to calculate revenue") + # Local (naive) month boundaries - interpreted in the property's time zone by SQL below. 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 - """ - - # 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 + + from sqlalchemy import text + from app.core.database_pool import db_pool + + await db_pool.initialize() + if not db_pool.session_factory: + raise RuntimeError("Database pool not available") + + query = 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 AT TIME ZONE p.timezone) >= :start_date + AND (r.check_in_date AT TIME ZONE p.timezone) < :end_date + """) + + async with db_pool.get_session() as session: + result = await session.execute(query, { + "property_id": property_id, + "tenant_id": tenant_id, + "start_date": start_date, + "end_date": end_date, + }) + total = result.scalar() or Decimal("0") + + return to_cents(total) 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 - } - 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} - } - - mock_property_data = mock_data.get(property_id, {'total': '0.00', 'count': 0}) - - return { + # Import database pool (shared instance - initialized once) + from app.core.database_pool import db_pool + from sqlalchemy import text + + await db_pool.initialize() + + if not db_pool.session_factory: + # Fail loudly instead of returning fabricated numbers for a financial report. + raise RuntimeError("Database pool not available") + + async with db_pool.get_session() as session: + # Start from properties so we can tell "unknown property for this tenant" + # apart from "known property with no bookings yet". + query = text(""" + SELECT + p.id AS property_id, + COALESCE(SUM(r.total_amount), 0) as total_revenue, + COUNT(r.id) as reservation_count + FROM properties p + LEFT JOIN reservations r + ON r.property_id = p.id AND r.tenant_id = p.tenant_id + WHERE p.id = :property_id AND p.tenant_id = :tenant_id + GROUP BY p.id + """) + + result = await session.execute(query, { "property_id": property_id, - "tenant_id": tenant_id, - "total": mock_property_data['total'], - "currency": "USD", - "count": mock_property_data['count'] - } + "tenant_id": tenant_id + }) + row = result.fetchone() + + if row is None: + # Not this tenant's property (or doesn't exist). Don't report fake zeros. + raise PropertyNotFoundError(property_id, tenant_id) + + # Sum exactly in NUMERIC/Decimal, round to cents ONCE at the end. + total_revenue = to_cents(row.total_revenue) + count = row.reservation_count + + return { + "property_id": property_id, + "tenant_id": tenant_id, + "total": str(total_revenue), + "currency": "USD", + "count": count + } + + +async def list_tenant_properties(tenant_id: str) -> List[Dict[str, Any]]: + """ + Lists the properties owned by a tenant (id, name, timezone). + """ + from app.core.database_pool import db_pool + from sqlalchemy import text + + await db_pool.initialize() + + if not db_pool.session_factory: + raise RuntimeError("Database pool not available") + + async with db_pool.get_session() as session: + result = await session.execute( + text(""" + SELECT id, name, timezone + FROM properties + WHERE tenant_id = :tenant_id + ORDER BY id + """), + {"tenant_id": tenant_id}, + ) + rows = result.fetchall() + + return [{"id": r.id, "name": r.name, "timezone": r.timezone} for r in rows] diff --git a/backend/requirements.txt b/backend/requirements.txt index 6b777d2aa..56beee1e5 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -18,7 +18,8 @@ pytz redis>=5.0.0 requests sendgrid -sqlalchemy>=2.0.0 +sqlalchemy[asyncio]>=2.0.0 +greenlet>=3.0.0 supabase tenacity>=8.0.0 uvicorn[standard] diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index a21bba404..77dcdb0d6 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -1,16 +1,39 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { RevenueSummary } from "./RevenueSummary"; +import { SecureAPI } from "../lib/secureApi"; -const PROPERTIES = [ - { id: 'prop-001', name: 'Beach House Alpha' }, - { id: 'prop-002', name: 'City Apartment Downtown' }, - { id: 'prop-003', name: 'Country Villa Estate' }, - { id: 'prop-004', name: 'Lakeside Cottage' }, - { id: 'prop-005', name: 'Urban Loft Modern' } -]; +interface Property { + id: string; + name: string; +} const Dashboard: React.FC = () => { - const [selectedProperty, setSelectedProperty] = useState('prop-001'); + // Only the logged-in tenant's own properties are offered (property ids are not unique across tenants) + const [properties, setProperties] = useState([]); + const [selectedProperty, setSelectedProperty] = useState(''); + const [loadingProperties, setLoadingProperties] = useState(true); + const [propertiesError, setPropertiesError] = useState(''); + + useEffect(() => { + let cancelled = false; + SecureAPI.getDashboardProperties() + .then((res) => { + if (cancelled) return; + setProperties(res.properties); + setSelectedProperty(res.properties[0]?.id ?? ''); + }) + .catch((err) => { + console.error(err); + if (!cancelled) setPropertiesError('Failed to load properties'); + }) + .finally(() => { + if (!cancelled) setLoadingProperties(false); + }); + return () => { + cancelled = true; + }; + }, []); + return (
@@ -33,9 +56,10 @@ const Dashboard: React.FC = () => {