Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 39 additions & 9 deletions backend/app/api/v1/dashboard.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,55 @@
from fastapi import APIRouter, Depends, HTTPException
from typing import Dict, Any
from decimal import Decimal, ROUND_HALF_UP
from typing import Dict, Any, List
from app.services.cache import get_revenue_summary
from app.services.reservations import get_tenant_properties
from app.core.auth import authenticate_request as get_current_user

router = APIRouter()


def _require_tenant(current_user) -> str:
"""Every request must belong to a tenant. Never fall back to a shared default tenant."""
tenant_id = getattr(current_user, "tenant_id", None)
if not tenant_id:
raise HTTPException(status_code=403, detail="No tenant assigned to this user")
return tenant_id


@router.get("/dashboard/properties")
async def get_dashboard_properties(
current_user: dict = Depends(get_current_user)
) -> List[Dict[str, Any]]:
"""Lists only the properties of the logged-in user's tenant."""
tenant_id = _require_tenant(current_user)
try:
return await get_tenant_properties(tenant_id)
except Exception as e:
print(f"Database error listing properties (tenant: {tenant_id}): {e}")
raise HTTPException(status_code=503, detail="Property data is temporarily unavailable")


@router.get("/dashboard/summary")
async def get_dashboard_summary(
property_id: str,
current_user: dict = Depends(get_current_user)
) -> Dict[str, Any]:

tenant_id = getattr(current_user, "tenant_id", "default_tenant") or "default_tenant"

revenue_data = await get_revenue_summary(property_id, tenant_id)

total_revenue_float = float(revenue_data['total'])


tenant_id = _require_tenant(current_user)

try:
revenue_data = await get_revenue_summary(property_id, tenant_id)
except Exception as e:
print(f"Database error for {property_id} (tenant: {tenant_id}): {e}")
raise HTTPException(status_code=503, detail="Revenue data is temporarily unavailable")

# Round once, on the final total, to whole cents (amounts are stored with 3 decimals).
# Decimal keeps the math exact; only the rounded value is turned into a JSON number.
total_revenue = Decimal(revenue_data['total']).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)

return {
"property_id": revenue_data['property_id'],
"total_revenue": total_revenue_float,
"total_revenue": float(total_revenue),
"currency": revenue_data['currency'],
"reservations_count": revenue_data['count']
}
24 changes: 15 additions & 9 deletions backend/app/core/database_pool.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.pool import QueuePool
import logging
from ..config import settings

Expand All @@ -10,16 +9,23 @@ 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 (only once)"""
async with self._init_lock:
if self.engine is not None:
return # Already initialized, reuse the same pool
self._create_engine()

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}"

# settings.database_url is "postgresql://...", async SQLAlchemy needs the asyncpg driver
database_url = settings.database_url.replace("postgresql://", "postgresql+asyncpg://", 1)

# No poolclass: async engines use AsyncAdaptedQueuePool by default (QueuePool is sync-only)
self.engine = create_async_engine(
database_url,
poolclass=QueuePool,
pool_size=20, # Number of connections to maintain
max_overflow=30, # Additional connections when needed
pool_pre_ping=True, # Validate connections
Expand All @@ -45,8 +51,8 @@ async def close(self):
if self.engine:
await self.engine.dispose()

async def get_session(self) -> AsyncSession:
"""Get database session from pool"""
def get_session(self) -> AsyncSession:
"""Get database session from pool. Use it as: `async with db_pool.get_session() as session:`"""
if not self.session_factory:
raise Exception("Database pool not initialized")
return self.session_factory()
Expand Down
8 changes: 8 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,14 @@ async def lifespan(app: FastAPI):
except Exception as e:
logger.warning(f"⚠️ Error closing connection pool: {e}")

# Close the shared database pool used by the revenue services
try:
from .core.database_pool import db_pool

await db_pool.close()
except Exception as e:
logger.warning(f"⚠️ Error closing database pool: {e}")


app = FastAPI(
title="Auth Skeleton API",
Expand Down
3 changes: 2 additions & 1 deletion backend/app/services/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ 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}"
# Include the tenant: the same property_id can exist for different tenants
cache_key = f"revenue:{tenant_id}:{property_id}"

# Try to get from cache
cached = await redis_client.get(cache_key)
Expand Down
229 changes: 138 additions & 91 deletions backend/app/services/reservations.py
Original file line number Diff line number Diff line change
@@ -1,109 +1,156 @@
from datetime import datetime
from datetime import datetime, timezone as dt_timezone
from decimal import Decimal
from typing import Dict, Any, List
from typing import Dict, Any, List, Optional, Tuple
from zoneinfo import ZoneInfo

async def calculate_monthly_revenue(property_id: str, month: int, year: int, db_session=None) -> Decimal:

def get_month_bounds_utc(year: int, month: int, timezone: str = "UTC") -> Tuple[datetime, datetime]:
"""
Calculates revenue for a specific month.
Returns [start, end) of a month in the property's local time, converted to UTC.
Example: March in Europe/Paris starts at 2024-02-29 23:00 UTC, not 2024-03-01 00:00 UTC.
"""
local_tz = ZoneInfo(timezone)

start_date = datetime(year, month, 1)
# Midnight on the 1st of this month and of next month, in the property's time zone
start_local = datetime(year, month, 1, tzinfo=local_tz)
if month < 12:
end_date = datetime(year, month + 1, 1)
end_local = datetime(year, month + 1, 1, tzinfo=local_tz)
else:
end_date = datetime(year + 1, 1, 1)

print(f"DEBUG: Querying revenue for {property_id} from {start_date} to {end_date}")
end_local = datetime(year + 1, 1, 1, tzinfo=local_tz)

# 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
# check_in_date is stored in UTC, so compare against UTC bounds
return start_local.astimezone(dt_timezone.utc), end_local.astimezone(dt_timezone.utc)


async def calculate_monthly_revenue(
property_id: str,
tenant_id: str,
month: int,
year: int,
db_session=None,
timezone: Optional[str] = None,
) -> Decimal:
"""

# 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
Calculates revenue for one property, for one tenant, for one month.
- db_session: a SQLAlchemy AsyncSession. If None, one is opened from DatabasePool.
- timezone: the property's time zone. If None, it is read from the properties table.
"""
if db_session is not None:
return await _fetch_monthly_revenue(db_session, property_id, tenant_id, month, year, timezone)

# No session given: open one from the shared pool
from app.core.database_pool import db_pool

await db_pool.initialize()
if not db_pool.session_factory:
raise Exception("Database pool not available")

async with db_pool.get_session() as session:
return await _fetch_monthly_revenue(session, property_id, tenant_id, month, year, timezone)
Comment on lines +39 to +49

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: calculate_monthly_revenue is implemented but has no callers, leaving /dashboard/summary returning all-time revenue totals instead of monthly figures.
Severity: MEDIUM

Suggested Fix

Add year and month parameters to /dashboard/summary, update the caching layer to include month scope, and update the endpoint to call calculate_monthly_revenue instead of calculate_total_revenue.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: backend/app/services/reservations.py#L25-L49

Potential issue: `calculate_monthly_revenue` is implemented to calculate month-scoped
revenue totals considering property timezones, but it is not invoked anywhere in the
codebase. The live summary endpoint at `/dashboard/summary` continues calling
`calculate_total_revenue`, which sums all-time reservation amounts across all dates.
Consequently, monthly revenue metrics remain incorrect and the new calculation logic has
no effect on API responses.

Did we get this right? 👍 / 👎 to inform future reviews.



async def _fetch_monthly_revenue(session, property_id: str, tenant_id: str, month: int, year: int, timezone: Optional[str]) -> Decimal:
"""Runs the monthly revenue query on an open session."""
from sqlalchemy import text

# Look up the property's time zone if the caller didn't pass it
if timezone is None:
tz_result = await session.execute(
text("SELECT timezone FROM properties WHERE id = :property_id AND tenant_id = :tenant_id"),
{"property_id": property_id, "tenant_id": tenant_id},
)
timezone = tz_result.scalar()
if timezone is None:
# This tenant has no such property, so it has no revenue for it
return Decimal("0")

start_date, end_date = get_month_bounds_utc(year, month, timezone)

print(f"DEBUG: Querying revenue for {property_id} (tenant: {tenant_id}) from {start_date} to {end_date}")

# Filter by tenant too: the same property_id can exist for several tenants
query = text("""
SELECT SUM(total_amount) AS total
FROM reservations
WHERE property_id = :property_id
AND tenant_id = :tenant_id
AND check_in_date >= :start_date
AND check_in_date < :end_date
""")

result = await session.execute(query, {
"property_id": property_id,
"tenant_id": tenant_id,
"start_date": start_date,
"end_date": end_date,
})

# SUM() returns NULL when there are no reservations, so fall back to 0
total = result.scalar()
return total if total is not None else Decimal("0")

async def get_tenant_properties(tenant_id: str) -> List[Dict[str, Any]]:
"""Returns only this tenant's properties (id, name, timezone)."""
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")

async with db_pool.get_session() as session:
result = await session.execute(
text("SELECT id, name, timezone FROM properties WHERE tenant_id = :tenant_id ORDER BY id"),
{"tenant_id": tenant_id},
)
return [{"id": row.id, "name": row.name, "timezone": row.timezone} for row in result]


async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, Any]:
"""
Aggregates revenue from database.
Errors are raised, never replaced by fake numbers: showing wrong revenue is worse than showing an error.
"""
try:
# Import database pool
from app.core.database_pool import DatabasePool

# Initialize pool if needed
db_pool = DatabasePool()
await db_pool.initialize()

if db_pool.session_factory:
async with db_pool.get_session() as session:
# Use SQLAlchemy text for raw SQL
from sqlalchemy import text

query = text("""
SELECT
property_id,
SUM(total_amount) as total_revenue,
COUNT(*) as reservation_count
FROM reservations
WHERE property_id = :property_id AND tenant_id = :tenant_id
GROUP BY property_id
""")

result = await session.execute(query, {
"property_id": property_id,
"tenant_id": tenant_id
})
row = result.fetchone()

if row:
total_revenue = Decimal(str(row.total_revenue))
return {
"property_id": property_id,
"tenant_id": tenant_id,
"total": str(total_revenue),
"currency": "USD",
"count": row.reservation_count
}
else:
# No reservations found for this property
return {
"property_id": property_id,
"tenant_id": tenant_id,
"total": "0.00",
"currency": "USD",
"count": 0
}
else:
raise Exception("Database pool not available")

except Exception as e:
print(f"Database error for {property_id} (tenant: {tenant_id}): {e}")

# Create property-specific mock data for testing when DB is unavailable
# This ensures each property shows different figures
mock_data = {
'prop-001': {'total': '1000.00', 'count': 3},
'prop-002': {'total': '4975.50', 'count': 4},
'prop-003': {'total': '6100.50', 'count': 2},
'prop-004': {'total': '1776.50', 'count': 4},
'prop-005': {'total': '3256.00', 'count': 3}
}

mock_property_data = mock_data.get(property_id, {'total': '0.00', 'count': 0})

from app.core.database_pool import db_pool
from sqlalchemy import text

# Reuse the one shared pool (creating a new pool per request leaks connections)
await db_pool.initialize()
if not db_pool.session_factory:
raise Exception("Database pool not available")

async with db_pool.get_session() as session:
query = text("""
SELECT
property_id,
SUM(total_amount) as total_revenue,
COUNT(*) as reservation_count
FROM reservations
WHERE property_id = :property_id AND tenant_id = :tenant_id
GROUP BY property_id
""")

result = await session.execute(query, {
"property_id": property_id,
"tenant_id": tenant_id
})
row = result.fetchone()

if row:
total_revenue = Decimal(str(row.total_revenue))
return {
"property_id": property_id,
"tenant_id": tenant_id,
"total": str(total_revenue),
"currency": "USD",
"count": row.reservation_count
}

# No reservations found for this property (for this tenant)
return {
"property_id": property_id,
"tenant_id": tenant_id,
"total": mock_property_data['total'],
"tenant_id": tenant_id,
"total": "0.00",
"currency": "USD",
"count": mock_property_data['count']
"count": 0
}
2 changes: 1 addition & 1 deletion backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading