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
18 changes: 16 additions & 2 deletions backend/app/api/v1/dashboard.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
from decimal import Decimal, ROUND_HALF_UP
from fastapi import APIRouter, Depends, HTTPException
from typing import Dict, Any
from typing import Dict, Any, List
from app.services.cache import get_revenue_summary
from app.services.properties import list_properties
from app.core.auth import authenticate_request as get_current_user

router = APIRouter()

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

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

return await list_properties(tenant_id)


@router.get("/dashboard/summary")
async def get_dashboard_summary(
property_id: str,
Expand All @@ -15,7 +27,9 @@ async def get_dashboard_summary(

revenue_data = await get_revenue_summary(property_id, tenant_id)

total_revenue_float = float(revenue_data['total'])
total_revenue_float = float(
Decimal(str(revenue_data['total'])).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
)

return {
"property_id": revenue_data['property_id'],
Expand Down
8 changes: 4 additions & 4 deletions backend/app/core/database_pool.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.pool import QueuePool
from sqlalchemy.pool import AsyncAdaptedQueuePool
import logging
from ..config import settings

Expand All @@ -15,11 +15,11 @@ async def initialize(self):
"""Initialize database connection pool"""
try:
# Create async engine with connection pooling
database_url = f"postgresql+asyncpg://{settings.supabase_db_user}:{settings.supabase_db_password}@{settings.supabase_db_host}:{settings.supabase_db_port}/{settings.supabase_db_name}"
database_url = settings.database_url.replace("postgresql://", "postgresql+asyncpg://", 1)

self.engine = create_async_engine(
database_url,
poolclass=QueuePool,
poolclass=AsyncAdaptedQueuePool,
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 +45,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
10 changes: 8 additions & 2 deletions backend/app/services/cache.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,28 @@
import json
import logging
import redis.asyncio as redis
from typing import Dict, Any
import os

logger = logging.getLogger(__name__)

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

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

# Try to get from cache
cached = await redis_client.get(cache_key)
if cached:
logger.info(f"REVENUE_CACHE HIT key={cache_key} tenant={tenant_id} property={property_id}")
return json.loads(cached)


logger.info(f"REVENUE_CACHE MISS key={cache_key} tenant={tenant_id} property={property_id}")

# Revenue calculation is delegated to the reservation service.
from app.services.reservations import calculate_total_revenue

Expand Down
30 changes: 30 additions & 0 deletions backend/app/services/properties.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from typing import Any, Dict, List


async def list_properties(tenant_id: str, db_session=None) -> List[Dict[str, Any]]:
"""
Returns the properties that belong to one tenant, ordered by id.
"""
from sqlalchemy import text

query = text("""
SELECT id, name
FROM properties
WHERE tenant_id = :tenant_id
ORDER BY id
""")
params = {"tenant_id": tenant_id}

if db_session is None:
from app.core.database_pool import db_pool

if not db_pool.session_factory:
await db_pool.initialize()
async with db_pool.get_session() as session:
result = await session.execute(query, params)
rows = result.fetchall()
else:
result = await db_session.execute(query, params)
rows = result.fetchall()

return [{"id": row.id, "name": row.name} for row in rows]
57 changes: 35 additions & 22 deletions backend/app/services/reservations.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,46 +2,59 @@
from decimal import Decimal
from typing import Dict, Any, List

async def calculate_monthly_revenue(property_id: str, month: int, year: int, db_session=None) -> Decimal:
async def calculate_monthly_revenue(property_id: str, tenant_id: str, month: int, year: int, db_session=None) -> Decimal:
"""
Calculates revenue for a specific month.
The month is the calendar month in the property's own timezone, not UTC.
"""
from sqlalchemy import text

# Month bounds as local wall-clock times; SQL converts them with the property's timezone
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
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 >= (CAST(:start_date AS timestamp) AT TIME ZONE p.timezone)
AND r.check_in_date < (CAST(:end_date AS timestamp) AT TIME ZONE p.timezone)
""")
Comment on lines +19 to +27

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 not wired into /dashboard/summary or get_revenue_summary, leaving the timezone fix unreachable by live production requests.
Severity: LOW

Suggested Fix

Update get_revenue_summary in the dashboard service flow to invoke calculate_monthly_revenue instead of calculate_total_revenue when monthly figures and timezone awareness are required.

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#L19-L27

Potential issue: The timezone-aware calculation implemented in
`calculate_monthly_revenue` is not integrated into production endpoints. The
`/dashboard/summary` endpoint routes through `get_revenue_summary` and calls
`calculate_total_revenue`, which aggregates all reservations without filtering by month
or handling property timezones. As a result, the timezone handling introduced in this PR
is unreachable from the API and has no effect on live dashboard metrics.

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

params = {
"property_id": property_id,
"tenant_id": tenant_id,
"start_date": start_date,
"end_date": end_date,
}

if db_session is None:
from app.core.database_pool import db_pool

if not db_pool.session_factory:
await db_pool.initialize()
async with db_pool.get_session() as session:
result = await session.execute(query, params)
else:
result = await db_session.execute(query, params)

return Decimal(str(result.scalar_one()))

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
from app.core.database_pool import db_pool

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

if db_pool.session_factory:
async with db_pool.get_session() as session:
Expand Down
2 changes: 1 addition & 1 deletion backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,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",
Expand Down
3 changes: 2 additions & 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 All @@ -27,3 +27,4 @@ loguru
redis>=5.0.0
asyncpg>=0.27.0
requests
pytest>=8.0.0
52 changes: 52 additions & 0 deletions backend/tests/test_dashboard_summary_rounding.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import types

import pytest
from fastapi.testclient import TestClient

from app.core.auth import authenticate_request
from app.main import app


def get_summary(total: str):
async def fake_get_revenue_summary(property_id, tenant_id):
return {
"property_id": "prop-002",
"tenant_id": "tenant-a",
"total": total,
"currency": "USD",
"count": 5,
}

app.dependency_overrides[authenticate_request] = lambda: types.SimpleNamespace(
tenant_id="tenant-a"
)
try:
import app.api.v1.dashboard as dashboard

original = dashboard.get_revenue_summary
dashboard.get_revenue_summary = fake_get_revenue_summary
try:
return TestClient(app).get("/api/v1/dashboard/summary?property_id=prop-002")
finally:
dashboard.get_revenue_summary = original
finally:
app.dependency_overrides.clear()


@pytest.mark.parametrize(
"total, expected",
[
("5095.565", 5095.57),
("2250.055", 2250.06),
("1.005", 1.01),
("2250.000", 2250.0),
("4975.5", 4975.5),
("0.00", 0.0),
],
)
def test_total_revenue_is_rounded_half_up_to_two_decimals(total, expected):
response = get_summary(total)
assert response.status_code == 200
value = response.json()["total_revenue"]
assert isinstance(value, (int, float)) and not isinstance(value, bool)
assert value == expected
33 changes: 33 additions & 0 deletions backend/tests/test_list_properties.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import asyncio

from app.core.database_pool import DatabasePool
from app.services.properties import list_properties


def properties_for(tenant_id: str):
async def run():
pool = DatabasePool()
await pool.initialize()
try:
async with pool.get_session() as session:
return await list_properties(tenant_id, db_session=session)
finally:
await pool.close()

return asyncio.run(run())


def test_tenant_b_sees_only_its_properties_ordered_by_id():
assert properties_for("tenant-b") == [
{"id": "prop-001", "name": "Mountain Lodge Beta"},
{"id": "prop-004", "name": "Lakeside Cottage"},
{"id": "prop-005", "name": "Urban Loft Modern"},
]


def test_tenant_a_sees_only_its_properties_ordered_by_id():
assert properties_for("tenant-a") == [
{"id": "prop-001", "name": "Beach House Alpha"},
{"id": "prop-002", "name": "City Apartment Downtown"},
{"id": "prop-003", "name": "Country Villa Estate"},
]
35 changes: 35 additions & 0 deletions backend/tests/test_monthly_revenue.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import asyncio
from decimal import Decimal

from app.core.database_pool import DatabasePool
from app.services.reservations import calculate_monthly_revenue


def monthly_revenue(property_id: str, tenant_id: str, month: int, year: int) -> Decimal:
async def run():
pool = DatabasePool()
await pool.initialize()
try:
async with pool.get_session() as session:
return await calculate_monthly_revenue(property_id, tenant_id, month, year, db_session=session)
finally:
await pool.close()

return asyncio.run(run())


def test_march_uses_property_timezone():
# res-tz-1 checks in 2024-02-29 23:30 UTC = 2024-03-01 00:30 Europe/Paris
assert monthly_revenue("prop-001", "tenant-a", 3, 2024) == Decimal("2250.000")


def test_february_excludes_booking_that_is_march_in_local_time():
assert monthly_revenue("prop-001", "tenant-a", 2, 2024) == Decimal("0")


def test_same_property_id_in_other_tenant_is_not_counted():
assert monthly_revenue("prop-001", "tenant-b", 3, 2024) == Decimal("0")


def test_new_york_property_march_total():
assert monthly_revenue("prop-004", "tenant-b", 3, 2024) == Decimal("1776.500")
Loading