diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index 1ec352d7e3..85894d5b13 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -1,10 +1,22 @@ +from decimal import Decimal, ROUND_HALF_UP from fastapi import APIRouter, Depends, HTTPException -from typing import Dict, Any +from typing import Dict, Any, List from app.services.cache import get_revenue_summary +from app.services.properties import list_properties from app.core.auth import authenticate_request as get_current_user router = APIRouter() +@router.get("/dashboard/properties") +async def get_dashboard_properties( + current_user: dict = Depends(get_current_user) +) -> List[Dict[str, Any]]: + + tenant_id = getattr(current_user, "tenant_id", "default_tenant") or "default_tenant" + + return await list_properties(tenant_id) + + @router.get("/dashboard/summary") async def get_dashboard_summary( property_id: str, @@ -15,7 +27,9 @@ async def get_dashboard_summary( revenue_data = await get_revenue_summary(property_id, tenant_id) - total_revenue_float = float(revenue_data['total']) + total_revenue_float = float( + Decimal(str(revenue_data['total'])).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + ) return { "property_id": revenue_data['property_id'], diff --git a/backend/app/core/database_pool.py b/backend/app/core/database_pool.py index d638dfcfe3..de8279c188 100644 --- a/backend/app/core/database_pool.py +++ b/backend/app/core/database_pool.py @@ -1,6 +1,6 @@ import asyncio from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker -from sqlalchemy.pool import QueuePool +from sqlalchemy.pool import AsyncAdaptedQueuePool import logging from ..config import settings @@ -15,11 +15,11 @@ 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, + poolclass=AsyncAdaptedQueuePool, 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/app/services/cache.py b/backend/app/services/cache.py index b81474957f..427e15f31c 100644 --- a/backend/app/services/cache.py +++ b/backend/app/services/cache.py @@ -1,8 +1,11 @@ import json +import logging import redis.asyncio as redis from typing import Dict, Any import os +logger = logging.getLogger(__name__) + # Initialize Redis client (typically configured centrally). redis_client = redis.Redis.from_url(os.getenv("REDIS_URL", "redis://localhost:6379/0")) @@ -10,13 +13,16 @@ 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) if cached: + logger.info(f"REVENUE_CACHE HIT key={cache_key} tenant={tenant_id} property={property_id}") return json.loads(cached) - + + logger.info(f"REVENUE_CACHE MISS key={cache_key} tenant={tenant_id} property={property_id}") + # Revenue calculation is delegated to the reservation service. from app.services.reservations import calculate_total_revenue diff --git a/backend/app/services/properties.py b/backend/app/services/properties.py new file mode 100644 index 0000000000..a97ccaac9a --- /dev/null +++ b/backend/app/services/properties.py @@ -0,0 +1,30 @@ +from typing import Any, Dict, List + + +async def list_properties(tenant_id: str, db_session=None) -> List[Dict[str, Any]]: + """ + Returns the properties that belong to one tenant, ordered by id. + """ + from sqlalchemy import text + + query = text(""" + SELECT id, name + FROM properties + WHERE tenant_id = :tenant_id + ORDER BY id + """) + params = {"tenant_id": tenant_id} + + if db_session is None: + from app.core.database_pool import db_pool + + if not db_pool.session_factory: + await db_pool.initialize() + async with db_pool.get_session() as session: + result = await session.execute(query, params) + rows = result.fetchall() + else: + result = await db_session.execute(query, params) + rows = result.fetchall() + + return [{"id": row.id, "name": row.name} for row in rows] diff --git a/backend/app/services/reservations.py b/backend/app/services/reservations.py index 384bd00abe..db43202553 100644 --- a/backend/app/services/reservations.py +++ b/backend/app/services/reservations.py @@ -2,34 +2,47 @@ 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, db_session=None) -> Decimal: """ Calculates revenue for a specific month. + The month is the calendar month in the property's own timezone, not UTC. """ + from sqlalchemy import text + # Month bounds as local wall-clock times; SQL converts them with 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 + 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 >= (CAST(:start_date AS timestamp) AT TIME ZONE p.timezone) + AND r.check_in_date < (CAST(:end_date AS timestamp) AT TIME ZONE p.timezone) + """) + params = { + "property_id": property_id, + "tenant_id": tenant_id, + "start_date": start_date, + "end_date": end_date, + } + + if db_session is None: + from app.core.database_pool import db_pool + + if not db_pool.session_factory: + await db_pool.initialize() + async with db_pool.get_session() as session: + result = await session.execute(query, params) + else: + result = await db_session.execute(query, params) + + return Decimal(str(result.scalar_one())) async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, Any]: """ @@ -37,11 +50,11 @@ async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, """ 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 not db_pool.session_factory: + await db_pool.initialize() if db_pool.session_factory: async with db_pool.get_session() as session: diff --git a/backend/pyproject.toml b/backend/pyproject.toml index ebaa4bc92d..6968736a46 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -27,7 +27,7 @@ dependencies = [ "reportlab>=4.4.3", "requests>=2.32.4", "sendgrid>=6.12.4", - "sqlalchemy>=2.0.0", + "sqlalchemy[asyncio]>=2.0.0", "stripe>=7.0.0", "supabase>=2.18.0", "tenacity>=9.1.2", diff --git a/backend/requirements.txt b/backend/requirements.txt index 6b777d2aaa..b615fdc1e4 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] @@ -27,3 +27,4 @@ loguru redis>=5.0.0 asyncpg>=0.27.0 requests +pytest>=8.0.0 diff --git a/backend/tests/test_dashboard_summary_rounding.py b/backend/tests/test_dashboard_summary_rounding.py new file mode 100644 index 0000000000..3d06e04491 --- /dev/null +++ b/backend/tests/test_dashboard_summary_rounding.py @@ -0,0 +1,52 @@ +import types + +import pytest +from fastapi.testclient import TestClient + +from app.core.auth import authenticate_request +from app.main import app + + +def get_summary(total: str): + async def fake_get_revenue_summary(property_id, tenant_id): + return { + "property_id": "prop-002", + "tenant_id": "tenant-a", + "total": total, + "currency": "USD", + "count": 5, + } + + app.dependency_overrides[authenticate_request] = lambda: types.SimpleNamespace( + tenant_id="tenant-a" + ) + try: + import app.api.v1.dashboard as dashboard + + original = dashboard.get_revenue_summary + dashboard.get_revenue_summary = fake_get_revenue_summary + try: + return TestClient(app).get("/api/v1/dashboard/summary?property_id=prop-002") + finally: + dashboard.get_revenue_summary = original + finally: + app.dependency_overrides.clear() + + +@pytest.mark.parametrize( + "total, expected", + [ + ("5095.565", 5095.57), + ("2250.055", 2250.06), + ("1.005", 1.01), + ("2250.000", 2250.0), + ("4975.5", 4975.5), + ("0.00", 0.0), + ], +) +def test_total_revenue_is_rounded_half_up_to_two_decimals(total, expected): + response = get_summary(total) + assert response.status_code == 200 + value = response.json()["total_revenue"] + assert isinstance(value, (int, float)) and not isinstance(value, bool) + assert value == expected diff --git a/backend/tests/test_list_properties.py b/backend/tests/test_list_properties.py new file mode 100644 index 0000000000..f05cb6a9cd --- /dev/null +++ b/backend/tests/test_list_properties.py @@ -0,0 +1,33 @@ +import asyncio + +from app.core.database_pool import DatabasePool +from app.services.properties import list_properties + + +def properties_for(tenant_id: str): + async def run(): + pool = DatabasePool() + await pool.initialize() + try: + async with pool.get_session() as session: + return await list_properties(tenant_id, db_session=session) + finally: + await pool.close() + + return asyncio.run(run()) + + +def test_tenant_b_sees_only_its_properties_ordered_by_id(): + assert properties_for("tenant-b") == [ + {"id": "prop-001", "name": "Mountain Lodge Beta"}, + {"id": "prop-004", "name": "Lakeside Cottage"}, + {"id": "prop-005", "name": "Urban Loft Modern"}, + ] + + +def test_tenant_a_sees_only_its_properties_ordered_by_id(): + assert properties_for("tenant-a") == [ + {"id": "prop-001", "name": "Beach House Alpha"}, + {"id": "prop-002", "name": "City Apartment Downtown"}, + {"id": "prop-003", "name": "Country Villa Estate"}, + ] diff --git a/backend/tests/test_monthly_revenue.py b/backend/tests/test_monthly_revenue.py new file mode 100644 index 0000000000..eef970b9af --- /dev/null +++ b/backend/tests/test_monthly_revenue.py @@ -0,0 +1,35 @@ +import asyncio +from decimal import Decimal + +from app.core.database_pool import DatabasePool +from app.services.reservations import calculate_monthly_revenue + + +def monthly_revenue(property_id: str, tenant_id: str, month: int, year: int) -> Decimal: + async def run(): + pool = DatabasePool() + await pool.initialize() + try: + async with pool.get_session() as session: + return await calculate_monthly_revenue(property_id, tenant_id, month, year, db_session=session) + finally: + await pool.close() + + return asyncio.run(run()) + + +def test_march_uses_property_timezone(): + # res-tz-1 checks in 2024-02-29 23:30 UTC = 2024-03-01 00:30 Europe/Paris + assert monthly_revenue("prop-001", "tenant-a", 3, 2024) == Decimal("2250.000") + + +def test_february_excludes_booking_that_is_march_in_local_time(): + assert monthly_revenue("prop-001", "tenant-a", 2, 2024) == Decimal("0") + + +def test_same_property_id_in_other_tenant_is_not_counted(): + assert monthly_revenue("prop-001", "tenant-b", 3, 2024) == Decimal("0") + + +def test_new_york_property_march_total(): + assert monthly_revenue("prop-004", "tenant-b", 3, 2024) == Decimal("1776.500") diff --git a/backend/tests/test_revenue_cache_tenant.py b/backend/tests/test_revenue_cache_tenant.py new file mode 100644 index 0000000000..0f095b51b0 --- /dev/null +++ b/backend/tests/test_revenue_cache_tenant.py @@ -0,0 +1,51 @@ +import asyncio +import logging +import os +from unittest.mock import patch + +import redis.asyncio as redis + +from app.services import cache + + +KEYS = ["revenue:prop-001", "revenue:tenant-a:prop-001", "revenue:tenant-b:prop-001"] + + +def test_cached_revenue_is_not_shared_between_tenants(): + async def run(): + client = redis.Redis.from_url(os.getenv("REDIS_URL", "redis://localhost:6379/0")) + try: + await client.delete(*KEYS) + with patch.object(cache, "redis_client", client): + a = await cache.get_revenue_summary("prop-001", "tenant-a") + b = await cache.get_revenue_summary("prop-001", "tenant-b") + await client.delete(*KEYS) + return a, b + finally: + await client.aclose() + + a, b = asyncio.run(run()) + assert a["total"] == "2250.000" and a["count"] == 4 + assert b["total"] == "0.00" and b["count"] == 0 + + +def test_cache_miss_then_hit_is_logged_with_tenant_key(caplog): + async def run(): + client = redis.Redis.from_url(os.getenv("REDIS_URL", "redis://localhost:6379/0")) + try: + await client.delete(*KEYS) + with patch.object(cache, "redis_client", client): + await cache.get_revenue_summary("prop-001", "tenant-a") + await cache.get_revenue_summary("prop-001", "tenant-a") + await client.delete(*KEYS) + finally: + await client.aclose() + + with caplog.at_level(logging.INFO, logger="app.services.cache"): + asyncio.run(run()) + + messages = [r.getMessage() for r in caplog.records if r.name == "app.services.cache"] + assert messages == [ + "REVENUE_CACHE MISS key=revenue:tenant-a:prop-001 tenant=tenant-a property=prop-001", + "REVENUE_CACHE HIT key=revenue:tenant-a:prop-001 tenant=tenant-a property=prop-001", + ] diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index a21bba404e..94b4673ffe 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -1,16 +1,34 @@ -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 { 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 [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + const loadProperties = async () => { + try { + const list = await SecureAPI.getDashboardProperties(); + if (cancelled) return; + setProperties(list); + setSelectedProperty(list.length > 0 ? list[0].id : ''); + } catch (err) { + if (cancelled) return; + console.error('Failed to load properties', err); + setError('Failed to load properties'); + } finally { + if (!cancelled) setLoading(false); + } + }; + loadProperties(); + return () => { + cancelled = true; + }; + }, []); return (
@@ -30,23 +48,31 @@ const Dashboard: React.FC = () => { {/* Property Selector */}
+ {loading ? ( + Loading properties… + ) : error ? ( + {error} + ) : properties.length === 0 ? ( + No properties found + ) : ( + )}
- + {selectedProperty && }
diff --git a/frontend/src/components/RevenueSummary.tsx b/frontend/src/components/RevenueSummary.tsx index dbb6d0629d..2de5fcfb72 100644 --- a/frontend/src/components/RevenueSummary.tsx +++ b/frontend/src/components/RevenueSummary.tsx @@ -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; + // Backend already rounds to cents (half-up); do not round floats here. + const displayTotal = data.total_revenue; return (
diff --git a/frontend/src/lib/secureApi.ts b/frontend/src/lib/secureApi.ts index f85f04c90a..b3ffeb6275 100644 --- a/frontend/src/lib/secureApi.ts +++ b/frontend/src/lib/secureApi.ts @@ -1468,6 +1468,13 @@ export class SecureAPIClient { return this.request(`/api/v1/dashboard/summary?${queryParams}`, requestOptions); } + /** + * Get the properties of the caller's tenant + */ + async getDashboardProperties() { + return this.request<{ id: string; name: string }[]>('/api/v1/dashboard/properties'); + } + async uploadCompanyLogo(logo_url: string) { return this.request('/api/v1/company-settings/logo', { method: 'POST',