From bad78cfe39e1fc0c101679cf62198d164ca562a6 Mon Sep 17 00:00:00 2001 From: Otitodev Date: Mon, 28 Sep 2026 18:31:26 +0100 Subject: [PATCH 1/4] fix: scope revenue cache key by tenant, not just property Both tenants can have a property with the same id (see seed data), so caching by property_id alone let one tenant's revenue get served to another. --- backend/app/services/cache.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/services/cache.py b/backend/app/services/cache.py index b81474957f..672e3f9d69 100644 --- a/backend/app/services/cache.py +++ b/backend/app/services/cache.py @@ -10,7 +10,7 @@ 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}" + cache_key = f"revenue:{tenant_id}:{property_id}" # Try to get from cache cached = await redis_client.get(cache_key) From 5681bd337adbaf28037000140e954569abf5bf62 Mon Sep 17 00:00:00 2001 From: Otitodev Date: Mon, 28 Sep 2026 18:31:37 +0100 Subject: [PATCH 2/4] fix: make property dropdown tenant-aware The property list was one hardcoded array shown to every user, so tenants saw each other's property names against their own ids. Also fixes the dashboard showing 0 revenue on first load since the initial selected property depended on tenant data that hadn't loaded yet. --- frontend/src/components/Dashboard.tsx | 38 ++++++++++++++++++++------- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index a21bba404e..b8081ee9ab 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -1,16 +1,36 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { RevenueSummary } from "./RevenueSummary"; +import { useAppContext } from "../contexts/AppContext"; -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' } -]; +// Each property id is only unique WITHIN a tenant (see database/seed.sql, +// properties has a composite PK of (id, tenant_id)) - prop-001 means a +// different property depending on which tenant you are. +const PROPERTIES_BY_TENANT: Record = { + 'tenant-a': [ + { id: 'prop-001', name: 'Beach House Alpha' }, + { id: 'prop-002', name: 'City Apartment Downtown' }, + { id: 'prop-003', name: 'Country Villa Estate' }, + ], + 'tenant-b': [ + { id: 'prop-001', name: 'Mountain Lodge Beta' }, + { id: 'prop-004', name: 'Lakeside Cottage' }, + { id: 'prop-005', name: 'Urban Loft Modern' }, + ], +}; const Dashboard: React.FC = () => { - const [selectedProperty, setSelectedProperty] = useState('prop-001'); + const { user } = useAppContext(); + const PROPERTIES = (user?.tenant_id && PROPERTIES_BY_TENANT[user.tenant_id]) || []; + const [selectedProperty, setSelectedProperty] = useState(''); + + // PROPERTIES depends on user.tenant_id, which loads asynchronously after + // mount - sync the selection once the tenant's property list becomes + // available instead of getting stuck on the empty initial value. + useEffect(() => { + if (!selectedProperty && PROPERTIES.length > 0) { + setSelectedProperty(PROPERTIES[0].id); + } + }, [PROPERTIES, selectedProperty]); return (
From 7b66b82704c5573e3e7d0a728027b99ab87811a7 Mon Sep 17 00:00:00 2001 From: Otitodev Date: Mon, 28 Sep 2026 18:31:47 +0100 Subject: [PATCH 3/4] fix: get the real database connection working DatabasePool was reading settings that don't exist (supabase_db_* instead of database_url), get_session was declared async but never awaited anything so 'async with' on it failed, greenlet was missing so sqlalchemy's async engine couldn't even import, and QueuePool isn't valid for an async engine. All four together meant every revenue query silently fell back to a hardcoded mock table, which is what was actually causing the wrong/leaked numbers. --- backend/app/core/database_pool.py | 8 ++++---- backend/requirements.txt | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/app/core/database_pool.py b/backend/app/core/database_pool.py index d638dfcfe3..adf825d466 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,12 @@ 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 +45,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/requirements.txt b/backend/requirements.txt index 6b777d2aaa..06cb8bd4a6 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 496e1be1a376f8256c07bc4371f9cc93dd38bb04 Mon Sep 17 00:00:00 2001 From: Otitodev Date: Mon, 28 Sep 2026 18:37:27 +0100 Subject: [PATCH 4/4] fix: bucket monthly revenue by property's local timezone, not UTC calculate_monthly_revenue was a dead placeholder that always returned zero. Implemented it properly, converting check_in_date to the property's own timezone before comparing against month boundaries. A booking at 2024-02-29 23:30 UTC is already March 1st in Europe/Paris, so a naive UTC comparison would have silently dropped it from March - which matches the client's complaint about March totals being wrong. Wired month/year as optional query params on /dashboard/summary. --- backend/app/api/v1/dashboard.py | 18 +++++--- backend/app/services/reservations.py | 66 ++++++++++++++++++++-------- 2 files changed, 59 insertions(+), 25 deletions(-) diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index 1ec352d7e3..a85ddb0ec6 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 typing import Dict, Any, Optional from app.services.cache import get_revenue_summary +from app.services.reservations import calculate_monthly_revenue from app.core.auth import authenticate_request as get_current_user router = APIRouter() @@ -8,15 +9,20 @@ @router.get("/dashboard/summary") async def get_dashboard_summary( property_id: str, + month: Optional[int] = None, + 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) - + + if month is not None and year is not None: + revenue_data = await calculate_monthly_revenue(property_id, tenant_id, month, year) + else: + revenue_data = await get_revenue_summary(property_id, tenant_id) + 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 384bd00abe..8b73ef77a6 100644 --- a/backend/app/services/reservations.py +++ b/backend/app/services/reservations.py @@ -2,34 +2,62 @@ from decimal import Decimal from typing import Dict, Any, List -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: int, year: int) -> Dict[str, Any]: """ - Calculates revenue for a specific month. + Calculates revenue for a specific calendar month. + + Reservations are bucketed by month using the PROPERTY'S OWN local + timezone, not UTC. check_in_date is stored as timestamptz (UTC), so a + booking near midnight local time can fall on the other side of the + month boundary once converted - e.g. 2024-02-29 23:30 UTC is already + 2024-03-01 in Europe/Paris. Comparing against naive UTC month + boundaries would silently drop that reservation from March. """ + from app.core.database_pool import DatabasePool + from sqlalchemy import text 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 + db_pool = DatabasePool() + await db_pool.initialize() + + if not db_pool.session_factory: + raise Exception("Database pool not available") + + async with db_pool.get_session() as session: + query = text(""" + SELECT SUM(r.total_amount) as total, COUNT(*) as cnt + 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 + """) + result = await session.execute(query, { + "property_id": property_id, + "tenant_id": tenant_id, + "start_date": start_date, + "end_date": end_date, + }) + row = result.fetchone() + + total = Decimal(str(row.total)) if row and row.total is not None else Decimal('0.00') + count = row.cnt if row else 0 + + return { + "property_id": property_id, + "tenant_id": tenant_id, + "month": month, + "year": year, + "total": str(total), + "currency": "USD", + "count": count, + } async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, Any]: """