From f8badb7540ab3622b5a54d82839a4d9d16a75209 Mon Sep 17 00:00:00 2001
From: Eslam <41278977+eslam5464@users.noreply.github.com>
Date: Tue, 10 Nov 2026 16:22:05 +0200
Subject: [PATCH 01/14] Add .gitignore file
---
.gitignore | 113 +++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 113 insertions(+)
create mode 100644 .gitignore
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000..8d2f30037e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,113 @@
+# Python
+__pycache__/
+*.py[cod]
+*$py.class
+*.so
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# Virtual Environment
+.env
+.venv
+env/
+venv/
+ENV/
+
+# Coverage
+.coverage
+.coverage.*
+.cache
+coverage.xml
+*.cover
+htmlcov/
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+.DS_Store
+
+# Logs
+*.log
+logs/
+log/
+
+# Database
+*.db
+*.sqlite3
+
+# Local development
+.env.local
+.env.*.local
+*.env
+
+# Docker
+.docker/
+
+# Test
+.pytest_cache/
+.hypothesis/
+# --- FEATURE[apple_pay] START ---
+.apple_pay_test_credentials/
+# --- FEATURE[apple_pay] END ---
+
+# Alembic
+versions/__pycache__/
+versions/*.pyc
+
+# Distribution
+dist/
+build/
+
+# Poetry
+# poetry.lock
+
+# vs code workspace
+*.code-workspace
+
+# Additional common patterns
+__pypackages__/
+# --- FEATURE[celery] START ---
+celerybeat-schedule
+celerybeat.pid
+# --- FEATURE[celery] END ---
+*.sage.py
+.ipynb_checkpoints
+profile_default/
+ipython_config.py
+.pdm.toml
+.mypy_cache/
+.ruff_cache/
+.dmypy.json
+dmypy.json
+
+# OS specific
+Thumbs.db
+ehthumbs.db
+Desktop.ini
+$RECYCLE.BIN/
+*.lnk
+
+# Backend specific
+media/
+static/uploads/
+*.pem
+node_modules/
+.sass-cache/
+tmp/
+temp/
From a261ade221c9a4beec10880f41a5d79cefceccf9 Mon Sep 17 00:00:00 2001
From: Eslam <41278977+eslam5464@users.noreply.github.com>
Date: Tue, 10 Nov 2026 16:38:02 +0200
Subject: [PATCH 02/14] Enhance error handling in dashboard summary and revenue
calculations
---
backend/app/api/v1/dashboard.py | 7 +++--
backend/app/core/database_pool.py | 39 +++++++++++++++++++---------
backend/app/services/reservations.py | 38 ++++++++-------------------
backend/requirements.txt | 2 +-
4 files changed, 44 insertions(+), 42 deletions(-)
diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py
index 1ec352d7e3..2b5a59eeed 100644
--- a/backend/app/api/v1/dashboard.py
+++ b/backend/app/api/v1/dashboard.py
@@ -13,8 +13,11 @@ async def get_dashboard_summary(
tenant_id = getattr(current_user, "tenant_id", "default_tenant") or "default_tenant"
- revenue_data = await get_revenue_summary(property_id, tenant_id)
-
+ try:
+ revenue_data = await get_revenue_summary(property_id, tenant_id)
+ except Exception:
+ raise HTTPException(status_code=503, detail="Revenue data temporarily unavailable")
+
total_revenue_float = float(revenue_data['total'])
return {
diff --git a/backend/app/core/database_pool.py b/backend/app/core/database_pool.py
index d638dfcfe3..7fae3d70d0 100644
--- a/backend/app/core/database_pool.py
+++ b/backend/app/core/database_pool.py
@@ -1,29 +1,44 @@
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
-from sqlalchemy.pool import QueuePool
import logging
from ..config import settings
logger = logging.getLogger(__name__)
+
+def _async_database_url(url: str) -> str:
+ """Use the asyncpg driver for a plain postgresql:// URL."""
+ for prefix in ("postgresql+asyncpg://", "postgresql://", "postgres://"):
+ if url.startswith(prefix):
+ return "postgresql+asyncpg://" + url[len(prefix):]
+ return url
+
+
class DatabasePool:
def __init__(self):
self.engine = None
self.session_factory = None
-
+ self._init_lock = asyncio.Lock()
+
async def initialize(self):
- """Initialize database connection pool"""
+ """Initialize database connection pool (once per process)"""
+ if self.session_factory:
+ return
+ async with self._init_lock:
+ if self.session_factory:
+ return
+ await self._create_engine()
+
+ async def _create_engine(self):
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}"
-
+ # The async engine uses AsyncAdaptedQueuePool by default.
+ # QueuePool is not valid for asyncio engines.
self.engine = create_async_engine(
- database_url,
- poolclass=QueuePool,
- pool_size=20, # Number of connections to maintain
- max_overflow=30, # Additional connections when needed
+ _async_database_url(settings.database_url),
+ pool_size=settings.database_pool_size,
+ max_overflow=settings.database_max_overflow,
pool_pre_ping=True, # Validate connections
- pool_recycle=3600, # Recycle connections every hour
+ pool_recycle=settings.database_pool_recycle,
echo=False # Set to True for SQL debugging
)
@@ -45,7 +60,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/reservations.py b/backend/app/services/reservations.py
index 384bd00abe..b19967e968 100644
--- a/backend/app/services/reservations.py
+++ b/backend/app/services/reservations.py
@@ -1,7 +1,10 @@
+import logging
from datetime import datetime
from decimal import Decimal
from typing import Dict, Any, List
+logger = logging.getLogger(__name__)
+
async def calculate_monthly_revenue(property_id: str, month: int, year: int, db_session=None) -> Decimal:
"""
Calculates revenue for a specific month.
@@ -36,13 +39,11 @@ async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str,
Aggregates revenue from database.
"""
try:
- # Import database pool
- from app.core.database_pool import DatabasePool
-
- # Initialize pool if needed
- db_pool = DatabasePool()
+ # Reuse the process-wide pool instead of creating one per request
+ from app.core.database_pool import db_pool
+
await db_pool.initialize()
-
+
if db_pool.session_factory:
async with db_pool.get_session() as session:
# Use SQLAlchemy text for raw SQL
@@ -86,24 +87,7 @@ async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str,
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']
- }
+ # Never return made-up figures: a financial dashboard must fail loudly
+ # instead of showing numbers that belong to no tenant.
+ logger.error(f"Database error for {property_id} (tenant: {tenant_id}): {e}")
+ raise
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 03f3c9a5e63e63e41eb63cdd769def0a5383219f Mon Sep 17 00:00:00 2001
From: Eslam <41278977+eslam5464@users.noreply.github.com>
Date: Tue, 10 Nov 2026 16:40:41 +0200
Subject: [PATCH 03/14] Update cache key generation in revenue summary to
include tenant_id for data isolation
---
backend/app/services/cache.py | 9 +++++++--
1 file changed, 7 insertions(+), 2 deletions(-)
diff --git a/backend/app/services/cache.py b/backend/app/services/cache.py
index b81474957f..c264d86484 100644
--- a/backend/app/services/cache.py
+++ b/backend/app/services/cache.py
@@ -10,8 +10,13 @@ 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}"
-
+ if not tenant_id:
+ raise ValueError("tenant_id is required for revenue lookups")
+
+ # Property IDs are only unique per tenant (prop-001 exists in several
+ # tenants), so the tenant must be part of the key to keep data isolated.
+ cache_key = f"revenue:{tenant_id}:{property_id}"
+
# Try to get from cache
cached = await redis_client.get(cache_key)
if cached:
From 129f08ee5ac7cde83455b634477df7b34e41d09b Mon Sep 17 00:00:00 2001
From: Eslam <41278977+eslam5464@users.noreply.github.com>
Date: Tue, 10 Nov 2026 16:43:56 +0200
Subject: [PATCH 04/14] Refactor tenant handling in dashboard summary and
tenant resolver for improved error management and clarity
---
backend/app/api/v1/dashboard.py | 13 +++++++++----
backend/app/core/tenant_resolver.py | 28 +++++++++++++++++++++-------
2 files changed, 30 insertions(+), 11 deletions(-)
diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py
index 2b5a59eeed..57b5a38685 100644
--- a/backend/app/api/v1/dashboard.py
+++ b/backend/app/api/v1/dashboard.py
@@ -2,17 +2,22 @@
from typing import Dict, Any
from app.services.cache import get_revenue_summary
from app.core.auth import authenticate_request as get_current_user
+from app.models.auth import AuthenticatedUser
router = APIRouter()
@router.get("/dashboard/summary")
async def get_dashboard_summary(
property_id: str,
- current_user: dict = Depends(get_current_user)
+ current_user: AuthenticatedUser = Depends(get_current_user)
) -> Dict[str, Any]:
-
- tenant_id = getattr(current_user, "tenant_id", "default_tenant") or "default_tenant"
-
+
+ # A user without a tenant must not fall into a shared bucket that other
+ # tenant-less users could read; refuse instead.
+ tenant_id = getattr(current_user, "tenant_id", None)
+ if not tenant_id:
+ raise HTTPException(status_code=403, detail="No tenant associated with this account")
+
try:
revenue_data = await get_revenue_summary(property_id, tenant_id)
except Exception:
diff --git a/backend/app/core/tenant_resolver.py b/backend/app/core/tenant_resolver.py
index db09a46290..fa4056ed60 100644
--- a/backend/app/core/tenant_resolver.py
+++ b/backend/app/core/tenant_resolver.py
@@ -4,6 +4,8 @@
from typing import Optional
import logging
+from jose import jwt, JWTError
+
logger = logging.getLogger(__name__)
@@ -69,17 +71,27 @@ def resolve_tenant_from_user(user_data: dict) -> Optional[str]:
return None
@staticmethod
- async def resolve_tenant_id(user_id: str, user_email: str, token: Optional[str] = None) -> str:
+ async def resolve_tenant_id(user_id: str, user_email: str, token: Optional[str] = None) -> Optional[str]:
"""
Resolve tenant ID for a user.
-
+
Args:
user_id: User ID
user_email: User email
-
+ token: JWT already verified by the caller; its tenant claim wins
+
Returns:
- Tenant ID
+ Tenant ID, or None when the user has no tenant
"""
+ # The signed token is the source of truth for the tenant.
+ if token:
+ try:
+ tenant_id = TenantResolver.resolve_tenant_from_token(jwt.get_unverified_claims(token))
+ if tenant_id:
+ return tenant_id
+ except JWTError:
+ logger.warning(f"Could not read tenant claim from token for {user_email}")
+
# Fallback mapping by known user email.
if user_email == "sunset@propertyflow.com":
return "tenant-a"
@@ -87,9 +99,11 @@ async def resolve_tenant_id(user_id: str, user_email: str, token: Optional[str]
return "tenant-b"
if user_email == "candidate@propertyflow.com":
return "tenant-a"
-
- # Default fallback
- return "tenant-a"
+
+ # No default: an unknown user must never be placed in a real tenant,
+ # or they would see that tenant's data.
+ logger.warning(f"No tenant found for user {user_email} ({user_id})")
+ return None
@staticmethod
async def update_user_tenant_metadata(user_id: str, tenant_id: str) -> None:
From f3c82697cd779c7e8bd9bbd70fe3cbd2f9ccdb9f Mon Sep 17 00:00:00 2001
From: Eslam <41278977+eslam5464@users.noreply.github.com>
Date: Tue, 10 Nov 2026 16:50:46 +0200
Subject: [PATCH 05/14] Add month and year parameters to revenue summary
calculations for improved data granularity
---
backend/app/api/v1/dashboard.py | 11 +++--
backend/app/services/cache.py | 23 +++++++---
backend/app/services/reservations.py | 68 ++++++++++++++++++++--------
3 files changed, 72 insertions(+), 30 deletions(-)
diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py
index 57b5a38685..7ba5df6f07 100644
--- a/backend/app/api/v1/dashboard.py
+++ b/backend/app/api/v1/dashboard.py
@@ -1,5 +1,5 @@
-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 app.services.cache import get_revenue_summary
from app.core.auth import authenticate_request as get_current_user
from app.models.auth import AuthenticatedUser
@@ -9,9 +9,14 @@
@router.get("/dashboard/summary")
async def get_dashboard_summary(
property_id: str,
+ month: Optional[int] = Query(None, ge=1, le=12, description="Calendar month in the property's timezone"),
+ year: Optional[int] = Query(None, ge=2000, le=2100),
current_user: AuthenticatedUser = Depends(get_current_user)
) -> Dict[str, Any]:
+ if (month is None) != (year is None):
+ raise HTTPException(status_code=422, detail="month and year must be given together")
+
# A user without a tenant must not fall into a shared bucket that other
# tenant-less users could read; refuse instead.
tenant_id = getattr(current_user, "tenant_id", None)
@@ -19,7 +24,7 @@ async def get_dashboard_summary(
raise HTTPException(status_code=403, detail="No tenant associated with this account")
try:
- revenue_data = await get_revenue_summary(property_id, tenant_id)
+ revenue_data = await get_revenue_summary(property_id, tenant_id, month, year)
except Exception:
raise HTTPException(status_code=503, detail="Revenue data temporarily unavailable")
diff --git a/backend/app/services/cache.py b/backend/app/services/cache.py
index c264d86484..d04467abea 100644
--- a/backend/app/services/cache.py
+++ b/backend/app/services/cache.py
@@ -1,12 +1,17 @@
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.
"""
@@ -15,18 +20,22 @@ async def get_revenue_summary(property_id: str, tenant_id: str) -> Dict[str, Any
# Property IDs are only unique per tenant (prop-001 exists in several
# tenants), so the tenant must be part of the key to keep data isolated.
- cache_key = f"revenue:{tenant_id}:{property_id}"
+ 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_monthly_revenue, calculate_total_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))
diff --git a/backend/app/services/reservations.py b/backend/app/services/reservations.py
index b19967e968..1105154849 100644
--- a/backend/app/services/reservations.py
+++ b/backend/app/services/reservations.py
@@ -5,34 +5,62 @@
logger = logging.getLogger(__name__)
-async def calculate_monthly_revenue(property_id: str, month: int, year: int, db_session=None) -> Decimal:
- """
- Calculates revenue for a specific month.
+async def calculate_monthly_revenue(property_id: str, tenant_id: str, month: int, year: int) -> Dict[str, Any]:
"""
+ Calculates revenue for a calendar month in the property's own timezone.
+ check_in_date is stored in UTC. A booking at 2024-02-29 23:30 UTC is
+ 2024-03-01 00:30 in Europe/Paris, so for a Paris property it belongs to
+ March. Month boundaries are therefore applied to the local check-in time.
+ """
+ # Naive local-time bounds; compared against check_in_date converted to the
+ # property's timezone (timestamptz AT TIME ZONE tz -> local timestamp).
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
+ from app.core.database_pool import db_pool
+ from sqlalchemy import text
+
+ await db_pool.initialize()
+ if not db_pool.session_factory:
+ raise Exception("Database pool not available")
+
+ 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
+ """)
+
+ try:
+ async with db_pool.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()
+ except Exception as e:
+ logger.error(f"Database error for {property_id} (tenant: {tenant_id}, {year}-{month:02d}): {e}")
+ raise
+
+ 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]:
"""
From 4743cb4917bd2c0f1f748b64daddaf418077f55e Mon Sep 17 00:00:00 2001
From: Eslam <41278977+eslam5464@users.noreply.github.com>
Date: Tue, 10 Nov 2026 16:53:33 +0200
Subject: [PATCH 06/14] Refactor revenue calculations to use Decimal for
accurate monetary representation and add rounding function for cents
---
backend/app/api/v1/dashboard.py | 11 ++++++++---
backend/app/services/reservations.py | 19 ++++++++++++++++---
2 files changed, 24 insertions(+), 6 deletions(-)
diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py
index 7ba5df6f07..b340ee441a 100644
--- a/backend/app/api/v1/dashboard.py
+++ b/backend/app/api/v1/dashboard.py
@@ -1,4 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException, Query
+from decimal import Decimal
from typing import Dict, Any, Optional
from app.services.cache import get_revenue_summary
from app.core.auth import authenticate_request as get_current_user
@@ -28,11 +29,15 @@ async def get_dashboard_summary(
except Exception:
raise HTTPException(status_code=503, detail="Revenue data temporarily unavailable")
- total_revenue_float = float(revenue_data['total'])
-
+ # 'total' is already rounded to cents as an exact decimal string.
+ # total_revenue keeps the numeric field the frontend reads; the string
+ # field carries the exact amount for consumers that must not use floats.
+ total_revenue = Decimal(revenue_data['total'])
+
return {
"property_id": revenue_data['property_id'],
- "total_revenue": total_revenue_float,
+ "total_revenue": float(total_revenue),
+ "total_revenue_str": str(total_revenue),
"currency": revenue_data['currency'],
"reservations_count": revenue_data['count']
}
diff --git a/backend/app/services/reservations.py b/backend/app/services/reservations.py
index 1105154849..7459d87d24 100644
--- a/backend/app/services/reservations.py
+++ b/backend/app/services/reservations.py
@@ -1,10 +1,23 @@
import logging
from datetime import datetime
-from decimal import Decimal
+from decimal import Decimal, ROUND_HALF_UP
from typing import Dict, Any, List
logger = logging.getLogger(__name__)
+CENT = Decimal("0.01")
+
+
+def _to_cents(value) -> Decimal:
+ """
+ Round a money amount to cents.
+
+ total_amount is NUMERIC(10,3), so sums can carry sub-cent digits.
+ Sum exactly in the database, then round once here with ROUND_HALF_UP
+ (never through float, which cannot represent most decimal values).
+ """
+ return Decimal(str(value if value is not None else 0)).quantize(CENT, rounding=ROUND_HALF_UP)
+
async def calculate_monthly_revenue(property_id: str, tenant_id: str, month: int, year: int) -> Dict[str, Any]:
"""
Calculates revenue for a calendar month in the property's own timezone.
@@ -57,7 +70,7 @@ async def calculate_monthly_revenue(property_id: str, tenant_id: str, month: int
return {
"property_id": property_id,
"tenant_id": tenant_id,
- "total": str(Decimal(str(row.total_revenue))),
+ "total": str(_to_cents(row.total_revenue)),
"currency": "USD",
"count": row.reservation_count,
}
@@ -94,7 +107,7 @@ async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str,
row = result.fetchone()
if row:
- total_revenue = Decimal(str(row.total_revenue))
+ total_revenue = _to_cents(row.total_revenue)
return {
"property_id": property_id,
"tenant_id": tenant_id,
From 1191677f44b9fa91a0948032826808ca99bd1825 Mon Sep 17 00:00:00 2001
From: Eslam <41278977+eslam5464@users.noreply.github.com>
Date: Tue, 10 Nov 2026 16:58:15 +0200
Subject: [PATCH 07/14] Implement tenant-specific property retrieval and
enhance dashboard summary API integration
---
backend/app/api/v1/dashboard.py | 33 +++++++++++++---
backend/app/services/reservations.py | 29 ++++++++++++++
frontend/src/components/Dashboard.tsx | 45 ++++++++++++++++------
frontend/src/components/RevenueSummary.tsx | 15 +++-----
frontend/src/lib/secureApi.ts | 18 ++++-----
5 files changed, 105 insertions(+), 35 deletions(-)
diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py
index b340ee441a..1942f8d188 100644
--- a/backend/app/api/v1/dashboard.py
+++ b/backend/app/api/v1/dashboard.py
@@ -1,12 +1,37 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from decimal import Decimal
-from typing import Dict, Any, Optional
+from typing import Dict, Any, List, Optional
from app.services.cache import get_revenue_summary
+from app.services.reservations import list_tenant_properties
from app.core.auth import authenticate_request as get_current_user
from app.models.auth import AuthenticatedUser
router = APIRouter()
+
+def _require_tenant(current_user: AuthenticatedUser) -> str:
+ """Return the user's tenant, or refuse when there is none."""
+ # A user without a tenant must not fall into a shared bucket that other
+ # tenant-less users could read; refuse instead.
+ tenant_id = getattr(current_user, "tenant_id", None)
+ if not tenant_id:
+ raise HTTPException(status_code=403, detail="No tenant associated with this account")
+ return tenant_id
+
+
+@router.get("/dashboard/properties")
+async def get_dashboard_properties(
+ current_user: AuthenticatedUser = Depends(get_current_user)
+) -> List[Dict[str, Any]]:
+ """Properties of the caller's tenant, for the dashboard selector."""
+ tenant_id = _require_tenant(current_user)
+
+ try:
+ return await list_tenant_properties(tenant_id)
+ except Exception:
+ raise HTTPException(status_code=503, detail="Property list temporarily unavailable")
+
+
@router.get("/dashboard/summary")
async def get_dashboard_summary(
property_id: str,
@@ -18,11 +43,7 @@ async def get_dashboard_summary(
if (month is None) != (year is None):
raise HTTPException(status_code=422, detail="month and year must be given together")
- # A user without a tenant must not fall into a shared bucket that other
- # tenant-less users could read; refuse instead.
- tenant_id = getattr(current_user, "tenant_id", None)
- if not tenant_id:
- raise HTTPException(status_code=403, detail="No tenant associated with this account")
+ tenant_id = _require_tenant(current_user)
try:
revenue_data = await get_revenue_summary(property_id, tenant_id, month, year)
diff --git a/backend/app/services/reservations.py b/backend/app/services/reservations.py
index 7459d87d24..fdf0ba2262 100644
--- a/backend/app/services/reservations.py
+++ b/backend/app/services/reservations.py
@@ -132,3 +132,32 @@ async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str,
# instead of showing numbers that belong to no tenant.
logger.error(f"Database error for {property_id} (tenant: {tenant_id}): {e}")
raise
+
+
+async def list_tenant_properties(tenant_id: str) -> List[Dict[str, Any]]:
+ """
+ Returns the properties that belong to one tenant.
+ """
+ from app.core.database_pool import db_pool
+ from sqlalchemy import text
+
+ await db_pool.initialize()
+ if not db_pool.session_factory:
+ raise Exception("Database pool not available")
+
+ query = text("""
+ SELECT id, name, timezone
+ FROM properties
+ WHERE tenant_id = :tenant_id
+ ORDER BY id
+ """)
+
+ try:
+ async with db_pool.get_session() as session:
+ result = await session.execute(query, {"tenant_id": tenant_id})
+ rows = result.fetchall()
+ except Exception as e:
+ logger.error(f"Database error listing properties (tenant: {tenant_id}): {e}")
+ raise
+
+ return [{"id": r.id, "name": r.name, "timezone": r.timezone} for r in rows]
diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx
index a21bba404e..5307358f9c 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 { 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' }
-];
+interface DashboardProperty {
+ id: string;
+ name: string;
+ timezone: string;
+}
const Dashboard: React.FC = () => {
- const [selectedProperty, setSelectedProperty] = useState('prop-001');
+ // Properties come from the backend for the logged-in tenant only, so one
+ // client never sees another client's property names.
+ const [properties, setProperties] = useState([]);
+ const [selectedProperty, setSelectedProperty] = useState('');
+ const [propertiesError, setPropertiesError] = useState('');
+
+ useEffect(() => {
+ let cancelled = false;
+ SecureAPI.getDashboardProperties()
+ .then((list) => {
+ if (cancelled) return;
+ setProperties(list);
+ setSelectedProperty(list[0]?.id ?? '');
+ })
+ .catch((err) => {
+ console.error(err);
+ if (!cancelled) setPropertiesError('Failed to load properties');
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
return (
@@ -35,7 +55,7 @@ const Dashboard: React.FC = () => {
onChange={(e) => setSelectedProperty(e.target.value)}
className="block w-full sm:w-auto min-w-[200px] px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 text-sm"
>
- {PROPERTIES.map((property) => (
+ {properties.map((property) => (
{property.name}
@@ -46,7 +66,10 @@ const Dashboard: React.FC = () => {
-
+ {propertiesError && (
+
{propertiesError}
+ )}
+ {selectedProperty &&
}
diff --git a/frontend/src/components/RevenueSummary.tsx b/frontend/src/components/RevenueSummary.tsx
index dbb6d0629d..1b8caffaa0 100644
--- a/frontend/src/components/RevenueSummary.tsx
+++ b/frontend/src/components/RevenueSummary.tsx
@@ -9,26 +9,23 @@ interface RevenueData {
}
interface RevenueSummaryProps {
- propertyId?: string;
- debugTenant?: string;
+ propertyId: string;
showRaw?: boolean;
}
-export const RevenueSummary: React.FC = ({ propertyId = 'prop-001', debugTenant, showRaw }) => {
+export const RevenueSummary: React.FC = ({ propertyId, 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);
+ setError('');
try {
- // Use SecureAPI to handle authentication automatically
- // We pass the simulatedTenant option which SecureAPI will attach as a header
+ // SecureAPI attaches the auth token; the backend takes the
+ // tenant from that token only.
const response = await SecureAPI.getDashboardSummary(propertyId, {
- simulatedTenant: activeTenant,
timestamp: Date.now()
});
setData(response);
@@ -41,7 +38,7 @@ export const RevenueSummary: React.FC = ({ propertyId = 'pr
};
fetchRevenue();
- }, [propertyId, activeTenant]);
+ }, [propertyId]);
if (loading) {
return (
diff --git a/frontend/src/lib/secureApi.ts b/frontend/src/lib/secureApi.ts
index f85f04c90a..94bc79d16a 100644
--- a/frontend/src/lib/secureApi.ts
+++ b/frontend/src/lib/secureApi.ts
@@ -1450,22 +1450,22 @@ export class SecureAPIClient {
// ============= DASHBOARD API =============
/**
- * Get dashboard summary with optional simulation header
+ * Get dashboard summary. The tenant always comes from the auth token.
*/
- async getDashboardSummary(propertyId: string, options?: { simulatedTenant?: string, timestamp?: number }) {
+ async getDashboardSummary(propertyId: string, options?: { timestamp?: number }) {
const queryParams = new URLSearchParams({ property_id: propertyId });
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}`);
+ }
- return this.request(`/api/v1/dashboard/summary?${queryParams}`, requestOptions);
+ /**
+ * Get the properties of the logged-in user's tenant
+ */
+ async getDashboardProperties() {
+ return this.request>('/api/v1/dashboard/properties');
}
async uploadCompanyLogo(logo_url: string) {
From 9914622860108fefbac66fd69abbbde821fd9134 Mon Sep 17 00:00:00 2001
From: Eslam <41278977+eslam5464@users.noreply.github.com>
Date: Tue, 10 Nov 2026 16:59:24 +0200
Subject: [PATCH 08/14] Update asyncpg dependency and modify sqlalchemy to use
asyncio variant for improved compatibility
---
backend/pyproject.toml | 3 +-
backend/uv.lock | 82 ++++++++++++++++++++++++++++++++++++++++--
2 files changed, 82 insertions(+), 3 deletions(-)
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index ebaa4bc92d..ccf6cc3ebe 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -7,6 +7,7 @@ requires-python = ">=3.11"
dependencies = [
"aiohttp>=3.12.15",
"aioredis>=2.0.0",
+ "asyncpg>=0.27.0",
"beautifulsoup4>=4.13.4",
"cachetools>=6.2.0",
"cryptography>=46.0.2",
@@ -27,7 +28,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/uv.lock b/backend/uv.lock
index 01e9327152..66d689da8f 100644
--- a/backend/uv.lock
+++ b/backend/uv.lock
@@ -141,6 +141,77 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" },
]
+[[package]]
+name = "asyncpg"
+version = "0.32.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/80/4e/59dc964f962f09e3ed472e5d2d3ba670a41a2be25080dc62ab3db507ff5e/asyncpg-0.32.0.tar.gz", hash = "sha256:45e64e56714d888330b884aad1dfb363d0bf43fb343e3d1a8968525f3bade478", size = 1075156, upload-time = "2026-10-06T20:32:40.251Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a3/27/1a7970f1ece6c205b03c79f45b89420dee9655ffb66bd2c11be8f40c248a/asyncpg-0.32.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5789340b9bcdab94a19eb8ff119322a09991e3626d131b55828535b373e285d4", size = 686071, upload-time = "2026-10-06T20:30:39.115Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/47/085934d0290806a92789eee860109c44bea71ff8bc7850a9d3a30da7a819/asyncpg-0.32.0-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:057ed2455e4e14ad9949f1ac1829112c7d0454c9810b124f36de1486febe6824", size = 692193, upload-time = "2026-10-06T20:30:40.563Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/2c/d92524b9e860aecd119c0ebe43f3b9eca26dc2b75c4dfe1be3e999e3f6b1/asyncpg-0.32.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c938c4da9166ac1ef330475e314e2b94c68bde2795be0f4e8a1e00ccd806cadd", size = 3196713, upload-time = "2026-10-06T20:30:42.123Z" },
+ { url = "https://files.pythonhosted.org/packages/85/b5/3ac7cb86aa287e5bbceaeb783ee6e4f51cd2a001f1747ef4f1236a20bde6/asyncpg-0.32.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:968c570c5913b7ce0995953d7239bd2367142d1af4359f87699f7a6ca75c4382", size = 3260618, upload-time = "2026-10-06T20:30:43.552Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/08/618ac36b2970b437d45523f50b5580dba0c34756bbf2153306f82a2697e5/asyncpg-0.32.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96c8226d2026e025852facb5a05035ea5e11b14bebb6b42e4e43948ef8f0d075", size = 3132973, upload-time = "2026-10-06T20:30:45.147Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/e6/54db41b3d5fe26b0401a49327ffce439195c5f6073d8afbbdc9758cb35c3/asyncpg-0.32.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d3f745f4947df9004e2637753ff81d52f305f790f49d67f72e1677db12b07a7b", size = 3251612, upload-time = "2026-10-06T20:30:46.923Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/e0/ed1e7536ce949896de29ee955b473659b3daa7887e7081030dba2b15ea5d/asyncpg-0.32.0-cp311-cp311-win32.whl", hash = "sha256:469e6520a839957304582eb8a708d874985914500b64517155f80e6fec00e742", size = 538739, upload-time = "2026-10-06T20:30:48.355Z" },
+ { url = "https://files.pythonhosted.org/packages/df/eb/52c4bddad17ff1bee485ae83e08c752a998ef04ac5df76f03fef6430d0ed/asyncpg-0.32.0-cp311-cp311-win_amd64.whl", hash = "sha256:6a1e671e67f4b0bef3c03f37a896d61706f769a83922c119070f1f04e415dc17", size = 610534, upload-time = "2026-10-06T20:30:50.003Z" },
+ { url = "https://files.pythonhosted.org/packages/85/c7/9af12f2b3300c425a151ef8f85f47c0db76135827c549031858954805ff7/asyncpg-0.32.0-cp311-cp311-win_arm64.whl", hash = "sha256:901bc87b94539f32853bd73a9b02fa78f7feed4cf628824caad3093ec6662f58", size = 574363, upload-time = "2026-10-06T20:30:51.489Z" },
+ { url = "https://files.pythonhosted.org/packages/73/06/d5f956db9c936c90cd3289cf948a86c3efc9849e26354356c23da29f6a2d/asyncpg-0.32.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7cb31f7a8472ddc6b6f5c9da1290e901d5c77c8441c7213bd13b13ef6fe6359c", size = 681566, upload-time = "2026-10-06T20:30:52.779Z" },
+ { url = "https://files.pythonhosted.org/packages/09/93/ea55f3b26fd40ec90e5b6d6c53b9ff52633cf6b87a468d9c033a727832f4/asyncpg-0.32.0-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:643d8d6e955a355045dddfe827d74f4f0d1dc4a18e06963a08260af838fbf093", size = 704359, upload-time = "2026-10-06T20:30:54.608Z" },
+ { url = "https://files.pythonhosted.org/packages/46/2c/a3704e8675d37b168f3584661fc9f64f3021659c9b94e51cf9ab957b2bc5/asyncpg-0.32.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:14ff79ca2574182ce258159c48978a086f9026fc121d935017b5d10c64fa3c72", size = 3707008, upload-time = "2026-10-06T20:30:56.326Z" },
+ { url = "https://files.pythonhosted.org/packages/30/30/4fd8d1155b3d7a32a2c241dcb9c5d9e9bd74a59ae71ed25ef8ddb8e038e1/asyncpg-0.32.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:54851411bee2aa51a30d0911524201fbb05f82cc0f7c248b140203db637c723d", size = 3810163, upload-time = "2026-10-06T20:30:58.114Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/25/5b0992d45661e1488aba775cf17a2e6c82c7d1d7e10acc71efd394760a00/asyncpg-0.32.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8592f0ed9c315b2117dbdc707cf3292f09a89d5b07661016a84dd881326965cf", size = 3600446, upload-time = "2026-10-06T20:30:59.946Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/88/1c82c6feacec813423401b5aef1a43baea951694157f4d405b2d14e80e6d/asyncpg-0.32.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4dbe0982cb3ded878de0867dfaeae3116faf471d484ea28b3e3da942f01fb778", size = 3764563, upload-time = "2026-10-06T20:31:01.462Z" },
+ { url = "https://files.pythonhosted.org/packages/84/f5/5a3796088f0c3f7d22aaf7c48536f40b27e44b7c9603d4d7abfeca2ed97e/asyncpg-0.32.0-cp312-cp312-win32.whl", hash = "sha256:fbe1f8c788fb5df18ea8a5432dfa2473fd8f7f088025fb83d089a7c7b37e37b0", size = 551810, upload-time = "2026-10-06T20:31:03.248Z" },
+ { url = "https://files.pythonhosted.org/packages/af/42/f4d333a3f67b0e7cf58ea855f9d5d9104ce38c21f2a2f22bf7dce524428c/asyncpg-0.32.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd7157a86817730c3239bc687abf8186a471525d695e225c187b9a523a808a98", size = 626763, upload-time = "2026-10-06T20:31:04.927Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/82/9d82e16e1d0b4e2a639a2db649d4b444b8a479cd52553a9c36ba0d6320a8/asyncpg-0.32.0-cp312-cp312-win_arm64.whl", hash = "sha256:9509e21fc526f1fc27cf80ad9f9b8dde3f3e21935d46be66d649635321d3407c", size = 577288, upload-time = "2026-10-06T20:31:06.776Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/ee/b6b5870b51e004880d9a216313ea7d4f180961c5869f32e58e8cb9b71e96/asyncpg-0.32.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c032869fd9c3c9fd1a86ad67e53f63906159068087c2674dd1e19be3cffff571", size = 683362, upload-time = "2026-10-06T20:31:08.078Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/8b/1f450742bc6eab0c015cae26aef94fac2ff29433e3f18a019126c3912c49/asyncpg-0.32.0-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:0c764dce865b41878396e736d4d2c6c6ce3a8e1b61d1f6bb292e30d265ae7ca6", size = 706652, upload-time = "2026-10-06T20:31:09.524Z" },
+ { url = "https://files.pythonhosted.org/packages/05/dc/13f3c0ef7e867bafdccd470e5cfae1f2fd9a7085c771546bd4b94018e043/asyncpg-0.32.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:925ce1cc54419d468bfb77632d91e5e2be5be0fdf9d43680c68fe7cedf87051a", size = 3698244, upload-time = "2026-10-06T20:31:10.894Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/64/b00ef3fc0d861c28a1937f08d2c7f6e6119c152b414d50fa800c3aee83b5/asyncpg-0.32.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4cec40b66a36b14921c155db78631cd96ed00e225fdf38dd5532e9aef350a498", size = 3801314, upload-time = "2026-10-06T20:31:12.964Z" },
+ { url = "https://files.pythonhosted.org/packages/de/1b/215067d97a13206ce1565da920ddbefe5a1e5f89903e6de862fdd0a034a1/asyncpg-0.32.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1fba43a9a230ce4d2b4593b761b8e03630c613c282b24566e27c7f53695273b1", size = 3598650, upload-time = "2026-10-06T20:31:14.797Z" },
+ { url = "https://files.pythonhosted.org/packages/37/45/2bfcb5c9b04df3f17fd367647c9f3ee9fe64ea0612b509a6b1832afcedae/asyncpg-0.32.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7a8f7fa8304f757e23cccb8ffef6a6fce0b6320ffc565a884ee3cd0dfad1ac5", size = 3762739, upload-time = "2026-10-06T20:31:17.186Z" },
+ { url = "https://files.pythonhosted.org/packages/08/45/e6b37756e6c8979fe070e9821654244f38319493f5b0589e549d9a40c001/asyncpg-0.32.0-cp313-cp313-win32.whl", hash = "sha256:d809399022e244eb86bb532a4ae9a45746e0f6dc5154fd6aa2f6ad63fa3f5373", size = 551065, upload-time = "2026-10-06T20:31:18.812Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/46/0a4e92f4310da644b28595b22ef2fff1ffd3dab84953dc8b4c5eef72b764/asyncpg-0.32.0-cp313-cp313-win_amd64.whl", hash = "sha256:38640b106705fef8b0f46cdb5fd9dcf6a638eed5cadb0f441714a21405ca8a0a", size = 625571, upload-time = "2026-10-06T20:31:20.571Z" },
+ { url = "https://files.pythonhosted.org/packages/35/f4/48ed4b580b99b1fabc480c707229bb8f1e4ba0f5b24a50822b339efe1e48/asyncpg-0.32.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78145adedfe51dc2fda623e6602cf816dabc2eafcff693bd50484321a1c9034", size = 576342, upload-time = "2026-10-06T20:31:22.29Z" },
+ { url = "https://files.pythonhosted.org/packages/25/25/a30ca6417f9142c6a63a7caf5f33717902b2d0ca8a8ff8fc72c6cc2fa77d/asyncpg-0.32.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ac18d9ee7a8ca70aed276f79b249d9f37e4d55e3525db1002b5f0b62ddec4f5", size = 691699, upload-time = "2026-10-06T20:31:24.168Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/b5/59f10f2381a073c199cd868fce0d8f7aa448b08412de4dc4dbe4118bcee9/asyncpg-0.32.0-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:e1120ef2ae3a5e514c9ea9fce83519ba692710ea5f38434eadbbf12789073dfe", size = 715194, upload-time = "2026-10-06T20:31:25.969Z" },
+ { url = "https://files.pythonhosted.org/packages/54/59/79a5aebd58250bedefa6dcd43b22b037d9cf0054ceb4c718c53ebf04e63f/asyncpg-0.32.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fa68acb42f22436597016e5d7feef7b0b5c49b4c56aece3fdb3ba0da2326cb2", size = 3729978, upload-time = "2026-10-06T20:31:27.541Z" },
+ { url = "https://files.pythonhosted.org/packages/68/db/fc91b503b3ec66cf242d83c799388285ea5f0ee238435d53dd9c1a8648a9/asyncpg-0.32.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63417b8f7369c54f6754c1fbd5a2968fbe632ff55bfbedd56a0177b6a96bd251", size = 3794539, upload-time = "2026-10-06T20:31:29.617Z" },
+ { url = "https://files.pythonhosted.org/packages/40/bd/7359320499fdb2733206191b8fd15b7ec602656cbc1444bff7a8c66a365c/asyncpg-0.32.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2c6366841a792d0a4d16991de240a8053b7c4772a18a5f27fa6fad09c0e359fb", size = 3632884, upload-time = "2026-10-06T20:31:31.298Z" },
+ { url = "https://files.pythonhosted.org/packages/18/75/dd3c3dd99f1db55b9736d23a44da29501f07f852bf4df91507f37b156fb1/asyncpg-0.32.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3ef1dfd11919280e011ffd1c873323c5088a94fd2c3f77946a5250cf306e2eb", size = 3764931, upload-time = "2026-10-06T20:31:32.916Z" },
+ { url = "https://files.pythonhosted.org/packages/38/4f/161b275759725a774d170a383c1208996865ebad50d6891e60d35461a3e6/asyncpg-0.32.0-cp314-cp314-win32.whl", hash = "sha256:77cf9d7023f063ae6f9e443077b55af0dc1807dd9afff1ae656b93ee0cddedc9", size = 557690, upload-time = "2026-10-06T20:31:34.856Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/03/880d0db1faedf8b740a57a7ba50e115651a0f05c5905140195813879b086/asyncpg-0.32.0-cp314-cp314-win_amd64.whl", hash = "sha256:2f87452025b47ce80dcc3a0be2b5d1f8aab5deec2516d266f1643d4e53cc40d5", size = 634859, upload-time = "2026-10-06T20:31:36.512Z" },
+ { url = "https://files.pythonhosted.org/packages/79/bb/2e86b462a2a2a795eaa7838266db019876b8e7a12c465b903517a4e87fd0/asyncpg-0.32.0-cp314-cp314-win_arm64.whl", hash = "sha256:d0e4508a3d62b0f42d7a99c030c364050b11e75f61c9dd4861e5fdda7cb60636", size = 594013, upload-time = "2026-10-06T20:31:37.91Z" },
+ { url = "https://files.pythonhosted.org/packages/20/1d/5369c4438496e654121cbda75be2e8043d1fcae3552b856d44011a19b723/asyncpg-0.32.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:afec11e0b9c001e69966becacd2f948cc8949b4916ec4c0f4dc9b52e47de4528", size = 743832, upload-time = "2026-10-06T20:31:39.261Z" },
+ { url = "https://files.pythonhosted.org/packages/60/b0/4b92582c2339a164275a6418ccaeeb0453b72f2e0d7003702379cb50e852/asyncpg-0.32.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:418d266a553e932bf961bb43bfd610ee6c5425fb1b9a599a5828fd12bae8f5c4", size = 769568, upload-time = "2026-10-06T20:31:40.691Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/88/919d9ff7ca3c3b96aa404b88b6a53e142b4422623c5ee5a69c4b733240ce/asyncpg-0.32.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b1666e1b747ebbc75c87cb31972704ae8a3ca15b950f94456e97d26781c67d10", size = 3948962, upload-time = "2026-10-06T20:31:42.456Z" },
+ { url = "https://files.pythonhosted.org/packages/27/8b/e9f412ae9a3e3f0eb23415249e8d5933e7aeb01068b4083fc86714043d1f/asyncpg-0.32.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83510bb25d38f0415e155aa3a7af78621369891f5ecd8730d012d9cb26143ffc", size = 3874815, upload-time = "2026-10-06T20:31:44.094Z" },
+ { url = "https://files.pythonhosted.org/packages/08/71/24364e9ff7bb9860548452513f295306b12f5b24e8fb0b78f1605c443946/asyncpg-0.32.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:87957755d11639cf248c6aaa094eee9d150f07065866d1710c9427e02dfc0790", size = 3762465, upload-time = "2026-10-06T20:31:45.908Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/e1/33cb7e805ec6806b196473e2c7a2ba9d5af3ad2928930aa06359c8eeef87/asyncpg-0.32.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:764227423bf30a3001d3da6df90e82d30a2a097d762e4ee5fa074236eda262f4", size = 3797285, upload-time = "2026-10-06T20:31:47.53Z" },
+ { url = "https://files.pythonhosted.org/packages/be/e7/85eb86d6040725f5c191fd6af9f10769c60ed971634b47f4b4bcab293d44/asyncpg-0.32.0-cp314-cp314t-win32.whl", hash = "sha256:f2342b1f3e87b2096320a77edcbb830fbd23b1d4d4842c57567764430b95e4fc", size = 594006, upload-time = "2026-10-06T20:31:49.197Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/aa/ea75defe55718457bcf41cde42248db5bbee65fce8c6f0a0e43d9eca1723/asyncpg-0.32.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5c3a48908cb0a02393e5bdab7fa92aefd700f2a93212bf91f04aa9657b4f554d", size = 674647, upload-time = "2026-10-06T20:31:50.547Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/0b/078d362872c6c72dd5d11c214dde8dac65b1c87ece96fd2fc2f786a8f66c/asyncpg-0.32.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f8eadd207c26850a2e15f3c2a1096b5d051ea6758a26f2f3e65ce16f84297ed8", size = 624589, upload-time = "2026-10-06T20:31:52.291Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/83/e0145d19197b965438693179c88dd99cfc69bc1bf954815f44762ab88843/asyncpg-0.32.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:58975b1a51a100c4716ebf22f84c249d27140f7b9385b64ad9b676836f1db9ab", size = 689708, upload-time = "2026-10-06T20:31:55.809Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/13/f394919a59f104288b1b17fb6c7a3ac4738b8c555690a63caf603f91ca83/asyncpg-0.32.0-cp315-cp315-macosx_11_0_x86_64.whl", hash = "sha256:6b95fc2ebdb4af072bfa8b64c6d0397b49242d17bef1c0337857904f9267dab2", size = 714408, upload-time = "2026-10-06T20:31:57.504Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/3d/1123cf41bff78fdfd80e6fd143cc86bf1ef2875af8f5d8742c03f471e913/asyncpg-0.32.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a759f98c5652443db501b20041aeee548e9a04fe7ae939067321acd207218447", size = 3733440, upload-time = "2026-10-06T20:31:59.308Z" },
+ { url = "https://files.pythonhosted.org/packages/de/24/ff4b045e85d7bdf6f61f67c285800abd6e82f26319671d7f0dfadadc1aa0/asyncpg-0.32.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ceea1064500d0d7a46c092cdbe9752064c23b720ab0e0bff83d1030fffe7a50a", size = 3824312, upload-time = "2026-10-06T20:32:01.021Z" },
+ { url = "https://files.pythonhosted.org/packages/12/63/1ec7eb6e20f7e8ae120a41aad9669044cce964f39773baf644897a046aee/asyncpg-0.32.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:543f02790d086244c7cdc849e4b671b6c2048be0242b78d943494da6e80c0001", size = 3637212, upload-time = "2026-10-06T20:32:02.699Z" },
+ { url = "https://files.pythonhosted.org/packages/79/68/528e362eb5adbc1a7defe4c5f157756a031346d3efa9920467b245e4ce41/asyncpg-0.32.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f24d20a68f0e37ca6fc490388e7eeb48abab3da0dbf06248135ed6179f5f521d", size = 3791355, upload-time = "2026-10-06T20:32:04.415Z" },
+ { url = "https://files.pythonhosted.org/packages/38/e3/22f443f456bf93d1806f43a820da8ee463dfe9b93a9d77a3f00fedcdaad6/asyncpg-0.32.0-cp315-cp315-win32.whl", hash = "sha256:110f72d33c8b944ab421ca383db0b8849cfeb861547fee6cbb61f65a6bcd0985", size = 557457, upload-time = "2026-10-06T20:32:06.52Z" },
+ { url = "https://files.pythonhosted.org/packages/54/d5/ccb76555a333f543c4d6ad6422b616efc0811dbbde5054fda071e249c7bf/asyncpg-0.32.0-cp315-cp315-win_amd64.whl", hash = "sha256:6d1d1cd1348ebb9b204b5f56f977c5d4380674c25cc094064bf32bd9c3b7273d", size = 635573, upload-time = "2026-10-06T20:32:08.197Z" },
+ { url = "https://files.pythonhosted.org/packages/38/70/dff17e837ba0eb4347bb33da33f54df87230d3d176793d4bb2ad7786b1b8/asyncpg-0.32.0-cp315-cp315-win_arm64.whl", hash = "sha256:cd5d16b3a5db37c1e6e445e362952b4af569f85f94e162f947bfa8ea25a45fa5", size = 594218, upload-time = "2026-10-06T20:32:09.717Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/b8/c5506dbde0cfb213963210fd0c80e60036ddaaa883ac0d3c55d05a10ebe8/asyncpg-0.32.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4ea1a72a00fe705b68a9727c3d538c4c56690af9bb1cbbf3c089f5d3ddcccea0", size = 741693, upload-time = "2026-10-06T20:32:11.168Z" },
+ { url = "https://files.pythonhosted.org/packages/23/98/9f998c651aa5d66b59ab6c13da71a15d74ccb1ddc4d65290ea5e2e5aedc1/asyncpg-0.32.0-cp315-cp315t-macosx_11_0_x86_64.whl", hash = "sha256:ed3ae4c3659aea1fb0e3a6c1061fc4c64d9b7a2a8f4a27443dc43d74fa84cf03", size = 768101, upload-time = "2026-10-06T20:32:12.948Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/ce/d8c63a71e908f5d80de1a3a057c8407aaea07cf19980d4b24ab624943c99/asyncpg-0.32.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db69b9cf879bddeea41210c80b8c8877bfe2709e2bee9d18d5a5c00e7eb75972", size = 3940715, upload-time = "2026-10-06T20:32:14.544Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/a5/5d2b17682e297e39206eda1dfe0120fc239e84d3440b39ff7c9cc7ec83db/asyncpg-0.32.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bee7bb5394bf55fc3bf4144625c33f298949961acdb1e0d67e60f958ac9a2e6", size = 3907504, upload-time = "2026-10-06T20:32:16.212Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/80/38ec7277f31f26267a0a0547d0997d936850d05007d1e0e1041bf8070e1d/asyncpg-0.32.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:d74eabd68e68861333e3fcb92b520a2a851f6485abf4b723887590399d4980c1", size = 3750324, upload-time = "2026-10-06T20:32:18.061Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/74/089e80eda7d543a49875687a84121e2ad61a7c69698963623ee77372c4e9/asyncpg-0.32.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6af2af292a93d5ef800007c8f8f66b85af2a49b49e4b56a10685a0dc24a6af83", size = 3826457, upload-time = "2026-10-06T20:32:19.757Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/3c/38104e60cda6131977f95b634d45536ddc1cde53ef8bc765f9056e3e17ee/asyncpg-0.32.0-cp315-cp315t-win32.whl", hash = "sha256:d148cb6a9081ed999ca3cd0d95fb9eaf79bf17d885bba93c83de52273d2fe0af", size = 592437, upload-time = "2026-10-06T20:32:21.668Z" },
+ { url = "https://files.pythonhosted.org/packages/95/09/85cba249db0910708826ea428b32a4a05630df993621c369bdb8d42c73c5/asyncpg-0.32.0-cp315-cp315t-win_amd64.whl", hash = "sha256:e101801b4124e905da0732cf2b0d838f682a9ea5273d7cced3d54bdbe744e6f7", size = 672417, upload-time = "2026-10-06T20:32:23.147Z" },
+ { url = "https://files.pythonhosted.org/packages/38/11/ec5f7f306dd361aa9558f002cbb6acfa1e9ba32fa59b8f53135fbdfa14f1/asyncpg-0.32.0-cp315-cp315t-win_arm64.whl", hash = "sha256:3bbf08c08e31f43be858255614518e78cdfb343571e557e818e9fe736334f4c8", size = 622767, upload-time = "2026-10-06T20:32:24.64Z" },
+]
+
[[package]]
name = "attrs"
version = "25.3.0"
@@ -157,6 +228,7 @@ source = { virtual = "." }
dependencies = [
{ name = "aiohttp" },
{ name = "aioredis" },
+ { name = "asyncpg" },
{ name = "beautifulsoup4" },
{ name = "cachetools" },
{ name = "cryptography" },
@@ -177,7 +249,7 @@ dependencies = [
{ name = "reportlab" },
{ name = "requests" },
{ name = "sendgrid" },
- { name = "sqlalchemy" },
+ { name = "sqlalchemy", extra = ["asyncio"] },
{ name = "stripe" },
{ name = "supabase" },
{ name = "tenacity" },
@@ -194,6 +266,7 @@ dev = [
requires-dist = [
{ name = "aiohttp", specifier = ">=3.12.15" },
{ name = "aioredis", specifier = ">=2.0.0" },
+ { name = "asyncpg", specifier = ">=0.27.0" },
{ name = "beautifulsoup4", specifier = ">=4.13.4" },
{ name = "cachetools", specifier = ">=6.2.0" },
{ name = "cryptography", specifier = ">=46.0.2" },
@@ -214,7 +287,7 @@ requires-dist = [
{ name = "reportlab", specifier = ">=4.4.3" },
{ name = "requests", specifier = ">=2.32.4" },
{ name = "sendgrid", specifier = ">=6.12.4" },
- { name = "sqlalchemy", specifier = ">=2.0.0" },
+ { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.0" },
{ name = "stripe", specifier = ">=7.0.0" },
{ name = "supabase", specifier = ">=2.18.0" },
{ name = "tenacity", specifier = ">=9.1.2" },
@@ -1863,6 +1936,11 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/55/ba2546ab09a6adebc521bf3974440dc1d8c06ed342cceb30ed62a8858835/sqlalchemy-2.0.42-py3-none-any.whl", hash = "sha256:defcdff7e661f0043daa381832af65d616e060ddb54d3fe4476f51df7eaa1835", size = 1922072, upload-time = "2025-07-29T13:09:17.061Z" },
]
+[package.optional-dependencies]
+asyncio = [
+ { name = "greenlet" },
+]
+
[[package]]
name = "starlette"
version = "0.47.2"
From d805bb000901ea532e71b48c11930fc48a26c6c3 Mon Sep 17 00:00:00 2001
From: Eslam <41278977+eslam5464@users.noreply.github.com>
Date: Tue, 10 Nov 2026 17:03:40 +0200
Subject: [PATCH 09/14] Add pytest and pytest-asyncio to development
dependencies for improved testing support
---
backend/pyproject.toml | 2 ++
backend/requirements-dev.txt | 3 ++
backend/uv.lock | 64 +++++++++++++++++++++++++++++++++++-
3 files changed, 68 insertions(+), 1 deletion(-)
create mode 100644 backend/requirements-dev.txt
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index ccf6cc3ebe..3bb020dbe0 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -39,6 +39,8 @@ dependencies = [
[dependency-groups]
dev = [
"pre-commit>=4.3.0",
+ "pytest>=9.2.0",
+ "pytest-asyncio>=1.4.1.post1",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt
new file mode 100644
index 0000000000..c1aa1129d7
--- /dev/null
+++ b/backend/requirements-dev.txt
@@ -0,0 +1,3 @@
+-r requirements.txt
+pytest>=9.2.0
+pytest-asyncio>=1.4.1.post1
diff --git a/backend/uv.lock b/backend/uv.lock
index 66d689da8f..0f4a4d53c2 100644
--- a/backend/uv.lock
+++ b/backend/uv.lock
@@ -260,6 +260,8 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "pre-commit" },
+ { name = "pytest" },
+ { name = "pytest-asyncio" },
]
[package.metadata]
@@ -296,7 +298,11 @@ requires-dist = [
]
[package.metadata.requires-dev]
-dev = [{ name = "pre-commit", specifier = ">=4.3.0" }]
+dev = [
+ { name = "pre-commit", specifier = ">=4.3.0" },
+ { name = "pytest", specifier = ">=9.2.0" },
+ { name = "pytest-asyncio", specifier = ">=1.4.1.post1" },
+]
[[package]]
name = "beautifulsoup4"
@@ -892,6 +898,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
]
+[[package]]
+name = "iniconfig"
+version = "2.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
+]
+
[[package]]
name = "jiter"
version = "0.11.0"
@@ -1376,6 +1391,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" },
]
+[[package]]
+name = "pluggy"
+version = "1.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" },
+]
+
[[package]]
name = "postgrest"
version = "1.1.1"
@@ -1665,6 +1689,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" },
]
+[[package]]
+name = "pygments"
+version = "2.21.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
+]
+
[[package]]
name = "pyhumps"
version = "3.8.0"
@@ -1683,6 +1716,35 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" },
]
+[[package]]
+name = "pytest"
+version = "9.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/79/42/bb4641652e58dedeac2ec51abc1f8cae4fee4ec63899a0999016229a0f2a/pytest-9.2.0.tar.gz", hash = "sha256:97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b", size = 1797555, upload-time = "2026-10-27T21:30:20.728Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/51/39/1f5d8c8877a0fa327db959c19341d4be61660de1ab6b7391f9eb805bcd67/pytest-9.2.0-py3-none-any.whl", hash = "sha256:07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588", size = 411776, upload-time = "2026-10-27T21:30:19.131Z" },
+]
+
+[[package]]
+name = "pytest-asyncio"
+version = "1.4.1.post1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pytest" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/73/7f/c60a4338420b33453d4f814ac0c930abd399f162b9bdd21af4b9e363037f/pytest_asyncio-1.4.1.post1.tar.gz", hash = "sha256:f582d9ea4a1c082195abe8bd29a1fe106aeb1cd435e3dad2d3bc2749935c36a2", size = 145360, upload-time = "2026-11-01T18:26:03.732Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e3/de/adbc7578959cfab07e5e7c05cf6d54224a51e7667ea2c07458b6bc571e75/pytest_asyncio-1.4.1.post1-py3-none-any.whl", hash = "sha256:0755e488cc0a2b9bd2eab7292cc6390d4cbb8390f1020745647bae5a708e0165", size = 17140, upload-time = "2026-11-01T18:26:02.168Z" },
+]
+
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
From 3c9e91e35de4ecfd099836a23da959e5f2d7e5cc Mon Sep 17 00:00:00 2001
From: Eslam <41278977+eslam5464@users.noreply.github.com>
Date: Tue, 10 Nov 2026 17:04:46 +0200
Subject: [PATCH 10/14] Add integration and unit tests for revenue calculations
and tenant resolution
---
backend/tests/conftest.py | 32 ++++
backend/tests/test_dashboard_api.py | 151 +++++++++++++++++++
backend/tests/test_money_rounding.py | 37 +++++
backend/tests/test_revenue_cache.py | 84 +++++++++++
backend/tests/test_revenue_db_integration.py | 117 ++++++++++++++
backend/tests/test_tenant_resolver.py | 63 ++++++++
6 files changed, 484 insertions(+)
create mode 100644 backend/tests/conftest.py
create mode 100644 backend/tests/test_dashboard_api.py
create mode 100644 backend/tests/test_money_rounding.py
create mode 100644 backend/tests/test_revenue_cache.py
create mode 100644 backend/tests/test_revenue_db_integration.py
create mode 100644 backend/tests/test_tenant_resolver.py
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
new file mode 100644
index 0000000000..85f22db670
--- /dev/null
+++ b/backend/tests/conftest.py
@@ -0,0 +1,32 @@
+import pytest
+
+
+def pytest_configure(config):
+ config.addinivalue_line(
+ "markers",
+ "integration: needs the seeded Postgres from docker-compose (skipped when unreachable)",
+ )
+
+
+class FakeRedis:
+ """In-memory stand-in for the async Redis client used by the revenue cache."""
+
+ def __init__(self):
+ self.store = {}
+ self.ttls = {}
+
+ async def get(self, key):
+ return self.store.get(key)
+
+ async def setex(self, key, ttl, value):
+ self.store[key] = value
+ self.ttls[key] = ttl
+
+
+@pytest.fixture
+def fake_redis(monkeypatch):
+ from app.services import cache
+
+ fake = FakeRedis()
+ monkeypatch.setattr(cache, "redis_client", fake)
+ return fake
diff --git a/backend/tests/test_dashboard_api.py b/backend/tests/test_dashboard_api.py
new file mode 100644
index 0000000000..2faa9f7ce6
--- /dev/null
+++ b/backend/tests/test_dashboard_api.py
@@ -0,0 +1,151 @@
+import pytest
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+from app.api.v1 import dashboard
+from app.core.auth import authenticate_request
+from app.models.auth import AuthenticatedUser
+
+
+def _user(tenant_id):
+ return AuthenticatedUser(
+ id="user-1",
+ email="user@example.test",
+ permissions=[],
+ cities=[],
+ is_admin=False,
+ tenant_id=tenant_id,
+ )
+
+
+@pytest.fixture
+def make_client():
+ """Build a client for the dashboard router only (no app startup hooks)."""
+
+ def _make(tenant_id):
+ app = FastAPI()
+ app.include_router(dashboard.router, prefix="/api/v1")
+ app.dependency_overrides[authenticate_request] = lambda: _user(tenant_id)
+ return TestClient(app)
+
+ return _make
+
+
+@pytest.fixture
+def summary_calls(monkeypatch):
+ calls = []
+
+ async def fake_summary(property_id, tenant_id, month=None, year=None):
+ calls.append((property_id, tenant_id, month, year))
+ return {"property_id": property_id, "tenant_id": tenant_id, "total": "2250.00", "currency": "USD", "count": 4}
+
+ monkeypatch.setattr(dashboard, "get_revenue_summary", fake_summary)
+ return calls
+
+
+def test_summary_uses_tenant_from_auth(make_client, summary_calls):
+ response = make_client("tenant-a").get("/api/v1/dashboard/summary", params={"property_id": "prop-001"})
+
+ assert response.status_code == 200
+ assert summary_calls == [("prop-001", "tenant-a", None, None)]
+
+
+def test_summary_ignores_simulated_tenant_header(make_client, summary_calls):
+ response = make_client("tenant-a").get(
+ "/api/v1/dashboard/summary",
+ params={"property_id": "prop-001"},
+ headers={"X-Simulated-Tenant": "tenant-b"},
+ )
+
+ assert response.status_code == 200
+ assert summary_calls[0][1] == "tenant-a"
+
+
+def test_summary_returns_cents_as_number_and_exact_string(make_client, summary_calls):
+ body = make_client("tenant-a").get("/api/v1/dashboard/summary", params={"property_id": "prop-001"}).json()
+
+ assert body == {
+ "property_id": "prop-001",
+ "total_revenue": 2250.0,
+ "total_revenue_str": "2250.00",
+ "currency": "USD",
+ "reservations_count": 4,
+ }
+
+
+@pytest.mark.parametrize("tenant_id", [None, ""])
+def test_summary_without_tenant_is_forbidden(make_client, summary_calls, tenant_id):
+ # Regression: this used to fall back to a shared "default_tenant".
+ response = make_client(tenant_id).get("/api/v1/dashboard/summary", params={"property_id": "prop-001"})
+
+ assert response.status_code == 403
+ assert summary_calls == []
+
+
+def test_summary_passes_month_and_year(make_client, summary_calls):
+ response = make_client("tenant-a").get(
+ "/api/v1/dashboard/summary", params={"property_id": "prop-001", "month": 3, "year": 2024}
+ )
+
+ assert response.status_code == 200
+ assert summary_calls == [("prop-001", "tenant-a", 3, 2024)]
+
+
+@pytest.mark.parametrize(
+ "params",
+ [
+ {"month": 3},
+ {"year": 2024},
+ {"month": 13, "year": 2024},
+ {"month": 0, "year": 2024},
+ ],
+)
+def test_summary_rejects_bad_period(make_client, summary_calls, params):
+ response = make_client("tenant-a").get(
+ "/api/v1/dashboard/summary", params={"property_id": "prop-001", **params}
+ )
+
+ assert response.status_code == 422
+ assert summary_calls == []
+
+
+def test_summary_returns_503_when_data_unavailable(make_client, monkeypatch):
+ # Regression: DB errors used to be hidden behind hardcoded mock totals.
+ async def failing_summary(*args, **kwargs):
+ raise RuntimeError("database down")
+
+ monkeypatch.setattr(dashboard, "get_revenue_summary", failing_summary)
+ response = make_client("tenant-a").get("/api/v1/dashboard/summary", params={"property_id": "prop-001"})
+
+ assert response.status_code == 503
+
+
+def test_properties_lists_only_callers_tenant(make_client, monkeypatch):
+ seen = []
+
+ async def fake_list(tenant_id):
+ seen.append(tenant_id)
+ return [{"id": "prop-004", "name": "Lakeside Cottage", "timezone": "America/New_York"}]
+
+ monkeypatch.setattr(dashboard, "list_tenant_properties", fake_list)
+ response = make_client("tenant-b").get("/api/v1/dashboard/properties")
+
+ assert response.status_code == 200
+ assert seen == ["tenant-b"]
+ assert response.json()[0]["id"] == "prop-004"
+
+
+def test_properties_without_tenant_is_forbidden(make_client, monkeypatch):
+ async def fake_list(tenant_id):
+ raise AssertionError("must not query without a tenant")
+
+ monkeypatch.setattr(dashboard, "list_tenant_properties", fake_list)
+ assert make_client(None).get("/api/v1/dashboard/properties").status_code == 403
+
+
+def test_properties_returns_503_when_data_unavailable(make_client, monkeypatch):
+ async def failing_list(tenant_id):
+ raise RuntimeError("database down")
+
+ monkeypatch.setattr(dashboard, "list_tenant_properties", failing_list)
+ assert make_client("tenant-a").get("/api/v1/dashboard/properties").status_code == 503
diff --git a/backend/tests/test_money_rounding.py b/backend/tests/test_money_rounding.py
new file mode 100644
index 0000000000..aaeabad443
--- /dev/null
+++ b/backend/tests/test_money_rounding.py
@@ -0,0 +1,37 @@
+from decimal import Decimal
+
+import pytest
+
+from app.services.reservations import _to_cents
+
+
+@pytest.mark.parametrize(
+ "value, expected",
+ [
+ ("2250.000", "2250.00"),
+ ("999.999", "1000.00"),
+ ("1776.505", "1776.51"),
+ ("333.333", "333.33"),
+ ("0.005", "0.01"),
+ ("0.004", "0.00"),
+ (Decimal("1000.000"), "1000.00"),
+ (0, "0.00"),
+ (None, "0.00"),
+ ],
+)
+def test_to_cents_rounds_half_up_to_two_places(value, expected):
+ result = _to_cents(value)
+ assert isinstance(result, Decimal)
+ assert str(result) == expected
+
+
+def test_to_cents_avoids_float_rounding_error():
+ # 2.675 has no exact binary float; float rounding gives 2.67.
+ assert round(2.675, 2) == 2.67
+ assert str(_to_cents("2.675")) == "2.68"
+
+
+def test_sum_then_round_matches_seed_data():
+ # The three seed reservations for prop-001 (tenant-a) in mid-March.
+ amounts = [Decimal("333.333"), Decimal("333.333"), Decimal("333.334")]
+ assert str(_to_cents(sum(amounts))) == "1000.00"
diff --git a/backend/tests/test_revenue_cache.py b/backend/tests/test_revenue_cache.py
new file mode 100644
index 0000000000..17231c14c5
--- /dev/null
+++ b/backend/tests/test_revenue_cache.py
@@ -0,0 +1,84 @@
+import json
+
+import pytest
+
+from app.services import cache
+from app.services import reservations
+
+
+def _result(property_id, tenant_id, total, count):
+ return {"property_id": property_id, "tenant_id": tenant_id, "total": total, "currency": "USD", "count": count}
+
+
+@pytest.fixture
+def calc_calls(monkeypatch):
+ """Replace the DB-backed calculators with fakes that record their calls."""
+ calls = []
+ totals = {("tenant-a", "prop-001"): "2250.00", ("tenant-b", "prop-001"): "0.00"}
+
+ async def fake_total(property_id, tenant_id):
+ calls.append(("total", tenant_id, property_id))
+ return _result(property_id, tenant_id, totals.get((tenant_id, property_id), "0.00"), 0)
+
+ async def fake_monthly(property_id, tenant_id, month, year):
+ calls.append(("monthly", tenant_id, property_id, month, year))
+ return _result(property_id, tenant_id, "123.45", 1)
+
+ monkeypatch.setattr(reservations, "calculate_total_revenue", fake_total)
+ monkeypatch.setattr(reservations, "calculate_monthly_revenue", fake_monthly)
+ return calls
+
+
+@pytest.mark.asyncio
+async def test_same_property_id_is_cached_per_tenant(fake_redis, calc_calls):
+ # Regression: the key was revenue:{property_id}, so tenant-b got tenant-a's
+ # cached prop-001 total.
+ a = await cache.get_revenue_summary("prop-001", "tenant-a")
+ b = await cache.get_revenue_summary("prop-001", "tenant-b")
+
+ assert a["total"] == "2250.00"
+ assert b["total"] == "0.00"
+ assert set(fake_redis.store) == {
+ "revenue:tenant-a:prop-001:all",
+ "revenue:tenant-b:prop-001:all",
+ }
+ assert len(calc_calls) == 2
+
+
+@pytest.mark.asyncio
+async def test_cache_hit_returns_cached_value_without_recalculating(fake_redis, calc_calls):
+ await cache.get_revenue_summary("prop-001", "tenant-a")
+ again = await cache.get_revenue_summary("prop-001", "tenant-a")
+
+ assert again["total"] == "2250.00"
+ assert len(calc_calls) == 1
+ assert fake_redis.ttls["revenue:tenant-a:prop-001:all"] == 300
+
+
+@pytest.mark.asyncio
+async def test_cached_entry_of_other_tenant_is_never_returned(fake_redis, calc_calls):
+ fake_redis.store["revenue:tenant-a:prop-001:all"] = json.dumps(_result("prop-001", "tenant-a", "2250.00", 4))
+
+ b = await cache.get_revenue_summary("prop-001", "tenant-b")
+
+ assert b["tenant_id"] == "tenant-b"
+ assert b["total"] == "0.00"
+
+
+@pytest.mark.asyncio
+async def test_monthly_and_all_time_use_separate_keys(fake_redis, calc_calls):
+ await cache.get_revenue_summary("prop-001", "tenant-a")
+ monthly = await cache.get_revenue_summary("prop-001", "tenant-a", month=3, year=2024)
+
+ assert monthly["total"] == "123.45"
+ assert "revenue:tenant-a:prop-001:2024-03" in fake_redis.store
+ assert calc_calls[-1] == ("monthly", "tenant-a", "prop-001", 3, 2024)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("tenant_id", ["", None])
+async def test_missing_tenant_is_rejected(fake_redis, calc_calls, tenant_id):
+ with pytest.raises(ValueError):
+ await cache.get_revenue_summary("prop-001", tenant_id)
+ assert fake_redis.store == {}
+ assert calc_calls == []
diff --git a/backend/tests/test_revenue_db_integration.py b/backend/tests/test_revenue_db_integration.py
new file mode 100644
index 0000000000..ae858c51ef
--- /dev/null
+++ b/backend/tests/test_revenue_db_integration.py
@@ -0,0 +1,117 @@
+"""
+Integration tests against the seeded Postgres from docker-compose
+(database/schema.sql + database/seed.sql). Skipped when the DB is unreachable.
+
+Run inside the backend container:
+ docker compose exec backend pytest -m integration
+"""
+import pytest
+import pytest_asyncio
+
+from app.core.database_pool import db_pool
+from app.services.reservations import (
+ calculate_monthly_revenue,
+ calculate_total_revenue,
+ list_tenant_properties,
+)
+
+pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
+
+
+@pytest_asyncio.fixture(autouse=True)
+async def fresh_pool():
+ """
+ The pool is process-wide, but each test runs in its own event loop and
+ asyncpg connections cannot move between loops. Start and end every test
+ with a fresh engine.
+ """
+ await _reset_pool()
+ await db_pool.initialize()
+ try:
+ async with db_pool.get_session() as session:
+ from sqlalchemy import text
+
+ await session.execute(text("SELECT 1"))
+ except Exception as e: # pragma: no cover - environment dependent
+ await _reset_pool()
+ pytest.skip(f"Postgres not reachable: {e}")
+ yield
+ await _reset_pool()
+
+
+async def _reset_pool():
+ if db_pool.engine:
+ await db_pool.engine.dispose()
+ db_pool.engine = None
+ db_pool.session_factory = None
+
+
+async def test_all_time_total_uses_real_data_not_mock():
+ # Mock fallback used to return 1000.00 / 3 for prop-001.
+ result = await calculate_total_revenue("prop-001", "tenant-a")
+ assert result["total"] == "2250.00"
+ assert result["count"] == 4
+
+
+async def test_same_property_id_is_isolated_per_tenant():
+ a = await calculate_total_revenue("prop-001", "tenant-a")
+ b = await calculate_total_revenue("prop-001", "tenant-b")
+ assert a["total"] == "2250.00"
+ assert b["total"] == "0.00"
+ assert b["count"] == 0
+
+
+async def test_tenant_cannot_read_other_tenants_property():
+ result = await calculate_total_revenue("prop-004", "tenant-a")
+ assert result["total"] == "0.00"
+ assert result["count"] == 0
+
+
+@pytest.mark.parametrize(
+ "property_id, tenant_id, total, count",
+ [
+ ("prop-001", "tenant-a", "2250.00", 4),
+ ("prop-002", "tenant-a", "4975.50", 4),
+ ("prop-003", "tenant-a", "6100.50", 2),
+ ("prop-004", "tenant-b", "1776.50", 4),
+ ("prop-005", "tenant-b", "3256.00", 3),
+ ("prop-001", "tenant-b", "0.00", 0),
+ ],
+)
+async def test_march_2024_totals_per_property(property_id, tenant_id, total, count):
+ result = await calculate_monthly_revenue(property_id, tenant_id, 3, 2024)
+ assert (result["total"], result["count"]) == (total, count)
+
+
+async def test_booking_late_on_feb_29_utc_counts_as_march_in_paris():
+ # res-tz-1: 2024-02-29 23:30 UTC == 2024-03-01 00:30 Europe/Paris.
+ # A UTC month boundary gives March = 1000.00 / 3 and February = 1250.00 / 1.
+ march = await calculate_monthly_revenue("prop-001", "tenant-a", 3, 2024)
+ february = await calculate_monthly_revenue("prop-001", "tenant-a", 2, 2024)
+
+ assert (march["total"], march["count"]) == ("2250.00", 4)
+ assert (february["total"], february["count"]) == ("0.00", 0)
+
+
+async def test_december_rolls_over_to_next_year():
+ result = await calculate_monthly_revenue("prop-001", "tenant-a", 12, 2023)
+ assert (result["total"], result["count"]) == ("0.00", 0)
+
+
+async def test_totals_always_have_two_decimals():
+ for property_id, tenant_id in [("prop-001", "tenant-a"), ("prop-004", "tenant-b")]:
+ result = await calculate_total_revenue(property_id, tenant_id)
+ assert len(result["total"].split(".")[1]) == 2
+
+
+@pytest.mark.parametrize(
+ "tenant_id, expected",
+ [
+ ("tenant-a", [("prop-001", "Beach House Alpha"), ("prop-002", "City Apartment Downtown"), ("prop-003", "Country Villa Estate")]),
+ ("tenant-b", [("prop-001", "Mountain Lodge Beta"), ("prop-004", "Lakeside Cottage"), ("prop-005", "Urban Loft Modern")]),
+ ("tenant-unknown", []),
+ ],
+)
+async def test_property_list_is_scoped_to_tenant(tenant_id, expected):
+ properties = await list_tenant_properties(tenant_id)
+ assert [(p["id"], p["name"]) for p in properties] == expected
diff --git a/backend/tests/test_tenant_resolver.py b/backend/tests/test_tenant_resolver.py
new file mode 100644
index 0000000000..13bb19f26c
--- /dev/null
+++ b/backend/tests/test_tenant_resolver.py
@@ -0,0 +1,63 @@
+import pytest
+from jose import jwt
+
+from app.core.tenant_resolver import TenantResolver
+
+
+def _token(claims):
+ return jwt.encode(claims, "test-secret", algorithm="HS256")
+
+
+@pytest.mark.asyncio
+async def test_unknown_user_without_tenant_gets_none():
+ # Regression: this used to fall back to "tenant-a" and expose its data.
+ tenant = await TenantResolver.resolve_tenant_id(
+ user_id="user-x", user_email="nobody@example.test", token=_token({"email": "nobody@example.test"})
+ )
+ assert tenant is None
+
+
+@pytest.mark.asyncio
+async def test_unknown_user_without_token_gets_none():
+ assert await TenantResolver.resolve_tenant_id(user_id="user-x", user_email="nobody@example.test") is None
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("location", ["app_metadata", "user_metadata", "root"])
+async def test_tenant_claim_in_token_is_used(location):
+ claims = {"email": "someone@example.test"}
+ if location == "root":
+ claims["tenant_id"] = "tenant-b"
+ else:
+ claims[location] = {"tenant_id": "tenant-b"}
+
+ tenant = await TenantResolver.resolve_tenant_id(
+ user_id="user-x", user_email="someone@example.test", token=_token(claims)
+ )
+ assert tenant == "tenant-b"
+
+
+@pytest.mark.asyncio
+async def test_token_claim_wins_over_email_mapping():
+ token = _token({"app_metadata": {"tenant_id": "tenant-b"}})
+ tenant = await TenantResolver.resolve_tenant_id(
+ user_id="user-sunset", user_email="sunset@propertyflow.com", token=token
+ )
+ assert tenant == "tenant-b"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "email, expected",
+ [("sunset@propertyflow.com", "tenant-a"), ("ocean@propertyflow.com", "tenant-b")],
+)
+async def test_known_emails_still_map_without_claim(email, expected):
+ assert await TenantResolver.resolve_tenant_id(user_id="u", user_email=email) == expected
+
+
+@pytest.mark.asyncio
+async def test_malformed_token_falls_back_to_email_mapping():
+ tenant = await TenantResolver.resolve_tenant_id(
+ user_id="u", user_email="ocean@propertyflow.com", token="not-a-jwt"
+ )
+ assert tenant == "tenant-b"
From 801c5eb611e85217af1b1cfa570867c4cf8513de Mon Sep 17 00:00:00 2001
From: Eslam <41278977+eslam5464@users.noreply.github.com>
Date: Tue, 10 Nov 2026 17:10:17 +0200
Subject: [PATCH 11/14] Remove fake trend indicator from RevenueSummary
component for cleaner UI
---
frontend/src/components/RevenueSummary.tsx | 7 -------
1 file changed, 7 deletions(-)
diff --git a/frontend/src/components/RevenueSummary.tsx b/frontend/src/components/RevenueSummary.tsx
index 1b8caffaa0..0b8581b2e7 100644
--- a/frontend/src/components/RevenueSummary.tsx
+++ b/frontend/src/components/RevenueSummary.tsx
@@ -77,13 +77,6 @@ export const RevenueSummary: React.FC = ({ propertyId, show
{data.currency} {displayTotal.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
- {/* Fake trend indicator for premium feel */}
-
-
-
-
- 12%
-
From 99c90111f49ffcff53116d50b1597ec1e20bb5e4 Mon Sep 17 00:00:00 2001
From: Eslam <41278977+eslam5464@users.noreply.github.com>
Date: Tue, 10 Nov 2026 17:12:53 +0200
Subject: [PATCH 12/14] Add MixedCurrencyError handling to revenue calculations
and update tests
---
backend/app/api/v1/dashboard.py | 7 ++++-
backend/app/services/reservations.py | 32 +++++++++++++++++---
backend/tests/test_dashboard_api.py | 11 +++++++
backend/tests/test_revenue_db_integration.py | 31 +++++++++++++++++++
4 files changed, 75 insertions(+), 6 deletions(-)
diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py
index 1942f8d188..750ddb031e 100644
--- a/backend/app/api/v1/dashboard.py
+++ b/backend/app/api/v1/dashboard.py
@@ -2,7 +2,7 @@
from decimal import Decimal
from typing import Dict, Any, List, Optional
from app.services.cache import get_revenue_summary
-from app.services.reservations import list_tenant_properties
+from app.services.reservations import MixedCurrencyError, list_tenant_properties
from app.core.auth import authenticate_request as get_current_user
from app.models.auth import AuthenticatedUser
@@ -47,6 +47,11 @@ async def get_dashboard_summary(
try:
revenue_data = await get_revenue_summary(property_id, tenant_id, month, year)
+ except MixedCurrencyError:
+ raise HTTPException(
+ status_code=409,
+ detail="Reservations use more than one currency; totals cannot be added",
+ )
except Exception:
raise HTTPException(status_code=503, detail="Revenue data temporarily unavailable")
diff --git a/backend/app/services/reservations.py b/backend/app/services/reservations.py
index fdf0ba2262..43800cd73f 100644
--- a/backend/app/services/reservations.py
+++ b/backend/app/services/reservations.py
@@ -18,6 +18,22 @@ def _to_cents(value) -> Decimal:
"""
return Decimal(str(value if value is not None else 0)).quantize(CENT, rounding=ROUND_HALF_UP)
+
+DEFAULT_CURRENCY = "USD"
+
+
+class MixedCurrencyError(Exception):
+ """Reservations in different currencies cannot be summed into one total."""
+
+
+def _currency_of(row, property_id: str, tenant_id: str) -> str:
+ """Currency of the summed reservations; refuse to mix currencies."""
+ if row.currency_count and row.currency_count > 1:
+ raise MixedCurrencyError(
+ f"{property_id} (tenant: {tenant_id}) has reservations in {row.currency_count} currencies"
+ )
+ return row.currency or DEFAULT_CURRENCY
+
async def calculate_monthly_revenue(property_id: str, tenant_id: str, month: int, year: int) -> Dict[str, Any]:
"""
Calculates revenue for a calendar month in the property's own timezone.
@@ -44,7 +60,9 @@ async def calculate_monthly_revenue(property_id: str, tenant_id: str, month: int
query = text("""
SELECT
COALESCE(SUM(r.total_amount), 0) AS total_revenue,
- COUNT(r.id) AS reservation_count
+ COUNT(r.id) AS reservation_count,
+ COUNT(DISTINCT r.currency) AS currency_count,
+ MIN(r.currency) AS currency
FROM reservations r
JOIN properties p
ON p.id = r.property_id AND p.tenant_id = r.tenant_id
@@ -71,7 +89,7 @@ async def calculate_monthly_revenue(property_id: str, tenant_id: str, month: int
"property_id": property_id,
"tenant_id": tenant_id,
"total": str(_to_cents(row.total_revenue)),
- "currency": "USD",
+ "currency": _currency_of(row, property_id, tenant_id),
"count": row.reservation_count,
}
@@ -94,7 +112,9 @@ async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str,
SELECT
property_id,
SUM(total_amount) as total_revenue,
- COUNT(*) as reservation_count
+ COUNT(*) as reservation_count,
+ COUNT(DISTINCT currency) as currency_count,
+ MIN(currency) as currency
FROM reservations
WHERE property_id = :property_id AND tenant_id = :tenant_id
GROUP BY property_id
@@ -112,7 +132,7 @@ 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_of(row, property_id, tenant_id),
"count": row.reservation_count
}
else:
@@ -121,12 +141,14 @@ 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": DEFAULT_CURRENCY,
"count": 0
}
else:
raise Exception("Database pool not available")
+ except MixedCurrencyError:
+ raise
except Exception as e:
# Never return made-up figures: a financial dashboard must fail loudly
# instead of showing numbers that belong to no tenant.
diff --git a/backend/tests/test_dashboard_api.py b/backend/tests/test_dashboard_api.py
index 2faa9f7ce6..6821419119 100644
--- a/backend/tests/test_dashboard_api.py
+++ b/backend/tests/test_dashboard_api.py
@@ -5,6 +5,7 @@
from app.api.v1 import dashboard
from app.core.auth import authenticate_request
from app.models.auth import AuthenticatedUser
+from app.services.reservations import MixedCurrencyError
def _user(tenant_id):
@@ -120,6 +121,16 @@ async def failing_summary(*args, **kwargs):
assert response.status_code == 503
+def test_summary_returns_409_for_mixed_currencies(make_client, monkeypatch):
+ async def mixed_summary(*args, **kwargs):
+ raise MixedCurrencyError("prop-002 has reservations in 2 currencies")
+
+ monkeypatch.setattr(dashboard, "get_revenue_summary", mixed_summary)
+ response = make_client("tenant-a").get("/api/v1/dashboard/summary", params={"property_id": "prop-002"})
+
+ assert response.status_code == 409
+
+
def test_properties_lists_only_callers_tenant(make_client, monkeypatch):
seen = []
diff --git a/backend/tests/test_revenue_db_integration.py b/backend/tests/test_revenue_db_integration.py
index ae858c51ef..31c19f42ec 100644
--- a/backend/tests/test_revenue_db_integration.py
+++ b/backend/tests/test_revenue_db_integration.py
@@ -10,6 +10,7 @@
from app.core.database_pool import db_pool
from app.services.reservations import (
+ MixedCurrencyError,
calculate_monthly_revenue,
calculate_total_revenue,
list_tenant_properties,
@@ -115,3 +116,33 @@ async def test_totals_always_have_two_decimals():
async def test_property_list_is_scoped_to_tenant(tenant_id, expected):
properties = await list_tenant_properties(tenant_id)
assert [(p["id"], p["name"]) for p in properties] == expected
+
+
+async def test_currency_comes_from_stored_reservations():
+ total = await calculate_total_revenue("prop-002", "tenant-a")
+ monthly = await calculate_monthly_revenue("prop-002", "tenant-a", 3, 2024)
+ assert total["currency"] == "USD"
+ assert monthly["currency"] == "USD"
+
+
+async def _exec(sql, params=None):
+ from sqlalchemy import text
+
+ async with db_pool.get_session() as session:
+ await session.execute(text(sql), params or {})
+ await session.commit()
+
+
+async def test_mixed_currencies_are_not_summed():
+ # A EUR booking must not be added to USD bookings as if it were dollars.
+ await _exec(
+ "INSERT INTO reservations (id, property_id, tenant_id, check_in_date, check_out_date, total_amount, currency) "
+ "VALUES ('res-test-eur', 'prop-002', 'tenant-a', '2024-03-10 12:00:00+00', '2024-03-12 10:00:00+00', 500.000, 'EUR')"
+ )
+ try:
+ with pytest.raises(MixedCurrencyError):
+ await calculate_total_revenue("prop-002", "tenant-a")
+ with pytest.raises(MixedCurrencyError):
+ await calculate_monthly_revenue("prop-002", "tenant-a", 3, 2024)
+ finally:
+ await _exec("DELETE FROM reservations WHERE id = 'res-test-eur'")
From cd513f57e330771a01a5edbcea04819e4043e239 Mon Sep 17 00:00:00 2001
From: Eslam <41278977+eslam5464@users.noreply.github.com>
Date: Tue, 10 Nov 2026 17:17:40 +0200
Subject: [PATCH 13/14] Add month and year filters to RevenueSummary and
Dashboard components
---
frontend/src/components/Dashboard.tsx | 34 +++++++++++++++++++++-
frontend/src/components/RevenueSummary.tsx | 15 ++++++++--
frontend/src/lib/secureApi.ts | 10 ++++++-
3 files changed, 55 insertions(+), 4 deletions(-)
diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx
index 5307358f9c..dbf0f99bc0 100644
--- a/frontend/src/components/Dashboard.tsx
+++ b/frontend/src/components/Dashboard.tsx
@@ -14,6 +14,9 @@ const Dashboard: React.FC = () => {
const [properties, setProperties] = useState([]);
const [selectedProperty, setSelectedProperty] = useState('');
const [propertiesError, setPropertiesError] = useState('');
+ // "YYYY-MM" from , or "" for all time.
+ const [period, setPeriod] = useState('');
+ const [periodYear, periodMonth] = period ? period.split('-').map(Number) : [undefined, undefined];
useEffect(() => {
let cancelled = false;
@@ -47,6 +50,32 @@ const Dashboard: React.FC = () => {
+
+ {/* Month filter (calendar month in each property's own timezone) */}
+
+
+ Month (property local time)
+
+
+ setPeriod(e.target.value)}
+ className="block px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 text-sm"
+ />
+ {period && (
+ setPeriod('')}
+ className="px-3 py-2 text-sm whitespace-nowrap text-gray-600 border border-gray-300 rounded-md hover:bg-gray-50"
+ >
+ All time
+
+ )}
+
+
+
{/* Property Selector */}
Select Property
@@ -62,6 +91,7 @@ const Dashboard: React.FC = () => {
))}
+
@@ -69,7 +99,9 @@ const Dashboard: React.FC = () => {
{propertiesError && (
{propertiesError}
)}
- {selectedProperty && }
+ {selectedProperty && (
+
+ )}
diff --git a/frontend/src/components/RevenueSummary.tsx b/frontend/src/components/RevenueSummary.tsx
index 0b8581b2e7..b8678ed3ef 100644
--- a/frontend/src/components/RevenueSummary.tsx
+++ b/frontend/src/components/RevenueSummary.tsx
@@ -10,10 +10,18 @@ interface RevenueData {
interface RevenueSummaryProps {
propertyId: string;
+ // Calendar month (1-12) and year; both unset means all time.
+ month?: number;
+ year?: number;
showRaw?: boolean;
}
-export const RevenueSummary: React.FC = ({ propertyId, showRaw }) => {
+const periodLabel = (month?: number, year?: number) =>
+ month && year
+ ? new Date(year, month - 1, 1).toLocaleString(undefined, { month: 'long', year: 'numeric' })
+ : 'All time';
+
+export const RevenueSummary: React.FC = ({ propertyId, month, year, showRaw }) => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
@@ -26,6 +34,8 @@ export const RevenueSummary: React.FC = ({ propertyId, show
// SecureAPI attaches the auth token; the backend takes the
// tenant from that token only.
const response = await SecureAPI.getDashboardSummary(propertyId, {
+ month,
+ year,
timestamp: Date.now()
});
setData(response);
@@ -38,7 +48,7 @@ export const RevenueSummary: React.FC = ({ propertyId, show
};
fetchRevenue();
- }, [propertyId]);
+ }, [propertyId, month, year]);
if (loading) {
return (
@@ -73,6 +83,7 @@ export const RevenueSummary: React.FC = ({ propertyId, show
Total Revenue
+
{periodLabel(month, year)}
{data.currency} {displayTotal.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
diff --git a/frontend/src/lib/secureApi.ts b/frontend/src/lib/secureApi.ts
index 94bc79d16a..fb83055bfa 100644
--- a/frontend/src/lib/secureApi.ts
+++ b/frontend/src/lib/secureApi.ts
@@ -1452,8 +1452,16 @@ export class SecureAPIClient {
/**
* Get dashboard summary. The tenant always comes from the auth token.
*/
- async getDashboardSummary(propertyId: string, options?: { timestamp?: number }) {
+ async getDashboardSummary(
+ propertyId: string,
+ options?: { month?: number; year?: number; timestamp?: number }
+ ) {
const queryParams = new URLSearchParams({ property_id: propertyId });
+ // The backend needs both or neither; month is in the property's timezone.
+ if (options?.month && options?.year) {
+ queryParams.append('month', options.month.toString());
+ queryParams.append('year', options.year.toString());
+ }
if (options?.timestamp) {
queryParams.append('_t', options.timestamp.toString());
}
From 6a6f89b5c089cedb57817663a1c0ccf4d5ac0c55 Mon Sep 17 00:00:00 2001
From: Eslam <41278977+eslam5464@users.noreply.github.com>
Date: Tue, 10 Nov 2026 17:29:20 +0200
Subject: [PATCH 14/14] Add findings document detailing bugs and fixes for
Property Revenue Dashboard
---
FINDINGS.md | 235 ++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 235 insertions(+)
create mode 100644 FINDINGS.md
diff --git a/FINDINGS.md b/FINDINGS.md
new file mode 100644
index 0000000000..22ee6dafa5
--- /dev/null
+++ b/FINDINGS.md
@@ -0,0 +1,235 @@
+# Findings: Property Revenue Dashboard
+
+This document lists the bugs behind the three reported problems, how each one was found, the root cause, the fix, and the test that now covers it. The last part lists recommended next steps for the codebase.
+
+Line references marked **before** point to the original code at commit `f8badb754`. You can open them with `git show f8badb754:`. References marked **after** point to the current code.
+
+## Summary
+
+Nine bugs were found and fixed, one commit each. A new backend test suite has 58 tests, all passing.
+
+| Reported problem | Caused by |
+|---|---|
+| Client A (Sunset Properties): "March totals don't match our records" | Bug 1 (mock data instead of the database), bug 4 (month boundaries in UTC instead of the property's timezone), bug 9 (no way to choose a month in the dashboard) |
+| Client B (Ocean Rentals): "sometimes we see revenue that belongs to another company" | Bug 2 (cache key without tenant), bug 3 (unknown users assigned to Sunset's tenant), bug 6 (every tenant's property names shown to everyone) |
+| Finance: "totals slightly off by a few cents" | Bug 5 (sub-cent amounts rounded through floats), bug 8 (amounts in different currencies would be added together) |
+| Not reported, found during review | Bug 7 (fake "+12%" growth badge shown next to real revenue) |
+
+## Bugs
+
+### 1. The dashboard never read the database and showed hardcoded mock numbers
+
+- **Symptom:** every client saw the same figures, for example prop-001 = 1000.00 from 3 bookings, for both tenants. The real value for Sunset is 2250.00 from 4 bookings.
+- **How found:** called `/api/v1/dashboard/summary` as both clients. The backend log showed `Database pool not available` on every request.
+- **Root cause (before):**
+ - `backend/app/core/database_pool.py:18` built the URL from `settings.supabase_db_*` fields. Those fields do not exist in `config.py`, so initialization failed and the error was swallowed.
+ - `database_pool.py:22` used `QueuePool`, which cannot be used with an async engine.
+ - `database_pool.py:48` defined `get_session` as `async def`, but callers used it with `async with`, which fails.
+ - `backend/app/services/reservations.py:43` created a new pool on every request, and never disposed it.
+ - `reservations.py:93` caught every error and returned a hardcoded `mock_data` dictionary keyed only by property ID.
+ - `requirements.txt` did not install `greenlet`, which the SQLAlchemy asyncio extension needs.
+- **Fix (after):**
+ - `database_pool.py:9` builds the URL from `settings.database_url`, using the asyncpg driver.
+ - `database_pool.py:23` creates one shared pool, once per process, protected by a lock.
+ - `database_pool.py:63` makes `get_session` a plain method that returns a session usable with `async with`.
+ - The mock fallback is removed. A database error is logged and the endpoint returns 503.
+ - `requirements.txt` now has `sqlalchemy[asyncio]`.
+- **Commits:** `a261ade22`, dependency alignment in `pyproject.toml` and `uv.lock` in `991462286`.
+- **Tests:** `test_revenue_db_integration.py::test_all_time_total_uses_real_data_not_mock`, `test_dashboard_api.py::test_summary_returns_503_when_data_unavailable`.
+
+### 2. The revenue cache mixed up tenants
+
+- **Symptom:** after Sunset loaded prop-001, Ocean saw Sunset's 2250.00 for its own prop-001 for 5 minutes. Which tenant saw wrong data depended on who loaded first, which explains "sometimes, when we refresh".
+- **How found:** after bug 1 was fixed, the same request as Ocean returned Sunset's total. The Redis keys showed only `revenue:prop-001`.
+- **Root cause (before):** `backend/app/services/cache.py:13` used the key `revenue:{property_id}`. Property IDs are unique only within a tenant, and `prop-001` exists in both tenants (`database/seed.sql`).
+- **Fix (after):** `cache.py:24` uses `revenue:{tenant_id}:{property_id}:{period}`. `cache.py:19` refuses a missing tenant, so a key without a tenant cannot be built.
+- **Commit:** `03f3c9a5e`.
+- **Tests:** `test_revenue_cache.py`, 6 tests. When the old key was put back temporarily, 3 of them failed.
+
+### 3. Users without a tenant were given Sunset's tenant
+
+- **Symptom:** a valid login for any email other than the two demo clients saw Sunset Properties' revenue.
+- **How found:** while testing a "no tenant" case, a token with an unknown email returned HTTP 200 with Sunset's numbers. The auth log showed `tenant=tenant-a`.
+- **Root cause (before):**
+ - `backend/app/core/tenant_resolver.py:92` ended with `return "tenant-a"` after checking three hardcoded emails. It also ignored the `tenant_id` claim already present in the signed token.
+ - `backend/app/api/v1/dashboard.py:14` fell back to a shared `"default_tenant"` value when the tenant was missing.
+- **Fix (after):**
+ - `tenant_resolver.py:89` reads the tenant claim from the token first.
+ - `tenant_resolver.py:106` returns `None` when no tenant is found.
+ - `dashboard.py:12-18` (`_require_tenant`) returns 403 when there is no tenant.
+- **Commit:** `129f08ee5`.
+- **Tests:** `test_tenant_resolver.py`, 9 tests. `test_dashboard_api.py::test_summary_without_tenant_is_forbidden`.
+
+### 4. Month boundaries used UTC instead of the property's timezone
+
+- **Symptom:** Sunset's March total for Beach House Alpha did not match their records.
+- **How found:** seed reservation `res-tz-1` checks in at `2024-02-29 23:30 UTC`. For a property in `Europe/Paris` that is `2024-03-01 00:30`, which is March. A UTC month boundary gives March = 1000.000 from 3 bookings. The correct local-time answer is 2250.00 from 4 bookings.
+- **Root cause (before):** `backend/app/services/reservations.py:5` (`calculate_monthly_revenue`) built naive UTC dates, had no tenant parameter, and was a stub that always returned `Decimal('0')` (line 32). Nothing called it.
+- **Fix (after):**
+ - `reservations.py:37` implements the monthly query.
+ - `reservations.py:71` compares `check_in_date AT TIME ZONE p.timezone` against the local month start and the next month start, joining `properties` on `(id, tenant_id)`.
+ - The summary endpoint accepts optional `month` and `year` (both or neither, otherwise 422).
+ - The cache key includes the period.
+- **Commit:** `f3c82697c`.
+- **Tests:** `test_revenue_db_integration.py::test_booking_late_on_feb_29_utc_counts_as_march_in_paris`, plus the March 2024 table for all six tenant and property pairs.
+
+### 5. Cent rounding went through floats
+
+- **Symptom:** totals could be off by a cent.
+- **Root cause (before):**
+ - `total_amount` is `NUMERIC(10,3)`, so it holds sub-cent values like `333.333`.
+ - The service returned the raw sum. `backend/app/api/v1/dashboard.py:18` converted it with `float()`.
+ - The frontend rounded the float with `Math.round(x * 100) / 100` (`RevenueSummary.tsx:64`).
+ - Floats cannot represent most decimal amounts exactly, for example `round(2.675, 2)` gives `2.67`.
+- **Fix (after):**
+ - `reservations.py:11` (`_to_cents`) rounds the exact database sum once, with `Decimal` and `ROUND_HALF_UP`.
+ - `dashboard.py:66` also returns `total_revenue_str`, the exact amount as a string. The numeric field stays for the existing frontend.
+- **Commit:** `4743cb491`.
+- **Tests:** `test_money_rounding.py`, 11 tests, including `2.675` rounding to `2.68`.
+
+### 6. Every tenant saw every tenant's property names
+
+- **Symptom:** Sunset's dropdown listed Ocean's properties, and the other way round. It also labelled Ocean's `prop-001` as "Beach House Alpha", although for Ocean it is "Mountain Lodge Beta".
+- **Root cause (before):**
+ - `frontend/src/components/Dashboard.tsx:4-10` hardcoded all five properties.
+ - `RevenueSummary.tsx:22,31` sent a leftover `X-Simulated-Tenant: candidate` header. The backend ignored it, but it suggested the client could choose its own tenant.
+- **Fix (after):**
+ - New endpoint `GET /api/v1/dashboard/properties` (`dashboard.py:22`), backed by `list_tenant_properties` (`reservations.py:159`).
+ - The dashboard loads the list on start.
+ - The simulated-tenant header is removed.
+- **Commit:** `1191677f4`.
+- **Tests:** `test_dashboard_api.py::test_properties_lists_only_callers_tenant`, `test_revenue_db_integration.py::test_property_list_is_scoped_to_tenant`.
+
+### 7. A fake "+12%" growth badge was shown next to real revenue
+
+- **Root cause (before):** `RevenueSummary.tsx:83-88` rendered a hardcoded green "+12%" badge. The code comment said `Fake trend indicator for premium feel`. Clients preparing board reports saw a growth figure that no data supports.
+- **Fix:** removed.
+- **Commit:** `801c5eb61`.
+
+### 8. The currency was hardcoded and mixed currencies would be added together
+
+- **Root cause (before):** the `reservations.currency` column exists, and the brief mentions "various amounts and currencies". Both revenue paths still returned `"currency": "USD"`, and `SUM(total_amount)` would add EUR and USD amounts as if they were the same money. All seed data is USD, so this was not visible yet.
+- **Fix (after):**
+ - `reservations.py:25-35` returns the stored currency. It raises `MixedCurrencyError` when one total would cover more than one currency.
+ - `dashboard.py:52` returns 409 in that case.
+- **Commit:** `99c90111f`.
+- **Tests:** `test_revenue_db_integration.py::test_mixed_currencies_are_not_summed` (adds a temporary EUR booking and removes it), `test_dashboard_api.py::test_summary_returns_409_for_mixed_currencies`.
+
+### 9. The dashboard could not show a single month
+
+- **Symptom:** the backend could compute March (bug 4), but the UI only showed all-time totals, so Sunset could not compare its March figure in the dashboard.
+- **Fix:**
+ - `Dashboard.tsx` has a month picker labelled "Month (property local time)", with an "All time" reset.
+ - `RevenueSummary` sends `month` and `year` and shows the period under the total.
+- **Commit:** `cd513f57e`.
+
+## Expected numbers (March 2024, property local time)
+
+| Tenant | Property | Total | Bookings |
+|---|---|---|---|
+| Sunset (tenant-a) | prop-001 Beach House Alpha | 2,250.00 USD | 4 |
+| Sunset (tenant-a) | prop-002 City Apartment Downtown | 4,975.50 USD | 4 |
+| Sunset (tenant-a) | prop-003 Country Villa Estate | 6,100.50 USD | 2 |
+| Ocean (tenant-b) | prop-001 Mountain Lodge Beta | 0.00 USD | 0 |
+| Ocean (tenant-b) | prop-004 Lakeside Cottage | 1,776.50 USD | 4 |
+| Ocean (tenant-b) | prop-005 Urban Loft Modern | 3,256.00 USD | 3 |
+
+Before the fixes, Sunset's prop-001 showed 1,000.00 from 3 bookings (mock data). A query with UTC month boundaries gives the same wrong 1,000.000 from 3 bookings.
+
+## How to verify
+
+```bash
+docker compose up --build -d
+docker compose exec backend pip install -r requirements-dev.txt
+docker compose exec backend pytest # 58 tests
+docker compose exec backend pytest -m integration # 17 tests against the seeded Postgres
+```
+
+Then open http://localhost:3000:
+
+1. Log in as Sunset. The dropdown shows only Sunset's three properties.
+2. Pick 2024-03: Beach House Alpha shows 2,250.00 USD from 4 bookings. Pick 2024-02: it shows 0.00.
+3. Log out and log in as Ocean. Its prop-001 is "Mountain Lodge Beta" with 0.00, and Lakeside Cottage shows 1,776.50 for March.
+
+The dev dependencies are not part of the image. Install them again after any `docker compose up --build`.
+
+## Recommendations
+
+These items are outside the scope of a debugging exercise ("do not rebuild the system"), but they are the changes that would stop this class of bug from coming back. They are ordered by priority.
+
+### Now
+
+**A. Move the backend to a layered architecture.** Today one module mixes concerns: `services/reservations.py` opens database sessions and writes raw SQL, `services/cache.py` creates its own Redis client, and `api/v1/dashboard.py` maps every exception by hand. All three tenant bugs happened in places where one layer had to remember the tenant on its own. A structure like the one in [eslam5464/Fastapi-Template](https://github.com/eslam5464/Fastapi-Template) makes the tenant a required input at every boundary:
+
+```
+api/v1/dashboard.py routes only: parse input, call the service, return a schema
+api/deps.py get_current_tenant, get_session, get_revenue_service
+services/revenue_service.py business rules: local-time months, cents, currency
+repositories/
+ reservation_repository.py all SQL; every method takes tenant_id
+ property_repository.py
+models/ SQLAlchemy Property, Reservation
+schemas/ Pydantic RevenueSummaryOut (Decimal money, currency)
+core/exceptions.py TenantRequiredError, MixedCurrencyError, DataUnavailableError
+ mapped to 403 / 409 / 503 in one exception handler
+```
+
+Further steps in the same direction:
+
+- Put caching in one wrapper around the service, with one key builder that requires a tenant.
+- Replace `database/schema.sql` and the nine `backend/lightning_optimization_*.sql` variants with Alembic migrations.
+- Enforce the layer rules with tach or import-linter, for example "api must not import repositories".
+- Migrate step by step: move the revenue slice first, which the 58 tests already cover, then the other routers.
+
+**B. Enforce tenant isolation in the database too.**
+
+- Row-level security is enabled on `properties` and `reservations` (`database/schema.sql:35-36`), but no policies exist. Add `USING (tenant_id = current_setting('app.tenant_id'))` and set `app.tenant_id` on each session. Then a query that forgets `WHERE tenant_id` returns nothing instead of another tenant's data.
+- Pass the verified JWT payload to `TenantResolver`, instead of reading the claims again with `jwt.get_unverified_claims` (`tenant_resolver.py:89`).
+- Remove the email-to-tenant mapping once every token carries the claim.
+
+**C. Security hygiene.**
+
+- Demo passwords are hardcoded in `backend/app/api/v1/login.py:43,73`.
+- `secret_key` defaults to `"debug_challenge_secret"` (`config.py:14`). Startup should fail outside development when the default is in use.
+- `frontend/.env` is committed.
+- The authentication cache is an in-process dictionary (`core/auth.py:20`) kept for 30 minutes. Each worker has its own copy, and a token cannot be revoked. Move it to Redis with a token blacklist, and add rate limiting on login. Both are optional features of the template above.
+
+### Next
+
+**D. Model money and time explicitly.**
+
+- Add a `Money(amount: Decimal, currency: str)` value object, and return per-currency totals or converted totals instead of 409.
+- Validate property timezones against the IANA list.
+- Agree with finance on the revenue recognition rule. Today revenue counts in the month of check-in. A stay that crosses the end of a month may need to be split by nights.
+
+**E. Cache correctness.**
+
+- Invalidate `revenue:{tenant}:{property}:*` when a reservation is created or changed, or use versioned keys. Today a new booking is invisible for up to 5 minutes.
+- Use the shared client in `core/redis_client.py` instead of the second client created in `services/cache.py:7`.
+
+**F. Repository and delivery hygiene.**
+
+- 72,348 `node_modules` files and the `frontend/dist` build output are committed. Remove them and rely on the lockfiles.
+- The backend Dockerfile runs `uvicorn --reload`, which is for development only.
+- CI runs only pre-commit. Add pytest with a coverage gate, ruff, mypy and a security scan.
+- Install dev dependencies in a separate image stage, so a rebuild keeps pytest.
+- Remove the obsolete `version:` key from `docker-compose.yml`.
+
+### Later
+
+**G. Code health.**
+
+- `api/v1/city_access_fast.py` and `api/v1/city_access_fixed.py` are two near-duplicate implementations of about 500 lines each.
+- `frontend/src/lib/secureApi.ts` is 2,483 lines; split it by domain.
+- Replace the remaining `print(` calls in the backend with logging.
+- When Supabase is not configured, startup logs `Failed to initialize Supabase connection pool`, and the login page logs `SessionRecovery` errors. Both are noise that hides real errors.
+
+**H. Observability.**
+
+- Add structured logs with `tenant_id` and a request ID.
+- Add alerts on the rate of 503 and 409 responses.
+- Keep an audit log of which user viewed which tenant's revenue, so a leak can be detected and measured.
+
+## Known leftovers
+
+Items G and H were not changed, because the brief asks to fix the reported issues and not rebuild the system. The Supabase and `SessionRecovery` messages existed before these changes and do not affect the dashboard.