From 2db5deaf2e797537c33b02c32ca185f11a7398b2 Mon Sep 17 00:00:00 2001 From: Girish Gopaul Date: Wed, 29 Jul 2026 17:33:39 +0400 Subject: [PATCH 1/5] fix(backend): scope revenue summaries by tenant, month, and currency --- backend/app/api/v1/dashboard.py | 13 +++- backend/app/services/cache.py | 19 ++++-- backend/app/services/reservations.py | 96 +++++++++++++++------------- 3 files changed, 79 insertions(+), 49 deletions(-) diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index 1ec352d7e..54966dec2 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, Query from typing import Dict, Any from app.services.cache import get_revenue_summary from app.core.auth import authenticate_request as get_current_user @@ -8,12 +8,20 @@ @router.get("/dashboard/summary") async def get_dashboard_summary( property_id: str, + month: str = Query(..., pattern=r"^\d{4}-(0[1-9]|1[0-2])$"), + currency: str = Query("USD", pattern=r"^[A-Za-z]{3}$"), 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) + normalized_currency = currency.upper() + revenue_data = await get_revenue_summary( + property_id, + tenant_id, + month, + normalized_currency, + ) total_revenue_float = float(revenue_data['total']) @@ -21,5 +29,6 @@ async def get_dashboard_summary( "property_id": revenue_data['property_id'], "total_revenue": total_revenue_float, "currency": revenue_data['currency'], + "month": revenue_data['month'], "reservations_count": revenue_data['count'] } diff --git a/backend/app/services/cache.py b/backend/app/services/cache.py index b81474957..0d3f5cc35 100644 --- a/backend/app/services/cache.py +++ b/backend/app/services/cache.py @@ -6,11 +6,17 @@ # 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: str, + currency: str = "USD", +) -> Dict[str, Any]: """ Fetches revenue summary, utilizing caching to improve performance. """ - cache_key = f"revenue:{property_id}" + currency = currency.upper() + cache_key = f"revenue:{tenant_id}:{property_id}:{month}:{currency}" # Try to get from cache cached = await redis_client.get(cache_key) @@ -18,10 +24,15 @@ async def get_revenue_summary(property_id: str, tenant_id: str) -> Dict[str, Any return json.loads(cached) # Revenue calculation is delegated to the reservation service. - from app.services.reservations import calculate_total_revenue + from app.services.reservations import calculate_monthly_revenue # Calculate revenue - result = await calculate_total_revenue(property_id, tenant_id) + result = await calculate_monthly_revenue( + property_id, + tenant_id, + month, + currency, + ) # 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 384bd00ab..b252e44ad 100644 --- a/backend/app/services/reservations.py +++ b/backend/app/services/reservations.py @@ -1,40 +1,24 @@ 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: +async def calculate_monthly_revenue( + property_id: str, + tenant_id: str, + month: str, + currency: str = "USD", +) -> Dict[str, Any]: """ - Calculates revenue for a specific month. + Aggregates revenue for a property's local calendar month and currency. """ - - start_date = datetime(year, month, 1) - if month < 12: - end_date = datetime(year, month + 1, 1) + start_date = datetime.strptime(month, "%Y-%m") + if start_date.month < 12: + end_year = start_date.year + end_month = start_date.month + 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 + end_year = start_date.year + 1 + end_month = 1 -async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, Any]: - """ - Aggregates revenue from database. - """ try: # Import database pool from app.core.database_pool import DatabasePool @@ -44,23 +28,39 @@ 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 query = text(""" SELECT - property_id, - SUM(total_amount) as total_revenue, + r.property_id, + SUM(r.total_amount) as total_revenue, COUNT(*) as reservation_count - FROM reservations - WHERE property_id = :property_id AND tenant_id = :tenant_id - GROUP BY property_id + FROM reservations r + JOIN properties p + ON p.id = r.property_id + AND p.tenant_id = r.tenant_id + WHERE r.property_id = :property_id + AND r.tenant_id = :tenant_id + AND r.currency = :currency + AND r.check_in_date >= make_timestamptz( + :start_year, :start_month, 1, 0, 0, 0, p.timezone + ) + AND r.check_in_date < make_timestamptz( + :end_year, :end_month, 1, 0, 0, 0, p.timezone + ) + GROUP BY r.property_id """) result = await session.execute(query, { "property_id": property_id, - "tenant_id": tenant_id + "tenant_id": tenant_id, + "currency": currency, + "start_year": start_date.year, + "start_month": start_date.month, + "end_year": end_year, + "end_month": end_month, }) row = result.fetchone() @@ -70,7 +70,8 @@ async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, "property_id": property_id, "tenant_id": tenant_id, "total": str(total_revenue), - "currency": "USD", + "currency": currency, + "month": month, "count": row.reservation_count } else: @@ -79,31 +80,40 @@ async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, "property_id": property_id, "tenant_id": tenant_id, "total": "0.00", - "currency": "USD", + "currency": currency, + "month": month, "count": 0 } else: raise Exception("Database pool not available") except Exception as e: - print(f"Database error for {property_id} (tenant: {tenant_id}): {e}") + print( + f"Database error for {property_id} " + f"(tenant: {tenant_id}, month: {month}, currency: {currency}): {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-001': {'total': '2250.00', '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}) + mock_property_data = ( + mock_data.get(property_id, {'total': '0.00', 'count': 0}) + if month == "2024-03" and currency == "USD" + else {'total': '0.00', 'count': 0} + ) return { "property_id": property_id, "tenant_id": tenant_id, "total": mock_property_data['total'], - "currency": "USD", + "currency": currency, + "month": month, "count": mock_property_data['count'] } From 9b7f10ef63e5c9112f041b5a28337fe06166c7ed Mon Sep 17 00:00:00 2001 From: Girish Gopaul Date: Wed, 29 Jul 2026 17:40:47 +0400 Subject: [PATCH 2/5] feat(frontend): add month and currency filters to revenue dashboard --- frontend/src/components/Dashboard.tsx | 85 ++++++++++++++++++---- frontend/src/components/RevenueSummary.tsx | 20 +++-- frontend/src/lib/secureApi.ts | 17 +++-- 3 files changed, 93 insertions(+), 29 deletions(-) diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index a21bba404..6f4db5772 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -9,8 +9,32 @@ const PROPERTIES = [ { id: 'prop-005', name: 'Urban Loft Modern' } ]; +const MONTH_OPTIONS = (() => { + const options: Array<{ value: string; label: string }> = []; + const today = new Date(); + + for (let year = 2024; year <= today.getFullYear(); year += 1) { + const lastMonth = year === today.getFullYear() ? today.getMonth() : 11; + + for (let month = 0; month <= lastMonth; month += 1) { + const value = `${year}-${String(month + 1).padStart(2, '0')}`; + const label = new Intl.DateTimeFormat(undefined, { + month: 'long', + year: 'numeric', + timeZone: 'UTC' + }).format(new Date(Date.UTC(year, month, 1))); + + options.push({ value, label }); + } + } + + return options.reverse(); +})(); + const Dashboard: React.FC = () => { const [selectedProperty, setSelectedProperty] = useState('prop-001'); + const [selectedMonth, setSelectedMonth] = useState('2024-03'); + const [selectedCurrency, setSelectedCurrency] = useState('USD'); return (
@@ -27,26 +51,57 @@ const Dashboard: React.FC = () => {

- {/* Property Selector */} -
- - +
+
+ + +
+ +
+ + +
+ +
+ + +
- +
diff --git a/frontend/src/components/RevenueSummary.tsx b/frontend/src/components/RevenueSummary.tsx index dbb6d0629..3bfdae13f 100644 --- a/frontend/src/components/RevenueSummary.tsx +++ b/frontend/src/components/RevenueSummary.tsx @@ -5,22 +5,27 @@ interface RevenueData { property_id: string; total_revenue: number; currency: string; + month: string; reservations_count: number; } interface RevenueSummaryProps { - propertyId?: string; - debugTenant?: string; + propertyId: string; + month: string; + currency: string; showRaw?: boolean; } -export const RevenueSummary: React.FC = ({ propertyId = 'prop-001', debugTenant, showRaw }) => { +export const RevenueSummary: React.FC = ({ + propertyId, + month, + currency, + showRaw +}) => { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); - const activeTenant = debugTenant || 'candidate'; - useEffect(() => { const fetchRevenue = async () => { setLoading(true); @@ -28,7 +33,8 @@ export const RevenueSummary: React.FC = ({ propertyId = 'pr // Use SecureAPI to handle authentication automatically // We pass the simulatedTenant option which SecureAPI will attach as a header const response = await SecureAPI.getDashboardSummary(propertyId, { - simulatedTenant: activeTenant, + month, + currency, timestamp: Date.now() }); setData(response); @@ -41,7 +47,7 @@ export const RevenueSummary: React.FC = ({ propertyId = 'pr }; fetchRevenue(); - }, [propertyId, activeTenant]); + }, [propertyId, month, currency]); if (loading) { return ( diff --git a/frontend/src/lib/secureApi.ts b/frontend/src/lib/secureApi.ts index f85f04c90..8a6debed2 100644 --- a/frontend/src/lib/secureApi.ts +++ b/frontend/src/lib/secureApi.ts @@ -1452,18 +1452,21 @@ export class SecureAPIClient { /** * Get dashboard summary with optional simulation header */ - async getDashboardSummary(propertyId: string, options?: { simulatedTenant?: string, timestamp?: number }) { - const queryParams = new URLSearchParams({ property_id: propertyId }); + async getDashboardSummary(propertyId: string, options: { + month: string; + currency?: string; + timestamp?: number; + }) { + const queryParams = new URLSearchParams({ + property_id: propertyId, + month: options.month, + currency: options.currency || 'USD' + }); 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}`, requestOptions); } From c68693ff2ce021de09f7c302b37bfbe4718d2f5e Mon Sep 17 00:00:00 2001 From: Girish Gopaul Date: Wed, 29 Jul 2026 18:04:05 +0400 Subject: [PATCH 3/5] fix(backend): preserve decimal precision in monthly totals - round aggregated revenue to cents using ROUND_HALF_UP - return revenue totals as fixed-point strings - reuse the shared database pool --- backend/app/api/v1/dashboard.py | 4 +--- backend/app/services/reservations.py | 14 +++++++------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index 54966dec2..33c79d806 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -23,11 +23,9 @@ async def get_dashboard_summary( normalized_currency, ) - total_revenue_float = float(revenue_data['total']) - return { "property_id": revenue_data['property_id'], - "total_revenue": total_revenue_float, + "total_revenue": revenue_data['total'], "currency": revenue_data['currency'], "month": revenue_data['month'], "reservations_count": revenue_data['count'] diff --git a/backend/app/services/reservations.py b/backend/app/services/reservations.py index b252e44ad..f5216963b 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 async def calculate_monthly_revenue( @@ -20,15 +20,12 @@ async def calculate_monthly_revenue( end_month = 1 try: - # Import database pool - from app.core.database_pool import DatabasePool + from app.core.database_pool import db_pool - # Initialize pool if needed - db_pool = DatabasePool() await db_pool.initialize() if db_pool.session_factory: - async with await db_pool.get_session() as session: + async with db_pool.get_session() as session: # Use SQLAlchemy text for raw SQL from sqlalchemy import text @@ -66,10 +63,13 @@ async def calculate_monthly_revenue( if row: total_revenue = Decimal(str(row.total_revenue)) + formatted_total = str( + total_revenue.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + ) return { "property_id": property_id, "tenant_id": tenant_id, - "total": str(total_revenue), + "total": formatted_total, "currency": currency, "month": month, "count": row.reservation_count From cda728950ba0c48cfac5e06dfd2cbe417eb7bc65 Mon Sep 17 00:00:00 2001 From: Girish Gopaul Date: Wed, 29 Jul 2026 18:04:47 +0400 Subject: [PATCH 4/5] fix(frontend): display revenue totals as decimal strings --- frontend/src/components/RevenueSummary.tsx | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/RevenueSummary.tsx b/frontend/src/components/RevenueSummary.tsx index 3bfdae13f..87db129ae 100644 --- a/frontend/src/components/RevenueSummary.tsx +++ b/frontend/src/components/RevenueSummary.tsx @@ -3,7 +3,7 @@ import { SecureAPI } from '../lib/secureApi'; interface RevenueData { property_id: string; - total_revenue: number; + total_revenue: string; currency: string; month: string; reservations_count: number; @@ -67,8 +67,6 @@ export const RevenueSummary: React.FC = ({ if (error) return
{error}
; if (!data) return null; - const displayTotal = Math.round(data.total_revenue * 100) / 100; - return (
{showRaw && ( @@ -84,7 +82,7 @@ export const RevenueSummary: React.FC = ({

Total Revenue

- {data.currency} {displayTotal.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + {data.currency} {data.total_revenue} {/* Fake trend indicator for premium feel */} @@ -107,18 +105,6 @@ export const RevenueSummary: React.FC = ({

{data.reservations_count} bookings

- - {/* Precision Warning Area */} -
- {Math.abs(data.total_revenue - displayTotal) > 0.000001 && showRaw && ( -
- - - - Precision Mismatch Detected -
- )} -
); From 2418d753165cc1adfd3fe6a1ab2ba30560a63607 Mon Sep 17 00:00:00 2001 From: Girish Gopaul Date: Wed, 29 Jul 2026 18:35:56 +0400 Subject: [PATCH 5/5] fix: Use local DB instead --- backend/app/core/database_pool.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/backend/app/core/database_pool.py b/backend/app/core/database_pool.py index d638dfcfe..cbf9f4993 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 @@ -13,18 +11,19 @@ def __init__(self): async def initialize(self): """Initialize database connection pool""" + if self.session_factory: + return + try: - # Create async engine with connection pooling - database_url = f"postgresql+asyncpg://{settings.supabase_db_user}:{settings.supabase_db_password}@{settings.supabase_db_host}:{settings.supabase_db_port}/{settings.supabase_db_name}" + database_url = 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 - pool_recycle=3600, # Recycle connections every hour - echo=False # Set to True for SQL debugging + pool_pre_ping=True, ) self.session_factory = async_sessionmaker( @@ -45,7 +44,7 @@ async def close(self): if self.engine: await self.engine.dispose() - async def get_session(self) -> AsyncSession: + def get_session(self) -> AsyncSession: """Get database session from pool""" if not self.session_factory: raise Exception("Database pool not initialized")