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
32 changes: 23 additions & 9 deletions backend/app/api/v1/dashboard.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,39 @@
import re
from decimal import Decimal, ROUND_HALF_UP
from fastapi import APIRouter, Depends, HTTPException
from typing import Dict, Any
from typing import Dict, Any, Optional
from app.services.cache import get_revenue_summary
from app.services.reservations import MixedCurrencyError
from app.core.auth import authenticate_request as get_current_user

router = APIRouter()

@router.get("/dashboard/summary")
async def get_dashboard_summary(
property_id: str,
month: Optional[str] = None,
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 = getattr(current_user, "tenant_id", None)
if not tenant_id:
raise HTTPException(status_code=403, detail="No tenant associated with user")

if month is not None and not re.fullmatch(r"\d{4}-(0[1-9]|1[0-2])", month):
raise HTTPException(status_code=400, detail="month must be in YYYY-MM format")

try:
revenue_data = await get_revenue_summary(property_id, tenant_id, month)
except MixedCurrencyError as e:
raise HTTPException(status_code=409, detail=str(e))

# Quantize once, at presentation. Explicit ROUND_HALF_UP: finance expects
# half-up; Python's Decimal default is ROUND_HALF_EVEN (banker's rounding).
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": str(total_revenue),
"currency": revenue_data['currency'],
"reservations_count": revenue_data['count']
}
6 changes: 2 additions & 4 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 @@ -15,11 +14,10 @@ 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,
pool_size=20, # Number of connections to maintain
max_overflow=30, # Additional connections when needed
pool_pre_ping=True, # Validate connections
Expand Down
22 changes: 14 additions & 8 deletions backend/app/services/cache.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,33 @@
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[str] = None) -> Dict[str, Any]:
"""
Fetches revenue summary, utilizing caching to improve performance.
"""
cache_key = f"revenue:{property_id}"

# Tenant segment isolates clients; period segment keeps months and all-time apart
period = month or "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)

result = json.loads(cached)
# Tripwire: a mis-keyed entry must fail closed as a miss, never cross scopes
if result.get("tenant_id") == tenant_id and result.get("period") == period:
return result
print(f"Cache scope mismatch for {cache_key}: expected {tenant_id}/{period}, got {result.get('tenant_id')}/{result.get('period')} - treating as miss")

# 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)
result = await calculate_total_revenue(property_id, tenant_id, month)

# Cache the result for 5 minutes
await redis_client.setex(cache_key, 300, json.dumps(result))
Expand Down
132 changes: 69 additions & 63 deletions backend/app/services/reservations.py
Original file line number Diff line number Diff line change
@@ -1,40 +1,38 @@
from datetime import datetime
from datetime import datetime, 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:
"""
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}")
class MixedCurrencyError(Exception):
"""Raised when a total would silently sum amounts in different currencies."""

# 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

def month_bounds_utc(month: str, tz_name: str) -> Tuple[datetime, datetime]:
"""Half-open UTC interval [start, end) covering the property-local month.

Each bound is derived from its own local wall time, so a month containing
a DST transition gets different UTC offsets at each edge.
Unrecognised/empty timezones fall back to UTC bucketing rather than failing.
"""

# 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
year, mon = int(month[:4]), int(month[5:7])
try:
tz = ZoneInfo(tz_name) if tz_name else timezone.utc
except Exception:
print(f"Unrecognised property timezone '{tz_name}' - falling back to UTC bucketing")
tz = timezone.utc
start_local = datetime(year, mon, 1, tzinfo=tz)
if mon < 12:
end_local = datetime(year, mon + 1, 1, tzinfo=tz)
else:
end_local = datetime(year + 1, 1, 1, tzinfo=tz)
return start_local.astimezone(timezone.utc), end_local.astimezone(timezone.utc)

async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, Any]:
async def calculate_total_revenue(property_id: str, tenant_id: str, month: Optional[str] = None) -> Dict[str, Any]:
"""
Aggregates revenue from database.
Aggregates revenue from database, optionally scoped to a property-local month (YYYY-MM).
"""
period = month or "all"
try:
# Import database pool
from app.core.database_pool import DatabasePool
Expand All @@ -44,66 +42,74 @@ async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str,
await db_pool.initialize()

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

query = text("""
SELECT

params = {"property_id": property_id, "tenant_id": tenant_id}
date_filter = ""
if month:
# Bucket by the property's local calendar month, comparing in UTC
# so the check_in_date column stays index-friendly
tz_result = await session.execute(text("""
SELECT timezone FROM properties
WHERE id = :property_id AND tenant_id = :tenant_id
"""), params)
tz_row = tz_result.fetchone()
tz_name = tz_row.timezone if tz_row else None
start_utc, end_utc = month_bounds_utc(month, tz_name)
date_filter = " AND check_in_date >= :start_utc AND check_in_date < :end_utc"
params.update({"start_utc": start_utc, "end_utc": end_utc})

query = text(f"""
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
COUNT(*) as reservation_count,
COUNT(DISTINCT COALESCE(NULLIF(currency, ''), 'USD')) as currency_count,
MIN(COALESCE(NULLIF(currency, ''), 'USD')) as currency
FROM reservations
WHERE property_id = :property_id AND tenant_id = :tenant_id{date_filter}
GROUP BY property_id
""")

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

result = await session.execute(query, params)
row = result.fetchone()

if row:
if row.currency_count > 1:
# 100 EUR + 100 USD is not 200 of anything without a
# conversion rate and the date that rate applied
raise MixedCurrencyError(
f"Property {property_id} has reservations in {row.currency_count} "
f"currencies for period {period}; refusing to sum without conversion"
)
total_revenue = Decimal(str(row.total_revenue))
return {
"property_id": property_id,
"tenant_id": tenant_id,
"period": period,
"total": str(total_revenue),
"currency": "USD",
"currency": row.currency,
"count": row.reservation_count
}
else:
# No reservations found for this property
# No reservations found for this property; currency falls back
# to the schema default since there is nothing to read it from
return {
"property_id": property_id,
"tenant_id": tenant_id,
"period": period,
"total": "0.00",
"currency": "USD",
"count": 0
}
else:
raise Exception("Database pool not available")

except MixedCurrencyError:
raise
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 substitute fabricated figures for a failed query - propagate instead
raise
27 changes: 13 additions & 14 deletions frontend/src/components/RevenueSummary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { SecureAPI } from '../lib/secureApi';

interface RevenueData {
property_id: string;
total_revenue: number;
// Decimal string quantized to 2dp server-side; never do float math on it
total_revenue: string;
currency: string;
reservations_count: number;
}
Expand All @@ -14,6 +15,12 @@ interface RevenueSummaryProps {
showRaw?: boolean;
}

// String-safe thousands grouping; money must never round-trip through float
const fmt = (v: string) => {
const [i, d] = v.split('.');
return i.replace(/\B(?=(\d{3})+(?!\d))/g, ',') + (d ? '.' + d : '');
};

export const RevenueSummary: React.FC<RevenueSummaryProps> = ({ propertyId = 'prop-001', debugTenant, showRaw }) => {
const [data, setData] = useState<RevenueData | null>(null);
const [loading, setLoading] = useState(true);
Expand Down Expand Up @@ -61,7 +68,7 @@ export const RevenueSummary: React.FC<RevenueSummaryProps> = ({ propertyId = 'pr
if (error) return <div className="p-4 text-red-500 bg-red-50 rounded-lg">{error}</div>;
if (!data) return null;

const displayTotal = Math.round(data.total_revenue * 100) / 100;
const displayTotal = data.total_revenue;

return (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow duration-300">
Expand All @@ -78,7 +85,7 @@ export const RevenueSummary: React.FC<RevenueSummaryProps> = ({ propertyId = 'pr
<h2 className="text-sm font-medium text-gray-500 uppercase tracking-wide">Total Revenue</h2>
<div className="flex items-baseline gap-2 mt-1">
<span className="text-3xl font-bold text-gray-900 tracking-tight">
{data.currency} {displayTotal.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
{data.currency} {fmt(displayTotal)}
</span>
{/* Fake trend indicator for premium feel */}
<span className="inline-flex items-baseline px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 md:mt-2 lg:mt-0">
Expand All @@ -102,17 +109,9 @@ export const RevenueSummary: React.FC<RevenueSummaryProps> = ({ propertyId = 'pr
</div>
</div>

{/* Precision Warning Area */}
<div className="mt-4 h-6">
{Math.abs(data.total_revenue - displayTotal) > 0.000001 && showRaw && (
<div className="flex items-center text-xs text-amber-600 bg-amber-50 px-2 py-1 rounded">
<svg className="h-4 w-4 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
Precision Mismatch Detected
</div>
)}
</div>
{/* Precision warning removed: the API now sends an exact 2dp decimal
string, so display can no longer diverge from the payload */}
<div className="mt-4 h-6"></div>
</div>
</div>
);
Expand Down