From 2ce05784e2b54678a485f4313e7b76acab346694 Mon Sep 17 00:00:00 2001 From: rahulanand2 Date: Mon, 27 Jul 2026 13:58:59 +0100 Subject: [PATCH 1/6] restored DB access and remove mock revenue fallback --- backend/app/core/database_pool.py | 6 ++---- backend/app/services/reservations.py | 24 +++--------------------- 2 files changed, 5 insertions(+), 25 deletions(-) diff --git a/backend/app/core/database_pool.py b/backend/app/core/database_pool.py index d638dfcfe..250af75bb 100644 --- a/backend/app/core/database_pool.py +++ b/backend/app/core/database_pool.py @@ -1,6 +1,5 @@ import asyncio from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker -from sqlalchemy.pool import QueuePool import logging from ..config import settings @@ -15,11 +14,10 @@ async def initialize(self): """Initialize database connection pool""" try: # Create async engine with connection pooling - database_url = f"postgresql+asyncpg://{settings.supabase_db_user}:{settings.supabase_db_password}@{settings.supabase_db_host}:{settings.supabase_db_port}/{settings.supabase_db_name}" - + database_url = settings.database_url.replace("postgresql://", "postgresql+asyncpg://", 1) + self.engine = create_async_engine( database_url, - poolclass=QueuePool, pool_size=20, # Number of connections to maintain max_overflow=30, # Additional connections when needed pool_pre_ping=True, # Validate connections diff --git a/backend/app/services/reservations.py b/backend/app/services/reservations.py index 384bd00ab..4b4f2b3c4 100644 --- a/backend/app/services/reservations.py +++ b/backend/app/services/reservations.py @@ -44,7 +44,7 @@ async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, await db_pool.initialize() if db_pool.session_factory: - async with db_pool.get_session() as session: + async with await db_pool.get_session() as session: # Use SQLAlchemy text for raw SQL from sqlalchemy import text @@ -87,23 +87,5 @@ async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, except Exception as e: print(f"Database error for {property_id} (tenant: {tenant_id}): {e}") - - # Create property-specific mock data for testing when DB is unavailable - # This ensures each property shows different figures - mock_data = { - 'prop-001': {'total': '1000.00', 'count': 3}, - 'prop-002': {'total': '4975.50', 'count': 4}, - 'prop-003': {'total': '6100.50', 'count': 2}, - 'prop-004': {'total': '1776.50', 'count': 4}, - 'prop-005': {'total': '3256.00', 'count': 3} - } - - mock_property_data = mock_data.get(property_id, {'total': '0.00', 'count': 0}) - - return { - "property_id": property_id, - "tenant_id": tenant_id, - "total": mock_property_data['total'], - "currency": "USD", - "count": mock_property_data['count'] - } + # Never substitute fabricated figures for a failed query - propagate instead + raise From fdd85e6fe8840283b928922042360a98c8095efb Mon Sep 17 00:00:00 2001 From: rahulanand2 Date: Mon, 27 Jul 2026 14:16:50 +0100 Subject: [PATCH 2/6] isolate revenue cache by tenant and fix default tennant issue. --- backend/app/api/v1/dashboard.py | 6 ++++-- backend/app/services/cache.py | 11 ++++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index 1ec352d7e..ffa4d6c24 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -11,8 +11,10 @@ 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="No tenant associated with user") + revenue_data = await get_revenue_summary(property_id, tenant_id) total_revenue_float = float(revenue_data['total']) diff --git a/backend/app/services/cache.py b/backend/app/services/cache.py index b81474957..ecd45844a 100644 --- a/backend/app/services/cache.py +++ b/backend/app/services/cache.py @@ -10,12 +10,17 @@ 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 segment isolates clients; trailing segment is reserved for month scoping + cache_key = f"revenue:{tenant_id}:{property_id}:all" + # Try to get from cache cached = await redis_client.get(cache_key) if cached: - return json.loads(cached) + result = json.loads(cached) + # Tripwire: a mis-keyed entry must fail closed as a miss, never cross tenants + if result.get("tenant_id") == tenant_id: + return result + print(f"Cache tenant mismatch for {cache_key}: expected {tenant_id}, got {result.get('tenant_id')} - treating as miss") # Revenue calculation is delegated to the reservation service. from app.services.reservations import calculate_total_revenue From 3c2eb7b134d8900c0f701dd78d147003d763c45c Mon Sep 17 00:00:00 2001 From: rahulanand2 Date: Mon, 27 Jul 2026 14:28:07 +0100 Subject: [PATCH 3/6] isolate revenue cache by tenant and fix default tennant issue. --- backend/app/api/v1/dashboard.py | 11 +++-- backend/app/services/cache.py | 21 ++++----- backend/app/services/reservations.py | 67 +++++++++++++++++++++------- 3 files changed, 71 insertions(+), 28 deletions(-) diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index ffa4d6c24..b76b4f5c0 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -1,5 +1,6 @@ +import re from fastapi import APIRouter, Depends, HTTPException -from typing import Dict, Any +from typing import Dict, Any, Optional from app.services.cache import get_revenue_summary from app.core.auth import authenticate_request as get_current_user @@ -8,14 +9,18 @@ @router.get("/dashboard/summary") async def get_dashboard_summary( property_id: str, + month: Optional[str] = None, current_user: dict = Depends(get_current_user) ) -> Dict[str, Any]: - + tenant_id = getattr(current_user, "tenant_id", None) if not tenant_id: raise HTTPException(status_code=403, detail="No tenant associated with user") - revenue_data = await get_revenue_summary(property_id, tenant_id) + if month is not None and not re.fullmatch(r"\d{4}-(0[1-9]|1[0-2])", month): + raise HTTPException(status_code=400, detail="month must be in YYYY-MM format") + + revenue_data = await get_revenue_summary(property_id, tenant_id, month) total_revenue_float = float(revenue_data['total']) diff --git a/backend/app/services/cache.py b/backend/app/services/cache.py index ecd45844a..fa16c17f1 100644 --- a/backend/app/services/cache.py +++ b/backend/app/services/cache.py @@ -1,32 +1,33 @@ import json import redis.asyncio as redis -from typing import Dict, Any +from typing import Dict, Any, Optional import os # Initialize Redis client (typically configured centrally). redis_client = redis.Redis.from_url(os.getenv("REDIS_URL", "redis://localhost:6379/0")) -async def get_revenue_summary(property_id: str, tenant_id: str) -> Dict[str, Any]: +async def get_revenue_summary(property_id: str, tenant_id: str, month: Optional[str] = None) -> Dict[str, Any]: """ Fetches revenue summary, utilizing caching to improve performance. """ - # Tenant segment isolates clients; trailing segment is reserved for month scoping - cache_key = f"revenue:{tenant_id}:{property_id}:all" + # Tenant segment isolates clients; period segment keeps months and all-time apart + period = month or "all" + cache_key = f"revenue:{tenant_id}:{property_id}:{period}" # Try to get from cache cached = await redis_client.get(cache_key) if cached: result = json.loads(cached) - # Tripwire: a mis-keyed entry must fail closed as a miss, never cross tenants - if result.get("tenant_id") == tenant_id: + # Tripwire: a mis-keyed entry must fail closed as a miss, never cross scopes + if result.get("tenant_id") == tenant_id and result.get("period") == period: return result - print(f"Cache tenant mismatch for {cache_key}: expected {tenant_id}, got {result.get('tenant_id')} - treating as miss") - + print(f"Cache scope mismatch for {cache_key}: expected {tenant_id}/{period}, got {result.get('tenant_id')}/{result.get('period')} - treating as miss") + # Revenue calculation is delegated to the reservation service. from app.services.reservations import calculate_total_revenue - + # Calculate revenue - result = await calculate_total_revenue(property_id, tenant_id) + result = await calculate_total_revenue(property_id, tenant_id, month) # Cache the result for 5 minutes await redis_client.setex(cache_key, 300, json.dumps(result)) diff --git a/backend/app/services/reservations.py b/backend/app/services/reservations.py index 4b4f2b3c4..eb9ecf1c5 100644 --- a/backend/app/services/reservations.py +++ b/backend/app/services/reservations.py @@ -1,6 +1,28 @@ -from datetime import datetime +from datetime import datetime, timezone from decimal import Decimal -from typing import Dict, Any, List +from typing import Dict, Any, List, Optional, Tuple +from zoneinfo import ZoneInfo + + +def month_bounds_utc(month: str, tz_name: str) -> Tuple[datetime, datetime]: + """Half-open UTC interval [start, end) covering the property-local month. + + Each bound is derived from its own local wall time, so a month containing + a DST transition gets different UTC offsets at each edge. + Unrecognised/empty timezones fall back to UTC bucketing rather than failing. + """ + year, mon = int(month[:4]), int(month[5:7]) + try: + tz = ZoneInfo(tz_name) if tz_name else timezone.utc + except Exception: + print(f"Unrecognised property timezone '{tz_name}' - falling back to UTC bucketing") + tz = timezone.utc + start_local = datetime(year, mon, 1, tzinfo=tz) + if mon < 12: + end_local = datetime(year, mon + 1, 1, tzinfo=tz) + else: + end_local = datetime(year + 1, 1, 1, tzinfo=tz) + return start_local.astimezone(timezone.utc), end_local.astimezone(timezone.utc) async def calculate_monthly_revenue(property_id: str, month: int, year: int, db_session=None) -> Decimal: """ @@ -31,10 +53,11 @@ async def calculate_monthly_revenue(property_id: str, month: int, year: int, db_ return Decimal('0') # Placeholder for now until DB connection is finalized -async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, Any]: +async def calculate_total_revenue(property_id: str, tenant_id: str, month: Optional[str] = None) -> Dict[str, Any]: """ - Aggregates revenue from database. + Aggregates revenue from database, optionally scoped to a property-local month (YYYY-MM). """ + period = month or "all" try: # Import database pool from app.core.database_pool import DatabasePool @@ -47,21 +70,33 @@ async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, async with await db_pool.get_session() as session: # Use SQLAlchemy text for raw SQL from sqlalchemy import text - - query = text(""" - SELECT + + params = {"property_id": property_id, "tenant_id": tenant_id} + date_filter = "" + if month: + # Bucket by the property's local calendar month, comparing in UTC + # so the check_in_date column stays index-friendly + tz_result = await session.execute(text(""" + SELECT timezone FROM properties + WHERE id = :property_id AND tenant_id = :tenant_id + """), params) + tz_row = tz_result.fetchone() + tz_name = tz_row.timezone if tz_row else None + start_utc, end_utc = month_bounds_utc(month, tz_name) + date_filter = " AND check_in_date >= :start_utc AND check_in_date < :end_utc" + params.update({"start_utc": start_utc, "end_utc": end_utc}) + + query = text(f""" + SELECT property_id, SUM(total_amount) as total_revenue, COUNT(*) as reservation_count - FROM reservations - WHERE property_id = :property_id AND tenant_id = :tenant_id + FROM reservations + WHERE property_id = :property_id AND tenant_id = :tenant_id{date_filter} GROUP BY property_id """) - - result = await session.execute(query, { - "property_id": property_id, - "tenant_id": tenant_id - }) + + result = await session.execute(query, params) row = result.fetchone() if row: @@ -69,8 +104,9 @@ async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, return { "property_id": property_id, "tenant_id": tenant_id, + "period": period, "total": str(total_revenue), - "currency": "USD", + "currency": "USD", "count": row.reservation_count } else: @@ -78,6 +114,7 @@ async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, return { "property_id": property_id, "tenant_id": tenant_id, + "period": period, "total": "0.00", "currency": "USD", "count": 0 From 634adc08ae5d1efb74a810495ea0a12303d71c5f Mon Sep 17 00:00:00 2001 From: rahulanand2 Date: Mon, 27 Jul 2026 14:36:44 +0100 Subject: [PATCH 4/6] preserve Decimal precision for monetary values --- backend/app/api/v1/dashboard.py | 11 +++++++---- frontend/src/components/RevenueSummary.tsx | 21 +++++++-------------- 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index b76b4f5c0..28886b65d 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -1,4 +1,5 @@ import re +from decimal import Decimal, ROUND_HALF_UP from fastapi import APIRouter, Depends, HTTPException from typing import Dict, Any, Optional from app.services.cache import get_revenue_summary @@ -21,12 +22,14 @@ async def get_dashboard_summary( raise HTTPException(status_code=400, detail="month must be in YYYY-MM format") revenue_data = await get_revenue_summary(property_id, tenant_id, month) - - total_revenue_float = float(revenue_data['total']) - + + # Quantize once, at presentation. Explicit ROUND_HALF_UP: finance expects + # half-up; Python's Decimal default is ROUND_HALF_EVEN (banker's rounding). + total_revenue = Decimal(revenue_data['total']).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) + return { "property_id": revenue_data['property_id'], - "total_revenue": total_revenue_float, + "total_revenue": str(total_revenue), "currency": revenue_data['currency'], "reservations_count": revenue_data['count'] } diff --git a/frontend/src/components/RevenueSummary.tsx b/frontend/src/components/RevenueSummary.tsx index dbb6d0629..0194f5726 100644 --- a/frontend/src/components/RevenueSummary.tsx +++ b/frontend/src/components/RevenueSummary.tsx @@ -3,7 +3,8 @@ import { SecureAPI } from '../lib/secureApi'; interface RevenueData { property_id: string; - total_revenue: number; + // Decimal string quantized to 2dp server-side; never do float math on it + total_revenue: string; currency: string; reservations_count: number; } @@ -61,7 +62,7 @@ export const RevenueSummary: React.FC = ({ propertyId = 'pr if (error) return
{error}
; if (!data) return null; - const displayTotal = Math.round(data.total_revenue * 100) / 100; + const displayTotal = data.total_revenue; return (
@@ -78,7 +79,7 @@ export const RevenueSummary: React.FC = ({ propertyId = 'pr

Total Revenue

- {data.currency} {displayTotal.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + {data.currency} {displayTotal} {/* Fake trend indicator for premium feel */} @@ -102,17 +103,9 @@ export const RevenueSummary: React.FC = ({ propertyId = 'pr
- {/* Precision Warning Area */} -
- {Math.abs(data.total_revenue - displayTotal) > 0.000001 && showRaw && ( -
- - - - Precision Mismatch Detected -
- )} -
+ {/* Precision warning removed: the API now sends an exact 2dp decimal + string, so display can no longer diverge from the payload */} +
); From e8eba0cec9997f5d66265456c1121e323519f515 Mon Sep 17 00:00:00 2001 From: rahulanand2 Date: Mon, 27 Jul 2026 14:42:56 +0100 Subject: [PATCH 5/6] fixed the use of correct currency and prevent mixed-currency totals --- backend/app/api/v1/dashboard.py | 6 +++++- backend/app/services/reservations.py | 22 +++++++++++++++++++--- frontend/src/components/RevenueSummary.tsx | 8 +++++++- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index 28886b65d..febe51b1d 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, HTTPException from typing import Dict, Any, Optional from app.services.cache import get_revenue_summary +from app.services.reservations import MixedCurrencyError from app.core.auth import authenticate_request as get_current_user router = APIRouter() @@ -21,7 +22,10 @@ async def get_dashboard_summary( if month is not None and not re.fullmatch(r"\d{4}-(0[1-9]|1[0-2])", month): raise HTTPException(status_code=400, detail="month must be in YYYY-MM format") - revenue_data = await get_revenue_summary(property_id, tenant_id, month) + try: + revenue_data = await get_revenue_summary(property_id, tenant_id, month) + except MixedCurrencyError as e: + raise HTTPException(status_code=409, detail=str(e)) # Quantize once, at presentation. Explicit ROUND_HALF_UP: finance expects # half-up; Python's Decimal default is ROUND_HALF_EVEN (banker's rounding). diff --git a/backend/app/services/reservations.py b/backend/app/services/reservations.py index eb9ecf1c5..9c2fb8285 100644 --- a/backend/app/services/reservations.py +++ b/backend/app/services/reservations.py @@ -4,6 +4,10 @@ from zoneinfo import ZoneInfo +class MixedCurrencyError(Exception): + """Raised when a total would silently sum amounts in different currencies.""" + + def month_bounds_utc(month: str, tz_name: str) -> Tuple[datetime, datetime]: """Half-open UTC interval [start, end) covering the property-local month. @@ -90,7 +94,9 @@ async def calculate_total_revenue(property_id: str, tenant_id: str, month: Optio SELECT property_id, SUM(total_amount) as total_revenue, - COUNT(*) as reservation_count + COUNT(*) as reservation_count, + COUNT(DISTINCT COALESCE(NULLIF(currency, ''), 'USD')) as currency_count, + MIN(COALESCE(NULLIF(currency, ''), 'USD')) as currency FROM reservations WHERE property_id = :property_id AND tenant_id = :tenant_id{date_filter} GROUP BY property_id @@ -100,17 +106,25 @@ async def calculate_total_revenue(property_id: str, tenant_id: str, month: Optio row = result.fetchone() if row: + if row.currency_count > 1: + # 100 EUR + 100 USD is not 200 of anything without a + # conversion rate and the date that rate applied + raise MixedCurrencyError( + f"Property {property_id} has reservations in {row.currency_count} " + f"currencies for period {period}; refusing to sum without conversion" + ) total_revenue = Decimal(str(row.total_revenue)) return { "property_id": property_id, "tenant_id": tenant_id, "period": period, "total": str(total_revenue), - "currency": "USD", + "currency": row.currency, "count": row.reservation_count } else: - # No reservations found for this property + # No reservations found for this property; currency falls back + # to the schema default since there is nothing to read it from return { "property_id": property_id, "tenant_id": tenant_id, @@ -122,6 +136,8 @@ async def calculate_total_revenue(property_id: str, tenant_id: str, month: Optio else: raise Exception("Database pool not available") + except MixedCurrencyError: + raise except Exception as e: print(f"Database error for {property_id} (tenant: {tenant_id}): {e}") # Never substitute fabricated figures for a failed query - propagate instead diff --git a/frontend/src/components/RevenueSummary.tsx b/frontend/src/components/RevenueSummary.tsx index 0194f5726..f3fea39e0 100644 --- a/frontend/src/components/RevenueSummary.tsx +++ b/frontend/src/components/RevenueSummary.tsx @@ -15,6 +15,12 @@ interface RevenueSummaryProps { showRaw?: boolean; } +// String-safe thousands grouping; money must never round-trip through float +const fmt = (v: string) => { + const [i, d] = v.split('.'); + return i.replace(/\B(?=(\d{3})+(?!\d))/g, ',') + (d ? '.' + d : ''); +}; + export const RevenueSummary: React.FC = ({ propertyId = 'prop-001', debugTenant, showRaw }) => { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); @@ -79,7 +85,7 @@ export const RevenueSummary: React.FC = ({ propertyId = 'pr

Total Revenue

- {data.currency} {displayTotal} + {data.currency} {fmt(displayTotal)} {/* Fake trend indicator for premium feel */} From ef62b1b7b80a78069f432d837ce82e8c5c25df39 Mon Sep 17 00:00:00 2001 From: rahulanand2 Date: Mon, 27 Jul 2026 15:03:39 +0100 Subject: [PATCH 6/6] calculate_monthly_revenue function for caluclating revenue by month --- backend/app/services/reservations.py | 29 ---------------------------- 1 file changed, 29 deletions(-) diff --git a/backend/app/services/reservations.py b/backend/app/services/reservations.py index 9c2fb8285..af254b214 100644 --- a/backend/app/services/reservations.py +++ b/backend/app/services/reservations.py @@ -28,35 +28,6 @@ def month_bounds_utc(month: str, tz_name: str) -> Tuple[datetime, datetime]: end_local = datetime(year + 1, 1, 1, tzinfo=tz) return start_local.astimezone(timezone.utc), end_local.astimezone(timezone.utc) -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') - - return Decimal('0') # Placeholder for now until DB connection is finalized - async def calculate_total_revenue(property_id: str, tenant_id: str, month: Optional[str] = None) -> Dict[str, Any]: """ Aggregates revenue from database, optionally scoped to a property-local month (YYYY-MM).