From bab79fd97e8447f392505f5bec40d2dcffd93e2d Mon Sep 17 00:00:00 2001 From: krowslyare Date: Tue, 13 Oct 2026 11:23:35 -0500 Subject: [PATCH 01/12] fix(db): make the revenue DB pool actually connect The pool never initialized, so every dashboard request silently fell back to mock figures. It read settings.supabase_db_* (undefined), used the sync-only QueuePool with an async engine, was used with `async with` on a coroutine, and greenlet (required by SQLAlchemy asyncio) was not installed. It was also re-created on every request; it is now initialized once at startup. Co-Authored-By: Claude Opus 5.5 --- backend/app/core/database_pool.py | 30 ++++++++++--------- backend/app/main.py | 10 +++++++ backend/app/services/reservations.py | 44 +++++++++++++--------------- backend/requirements.txt | 2 +- 4 files changed, 47 insertions(+), 39 deletions(-) diff --git a/backend/app/core/database_pool.py b/backend/app/core/database_pool.py index d638dfcfe..6a54636f7 100644 --- a/backend/app/core/database_pool.py +++ b/backend/app/core/database_pool.py @@ -1,6 +1,4 @@ -import asyncio from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker -from sqlalchemy.pool import QueuePool import logging from ..config import settings @@ -10,43 +8,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). + 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_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/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/reservations.py b/backend/app/services/reservations.py index 384bd00ab..99c058811 100644 --- a/backend/app/services/reservations.py +++ b/backend/app/services/reservations.py @@ -1,6 +1,6 @@ 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: """ @@ -12,7 +12,7 @@ async def calculate_monthly_revenue(property_id: str, month: int, year: int, db_ 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) @@ -24,11 +24,11 @@ async def calculate_monthly_revenue(property_id: str, month: int, year: int, db_ 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]: @@ -36,41 +36,37 @@ async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, 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() - + # Shared pool, initialized once at app startup + from app.core.database_pool import db_pool + 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 + SELECT property_id, SUM(total_amount) as total_revenue, COUNT(*) as reservation_count - FROM reservations + 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, + "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", + "currency": "USD", "count": row.reservation_count } else: @@ -84,25 +80,25 @@ async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, } 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-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, + "tenant_id": tenant_id, "total": mock_property_data['total'], "currency": "USD", "count": mock_property_data['count'] 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] From c726e56e90606f88561f7c9112ce5cbf0edd5303 Mon Sep 17 00:00:00 2001 From: krowslyare Date: Tue, 13 Oct 2026 11:24:28 -0500 Subject: [PATCH 02/12] fix(revenue): return 503 instead of silently serving mock figures When the query failed, calculate_total_revenue returned a hardcoded table keyed only by property_id, ignoring the tenant. Clients saw plausible but fake totals: Ocean's prop-001 (no reservations) showed 1000.00, and Sunset could load prop-004 and see Ocean's real total. Errors are now logged with the traceback and surfaced as 503, and are never cached. Co-Authored-By: Claude Opus 5.5 --- backend/app/api/v1/dashboard.py | 14 ++-- backend/app/services/reservations.py | 108 ++++++++++++--------------- 2 files changed, 58 insertions(+), 64 deletions(-) diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index 1ec352d7e..faf2b71e9 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -1,6 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException from typing import Dict, Any from app.services.cache import get_revenue_summary +from app.services.reservations import RevenueUnavailableError from app.core.auth import authenticate_request as get_current_user router = APIRouter() @@ -10,13 +11,16 @@ async def get_dashboard_summary( property_id: str, 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) - + + try: + revenue_data = await get_revenue_summary(property_id, tenant_id) + except RevenueUnavailableError: + raise HTTPException(status_code=503, detail="Revenue data is temporarily unavailable") + total_revenue_float = float(revenue_data['total']) - + return { "property_id": revenue_data['property_id'], "total_revenue": total_revenue_float, diff --git a/backend/app/services/reservations.py b/backend/app/services/reservations.py index 99c058811..6e77b49fc 100644 --- a/backend/app/services/reservations.py +++ b/backend/app/services/reservations.py @@ -1,6 +1,9 @@ from datetime import datetime from decimal import Decimal from typing import Dict, Any +import logging + +logger = logging.getLogger(__name__) async def calculate_monthly_revenue(property_id: str, month: int, year: int, db_session=None) -> Decimal: """ @@ -31,75 +34,62 @@ async def calculate_monthly_revenue(property_id: str, month: int, year: int, db_ return Decimal('0') # Placeholder for now until DB connection is finalized +class RevenueUnavailableError(Exception): + """Raised when revenue cannot be read from the database.""" + + async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, Any]: """ Aggregates revenue from database. + + 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: - # Shared pool, initialized once at app startup - from app.core.database_pool import db_pool - - 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 - 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} - } + 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 + """) - mock_property_data = mock_data.get(property_id, {'total': '0.00', 'count': 0}) + result = await session.execute(query, { + "property_id": property_id, + "tenant_id": tenant_id + }) + row = result.fetchone() + except Exception as e: + logger.exception(f"Revenue query failed for {property_id} (tenant: {tenant_id})") + raise RevenueUnavailableError(str(e)) from e + if row: + total_revenue = Decimal(str(row.total_revenue)) return { "property_id": property_id, "tenant_id": tenant_id, - "total": mock_property_data['total'], + "total": str(total_revenue), "currency": "USD", - "count": mock_property_data['count'] + "count": row.reservation_count } + + # No reservations found for this property + return { + "property_id": property_id, + "tenant_id": tenant_id, + "total": "0.00", + "currency": "USD", + "count": 0 + } From 99e173ef938bb55f73e724e4a12347e7a9bfaf06 Mon Sep 17 00:00:00 2001 From: krowslyare Date: Tue, 13 Oct 2026 11:27:10 -0500 Subject: [PATCH 03/12] fix(cache): scope revenue cache key by tenant_id The key was `revenue:{property_id}`, but property IDs repeat across tenants. Whichever client loaded prop-001 first populated the entry, and the other client was served that payload (it even carried the other tenant_id) for the next 5 minutes, which is why the leak only showed up "sometimes on refresh". Co-Authored-By: Claude Opus 5.5 --- backend/app/services/cache.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/backend/app/services/cache.py b/backend/app/services/cache.py index b81474957..f86be952f 100644 --- a/backend/app/services/cache.py +++ b/backend/app/services/cache.py @@ -10,20 +10,22 @@ 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}" - + # Property IDs are only unique per tenant (prop-001 exists for several + # clients), so the tenant must be part of the key. + 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) - + # Cache the result for 5 minutes await redis_client.setex(cache_key, 300, json.dumps(result)) - + return result From b9f42131ca44d628914d3f889a7243627b842018 Mon Sep 17 00:00:00 2001 From: krowslyare Date: Tue, 13 Oct 2026 11:27:56 -0500 Subject: [PATCH 04/12] fix(revenue): return 404 for properties outside the caller's tenant The summary endpoint answered for any property_id: Sunset could request prop-004 (Ocean's) and get a 200. Ownership is now checked against properties(id, tenant_id) before aggregating, and unknown and foreign properties get the same 404 so the response doesn't reveal which IDs exist. Co-Authored-By: Claude Opus 5.5 --- backend/app/api/v1/dashboard.py | 5 ++++- backend/app/services/reservations.py | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index faf2b71e9..a124eae0e 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException from typing import Dict, Any from app.services.cache import get_revenue_summary -from app.services.reservations import RevenueUnavailableError +from app.services.reservations import PropertyNotFoundError, RevenueUnavailableError from app.core.auth import authenticate_request as get_current_user router = APIRouter() @@ -16,6 +16,9 @@ async def get_dashboard_summary( try: revenue_data = await get_revenue_summary(property_id, tenant_id) + 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 RevenueUnavailableError: raise HTTPException(status_code=503, detail="Revenue data is temporarily unavailable") diff --git a/backend/app/services/reservations.py b/backend/app/services/reservations.py index 6e77b49fc..19868bb72 100644 --- a/backend/app/services/reservations.py +++ b/backend/app/services/reservations.py @@ -38,6 +38,10 @@ 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.""" + + async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, Any]: """ Aggregates revenue from database. @@ -56,6 +60,13 @@ async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, # 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 1 FROM properties WHERE id = :property_id AND tenant_id = :tenant_id"), + {"property_id": property_id, "tenant_id": tenant_id}, + ) + property_found = owned.first() is not None + query = text(""" SELECT property_id, @@ -75,6 +86,9 @@ async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, 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 row: total_revenue = Decimal(str(row.total_revenue)) return { From 6a80dbf9cb7c5a26206bc19a84938b4ec0bae109 Mon Sep 17 00:00:00 2001 From: krowslyare Date: Tue, 13 Oct 2026 11:28:01 -0500 Subject: [PATCH 05/12] fix(auth): stop defaulting unknown users to a tenant TenantResolver mapped any unrecognised email to tenant-a, and the dashboard fell back to "default_tenant", so a user without a tenant would read another client's data. The tenant now comes from the verified token's app_metadata, then the known-user mapping, and the dashboard returns 403 when none exists. Co-Authored-By: Claude Opus 5.5 --- backend/app/api/v1/dashboard.py | 4 ++- backend/app/core/auth.py | 38 +++++++++++++++-------------- backend/app/core/tenant_resolver.py | 17 +++++++------ 3 files changed, 32 insertions(+), 27 deletions(-) diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index a124eae0e..2b10e5692 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -12,7 +12,9 @@ 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" + 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") try: revenue_data = await get_revenue_summary(property_id, tenant_id) 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/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 From 499e6a293cd177113de4a25b3077be81bad89260 Mon Sep 17 00:00:00 2001 From: krowslyare Date: Tue, 13 Oct 2026 11:28:53 -0500 Subject: [PATCH 06/12] fix(revenue): keep money as Decimal and round once to cents total_amount is NUMERIC(10,3) (sub-cent precision), but the API converted the sum to float and the browser rounded it again with Math.round(x*100)/100. Floats can't represent most cent values exactly, so double rounding can drift by a cent. Totals now stay Decimal, are rounded once half-up at the API boundary and returned as a decimal string ("2250.00"); the UI only formats it. Rounding once on the total matters: prop-001's three 333.333 / 333.333 / 333.334 bookings sum to 1000.00, while rounding each booking first gives 999.99. Also reports the reservations' real currency and rejects mixed-currency sums. Co-Authored-By: Claude Opus 5.5 --- backend/app/api/v1/dashboard.py | 9 +++--- backend/app/services/reservations.py | 35 ++++++++++++++++------ frontend/src/components/RevenueSummary.tsx | 18 +++-------- 3 files changed, 35 insertions(+), 27 deletions(-) diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index 2b10e5692..cde5bf00d 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException from typing import Dict, Any from app.services.cache import get_revenue_summary -from app.services.reservations import PropertyNotFoundError, RevenueUnavailableError +from app.services.reservations import MixedCurrencyError, PropertyNotFoundError, RevenueUnavailableError from app.core.auth import authenticate_request as get_current_user router = APIRouter() @@ -21,14 +21,15 @@ async def get_dashboard_summary( 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") - total_revenue_float = float(revenue_data['total']) - 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/services/reservations.py b/backend/app/services/reservations.py index 19868bb72..5089251a5 100644 --- a/backend/app/services/reservations.py +++ b/backend/app/services/reservations.py @@ -1,5 +1,5 @@ from datetime import datetime -from decimal import Decimal +from decimal import Decimal, ROUND_HALF_UP from typing import Dict, Any import logging @@ -42,6 +42,19 @@ 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) -> Dict[str, Any]: """ Aggregates revenue from database. @@ -69,19 +82,19 @@ async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, query = text(""" SELECT - property_id, + currency, 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 + GROUP BY currency """) result = await session.execute(query, { "property_id": property_id, "tenant_id": tenant_id }) - row = result.fetchone() + rows = result.fetchall() except Exception as e: logger.exception(f"Revenue query failed for {property_id} (tenant: {tenant_id})") raise RevenueUnavailableError(str(e)) from e @@ -89,13 +102,17 @@ async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, if not property_found: raise PropertyNotFoundError(property_id) - if row: - total_revenue = Decimal(str(row.total_revenue)) + 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": str(total_revenue), - "currency": "USD", + "total": to_money(row.total_revenue), + "currency": row.currency, "count": row.reservation_count } @@ -103,7 +120,7 @@ async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, return { "property_id": property_id, "tenant_id": tenant_id, - "total": "0.00", + "total": to_money(0), "currency": "USD", "count": 0 } diff --git a/frontend/src/components/RevenueSummary.tsx b/frontend/src/components/RevenueSummary.tsx index dbb6d0629..4d20153c3 100644 --- a/frontend/src/components/RevenueSummary.tsx +++ b/frontend/src/components/RevenueSummary.tsx @@ -3,14 +3,14 @@ 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; + debugTenant?: string; showRaw?: boolean; } @@ -61,7 +61,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 (
@@ -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 -
- )} -
); From 2e482490ba9d4ae715901d90adc4480f75e2082e Mon Sep 17 00:00:00 2001 From: krowslyare Date: Tue, 13 Oct 2026 11:29:42 -0500 Subject: [PATCH 07/12] feat(revenue): monthly totals computed in the property's timezone The dashboard is labelled "monthly" but only exposed all-time totals, and calculate_monthly_revenue was an unused stub that returned 0 and built its range from naive (UTC) datetimes. A check-in at 2024-02-29 23:30 UTC is 1 March 00:30 in Paris, so a UTC month boundary moves that booking into February and March for prop-001 drops from 2250.00 (4 bookings) to 1000.00 (3). /dashboard/summary now accepts optional year+month and filters check-ins with half-open [start, end) bounds in the property's timezone; the period is part of the cache key. The unused stub is removed. Co-Authored-By: Claude Opus 5.5 --- backend/app/api/v1/dashboard.py | 11 +++-- backend/app/services/cache.py | 13 +++--- backend/app/services/reservations.py | 60 +++++++++++++--------------- 3 files changed, 43 insertions(+), 41 deletions(-) diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index cde5bf00d..5fa856332 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -1,5 +1,5 @@ -from fastapi import APIRouter, Depends, HTTPException -from typing import Dict, Any +from fastapi import APIRouter, Depends, HTTPException, Query +from typing import Any, Dict, Optional from app.services.cache import get_revenue_summary from app.services.reservations import MixedCurrencyError, PropertyNotFoundError, RevenueUnavailableError from app.core.auth import authenticate_request as get_current_user @@ -9,6 +9,8 @@ @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]: @@ -16,8 +18,11 @@ async def get_dashboard_summary( if not tenant_id: raise HTTPException(status_code=403, detail="User is not assigned to a tenant") + 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) + 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") diff --git a/backend/app/services/cache.py b/backend/app/services/cache.py index f86be952f..0e64b357b 100644 --- a/backend/app/services/cache.py +++ b/backend/app/services/cache.py @@ -1,18 +1,21 @@ 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. """ # Property IDs are only unique per tenant (prop-001 exists for several - # clients), so the tenant must be part of the key. - cache_key = f"revenue:{tenant_id}:{property_id}" + # 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) @@ -23,7 +26,7 @@ async def get_revenue_summary(property_id: str, tenant_id: str) -> Dict[str, Any 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)) diff --git a/backend/app/services/reservations.py b/backend/app/services/reservations.py index 5089251a5..b3c978c58 100644 --- a/backend/app/services/reservations.py +++ b/backend/app/services/reservations.py @@ -1,38 +1,20 @@ from datetime import datetime from decimal import Decimal, ROUND_HALF_UP -from typing import Dict, Any +from typing import Any, Dict, Optional, Tuple +from zoneinfo import ZoneInfo import logging logger = logging.getLogger(__name__) -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}") - - # 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') +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 - return Decimal('0') # Placeholder for now until DB connection is finalized class RevenueUnavailableError(Exception): """Raised when revenue cannot be read from the database.""" @@ -55,9 +37,12 @@ def to_money(amount) -> str: return str(Decimal(amount or 0).quantize(CENTS, rounding=ROUND_HALF_UP)) -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, 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. @@ -75,10 +60,15 @@ async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, # Property IDs are only unique per tenant: check ownership first owned = await session.execute( - text("SELECT 1 FROM properties WHERE id = :property_id AND tenant_id = :tenant_id"), + text("SELECT timezone FROM properties WHERE id = :property_id AND tenant_id = :tenant_id"), {"property_id": property_id, "tenant_id": tenant_id}, ) - property_found = owned.first() is not None + 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 @@ -87,12 +77,16 @@ async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, 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 + "tenant_id": tenant_id, + "start": start, + "end": end, }) rows = result.fetchall() except Exception as e: From b2b6984f817a60ee12763a3b9de508908814af0d Mon Sep 17 00:00:00 2001 From: krowslyare Date: Tue, 13 Oct 2026 11:33:21 -0500 Subject: [PATCH 08/12] fix(dashboard): show only the signed-in tenant's properties The property selector was a hardcoded list of all five properties, so each client saw the other client's property names, and Ocean's prop-001 (Mountain Lodge Beta) was labelled "Beach House Alpha", which is Sunset's prop-001. It is now loaded from a new tenant-scoped GET /dashboard/properties. Also removes two misleading UI details: a hardcoded "12%" trend badge and the X-Simulated-Tenant header (always "candidate"), which the backend never reads; the tenant comes only from the auth token. Co-Authored-By: Claude Opus 5.5 --- backend/app/api/v1/dashboard.py | 31 +++++++++++++++---- backend/app/services/reservations.py | 25 +++++++++++++++- frontend/src/components/Dashboard.tsx | 35 ++++++++++++++-------- frontend/src/components/RevenueSummary.tsx | 20 ++++--------- frontend/src/lib/secureApi.ts | 21 ++++++------- 5 files changed, 88 insertions(+), 44 deletions(-) diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index 5fa856332..8048c1b4e 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -1,11 +1,34 @@ from fastapi import APIRouter, Depends, HTTPException, Query -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional from app.services.cache import get_revenue_summary -from app.services.reservations import MixedCurrencyError, PropertyNotFoundError, RevenueUnavailableError +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, @@ -14,9 +37,7 @@ async def get_dashboard_summary( current_user: dict = Depends(get_current_user) ) -> Dict[str, Any]: - 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") + 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") diff --git a/backend/app/services/reservations.py b/backend/app/services/reservations.py index b3c978c58..0d1826d45 100644 --- a/backend/app/services/reservations.py +++ b/backend/app/services/reservations.py @@ -1,6 +1,6 @@ from datetime import datetime from decimal import Decimal, ROUND_HALF_UP -from typing import Any, Dict, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple from zoneinfo import ZoneInfo import logging @@ -118,3 +118,26 @@ async def calculate_total_revenue( "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/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index a21bba404..67783e20d 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -1,16 +1,25 @@ -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(''); + + useEffect(() => { + SecureAPI.getDashboardProperties() + .then((list) => { + setProperties(list); + setSelectedProperty(list[0]?.id ?? ''); + }) + .catch((err) => console.error('Failed to load properties', err)); + }, []); return (
@@ -26,7 +35,7 @@ const Dashboard: React.FC = () => { Monthly performance insights for your properties

- + {/* Property Selector */}
@@ -35,7 +44,7 @@ const Dashboard: React.FC = () => { onChange={(e) => setSelectedProperty(e.target.value)} className="block w-full sm:w-auto min-w-[200px] 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" > - {PROPERTIES.map((property) => ( + {properties.map((property) => ( @@ -46,7 +55,9 @@ const Dashboard: React.FC = () => {
- + {selectedProperty && ( + + )}
diff --git a/frontend/src/components/RevenueSummary.tsx b/frontend/src/components/RevenueSummary.tsx index 4d20153c3..f3299e3fc 100644 --- a/frontend/src/components/RevenueSummary.tsx +++ b/frontend/src/components/RevenueSummary.tsx @@ -14,21 +14,20 @@ interface RevenueSummaryProps { showRaw?: boolean; } -export const RevenueSummary: React.FC = ({ propertyId = 'prop-001', debugTenant, showRaw }) => { +export const RevenueSummary: React.FC = ({ propertyId, showRaw }) => { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); - const activeTenant = debugTenant || 'candidate'; - useEffect(() => { + if (!propertyId) return; 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, timestamp: Date.now() }); setData(response); @@ -41,7 +40,7 @@ export const RevenueSummary: React.FC = ({ propertyId = 'pr }; fetchRevenue(); - }, [propertyId, activeTenant]); + }, [propertyId]); if (loading) { return ( @@ -81,13 +80,6 @@ export const RevenueSummary: React.FC = ({ propertyId = 'pr {data.currency} {displayTotal.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} - {/* Fake trend indicator for premium feel */} - - - 12% - diff --git a/frontend/src/lib/secureApi.ts b/frontend/src/lib/secureApi.ts index f85f04c90..9570fef0f 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,17 @@ export class SecureAPIClient { /** * Get dashboard summary with optional simulation header */ - async getDashboardSummary(propertyId: string, options?: { simulatedTenant?: string, timestamp?: number }) { + async getDashboardSummary(propertyId: string, options?: { timestamp?: number }) { const queryParams = new URLSearchParams({ property_id: propertyId }); 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) { From a0c921099fda158c20767e37c5eddd5c0dff0159 Mon Sep 17 00:00:00 2001 From: krowslyare Date: Tue, 13 Oct 2026 11:37:00 -0500 Subject: [PATCH 09/12] feat(dashboard): month selector for the revenue summary The dashboard is labelled "Monthly performance insights" but had no way to pick a month. Adds a month picker (property local time) that passes year/month to /dashboard/summary; leaving it empty shows the all-time total. Co-Authored-By: Claude Opus 5.5 --- frontend/src/components/Dashboard.tsx | 47 +++++++++++++++------- frontend/src/components/RevenueSummary.tsx | 9 +++-- frontend/src/lib/secureApi.ts | 6 ++- 3 files changed, 43 insertions(+), 19 deletions(-) diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index 67783e20d..82e89b59b 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -11,6 +11,8 @@ const Dashboard: React.FC = () => { // Only the signed-in tenant's properties (property IDs repeat across tenants) const [properties, setProperties] = useState([]); const [selectedProperty, setSelectedProperty] = useState(''); + // "YYYY-MM" from the month picker; empty means all-time + const [selectedMonth, setSelectedMonth] = useState(''); useEffect(() => { SecureAPI.getDashboardProperties() @@ -21,6 +23,8 @@ const Dashboard: React.FC = () => { .catch((err) => console.error('Failed to load properties', err)); }, []); + const [year, month] = selectedMonth ? selectedMonth.split('-').map(Number) : [undefined, undefined]; + return (
@@ -36,27 +40,40 @@ const Dashboard: React.FC = () => {

- {/* 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 */} +
+ + +
{selectedProperty && ( - + )}
diff --git a/frontend/src/components/RevenueSummary.tsx b/frontend/src/components/RevenueSummary.tsx index f3299e3fc..69510eabd 100644 --- a/frontend/src/components/RevenueSummary.tsx +++ b/frontend/src/components/RevenueSummary.tsx @@ -10,11 +10,12 @@ interface RevenueData { interface RevenueSummaryProps { propertyId?: string; - debugTenant?: string; + year?: number; + month?: number; showRaw?: boolean; } -export const RevenueSummary: React.FC = ({ propertyId, showRaw }) => { +export const RevenueSummary: React.FC = ({ propertyId, year, month, showRaw }) => { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); @@ -28,6 +29,8 @@ export const RevenueSummary: React.FC = ({ propertyId, show // Use SecureAPI to handle authentication automatically. // The tenant is derived server-side from the auth token. const response = await SecureAPI.getDashboardSummary(propertyId, { + year, + month, timestamp: Date.now() }); setData(response); @@ -40,7 +43,7 @@ export const RevenueSummary: React.FC = ({ propertyId, show }; fetchRevenue(); - }, [propertyId]); + }, [propertyId, year, month]); if (loading) { return ( diff --git a/frontend/src/lib/secureApi.ts b/frontend/src/lib/secureApi.ts index 9570fef0f..28a285e86 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?: { 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()); } From 91713abbbceb3a71075a9045c45297b39e4253b2 Mon Sep 17 00:00:00 2001 From: krowslyare Date: Tue, 13 Oct 2026 11:43:11 -0500 Subject: [PATCH 10/12] fix(dashboard): handle property-list errors and stale revenue responses If loading the property list failed the dashboard rendered nothing, with no message; it now shows an error, or "No properties" for an empty account. Quickly switching property/month could let a slower, outdated response overwrite the current one; stale responses are now ignored. Co-Authored-By: Claude Opus 5.5 --- frontend/src/components/Dashboard.tsx | 14 +++++++++++++- frontend/src/components/RevenueSummary.tsx | 11 ++++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index 82e89b59b..ce55fe47a 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -11,6 +11,8 @@ const Dashboard: React.FC = () => { // 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(''); @@ -20,7 +22,11 @@ const Dashboard: React.FC = () => { setProperties(list); setSelectedProperty(list[0]?.id ?? ''); }) - .catch((err) => console.error('Failed to load properties', err)); + .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]; @@ -72,6 +78,12 @@ const Dashboard: React.FC = () => {
+ {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 69510eabd..9add1862f 100644 --- a/frontend/src/components/RevenueSummary.tsx +++ b/frontend/src/components/RevenueSummary.tsx @@ -22,6 +22,8 @@ export const RevenueSummary: React.FC = ({ propertyId, year useEffect(() => { if (!propertyId) return; + // Ignore responses that arrive after the user picked another property/month + let cancelled = false; const fetchRevenue = async () => { setLoading(true); setError(''); @@ -33,16 +35,19 @@ export const RevenueSummary: React.FC = ({ propertyId, 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(); + return () => { + cancelled = true; + }; }, [propertyId, year, month]); if (loading) { From 11117ccfd9df08f4d65b621e5a4706841b6578e2 Mon Sep 17 00:00:00 2001 From: krowslyare Date: Tue, 13 Oct 2026 11:53:18 -0500 Subject: [PATCH 11/12] test(revenue): unit tests for money rounding and month bounds Covers the rounding policy (one half-up rounding on the total: 1000.00 vs 999.99 when rounding per booking) and month bounds in the property's timezone, including the Paris check-in that falls on 1 March local time and the DST month. Pure functions, no database needed: cd backend && uv run --no-project --with pytest pytest -q -p no:cacheprovider Co-Authored-By: Claude Opus 5.5 --- backend/tests/test_revenue_helpers.py | 35 +++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 backend/tests/test_revenue_helpers.py 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) From 80e26e2ed9376a05bd114d9649f4ebe6b0a22da9 Mon Sep 17 00:00:00 2001 From: krowslyare Date: Tue, 13 Oct 2026 12:07:58 -0500 Subject: [PATCH 12/12] fix(db): normalize any Postgres URL scheme for the async engine The pool built its URL with a string replace of "postgresql://", so a legacy "postgres://" (or "postgresql+psycopg2://") DATABASE_URL would keep an unsupported driver and the pool would never start. Parse the URL and set the asyncpg driver explicitly. Co-Authored-By: Claude Opus 5.5 --- backend/app/core/database_pool.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/backend/app/core/database_pool.py b/backend/app/core/database_pool.py index 6a54636f7..b5ffd63b7 100644 --- a/backend/app/core/database_pool.py +++ b/backend/app/core/database_pool.py @@ -1,3 +1,4 @@ +from sqlalchemy.engine import make_url from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker import logging from ..config import settings @@ -14,9 +15,9 @@ async def initialize(self): try: # Create async engine with connection pooling (async engines use # AsyncAdaptedQueuePool by default; QueuePool is sync-only). - database_url = settings.database_url.replace( - "postgresql://", "postgresql+asyncpg://", 1 - ) + # 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,