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
42 changes: 38 additions & 4 deletions backend/app/api/v1/dashboard.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,59 @@
import logging
from fastapi import APIRouter, Depends, HTTPException
from decimal import Decimal
from typing import Dict, Any
from app.services.cache import get_revenue_summary
from app.services.reservations import to_cents, PropertyNotFoundError, list_tenant_properties
from app.core.auth import authenticate_request as get_current_user

router = APIRouter()
logger = logging.getLogger(__name__)

@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"
# Never fall back to a shared/default tenant - that would mix clients' data.
tenant_id = getattr(current_user, "tenant_id", None)
if not tenant_id:
raise HTTPException(status_code=403, detail="No tenant associated with this user")

revenue_data = await get_revenue_summary(property_id, tenant_id)
try:
revenue_data = await get_revenue_summary(property_id, tenant_id)
except HTTPException:
raise
except PropertyNotFoundError:
raise HTTPException(status_code=404, detail="Property not found")
except Exception:
logger.exception("Failed to load revenue summary for property %s (tenant %s)", property_id, tenant_id)
raise HTTPException(status_code=503, detail="Revenue data temporarily unavailable")

total_revenue_float = float(revenue_data['total'])
# Total is an exact 2-decimal Decimal string; convert once for JSON (a 2dp value is float-safe).
total_revenue = float(to_cents(Decimal(str(revenue_data['total']))))

return {
"property_id": revenue_data['property_id'],
"total_revenue": total_revenue_float,
"total_revenue": total_revenue,
"currency": revenue_data['currency'],
"reservations_count": revenue_data['count']
}


@router.get("/dashboard/properties")
async def get_dashboard_properties(
current_user: dict = Depends(get_current_user)
) -> Dict[str, Any]:
"""Properties belonging to the logged-in tenant (drives the dashboard dropdown)."""
tenant_id = getattr(current_user, "tenant_id", None)
if not tenant_id:
raise HTTPException(status_code=403, detail="No tenant associated with this user")

try:
properties = await list_tenant_properties(tenant_id)
except Exception:
logger.exception("Failed to list properties for tenant %s", tenant_id)
raise HTTPException(status_code=503, detail="Properties temporarily unavailable")

return {"properties": properties}
19 changes: 13 additions & 6 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 @@ -13,13 +12,21 @@ def __init__(self):

async def initialize(self):
"""Initialize database connection pool"""
if self.session_factory:
# Already initialized - avoid creating a new engine on every call
return
try:
# Create async engine with connection pooling
database_url = f"postgresql+asyncpg://{settings.supabase_db_user}:{settings.supabase_db_password}@{settings.supabase_db_host}:{settings.supabase_db_port}/{settings.supabase_db_name}"

# Create async engine with connection pooling.
# DATABASE_URL is a plain postgresql:// URL; switch it to the asyncpg driver.
database_url = settings.database_url
if database_url.startswith("postgresql://"):
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
elif database_url.startswith("postgres://"):
database_url = database_url.replace("postgres://", "postgresql+asyncpg://", 1)

# Default pool for async engines is AsyncAdaptedQueuePool (QueuePool is not valid here)
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,7 +52,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")
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}"
# Tenant MUST be part of the key: property ids (e.g. prop-001) are only unique per tenant.
cache_key = f"revenue:{tenant_id}:{property_id}"

# Try to get from cache
cached = await redis_client.get(cache_key)
Expand Down
226 changes: 134 additions & 92 deletions backend/app/services/reservations.py
Original file line number Diff line number Diff line change
@@ -1,109 +1,151 @@
from datetime import datetime
from decimal import Decimal
from typing import Dict, Any, List
from decimal import Decimal, ROUND_HALF_UP
from typing import Dict, Any, List, Optional

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


class PropertyNotFoundError(Exception):
"""Raised when a property does not exist for the requesting tenant."""

def __init__(self, property_id: str, tenant_id: str):
super().__init__(f"Property {property_id} not found for tenant {tenant_id}")
self.property_id = property_id
self.tenant_id = tenant_id


def to_cents(value) -> Decimal:
"""Round a monetary amount to 2 decimals (half-up). Done once, at the very end."""
return Decimal(str(value)).quantize(CENT, rounding=ROUND_HALF_UP)


async def calculate_monthly_revenue(
property_id: str,
month: int,
year: int,
db_session=None,
tenant_id: Optional[str] = None,
) -> Decimal:
"""
Calculates revenue for a specific month.

Month boundaries are computed in the PROPERTY'S local time zone (properties.timezone),
not UTC, so a reservation checking in at 2024-02-29 23:30 UTC is counted in March
for a property in Europe/Paris.
"""
if not tenant_id:
raise ValueError("tenant_id is required to calculate revenue")

# Local (naive) month boundaries - interpreted in the property's time zone by SQL below.
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 sqlalchemy import text
from app.core.database_pool import db_pool

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

query = text("""
SELECT COALESCE(SUM(r.total_amount), 0) AS total
FROM reservations r
JOIN properties p
ON p.id = r.property_id AND p.tenant_id = r.tenant_id
WHERE r.property_id = :property_id
AND r.tenant_id = :tenant_id
AND (r.check_in_date AT TIME ZONE p.timezone) >= :start_date
AND (r.check_in_date AT TIME ZONE p.timezone) < :end_date
""")

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,
})
total = result.scalar() or Decimal("0")

return to_cents(total)

async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, Any]:
"""
Aggregates revenue from database.
"""
try:
# Import database pool
from app.core.database_pool import DatabasePool

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

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

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

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

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

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

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

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

return {
# Import database pool (shared instance - initialized once)
from app.core.database_pool import db_pool
from sqlalchemy import text

await db_pool.initialize()

if not db_pool.session_factory:
# Fail loudly instead of returning fabricated numbers for a financial report.
raise RuntimeError("Database pool not available")

async with db_pool.get_session() as session:
# Start from properties so we can tell "unknown property for this tenant"
# apart from "known property with no bookings yet".
query = text("""
SELECT
p.id AS property_id,
COALESCE(SUM(r.total_amount), 0) as total_revenue,
COUNT(r.id) as reservation_count
FROM properties p
LEFT JOIN reservations r
ON r.property_id = p.id AND r.tenant_id = p.tenant_id
WHERE p.id = :property_id AND p.tenant_id = :tenant_id
GROUP BY p.id
""")

result = await session.execute(query, {
"property_id": property_id,
"tenant_id": tenant_id,
"total": mock_property_data['total'],
"currency": "USD",
"count": mock_property_data['count']
}
"tenant_id": tenant_id
})
row = result.fetchone()

if row is None:
# Not this tenant's property (or doesn't exist). Don't report fake zeros.
raise PropertyNotFoundError(property_id, tenant_id)

# Sum exactly in NUMERIC/Decimal, round to cents ONCE at the end.
total_revenue = to_cents(row.total_revenue)
count = row.reservation_count

return {
"property_id": property_id,
"tenant_id": tenant_id,
"total": str(total_revenue),
"currency": "USD",
"count": count
}


async def list_tenant_properties(tenant_id: str) -> List[Dict[str, Any]]:
"""
Lists the properties owned by a tenant (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 RuntimeError("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},
)
rows = result.fetchall()

return [{"id": r.id, "name": r.name, "timezone": r.timezone} for r in rows]
3 changes: 2 additions & 1 deletion backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ pytz
redis>=5.0.0
requests
sendgrid
sqlalchemy>=2.0.0
sqlalchemy[asyncio]>=2.0.0
greenlet>=3.0.0
supabase
tenacity>=8.0.0
uvicorn[standard]
Expand Down
Loading