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
1 change: 1 addition & 0 deletions backend/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
__pycache__
21 changes: 16 additions & 5 deletions backend/app/api/v1/dashboard.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
from typing import Dict, Any
from decimal import Decimal, ROUND_HALF_UP
from app.services.cache import get_revenue_summary
from app.core.auth import authenticate_request as get_current_user

Expand All @@ -8,18 +9,28 @@
@router.get("/dashboard/summary")
async def get_dashboard_summary(
property_id: str,
current_user: dict = Depends(get_current_user)
current_user: dict = Depends(get_current_user),
month: int | None = Query(default=None, ge=1, le=12),
year: int | None = Query(default=None, ge=1, le=9998),
) -> 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)
if (month is None) != (year is None):
raise HTTPException(status_code=422, detail="Month and year must be supplied together")
try:
revenue_data = await get_revenue_summary(property_id, tenant_id, month, year)
except Exception:
raise HTTPException(status_code=503, detail="Revenue temporarily unavailable")

total_revenue_float = float(revenue_data['total'])
# Sum at full precision, then round once for the two-decimal USD report.
total_revenue = Decimal(revenue_data['total']).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
if total_revenue == 0:
total_revenue = abs(total_revenue) # Avoid displaying negative zero.

return {
"property_id": revenue_data['property_id'],
"total_revenue": total_revenue_float,
"total_revenue": format(total_revenue, ".2f"),
"currency": revenue_data['currency'],
"reservations_count": revenue_data['count']
}
9 changes: 5 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.engine import make_url
import logging
from ..config import settings

Expand All @@ -13,13 +13,14 @@ def __init__(self):

async def initialize(self):
"""Initialize database connection pool"""
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}"
database_url = make_url(settings.database_url).set(drivername="postgresql+asyncpg")

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 +46,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
18 changes: 13 additions & 5 deletions backend/app/services/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,31 @@
# 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: int = None, year: int = None) -> Dict[str, Any]:
"""
Fetches revenue summary, utilizing caching to improve performance.
"""
cache_key = f"revenue:{property_id}"

if not tenant_id or not tenant_id.strip():
raise ValueError("Tenant is required for revenue summaries")
# Include tenant and reporting period; skip all legacy shared cache entries.
cache_key = "revenue:v2:" + json.dumps([tenant_id, property_id, year, month])

# Try to get from cache
cached = await redis_client.get(cache_key)
if cached:
return json.loads(cached)
data = json.loads(cached)
if data.get("tenant_id") == tenant_id and data.get("property_id") == property_id:
return data

# 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, year)

if result.get("tenant_id") != tenant_id or result.get("property_id") != property_id:
raise ValueError("Revenue ownership mismatch")

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

Expand Down
80 changes: 24 additions & 56 deletions backend/app/services/reservations.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,65 +2,50 @@
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:
"""
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}")
async def calculate_monthly_revenue(property_id: str, month: int, year: int, tenant_id: str) -> Decimal:
"""Calculate revenue for the month in the property's local time zone."""
result = await calculate_total_revenue(property_id, tenant_id, month, year)
return Decimal(result["total"])

# 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

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: int = None, year: int = None) -> Dict[str, Any]:
"""
Aggregates revenue from database.
"""
if (month is None) != (year is None):
raise ValueError("Month and year must be supplied together")
start_date = datetime(year, month, 1) if month is not None else None
end_date = (datetime(year + 1, 1, 1) if month == 12 else datetime(year, month + 1, 1)) if month is not None else None
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 db_pool.session_factory:
async with db_pool.get_session() as session:
# Use SQLAlchemy text for raw SQL
from sqlalchemy import text

date_filter = "" if month is None else """
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)
"""
query = text("""
SELECT
property_id,
r.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
""")
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
""" + date_filter + " GROUP BY r.property_id")

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

Expand Down Expand Up @@ -88,22 +73,5 @@ async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str,
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 serve or cache tenant-independent mock amounts on database failure.
raise
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
14 changes: 13 additions & 1 deletion frontend/src/components/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ const PROPERTIES = [

const Dashboard: React.FC = () => {
const [selectedProperty, setSelectedProperty] = useState('prop-001');
const [period, setPeriod] = useState(() => {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
});
const [year, month] = period.split('-').map(Number);
Comment on lines +14 to +18

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: Partial or invalid text in <input type="month"> fallback inputs triggers API requests with invalid month/year parameters, resulting in 422 errors.
Severity: LOW

Suggested Fix

Validate that the input matches the expected YYYY-MM format (e.g. using a regular expression /^\d{4}-\d{2}$/) before updating period and triggering queries.

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: frontend/src/components/Dashboard.tsx#L14-L18

Potential issue: On browsers lacking native `<input type="month">` support, the control
defaults to a plain text field where `validity.valid` evaluates to true for non-empty
text. Typing partial or invalid input like '2024-' or 'abc' updates `period`, resulting
in invalid `month` and `year` values such as 0 or `NaN`. These values trigger API
requests on each keystroke that fail with 422 errors and cause the UI to display 'Failed
to load revenue data'.

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


return (
<div className="p-4 lg:p-6 min-h-full">
Expand All @@ -27,6 +32,13 @@ const Dashboard: React.FC = () => {
</p>
</div>

<div>
<label htmlFor="revenue-month" className="block text-xs font-medium text-gray-700 mb-1">Reporting month</label>
<input id="revenue-month" type="month" value={period}
min="0001-01" max="9998-12" required
onChange={(e) => { if (e.target.validity.valid && e.target.value) setPeriod(e.target.value); }}
className="border border-gray-300 rounded-md px-3 py-2 text-sm" />
</div>
{/* Property Selector */}
<div className="flex flex-col sm:items-end">
<label className="text-xs font-medium text-gray-700 mb-1">Select Property</label>
Expand All @@ -46,7 +58,7 @@ const Dashboard: React.FC = () => {
</div>

<div className="space-y-6">
<RevenueSummary propertyId={selectedProperty} />
<RevenueSummary propertyId={selectedProperty} month={month} year={year} />
</div>
</div>
</div>
Expand Down
41 changes: 20 additions & 21 deletions frontend/src/components/RevenueSummary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,45 +3,52 @@ import { SecureAPI } from '../lib/secureApi';

interface RevenueData {
property_id: string;
total_revenue: number;
total_revenue: string;
currency: string;
reservations_count: number;
}

interface RevenueSummaryProps {
propertyId?: string;
month: number;
year: number;
debugTenant?: string;
showRaw?: boolean;
}

export const RevenueSummary: React.FC<RevenueSummaryProps> = ({ propertyId = 'prop-001', debugTenant, showRaw }) => {
export const RevenueSummary: React.FC<RevenueSummaryProps> = ({ propertyId = 'prop-001', month, year, debugTenant, showRaw }) => {
const [data, setData] = useState<RevenueData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');

const activeTenant = debugTenant || 'candidate';

useEffect(() => {
let cancelled = false;
const fetchRevenue = async () => {
setLoading(true);
setError('');
try {
// Use SecureAPI to handle authentication automatically
// We pass the simulatedTenant option which SecureAPI will attach as a header
const response = await SecureAPI.getDashboardSummary(propertyId, {
simulatedTenant: activeTenant,
timestamp: Date.now()
timestamp: Date.now(),
month,
year
});
setData(response);
if (!cancelled) setData(response);
} catch (err) {
setError('Failed to load revenue data');
if (!cancelled) setError('Failed to load revenue data');
console.error(err);
} finally {
setLoading(false);
if (!cancelled) setLoading(false);
}
};

fetchRevenue();
}, [propertyId, activeTenant]);
return () => { cancelled = true; };
}, [propertyId, activeTenant, month, year]);

if (loading) {
return (
Expand All @@ -61,7 +68,9 @@ 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;
// Format the server-rounded decimal string without floating-point conversion.
const [whole, cents] = data.total_revenue.split('.');
const displayTotal = `${whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}.${cents}`;

return (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow duration-300">
Expand All @@ -75,10 +84,10 @@ export const RevenueSummary: React.FC<RevenueSummaryProps> = ({ propertyId = 'pr
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-sm font-medium text-gray-500 uppercase tracking-wide">Total Revenue</h2>
<h2 className="text-sm font-medium text-gray-500 uppercase tracking-wide">Monthly 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} {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 +111,7 @@ 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>

</div>
</div>
);
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/lib/secureApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1452,8 +1452,12 @@ export class SecureAPIClient {
/**
* Get dashboard summary with optional simulation header
*/
async getDashboardSummary(propertyId: string, options?: { simulatedTenant?: string, timestamp?: number }) {
async getDashboardSummary(propertyId: string, options?: { simulatedTenant?: string, timestamp?: number, month?: number, year?: number }) {
const queryParams = new URLSearchParams({ property_id: propertyId });
if (options?.month !== undefined && options?.year !== undefined) {
queryParams.set('month', String(options.month));
queryParams.set('year', String(options.year));
}
if (options?.timestamp) {
queryParams.append('_t', options.timestamp.toString());
}
Expand Down