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
17 changes: 11 additions & 6 deletions backend/app/api/v1/dashboard.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException
from decimal import Decimal, ROUND_HALF_UP
from fastapi import APIRouter, Depends
from typing import Dict, Any
from app.services.cache import get_revenue_summary
from app.core.auth import authenticate_request as get_current_user
Expand All @@ -12,14 +13,18 @@ async def get_dashboard_summary(
) -> 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'])


# Round to cents with Decimal before serialising; going straight through a
# binary float is what caused the reported few-cents drift.
total_revenue = Decimal(str(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']
}
62 changes: 33 additions & 29 deletions backend/app/core/database_pool.py
Original file line number Diff line number Diff line change
@@ -1,60 +1,64 @@
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.pool import QueuePool
import logging

from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker

from ..config import settings

logger = logging.getLogger(__name__)


class DatabasePool:
def __init__(self):
self.engine = None
self.session_factory = None


@staticmethod
def _build_async_url() -> str:
"""Normalise the configured DB URL to use the asyncpg driver."""
url = settings.database_url
for prefix in ("postgresql://", "postgres://"):
if url.startswith(prefix):
return url.replace(prefix, "postgresql+asyncpg://", 1)
return url

async def initialize(self):
"""Initialize database connection pool"""
"""Build the connection pool once; safe to call repeatedly."""
if self.session_factory is not None:
return

try:
# Create async engine with connection pooling
database_url = f"postgresql+asyncpg://{settings.supabase_db_user}:{settings.supabase_db_password}@{settings.supabase_db_host}:{settings.supabase_db_port}/{settings.supabase_db_name}"

self.engine = create_async_engine(
database_url,
poolclass=QueuePool,
pool_size=20, # Number of connections to maintain
max_overflow=30, # Additional connections when needed
pool_pre_ping=True, # Validate connections
pool_recycle=3600, # Recycle connections every hour
echo=False # Set to True for SQL debugging
self._build_async_url(),
pool_size=20,
max_overflow=30,
pool_pre_ping=True,
pool_recycle=3600,
)

self.session_factory = async_sessionmaker(
bind=self.engine,
class_=AsyncSession,
expire_on_commit=False
expire_on_commit=False,
)

logger.info("✅ Database connection pool initialized")

logger.info("Database connection pool initialized")
except Exception as e:
logger.error(f"❌ Database pool initialization failed: {e}")
logger.error("Database pool initialization failed: %s", e)
self.engine = None
self.session_factory = None

async def close(self):
"""Close database connections"""
if self.engine:
await self.engine.dispose()
async def get_session(self) -> AsyncSession:
"""Get database session from pool"""

def get_session(self) -> AsyncSession:
"""Return a session; use as ``async with db_pool.get_session() as session:``."""
if not self.session_factory:
raise Exception("Database pool not initialized")
raise RuntimeError("Database pool not initialized")
return self.session_factory()

# Global database pool instance

db_pool = DatabasePool()


async def get_db_session() -> AsyncSession:
"""Dependency to get database session"""
async with db_pool.get_session() as session:
yield session
31 changes: 14 additions & 17 deletions backend/app/services/cache.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,26 @@
import json
import redis.asyncio as redis
from typing import Dict, Any
import os
from typing import Dict, Any

import redis.asyncio as redis

# Initialize Redis client (typically configured centrally).
redis_client = redis.Redis.from_url(os.getenv("REDIS_URL", "redis://localhost:6379/0"))

CACHE_TTL_SECONDS = 300


async def get_revenue_summary(property_id: str, tenant_id: str) -> Dict[str, Any]:
"""
Fetches revenue summary, utilizing caching to improve performance.
"""
cache_key = f"revenue:{property_id}"

# Try to get from cache
"""Return the revenue summary for a property, cached per tenant."""
# Property IDs are only unique within a tenant (prop-001 exists for both
# tenant-a and tenant-b), so the key has to include the tenant.
cache_key = f"revenue:{tenant_id}:{property_id}"

cached = await redis_client.get(cache_key)
if cached:
return json.loads(cached)

# Revenue calculation is delegated to the reservation service.

from app.services.reservations import calculate_total_revenue

# Calculate revenue

result = await calculate_total_revenue(property_id, tenant_id)

# Cache the result for 5 minutes
await redis_client.setex(cache_key, 300, json.dumps(result))

await redis_client.setex(cache_key, CACHE_TTL_SECONDS, json.dumps(result))
return result
209 changes: 116 additions & 93 deletions backend/app/services/reservations.py
Original file line number Diff line number Diff line change
@@ -1,109 +1,132 @@
import logging
from datetime import datetime
from decimal import Decimal
from typing import Dict, Any, List
from decimal import Decimal, ROUND_HALF_UP
from typing import Dict, Any
from zoneinfo import ZoneInfo

from sqlalchemy import text

from app.core.database_pool import db_pool

logger = logging.getLogger(__name__)

_CENTS = Decimal("0.01")


def _to_currency(amount: Decimal) -> Decimal:
"""Round a monetary value to whole cents (half-up)."""
return amount.quantize(_CENTS, rounding=ROUND_HALF_UP)

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

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
async def _get_property_timezone(session, property_id: str, tenant_id: str) -> ZoneInfo:
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},
)
row = result.fetchone()
tz_name = row.timezone if row and row.timezone else "UTC"
try:
return ZoneInfo(tz_name)
except Exception:
logger.warning("Unknown timezone %r for property %s, using UTC", tz_name, property_id)
return ZoneInfo("UTC")


async def calculate_monthly_revenue(property_id: str, tenant_id: str, month: int, year: int) -> Decimal:
"""Revenue for a calendar month, bucketed in the property's local timezone.

check_in_date is stored in UTC. A 23:30 UTC check-in on Feb 29 is March 1st
in Europe/Paris, so the month boundaries are built in the property's timezone
rather than naive UTC; otherwise the booking lands in the wrong month.
"""

# 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
await db_pool.initialize()
if not db_pool.session_factory:
raise RuntimeError("Database pool not available")

async with db_pool.get_session() as session:
tz = await _get_property_timezone(session, property_id, tenant_id)

start_date = datetime(year, month, 1, tzinfo=tz)
if month < 12:
end_date = datetime(year, month + 1, 1, tzinfo=tz)
else:
end_date = datetime(year + 1, 1, 1, tzinfo=tz)

result = await session.execute(
text(
"""
SELECT COALESCE(SUM(total_amount), 0) 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
"""
),
{
"property_id": property_id,
"tenant_id": tenant_id,
"start_date": start_date,
"end_date": end_date,
},
)
row = result.fetchone()
total = row.total if row else 0
return _to_currency(Decimal(str(total)))


async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, Any]:
"""
Aggregates revenue from database.
"""
"""Total revenue and reservation count for a property within a tenant."""
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:
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 SUM(total_amount) AS total_revenue,
COUNT(*) AS reservation_count
FROM reservations
WHERE property_id = :property_id AND tenant_id = :tenant_id
"""
),
{"property_id": property_id, "tenant_id": tenant_id},
)
row = result.fetchone()

total_amount = row.total_revenue if row else None
count = row.reservation_count if row else 0
total = _to_currency(Decimal(str(total_amount))) if total_amount is not None else Decimal("0.00")

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

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
logger.warning("Database error for %s (tenant: %s): %s", property_id, tenant_id, e)

# Property-specific mock data used when the database is unavailable.
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}
"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})

mock = mock_data.get(property_id, {"total": "0.00", "count": 0})

return {
"property_id": property_id,
"tenant_id": tenant_id,
"total": mock_property_data['total'],
"tenant_id": tenant_id,
"total": mock["total"],
"currency": "USD",
"count": mock_property_data['count']
"count": mock["count"],
}