From 9c25c23ab31b39ad073800c33cfbbd112e1a8e78 Mon Sep 17 00:00:00 2001 From: karthikeyan18v Date: Mon, 28 Sep 2026 20:11:46 +0530 Subject: [PATCH 1/4] Fix wrong and leaking revenue numbers on the dashboard Found these while going through the Sunset and Ocean accounts. 1. Ocean seeing Sunset's revenue The revenue cache key was only the property id (revenue:prop-001). Both tenants have a prop-001, so whoever loaded it first got cached and the other tenant saw those numbers for the next 5 minutes. Added tenant_id to the cache key. 2. Dashboard numbers not coming from the database The db pool built its url from settings.supabase_db_*, which don't exist in config. It also used QueuePool, which doesn't work with the async engine, and get_session was async so "async with" failed on it. All of that was caught by a try/except that returned a hardcoded mock dict, the same for every tenant. Now it uses DATABASE_URL with one shared pool and the mock fallback is removed. Added sqlalchemy[asyncio] so greenlet gets installed, the async engine won't load without it. 3. Sunset's March total doesn't match Month boundaries were done in UTC. res-tz-1 checks in at 2024-02-29 23:30 UTC, which is 1 March 00:30 in Paris, so for that property it's a March booking. The month filter now converts check_in_date to the property's timezone first. 4. Totals off by a few cents Amounts are stored with 3 decimals and the total went out as a raw float and got rounded in the browser. It's rounded to cents on the backend now, using Decimal with half up rounding. Also added two endpoints the dashboard uses: the tenant's properties, and the reservations for a property (optionally for one month). --- backend/app/api/v1/dashboard.py | 41 ++++-- backend/app/core/database_pool.py | 6 +- backend/app/services/cache.py | 25 ++-- backend/app/services/reservations.py | 208 +++++++++++++++------------ backend/requirements.txt | 2 +- 5 files changed, 166 insertions(+), 116 deletions(-) diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index 1ec352d7e..f702ed299 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -1,6 +1,8 @@ -from fastapi import APIRouter, Depends, HTTPException -from typing import Dict, Any +from fastapi import APIRouter, Depends, HTTPException, Query +from typing import Dict, Any, Optional +from decimal import Decimal, ROUND_HALF_UP from app.services.cache import get_revenue_summary +from app.services.reservations import get_tenant_properties, get_reservations from app.core.auth import authenticate_request as get_current_user router = APIRouter() @@ -8,18 +10,39 @@ @router.get("/dashboard/summary") async def get_dashboard_summary( property_id: str, + month: Optional[int] = Query(None, ge=1, le=12), + year: Optional[int] = None, current_user: dict = Depends(get_current_user) ) -> Dict[str, Any]: - + tenant_id = getattr(current_user, "tenant_id", "default_tenant") or "default_tenant" - - revenue_data = await get_revenue_summary(property_id, tenant_id) - - total_revenue_float = float(revenue_data['total']) - + + revenue_data = await get_revenue_summary(property_id, tenant_id, month, year) + + 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": float(total_revenue), "currency": revenue_data['currency'], "reservations_count": revenue_data['count'] } + + +@router.get("/dashboard/properties") +async def get_dashboard_properties( + current_user: dict = Depends(get_current_user) +) -> Dict[str, Any]: + tenant_id = getattr(current_user, "tenant_id", "default_tenant") or "default_tenant" + return {"properties": await get_tenant_properties(tenant_id)} + + +@router.get("/dashboard/reservations") +async def get_dashboard_reservations( + property_id: str, + month: Optional[int] = Query(None, ge=1, le=12), + year: Optional[int] = None, + current_user: dict = Depends(get_current_user) +) -> Dict[str, Any]: + tenant_id = getattr(current_user, "tenant_id", "default_tenant") or "default_tenant" + return {"reservations": await get_reservations(property_id, tenant_id, month, year)} diff --git a/backend/app/core/database_pool.py b/backend/app/core/database_pool.py index d638dfcfe..eea590f43 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 @@ -45,7 +43,7 @@ async def close(self): if self.engine: await self.engine.dispose() - async def get_session(self) -> AsyncSession: + def get_session(self) -> AsyncSession: """Get database session from pool""" if not self.session_factory: raise Exception("Database pool not initialized") diff --git a/backend/app/services/cache.py b/backend/app/services/cache.py index b81474957..a49022800 100644 --- a/backend/app/services/cache.py +++ b/backend/app/services/cache.py @@ -1,29 +1,34 @@ import json import redis.asyncio as redis -from typing import Dict, Any +from typing import 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[int] = None, year: Optional[int] = None) -> Dict[str, Any]: """ Fetches revenue summary, utilizing caching to improve performance. """ - cache_key = f"revenue:{property_id}" - + # property ids are not unique across tenants + period = f"{year}-{month:02d}" if month and year else "all" + cache_key = f"revenue:{tenant_id}:{property_id}:{period}" + # Try to get from cache cached = await redis_client.get(cache_key) if cached: return json.loads(cached) - + # Revenue calculation is delegated to the reservation service. - from app.services.reservations import calculate_total_revenue - + from app.services.reservations import calculate_total_revenue, calculate_monthly_revenue + # Calculate revenue - result = await calculate_total_revenue(property_id, tenant_id) - + if month and year: + result = await calculate_monthly_revenue(property_id, tenant_id, month, year) + else: + 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 diff --git a/backend/app/services/reservations.py b/backend/app/services/reservations.py index 384bd00ab..f3375c8bf 100644 --- a/backend/app/services/reservations.py +++ b/backend/app/services/reservations.py @@ -1,109 +1,133 @@ 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: +from sqlalchemy import text + +from app.core.database_pool import db_pool + + +async def _get_session(): + if db_pool.session_factory is None: + await db_pool.initialize() + if db_pool.session_factory is None: + raise Exception("Database pool not available") + return db_pool.get_session() + + +async def calculate_monthly_revenue(property_id: str, tenant_id: str, month: int, year: int) -> Dict[str, Any]: """ - Calculates revenue for a specific month. + Calculates revenue for a specific month, in the property's timezone. """ - 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 + query = text(""" + SELECT + COALESCE(SUM(r.total_amount), 0) as total_revenue, + COUNT(r.id) as reservation_count + FROM reservations r + JOIN properties p ON p.id = r.property_id AND p.tenant_id = r.tenant_id + WHERE r.property_id = :property_id + AND r.tenant_id = :tenant_id + AND (r.check_in_date AT TIME ZONE p.timezone) >= :start_date + AND (r.check_in_date AT TIME ZONE p.timezone) < :end_date + """) + + async with await _get_session() as session: + result = await session.execute(query, { + "property_id": property_id, + "tenant_id": tenant_id, + "start_date": start_date, + "end_date": end_date, + }) + row = result.fetchone() + + return { + "property_id": property_id, + "tenant_id": tenant_id, + "total": str(Decimal(str(row.total_revenue))), + "currency": "USD", + "count": row.reservation_count + } + async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, Any]: """ Aggregates revenue from database. """ - try: - # Import database pool - from app.core.database_pool import DatabasePool - - # Initialize pool if needed - db_pool = DatabasePool() - await db_pool.initialize() - - if db_pool.session_factory: - async with db_pool.get_session() as session: - # Use SQLAlchemy text for raw SQL - from sqlalchemy import text - - query = text(""" - SELECT - property_id, - SUM(total_amount) as total_revenue, - COUNT(*) as reservation_count - FROM reservations - WHERE property_id = :property_id AND tenant_id = :tenant_id - GROUP BY property_id - """) - - result = await session.execute(query, { - "property_id": property_id, - "tenant_id": tenant_id - }) - row = result.fetchone() - - if row: - total_revenue = Decimal(str(row.total_revenue)) - return { - "property_id": property_id, - "tenant_id": tenant_id, - "total": str(total_revenue), - "currency": "USD", - "count": row.reservation_count - } - else: - # No reservations found for this property - return { - "property_id": property_id, - "tenant_id": tenant_id, - "total": "0.00", - "currency": "USD", - "count": 0 - } - else: - raise Exception("Database pool not available") - - except Exception as e: - print(f"Database error for {property_id} (tenant: {tenant_id}): {e}") - - # Create property-specific mock data for testing when DB is unavailable - # This ensures each property shows different figures - mock_data = { - 'prop-001': {'total': '1000.00', 'count': 3}, - 'prop-002': {'total': '4975.50', 'count': 4}, - 'prop-003': {'total': '6100.50', 'count': 2}, - 'prop-004': {'total': '1776.50', 'count': 4}, - 'prop-005': {'total': '3256.00', 'count': 3} - } - - mock_property_data = mock_data.get(property_id, {'total': '0.00', 'count': 0}) - - return { + query = text(""" + SELECT + COALESCE(SUM(total_amount), 0) as total_revenue, + COUNT(*) as reservation_count + FROM reservations + WHERE property_id = :property_id AND tenant_id = :tenant_id + """) + + async with await _get_session() as session: + result = await session.execute(query, { "property_id": property_id, - "tenant_id": tenant_id, - "total": mock_property_data['total'], - "currency": "USD", - "count": mock_property_data['count'] - } + "tenant_id": tenant_id + }) + row = result.fetchone() + + return { + "property_id": property_id, + "tenant_id": tenant_id, + "total": str(Decimal(str(row.total_revenue))), + "currency": "USD", + "count": row.reservation_count + } + + +async def get_tenant_properties(tenant_id: str) -> list: + query = text(""" + SELECT id, name, timezone + FROM properties + WHERE tenant_id = :tenant_id + ORDER BY id + """) + + async with await _get_session() as session: + result = await session.execute(query, {"tenant_id": tenant_id}) + return [{"id": r.id, "name": r.name, "timezone": r.timezone} for r in result.fetchall()] + + +async def get_reservations(property_id: str, tenant_id: str, month: int = None, year: int = None) -> list: + params = {"property_id": property_id, "tenant_id": tenant_id} + month_filter = "" + if month and year: + params["start_date"] = datetime(year, month, 1) + params["end_date"] = datetime(year + 1, 1, 1) if month == 12 else datetime(year, month + 1, 1) + month_filter = """ + AND (r.check_in_date AT TIME ZONE p.timezone) >= :start_date + AND (r.check_in_date AT TIME ZONE p.timezone) < :end_date""" + + query = text(f""" + SELECT + r.id, + to_char(r.check_in_date AT TIME ZONE p.timezone, 'YYYY-MM-DD HH24:MI') as check_in, + to_char(r.check_out_date AT TIME ZONE p.timezone, 'YYYY-MM-DD HH24:MI') as check_out, + r.total_amount, + r.currency + 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{month_filter} + ORDER BY r.check_in_date + """) + + async with await _get_session() as session: + result = await session.execute(query, params) + return [ + { + "id": r.id, + "check_in": r.check_in, + "check_out": r.check_out, + "amount": str(r.total_amount), + "currency": r.currency, + } + for r in result.fetchall() + ] 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 c931d0daa173385693a95f9edd37f133936cab72 Mon Sep 17 00:00:00 2001 From: karthikeyan18v Date: Mon, 28 Sep 2026 20:11:47 +0530 Subject: [PATCH 2/4] Dashboard: show only the tenant's properties, add month filter 1. Every client saw every property The property dropdown was a hardcoded list of all 5 properties from both tenants, so Ocean saw Sunset's property names. prop-001 is also a different property for each tenant. The list now comes from /dashboard/properties for the logged in tenant. 2. Profile page failing to load getApiBase() uses VITE_BACKEND_URL, but .env is in .dockerignore, so in the docker build it was empty and the profile request went to nginx on port 3000. nginx sent back index.html and parsing it as JSON failed. It now falls back to localhost:8000, same as localAuthClient already does. Also added a month picker and a bookings table under the revenue card, so a month's total can be checked against the actual bookings. --- frontend/src/components/BookingsList.tsx | 78 ++++++++++++++++++++++ frontend/src/components/Dashboard.tsx | 37 ++++++---- frontend/src/components/RevenueSummary.tsx | 9 ++- frontend/src/lib/apiBase.ts | 3 +- frontend/src/lib/secureApi.ts | 19 +++++- 5 files changed, 129 insertions(+), 17 deletions(-) create mode 100644 frontend/src/components/BookingsList.tsx diff --git a/frontend/src/components/BookingsList.tsx b/frontend/src/components/BookingsList.tsx new file mode 100644 index 000000000..ed30f2840 --- /dev/null +++ b/frontend/src/components/BookingsList.tsx @@ -0,0 +1,78 @@ +import React, { useEffect, useState } from 'react'; +import { SecureAPI } from '../lib/secureApi'; + +interface Booking { + id: string; + check_in: string; + check_out: string; + amount: string; + currency: string; +} + +interface BookingsListProps { + propertyId: string; + month?: string; // "YYYY-MM" +} + +export const BookingsList: React.FC = ({ propertyId, month }) => { + const [bookings, setBookings] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { + setLoading(true); + setError(''); + SecureAPI.getDashboardReservations(propertyId, { + year: month ? Number(month.split('-')[0]) : undefined, + month: month ? Number(month.split('-')[1]) : undefined + }) + .then((res) => setBookings(res.reservations)) + .catch((err) => { + setError('Failed to load bookings'); + console.error(err); + }) + .finally(() => setLoading(false)); + }, [propertyId, month]); + + if (loading) return
Loading bookings...
; + if (error) return
{error}
; + + return ( +
+
+

+ Bookings ({bookings.length}) +

+
+ + {bookings.length === 0 ? ( +

No bookings for this period.

+ ) : ( +
+ + + + + + + + + + + {bookings.map((b) => ( + + + + + + + ))} + +
BookingCheck-inCheck-outAmount
{b.id}{b.check_in}{b.check_out} + {b.currency} {Number(b.amount).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 3 })} +
+
+ )} +
+ ); +}; diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index a21bba404..99d88c5f0 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -1,16 +1,21 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { RevenueSummary } from "./RevenueSummary"; - -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' } -]; +import { BookingsList } from "./BookingsList"; +import { SecureAPI } from "../lib/secureApi"; const Dashboard: React.FC = () => { - const [selectedProperty, setSelectedProperty] = useState('prop-001'); + const [properties, setProperties] = useState<{ id: string; name: string }[]>([]); + const [selectedProperty, setSelectedProperty] = useState(''); + const [selectedMonth, setSelectedMonth] = useState(''); // "YYYY-MM", empty = all time + + useEffect(() => { + SecureAPI.getDashboardProperties() + .then((res) => { + setProperties(res.properties); + if (res.properties.length) setSelectedProperty(res.properties[0].id); + }) + .catch((err) => console.error(err)); + }, []); return (
@@ -29,13 +34,20 @@ const Dashboard: React.FC = () => { {/* Property Selector */}
+ + setSelectedMonth(e.target.value)} + className="block w-full sm:w-auto min-w-[200px] px-3 py-2 mb-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 text-sm" + />