diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index 1ec352d7e..8048c1b4e 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -1,25 +1,61 @@ -from fastapi import APIRouter, Depends, HTTPException -from typing import Dict, Any +from fastapi import APIRouter, Depends, HTTPException, Query +from typing import Any, Dict, List, Optional from app.services.cache import get_revenue_summary +from app.services.reservations import ( + MixedCurrencyError, + PropertyNotFoundError, + RevenueUnavailableError, + list_properties, +) from app.core.auth import authenticate_request as get_current_user router = APIRouter() +def _require_tenant(current_user) -> str: + tenant_id = getattr(current_user, "tenant_id", None) + if not tenant_id: + raise HTTPException(status_code=403, detail="User is not assigned to a tenant") + return tenant_id + + +@router.get("/dashboard/properties") +async def get_dashboard_properties( + current_user: dict = Depends(get_current_user) +) -> List[Dict[str, str]]: + tenant_id = _require_tenant(current_user) + try: + return await list_properties(tenant_id) + except RevenueUnavailableError: + raise HTTPException(status_code=503, detail="Property list is temporarily unavailable") + + @router.get("/dashboard/summary") async def get_dashboard_summary( property_id: str, + year: Optional[int] = Query(None, ge=2000, le=2100), + month: Optional[int] = Query(None, ge=1, le=12), 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 = _require_tenant(current_user) + + if (year is None) != (month is None): + raise HTTPException(status_code=422, detail="year and month must be provided together") + + try: + revenue_data = await get_revenue_summary(property_id, tenant_id, year, month) + except PropertyNotFoundError: + # Same response whether the property doesn't exist or belongs to another tenant + raise HTTPException(status_code=404, detail="Property not found") + except MixedCurrencyError: + raise HTTPException(status_code=409, detail="Reservations use more than one currency") + except RevenueUnavailableError: + raise HTTPException(status_code=503, detail="Revenue data is temporarily unavailable") + return { "property_id": revenue_data['property_id'], - "total_revenue": total_revenue_float, + # Decimal string rounded to cents, e.g. "2250.00" (never a float) + "total_revenue": revenue_data['total'], "currency": revenue_data['currency'], "reservations_count": revenue_data['count'] } diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index a85fd5c12..90c44d7e1 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -108,13 +108,13 @@ async def authenticate_request( # First try to decode as a custom JWT token. try: payload = jwt.decode( - token, - settings.secret_key, + token, + settings.secret_key, algorithms=["HS256"], audience="authenticated" # Accept tokens with aud: "authenticated" ) logger.info(f"AUTH: Successfully decoded custom JWT token for {payload.get('email')}") - + # Create a mock user object from JWT payload class MockUser: def __init__(self, payload): @@ -123,14 +123,14 @@ def __init__(self, payload): self.app_metadata = payload.get('app_metadata', {}) self.user_metadata = payload.get('user_metadata', {}) self.raw_app_metadata = payload.get('app_metadata', {}) - + user = MockUser(payload) - + except JWTError: # If custom JWT fails, try Supabase auth response = supabase.auth.get_user(token) user = response.user - + except Exception as e: # Malformed or invalid token (e.g., wrong number of segments) logger.warning(f"AUTH: Token verification failed (hash={token_hash}): {e.__class__.__name__}") @@ -252,16 +252,18 @@ def __init__(self, payload): logger.info(f"==================== TENANT ID EXTRACTION ====================") logger.info(f"User: {user.email} (ID: {user.id})") - # Use TenantResolver for comprehensive tenant resolution - tenant_id = await TenantResolver.resolve_tenant_id(token=token, user_id=user.id, user_email=user.email) - - # If we found a tenant_id and it's not in the user's metadata, update it for next time + # Prefer the tenant from the verified token's app_metadata (set at login); + # fall back to the resolver's email mapping. current_tenant_in_metadata = None if hasattr(user, "raw_app_metadata") and user.raw_app_metadata: current_tenant_in_metadata = user.raw_app_metadata.get("tenant_id") elif hasattr(user, "app_metadata") and user.app_metadata: current_tenant_in_metadata = user.app_metadata.get("tenant_id") + tenant_id = current_tenant_in_metadata or await TenantResolver.resolve_tenant_id( + token=token, user_id=user.id, user_email=user.email + ) + if tenant_id and current_tenant_in_metadata != tenant_id: logger.info(f"Updating user metadata with tenant_id for future requests...") asyncio.create_task(TenantResolver.update_user_tenant_metadata(user.id, tenant_id)) @@ -358,24 +360,24 @@ def permission_checker(user: AuthenticatedUser = Depends(authenticate_request)): def require_any_permission(*permissions): """Dependency to require any of the specified permissions (OR logic) - + Args: *permissions: List of tuples (section, action) representing permissions - + Example: require_any_permission( ("guest_portal", "read"), - ("lockbox", "create"), + ("lockbox", "create"), ("internal_keys", "create") ) """ def permission_checker(user: AuthenticatedUser = Depends(authenticate_request)): # Check if user has any of the required permissions has_any_permission = any( - has_permission(user, section, action) + has_permission(user, section, action) for section, action in permissions ) - + if not has_any_permission: permission_strings = [f"{section}.{action}" for section, action in permissions] logger.warning( @@ -386,14 +388,14 @@ def permission_checker(user: AuthenticatedUser = Depends(authenticate_request)): status_code=status.HTTP_403_FORBIDDEN, detail=f"Insufficient permissions: requires any of {permission_strings}", ) - + # Log which permission was matched for debugging matched_permissions = [ f"{section}.{action}" for section, action in permissions if has_permission(user, section, action) ] logger.info(f"User {user.email} granted access via permissions: {matched_permissions}") - + return user return permission_checker @@ -505,7 +507,7 @@ async def verify_token_ws(token: str) -> Optional[AuthenticatedUser]: else: # Keep user's explicitly assigned cities even if no properties exist logger.debug(f"WS_AUTH: Admin user, keeping assigned cities (no properties found): {user_cities}") - + logger.info(f"WS_AUTH: Final user cities after processing: {user_cities}") # Use the comprehensive tenant resolver (same as regular auth) diff --git a/backend/app/core/database_pool.py b/backend/app/core/database_pool.py index d638dfcfe..b5ffd63b7 100644 --- a/backend/app/core/database_pool.py +++ b/backend/app/core/database_pool.py @@ -1,6 +1,5 @@ -import asyncio +from sqlalchemy.engine import make_url from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker -from sqlalchemy.pool import QueuePool import logging from ..config import settings @@ -10,43 +9,47 @@ class DatabasePool: def __init__(self): self.engine = None self.session_factory = None - + 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 (async engines use + # AsyncAdaptedQueuePool by default; QueuePool is sync-only). + # Normalize any Postgres scheme (postgres://, postgresql://, + # postgresql+psycopg2://) to the asyncpg driver. + 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_timeout=5, # Fail fast instead of queueing forever + connect_args={"timeout": 5}, # asyncpg connect timeout (seconds) pool_pre_ping=True, # Validate connections pool_recycle=3600, # Recycle connections every hour echo=False # Set to True for SQL debugging ) - + self.session_factory = async_sessionmaker( bind=self.engine, class_=AsyncSession, expire_on_commit=False ) - + logger.info("✅ Database connection pool initialized") - + except Exception as e: logger.error(f"❌ Database pool initialization failed: {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: + """Get database session from pool (use as `async with db_pool.get_session()`)""" if not self.session_factory: raise Exception("Database pool not initialized") return self.session_factory() diff --git a/backend/app/core/tenant_resolver.py b/backend/app/core/tenant_resolver.py index db09a4629..b913e6dce 100644 --- a/backend/app/core/tenant_resolver.py +++ b/backend/app/core/tenant_resolver.py @@ -69,16 +69,16 @@ def resolve_tenant_from_user(user_data: dict) -> Optional[str]: return None @staticmethod - async def resolve_tenant_id(user_id: str, user_email: str, token: Optional[str] = None) -> str: + async def resolve_tenant_id(user_id: str, user_email: str, token: Optional[str] = None) -> Optional[str]: """ Resolve tenant ID for a user. - + Args: user_id: User ID user_email: User email - + Returns: - Tenant ID + Tenant ID, or None when the user cannot be mapped to a tenant """ # Fallback mapping by known user email. if user_email == "sunset@propertyflow.com": @@ -87,15 +87,16 @@ async def resolve_tenant_id(user_id: str, user_email: str, token: Optional[str] return "tenant-b" if user_email == "candidate@propertyflow.com": return "tenant-a" - - # Default fallback - return "tenant-a" + + # Unknown users must not be silently attached to another client's data + logger.warning(f"No tenant mapping for user {user_email}") + return None @staticmethod async def update_user_tenant_metadata(user_id: str, tenant_id: str) -> None: """ Update user metadata with tenant_id. - + Args: user_id: User ID tenant_id: Tenant ID diff --git a/backend/app/main.py b/backend/app/main.py index 00734b2fa..308dc8c1f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -100,6 +100,11 @@ async def lifespan(app: FastAPI): logger.error(f"❌ Supabase connection pool initialization failed: {e}") # Continue startup - fallback to direct connections + # Initialize the SQLAlchemy pool used by revenue reporting (once per process) + from .core.database_pool import db_pool + + await db_pool.initialize() + # Initialize Redis connection with timeout try: await redis_client.initialize() @@ -127,6 +132,11 @@ async def lifespan(app: FastAPI): await async_processor.shutdown() logger.info("Async processor shutdown completed") + try: + await db_pool.close() + except Exception as e: + logger.warning(f"⚠️ Error closing database pool: {e}") + # Close connection pool try: from .core.supabase_connection_pool import supabase_pool diff --git a/backend/app/services/cache.py b/backend/app/services/cache.py index b81474957..0e64b357b 100644 --- a/backend/app/services/cache.py +++ b/backend/app/services/cache.py @@ -1,29 +1,34 @@ import json import redis.asyncio as redis -from typing import Dict, Any +from typing import Any, Dict, 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, year: Optional[int] = None, month: Optional[int] = None +) -> Dict[str, Any]: """ Fetches revenue summary, utilizing caching to improve performance. """ - cache_key = f"revenue:{property_id}" - + # Property IDs are only unique per tenant (prop-001 exists for several + # clients), so the tenant must be part of the key, and so must the period. + period = f"{year:04d}-{month:02d}" if year and month else "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) - + # 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, year, month) + # 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..0d1826d45 100644 --- a/backend/app/services/reservations.py +++ b/backend/app/services/reservations.py @@ -1,109 +1,143 @@ from datetime import datetime -from decimal import Decimal -from typing import Dict, Any, List +from decimal import Decimal, ROUND_HALF_UP +from typing import Any, Dict, List, Optional, Tuple +from zoneinfo import ZoneInfo +import logging -async def calculate_monthly_revenue(property_id: str, month: int, year: int, db_session=None) -> Decimal: - """ - Calculates revenue for a specific month. - """ +logger = logging.getLogger(__name__) - 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 - -async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, Any]: +def month_bounds(year: int, month: int, timezone: str) -> Tuple[datetime, datetime]: + """Half-open [start, end) bounds of a calendar month in the property's + local timezone, so a 00:30 check-in on the 1st in Paris counts for that + month even though it is still the previous month in UTC.""" + tz = ZoneInfo(timezone) + start = datetime(year, month, 1, tzinfo=tz) + end = datetime(year + 1, 1, 1, tzinfo=tz) if month == 12 else datetime(year, month + 1, 1, tzinfo=tz) + return start, end + + +class RevenueUnavailableError(Exception): + """Raised when revenue cannot be read from the database.""" + + +class PropertyNotFoundError(Exception): + """Raised when the property does not exist for the requesting tenant.""" + + +class MixedCurrencyError(Exception): + """Raised when a property's reservations use more than one currency.""" + + +CENTS = Decimal("0.01") + + +def to_money(amount) -> str: + """Round once, at the API boundary, to cents (half-up) and serialize as a + fixed-point string so no float ever touches the value.""" + return str(Decimal(amount or 0).quantize(CENTS, rounding=ROUND_HALF_UP)) + + +async def calculate_total_revenue( + property_id: str, tenant_id: str, year: Optional[int] = None, month: Optional[int] = None +) -> Dict[str, Any]: """ - Aggregates revenue from database. + Aggregates revenue from database: all-time, or for one calendar month + (in the property's timezone) when year and month are given. + + Raises RevenueUnavailableError instead of returning placeholder figures: + showing made-up numbers to a client is worse than showing an error. """ + # Shared pool, initialized once at app startup + from app.core.database_pool import db_pool + + if not db_pool.session_factory: + raise RevenueUnavailableError("Database pool not available") + 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") - + async with db_pool.get_session() as session: + # Use SQLAlchemy text for raw SQL + from sqlalchemy import text + + # Property IDs are only unique per tenant: check ownership first + owned = 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}, + ) + property_row = owned.first() + property_found = property_row is not None + + start = end = None + if property_found and year and month: + start, end = month_bounds(year, month, property_row.timezone) + + query = text(""" + SELECT + currency, + SUM(total_amount) as total_revenue, + COUNT(*) as reservation_count + FROM reservations + WHERE property_id = :property_id AND tenant_id = :tenant_id + AND (CAST(:start AS timestamptz) IS NULL OR check_in_date >= :start) + AND (CAST(:end AS timestamptz) IS NULL OR check_in_date < :end) + GROUP BY currency + """) + + result = await session.execute(query, { + "property_id": property_id, + "tenant_id": tenant_id, + "start": start, + "end": end, + }) + rows = result.fetchall() 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}) - + logger.exception(f"Revenue query failed for {property_id} (tenant: {tenant_id})") + raise RevenueUnavailableError(str(e)) from e + + if not property_found: + raise PropertyNotFoundError(property_id) + + if len(rows) > 1: + # Summing amounts in different currencies would produce a meaningless total + raise MixedCurrencyError(property_id) + + if rows: + row = rows[0] return { "property_id": property_id, - "tenant_id": tenant_id, - "total": mock_property_data['total'], - "currency": "USD", - "count": mock_property_data['count'] + "tenant_id": tenant_id, + "total": to_money(row.total_revenue), + "currency": row.currency, + "count": row.reservation_count } + + # No reservations found for this property + return { + "property_id": property_id, + "tenant_id": tenant_id, + "total": to_money(0), + "currency": "USD", + "count": 0 + } + + +async def list_properties(tenant_id: str) -> List[Dict[str, str]]: + """Properties owned by the tenant, for the dashboard's property selector.""" + from app.core.database_pool import db_pool + + if not db_pool.session_factory: + raise RevenueUnavailableError("Database pool not available") + + try: + async with db_pool.get_session() as session: + from sqlalchemy import text + + result = await session.execute( + text("SELECT id, name FROM properties WHERE tenant_id = :tenant_id ORDER BY name"), + {"tenant_id": tenant_id}, + ) + rows = result.fetchall() + except Exception as e: + logger.exception(f"Property list query failed (tenant: {tenant_id})") + raise RevenueUnavailableError(str(e)) from e + + return [{"id": row.id, "name": row.name} for row in rows] diff --git a/backend/requirements.txt b/backend/requirements.txt index 6b777d2aa..06cb8bd4a 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/backend/tests/test_revenue_helpers.py b/backend/tests/test_revenue_helpers.py new file mode 100644 index 000000000..d62b47c30 --- /dev/null +++ b/backend/tests/test_revenue_helpers.py @@ -0,0 +1,35 @@ +"""Unit tests for revenue helpers (no database needed). + +Run from backend/: uv run --no-project --with pytest pytest -q -p no:cacheprovider +""" +from datetime import datetime, timedelta, timezone +from decimal import Decimal + +from app.services.reservations import month_bounds, to_money + + +def test_total_is_rounded_once_not_per_booking(): + bookings = [Decimal("333.333"), Decimal("333.333"), Decimal("333.334")] + assert to_money(sum(bookings)) == "1000.00" + # Rounding each booking first would drift by a cent + assert sum(Decimal(to_money(b)) for b in bookings) == Decimal("999.99") + + +def test_to_money_rounds_half_up_and_handles_empty(): + assert to_money(Decimal("0.005")) == "0.01" + assert to_money(None) == "0.00" + + +def test_month_bounds_use_property_timezone(): + start, end = month_bounds(2024, 3, "Europe/Paris") + # 2024-02-29 23:30 UTC is 1 March 00:30 in Paris, so it belongs to March + check_in = datetime(2024, 2, 29, 23, 30, tzinfo=timezone.utc) + assert start <= check_in < end + # Absolute length (what Postgres compares) is 31 days minus the DST hour + utc = timezone.utc + assert end.astimezone(utc) - start.astimezone(utc) == timedelta(days=31, hours=-1) + + +def test_month_bounds_december_rolls_over_to_next_year(): + start, end = month_bounds(2024, 12, "America/New_York") + assert (start.year, start.month, end.year, end.month) == (2024, 12, 2025, 1) diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index a21bba404..ce55fe47a 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -1,16 +1,35 @@ -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 PropertyOption { + id: string; + name: string; +} const Dashboard: React.FC = () => { - const [selectedProperty, setSelectedProperty] = useState('prop-001'); + // Only the signed-in tenant's properties (property IDs repeat across tenants) + const [properties, setProperties] = useState([]); + const [selectedProperty, setSelectedProperty] = useState(''); + const [propertiesError, setPropertiesError] = useState(''); + const [propertiesLoaded, setPropertiesLoaded] = useState(false); + // "YYYY-MM" from the month picker; empty means all-time + const [selectedMonth, setSelectedMonth] = useState(''); + + useEffect(() => { + SecureAPI.getDashboardProperties() + .then((list) => { + setProperties(list); + setSelectedProperty(list[0]?.id ?? ''); + }) + .catch((err) => { + console.error('Failed to load properties', err); + setPropertiesError('Failed to load your properties'); + }) + .finally(() => setPropertiesLoaded(true)); + }, []); + + const [year, month] = selectedMonth ? selectedMonth.split('-').map(Number) : [undefined, undefined]; return (
@@ -26,27 +45,48 @@ const Dashboard: React.FC = () => { Monthly performance insights for your properties

- - {/* Property Selector */} -
- - + +
+ {/* Month Selector */} +
+ + setSelectedMonth(e.target.value)} + className="block w-full sm:w-auto px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 text-sm" + /> +
+ + {/* Property Selector */} +
+ + +
- + {propertiesError && ( +
{propertiesError}
+ )} + {propertiesLoaded && !propertiesError && properties.length === 0 && ( +
No properties found for your account.
+ )} + {selectedProperty && ( + + )}
diff --git a/frontend/src/components/RevenueSummary.tsx b/frontend/src/components/RevenueSummary.tsx index dbb6d0629..9add1862f 100644 --- a/frontend/src/components/RevenueSummary.tsx +++ b/frontend/src/components/RevenueSummary.tsx @@ -3,45 +3,52 @@ import { SecureAPI } from '../lib/secureApi'; interface RevenueData { property_id: string; - total_revenue: number; + total_revenue: string; // decimal string rounded to cents by the API, e.g. "2250.00" currency: string; reservations_count: number; } interface RevenueSummaryProps { propertyId?: string; - debugTenant?: string; + year?: number; + month?: number; showRaw?: boolean; } -export const RevenueSummary: React.FC = ({ propertyId = 'prop-001', debugTenant, showRaw }) => { +export const RevenueSummary: React.FC = ({ propertyId, year, month, showRaw }) => { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); - const activeTenant = debugTenant || 'candidate'; - useEffect(() => { + if (!propertyId) return; + // Ignore responses that arrive after the user picked another property/month + 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 + // Use SecureAPI to handle authentication automatically. + // The tenant is derived server-side from the auth token. const response = await SecureAPI.getDashboardSummary(propertyId, { - simulatedTenant: activeTenant, + year, + month, timestamp: Date.now() }); - 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, year, month]); if (loading) { return ( @@ -61,7 +68,8 @@ export const RevenueSummary: React.FC = ({ propertyId = 'pr if (error) return
{error}
; if (!data) return null; - const displayTotal = Math.round(data.total_revenue * 100) / 100; + // The API already rounds to cents; Number() is used for display formatting only + const displayTotal = Number(data.total_revenue); return (
@@ -80,13 +88,6 @@ export const RevenueSummary: React.FC = ({ propertyId = 'pr {data.currency} {displayTotal.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} - {/* Fake trend indicator for premium feel */} - - - 12% -
@@ -102,17 +103,6 @@ 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 f85f04c90..28a285e86 100644 --- a/frontend/src/lib/secureApi.ts +++ b/frontend/src/lib/secureApi.ts @@ -1,11 +1,11 @@ /** * SECURE MULTI-TENANT API CLIENT - * + * * This module provides a centralized, secure API client that ensures ALL database * queries go through the backend with proper tenant isolation. - * + * * Always use this SecureAPI client instead. - * + * * @security This prevents cross-tenant data leakage by enforcing backend-only database access */ @@ -1181,7 +1181,7 @@ export class SecureAPIClient { }); } - // Alias for compatibility + // Alias for compatibility async deleteAppliance(propertyId: string, applianceId: string) { return this.deletePropertyAppliance(propertyId, applianceId); } @@ -1452,20 +1452,21 @@ export class SecureAPIClient { /** * Get dashboard summary with optional simulation header */ - async getDashboardSummary(propertyId: string, options?: { simulatedTenant?: string, timestamp?: number }) { + async getDashboardSummary(propertyId: string, options?: { year?: number, month?: number, timestamp?: number }) { const queryParams = new URLSearchParams({ property_id: propertyId }); + if (options?.year && options?.month) { + queryParams.append('year', options.year.toString()); + queryParams.append('month', options.month.toString()); + } if (options?.timestamp) { queryParams.append('_t', options.timestamp.toString()); } - const requestOptions: RequestInit = {}; - if (options?.simulatedTenant) { - requestOptions.headers = { - 'X-Simulated-Tenant': options.simulatedTenant - }; - } + return this.request(`/api/v1/dashboard/summary?${queryParams}`); + } - return this.request(`/api/v1/dashboard/summary?${queryParams}`, requestOptions); + async getDashboardProperties() { + return this.request<{ id: string; name: string }[]>('/api/v1/dashboard/properties'); } async uploadCompanyLogo(logo_url: string) {