From a99e713d867cb3e85cf7eb78b1c882bdc0ec90cd Mon Sep 17 00:00:00 2001 From: Alozie hukwuemeka Date: Thu, 29 Oct 2026 16:00:16 +0100 Subject: [PATCH 1/7] Connect the revenue queries to DATABASE_URL. The pool was building a Supabase URL from settings that are not defined, so every request fell through to shared mock totals. --- backend/app/core/database_pool.py | 73 ++++++++++++++++++------------- backend/app/main.py | 7 ++- 2 files changed, 45 insertions(+), 35 deletions(-) diff --git a/backend/app/core/database_pool.py b/backend/app/core/database_pool.py index d638dfcfe3..25d12586fe 100644 --- a/backend/app/core/database_pool.py +++ b/backend/app/core/database_pool.py @@ -1,60 +1,71 @@ -import asyncio -from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker -from sqlalchemy.pool import QueuePool import logging +from contextlib import asynccontextmanager + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + from ..config import settings logger = logging.getLogger(__name__) + +def to_asyncpg_url(url: str) -> str: + if url.startswith("postgresql+asyncpg://"): + return url + if url.startswith("postgresql://"): + return "postgresql+asyncpg://" + url[len("postgresql://"):] + if url.startswith("postgres://"): + return "postgresql+asyncpg://" + url[len("postgres://"):] + return url + + class DatabasePool: def __init__(self): self.engine = None self.session_factory = None - + async def initialize(self): - """Initialize database connection pool""" + if self.session_factory is not None: + 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 = to_asyncpg_url(settings.database_url) 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_size=20, + max_overflow=30, + pool_pre_ping=True, + pool_recycle=3600, + echo=False, ) - self.session_factory = async_sessionmaker( bind=self.engine, class_=AsyncSession, - expire_on_commit=False + expire_on_commit=False, ) - - logger.info("✅ Database connection pool initialized") - + logger.info("Database connection pool initialized") except Exception as e: - logger.error(f"❌ Database pool initialization failed: {e}") + logger.error("Database pool initialization failed: %s", e) self.engine = None self.session_factory = None - + async def close(self): - """Close database connections""" if self.engine: await self.engine.dispose() - - async def get_session(self) -> AsyncSession: - """Get database session from pool""" + self.engine = None + self.session_factory = None + + @asynccontextmanager + async def get_session(self): if not self.session_factory: - raise Exception("Database pool not initialized") - return self.session_factory() + await self.initialize() + if not self.session_factory: + raise RuntimeError("Database pool not available") + async with self.session_factory() as session: + yield session + -# Global database pool instance db_pool = DatabasePool() -async def get_db_session() -> AsyncSession: - """Dependency to get database session""" + +async def get_db_session(): async with db_pool.get_session() as session: - yield session + yield session \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py index 00734b2fa3..617cc211d8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -92,12 +92,11 @@ async def lifespan(app: FastAPI): # Initialize Supabase connection pool try: - from .core.supabase_connection_pool import supabase_pool + from .core.database_pool import db_pool - await supabase_pool.initialize() - logger.info("✅ Supabase connection pool initialized") + await db_pool.initialize() except Exception as e: - logger.error(f"❌ Supabase connection pool initialization failed: {e}") + logger.warning(f"Database pool initialization warning: {e}") # Continue startup - fallback to direct connections # Initialize Redis connection with timeout From 20e72091fe2e43e7a6dfb7a122f5e11c5112cb5b Mon Sep 17 00:00:00 2001 From: Alozie hukwuemeka Date: Thu, 29 Oct 2026 16:00:50 +0100 Subject: [PATCH 2/7] Calculate monthly revenue in each property's timezone. A late-UTC February check-in is March in Paris, and sub-cent bookings are rounded only after the sum. --- backend/app/services/reservations.py | 234 +++++++++++++++------------ backend/tests/test_revenue_rules.py | 37 +++++ 2 files changed, 166 insertions(+), 105 deletions(-) create mode 100644 backend/tests/test_revenue_rules.py diff --git a/backend/app/services/reservations.py b/backend/app/services/reservations.py index 384bd00abe..009cae3505 100644 --- a/backend/app/services/reservations.py +++ b/backend/app/services/reservations.py @@ -1,109 +1,133 @@ +import logging from datetime import datetime -from decimal import Decimal -from typing import Dict, Any, List +from decimal import Decimal, ROUND_HALF_UP +from typing import Any, Dict, Optional +from zoneinfo import ZoneInfo -async def calculate_monthly_revenue(property_id: str, month: int, year: int, db_session=None) -> Decimal: - """ - Calculates revenue for a specific month. - """ +logger = logging.getLogger(__name__) - start_date = datetime(year, month, 1) - if month < 12: - end_date = datetime(year, month + 1, 1) +MONEY_QUANTUM = Decimal("0.01") +DEFAULT_REPORT_YEAR = 2024 +DEFAULT_REPORT_MONTH = 3 + + +def quantize_money(amount: Any) -> Decimal: + return Decimal(str(amount)).quantize(MONEY_QUANTUM, rounding=ROUND_HALF_UP) + + +def format_money(amount: Any) -> str: + return format(quantize_money(amount), "f") + + +def month_bounds(year: int, month: int, timezone_name: str) -> tuple[datetime, datetime]: + tz = ZoneInfo(timezone_name or "UTC") + start = datetime(year, month, 1, tzinfo=tz) + if month == 12: + end = datetime(year + 1, 1, 1, tzinfo=tz) 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) -> 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 { - "property_id": property_id, - "tenant_id": tenant_id, - "total": mock_property_data['total'], - "currency": "USD", - "count": mock_property_data['count'] - } + end = datetime(year, month + 1, 1, tzinfo=tz) + return start, end + + +def revenue_cache_key(tenant_id: str, property_id: str, year: int, month: int) -> str: + return f"revenue:{tenant_id}:{property_id}:{year}:{month:02d}" + + +async def calculate_monthly_revenue( + property_id: str, + month: int, + year: int, + tenant_id: str, +) -> Optional[Dict[str, Any]]: + from sqlalchemy import text + from app.core.database_pool import db_pool + + async with db_pool.get_session() as session: + property_row = ( + await session.execute( + text( + """ + SELECT name, timezone + FROM properties + WHERE id = :property_id AND tenant_id = :tenant_id + """ + ), + {"property_id": property_id, "tenant_id": tenant_id}, + ) + ).fetchone() + + if property_row is None: + logger.info( + "revenue property not in tenant property_id=%s tenant_id=%s", + property_id, + tenant_id, + ) + return None + + start, end = month_bounds(year, month, property_row.timezone) + logger.info( + "revenue query property_id=%s tenant_id=%s tz=%s start=%s end=%s", + property_id, + tenant_id, + property_row.timezone, + start.isoformat(), + end.isoformat(), + ) + + totals = ( + await session.execute( + text( + """ + SELECT + COALESCE(SUM(total_amount), 0) AS total_revenue, + COUNT(*) AS reservation_count, + COALESCE(MAX(currency), 'USD') AS currency + FROM reservations + WHERE property_id = :property_id + AND tenant_id = :tenant_id + AND check_in_date >= :start + AND check_in_date < :end + """ + ), + { + "property_id": property_id, + "tenant_id": tenant_id, + "start": start, + "end": end, + }, + ) + ).fetchone() + + return { + "property_id": property_id, + "property_name": property_row.name, + "tenant_id": tenant_id, + "timezone": property_row.timezone, + "year": year, + "month": month, + "total": format_money(totals.total_revenue), + "currency": totals.currency or "USD", + "count": int(totals.reservation_count or 0), + } + + +async def list_tenant_properties(tenant_id: str) -> list[Dict[str, str]]: + from sqlalchemy import text + from app.core.database_pool import db_pool + + async with db_pool.get_session() as session: + rows = ( + await session.execute( + text( + """ + SELECT id, name, timezone + FROM properties + WHERE tenant_id = :tenant_id + ORDER BY name + """ + ), + {"tenant_id": tenant_id}, + ) + ).fetchall() + + return [{"id": row.id, "name": row.name, "timezone": row.timezone} for row in rows] \ No newline at end of file diff --git a/backend/tests/test_revenue_rules.py b/backend/tests/test_revenue_rules.py new file mode 100644 index 0000000000..aff612680c --- /dev/null +++ b/backend/tests/test_revenue_rules.py @@ -0,0 +1,37 @@ +import unittest +from datetime import datetime, timezone +from decimal import Decimal + +from app.services.reservations import format_money, month_bounds, quantize_money, revenue_cache_key + + +class RevenueRulesTest(unittest.TestCase): + def test_paris_march_includes_late_utc_february_check_in(self): + start, end = month_bounds(2024, 3, "Europe/Paris") + check_in = datetime(2024, 2, 29, 23, 30, tzinfo=timezone.utc) + utc_march = datetime(2024, 3, 1, tzinfo=timezone.utc) + self.assertLess(check_in, utc_march) + self.assertLessEqual(start, check_in) + self.assertLess(check_in, end) + + def test_new_york_march_excludes_that_same_check_in(self): + start, end = month_bounds(2024, 3, "America/New_York") + check_in = datetime(2024, 2, 29, 23, 30, tzinfo=timezone.utc) + self.assertFalse(start <= check_in < end) + + def test_subcent_bookings_round_after_sum(self): + lines = [Decimal("333.333"), Decimal("333.333"), Decimal("333.334")] + self.assertEqual(format_money(sum(lines, Decimal("0"))), "1000.00") + self.assertEqual( + sum((quantize_money(line) for line in lines), Decimal("0")), + Decimal("999.99"), + ) + + def test_cache_key_isolates_shared_property_ids(self): + sunset = revenue_cache_key("tenant-a", "prop-001", 2024, 3) + ocean = revenue_cache_key("tenant-b", "prop-001", 2024, 3) + self.assertNotEqual(sunset, ocean) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file From 47fd6f3878c73748b7a8d8b19c71a98a01d2f306 Mon Sep 17 00:00:00 2001 From: Alozie hukwuemeka Date: Thu, 29 Oct 2026 16:01:14 +0100 Subject: [PATCH 3/7] Scope the revenue cache by tenant. prop-001 exists for both companies, and a property-only cache key was serving one client's totals to the other. --- backend/app/services/cache.py | 64 ++++++++++++++++++++++------------- 1 file changed, 40 insertions(+), 24 deletions(-) diff --git a/backend/app/services/cache.py b/backend/app/services/cache.py index b81474957f..e656c92e79 100644 --- a/backend/app/services/cache.py +++ b/backend/app/services/cache.py @@ -1,29 +1,45 @@ import json -import redis.asyncio as redis -from typing import Dict, Any +import logging import os +from typing import Any, Dict + +import redis.asyncio as redis + +from app.services.reservations import ( + DEFAULT_REPORT_MONTH, + DEFAULT_REPORT_YEAR, + calculate_monthly_revenue, + revenue_cache_key, +) + +logger = logging.getLogger(__name__) -# 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]: - """ - Fetches revenue summary, utilizing caching to improve performance. - """ - cache_key = f"revenue:{property_id}" - - # 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 - - # Calculate revenue - 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 + +async def get_revenue_summary( + property_id: str, + tenant_id: str, + year: int = DEFAULT_REPORT_YEAR, + month: int = DEFAULT_REPORT_MONTH, +) -> Dict[str, Any] | None: + cache_key = revenue_cache_key(tenant_id, property_id, year, month) + + try: + cached = await redis_client.get(cache_key) + if cached: + logger.info("revenue cache hit key=%s", cache_key) + return json.loads(cached) + except Exception as exc: + logger.warning("revenue cache read failed key=%s error=%s", cache_key, exc) + + result = await calculate_monthly_revenue(property_id, month, year, tenant_id) + if result is None: + return None + + try: + await redis_client.setex(cache_key, 300, json.dumps(result)) + except Exception as exc: + logger.warning("revenue cache write failed key=%s error=%s", cache_key, exc) + + return result \ No newline at end of file From d3ba118eb37894a03fdc0f4c2a74d803031aedf5 Mon Sep 17 00:00:00 2001 From: Alozie hukwuemeka Date: Thu, 29 Oct 2026 16:01:35 +0100 Subject: [PATCH 4/7] Return tenant-scoped March revenue without float conversion. Missing properties now 404, and totals stay as cent strings instead of binary floats. --- backend/app/api/v1/dashboard.py | 75 +++++++++++++++++++++++++-------- 1 file changed, 58 insertions(+), 17 deletions(-) diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index 1ec352d7e3..a034aa7057 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -1,25 +1,66 @@ -from fastapi import APIRouter, Depends, HTTPException -from typing import Dict, Any -from app.services.cache import get_revenue_summary +import logging + +from fastapi import APIRouter, Depends, HTTPException, Query + from app.core.auth import authenticate_request as get_current_user +from app.services.cache import get_revenue_summary +from app.services.reservations import ( + DEFAULT_REPORT_MONTH, + DEFAULT_REPORT_YEAR, + list_tenant_properties, +) +logger = logging.getLogger(__name__) router = APIRouter() + +def _tenant_id(current_user) -> str: + tenant_id = getattr(current_user, "tenant_id", None) + if not tenant_id: + raise HTTPException(status_code=403, detail="Tenant context missing") + return tenant_id + + +@router.get("/properties") +async def get_properties(current_user: dict = Depends(get_current_user)): + tenant_id = _tenant_id(current_user) + try: + properties = await list_tenant_properties(tenant_id) + except Exception as exc: + logger.error("property list failed tenant_id=%s error=%s", tenant_id, exc) + raise HTTPException(status_code=503, detail="Property data is temporarily unavailable") + return {"data": properties, "total": len(properties)} + + @router.get("/dashboard/summary") async def get_dashboard_summary( property_id: str, - 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']) - + month: int = Query(DEFAULT_REPORT_MONTH, ge=1, le=12), + year: int = Query(DEFAULT_REPORT_YEAR, ge=2000, le=2100), + current_user: dict = Depends(get_current_user), +): + tenant_id = _tenant_id(current_user) + try: + revenue_data = await get_revenue_summary(property_id, tenant_id, year, month) + except Exception as exc: + logger.error( + "revenue summary failed property_id=%s tenant_id=%s error=%s", + property_id, + tenant_id, + exc, + ) + raise HTTPException(status_code=503, detail="Revenue data is temporarily unavailable") + + if revenue_data is None: + raise HTTPException(status_code=404, detail="Property not found") + return { - "property_id": revenue_data['property_id'], - "total_revenue": total_revenue_float, - "currency": revenue_data['currency'], - "reservations_count": revenue_data['count'] - } + "property_id": revenue_data["property_id"], + "property_name": revenue_data["property_name"], + "timezone": revenue_data["timezone"], + "year": revenue_data["year"], + "month": revenue_data["month"], + "total_revenue": revenue_data["total"], + "currency": revenue_data["currency"], + "reservations_count": revenue_data["count"], + } \ No newline at end of file From 08b4f3e6ef9ca0c40526b714534163d012c8a959 Mon Sep 17 00:00:00 2001 From: Alozie hukwuemeka Date: Thu, 29 Oct 2026 16:02:44 +0100 Subject: [PATCH 5/7] Show March revenue for the signed-in tenant only. The dashboard no longer lists the other company's properties or sends a simulated tenant header. --- frontend/src/components/Dashboard.tsx | 63 ++++++++++++----- frontend/src/components/RevenueSummary.tsx | 80 +++++++++------------- frontend/src/lib/secureApi.ts | 70 +++++++------------ 3 files changed, 106 insertions(+), 107 deletions(-) diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index a21bba404e..e6db29d5fa 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -1,16 +1,38 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { RevenueSummary } from "./RevenueSummary"; +import { SecureAPI } from "../lib/secureApi"; -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' } -]; +const REPORT_MONTH = 3; +const REPORT_YEAR = 2024; + +interface PropertyOption { + id: string; + name: string; + timezone: string; +} const Dashboard: React.FC = () => { - const [selectedProperty, setSelectedProperty] = useState('prop-001'); + const [properties, setProperties] = useState([]); + const [selectedProperty, setSelectedProperty] = useState(""); + const [loadError, setLoadError] = useState(""); + + useEffect(() => { + let cancelled = false; + SecureAPI.getProperties() + .then((response) => { + if (cancelled) return; + const list = response.data || []; + setProperties(list); + setSelectedProperty(list[0]?.id ?? ""); + }) + .catch((err) => { + console.error(err); + if (!cancelled) setLoadError("Failed to load properties"); + }); + return () => { + cancelled = true; + }; + }, []); return (
@@ -23,19 +45,19 @@ const Dashboard: React.FC = () => {

Revenue Overview

- Monthly performance insights for your properties + March 2024 performance for your properties, using each property's local time zone

- - {/* Property Selector */} +