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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
__pycache__/
*.py[cod]
before_fix.md
17 changes: 12 additions & 5 deletions backend/app/api/v1/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,18 @@ 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(revenue_data['total'])

# return {
# "property_id": revenue_data['property_id'],
# "total_revenue": total_revenue_float,
# "currency": revenue_data['currency'],
# "reservations_count": revenue_data['count']
# }
return {
"property_id": revenue_data['property_id'],
"total_revenue": total_revenue_float,
"currency": revenue_data['currency'],
"reservations_count": revenue_data['count']
"property_id": revenue_data["property_id"],
"tenant_id": revenue_data["tenant_id"],
"total_revenue": revenue_data["total"],
"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 All @@ -45,7 +43,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}"
#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)
Expand Down
108 changes: 74 additions & 34 deletions backend/app/services/reservations.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
from datetime import datetime
from decimal import Decimal
from typing import Dict, Any, List
from datetime import datetime, timezone
from decimal import Decimal, ROUND_HALF_UP
from typing import Dict, Any
from zoneinfo import ZoneInfo


def format_money(amount: Decimal) -> str:
return str(amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))

async def calculate_monthly_revenue(property_id: str, month: int, year: int, db_session=None) -> Decimal:
"""
Expand Down Expand Up @@ -33,45 +38,87 @@ async def calculate_monthly_revenue(property_id: str, month: int, year: int, db_

async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, Any]:
"""
Aggregates revenue from database.
Aggregates March 2024 revenue from database using the property's local timezone.
"""
try:
# Import database pool
from app.core.database_pool import DatabasePool
from sqlalchemy import text

# 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
property_query = text("""
SELECT timezone
FROM properties
WHERE id = :property_id AND tenant_id = :tenant_id
""")

property_result = await session.execute(property_query, {
"property_id": property_id,
"tenant_id": tenant_id
})
property_row = property_result.fetchone()

if not property_row:
return {
"property_id": property_id,
"tenant_id": tenant_id,
"total": "0.00",
"currency": "USD",
"count": 0
}

property_timezone = ZoneInfo(property_row.timezone or "UTC")
start_local = datetime(2024, 3, 1, tzinfo=property_timezone)
end_local = datetime(2024, 4, 1, tzinfo=property_timezone)
start_utc = start_local.astimezone(timezone.utc)
end_utc = end_local.astimezone(timezone.utc)

# Previous query summed all reservations for the property regardless of
# reporting month or property timezone:
#
# 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
# """)

query = text("""
SELECT
property_id,
SUM(total_amount) as total_revenue,
COUNT(*) as reservation_count
COUNT(*) as reservation_count,
COALESCE(MAX(currency), 'USD') as currency
FROM reservations
WHERE property_id = :property_id AND tenant_id = :tenant_id
GROUP BY property_id
WHERE property_id = :property_id
AND tenant_id = :tenant_id
AND check_in_date >= :start_date
AND check_in_date < :end_date
""")

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

if row:
total_revenue = Decimal(str(row.total_revenue))
total_revenue = Decimal(str(row.total_revenue or "0"))
return {
"property_id": property_id,
"tenant_id": tenant_id,
"total": str(total_revenue),
"currency": "USD",
"count": row.reservation_count
"total": format_money(total_revenue),
"currency": row.currency or "USD",
"count": row.reservation_count or 0
}
else:
# No reservations found for this property
Expand All @@ -88,22 +135,15 @@ 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']
}
# Previous fallback returned property-only mock data, which could hide
# tenant isolation bugs because tenants with the same property id received
# the same fake revenue:
#
# 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}
# }
raise
45 changes: 35 additions & 10 deletions frontend/src/components/Dashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,41 @@
import React, { useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { useAuth } from "../contexts/AuthContext.new";
import { RevenueSummary } from "./RevenueSummary";

const PROPERTIES = [
{ id: 'prop-001', name: 'Beach House Alpha' },
{ id: 'prop-002', name: 'City Apartment Downtown' },
{ id: 'prop-003', name: 'Country Villa Estate' },
{ id: 'prop-004', name: 'Lakeside Cottage' },
{ id: 'prop-005', name: 'Urban Loft Modern' }
];
// const PROPERTIES = [
// { id: 'prop-001', name: 'Beach House Alpha' },
// { id: 'prop-002', name: 'City Apartment Downtown' },
// { id: 'prop-003', name: 'Country Villa Estate' },
// { id: 'prop-004', name: 'Lakeside Cottage' },
// { id: 'prop-005', name: 'Urban Loft Modern' }
// ];

const PROPERTIES_BY_TENANT: Record<string, Array<{ id: string; name: string }>> = {
'tenant-a': [
{ id: 'prop-001', name: 'Beach House Alpha' },
{ id: 'prop-002', name: 'City Apartment Downtown' },
{ id: 'prop-003', name: 'Country Villa Estate' }
],
'tenant-b': [
{ id: 'prop-001', name: 'Mountain Lodge Beta' },
{ id: 'prop-004', name: 'Lakeside Cottage' },
{ id: 'prop-005', name: 'Urban Loft Modern' }
]
};

const Dashboard: React.FC = () => {
const { user } = useAuth();
const [selectedProperty, setSelectedProperty] = useState('prop-001');
const tenantId = user?.tenant_id;
const properties = useMemo(() => {
return tenantId ? PROPERTIES_BY_TENANT[tenantId] || [] : [];
}, [tenantId]);

useEffect(() => {
if (properties.length > 0 && !properties.some((property) => property.id === selectedProperty)) {
setSelectedProperty(properties[0].id);
}
}, [properties, selectedProperty]);

return (
<div className="p-4 lg:p-6 min-h-full">
Expand All @@ -35,7 +60,7 @@ const Dashboard: React.FC = () => {
onChange={(e) => setSelectedProperty(e.target.value)}
className="block w-full sm:w-auto min-w-[200px] px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 text-sm"
>
{PROPERTIES.map((property) => (
{properties.map((property) => (
<option key={property.id} value={property.id}>
{property.name}
</option>
Expand All @@ -46,7 +71,7 @@ const Dashboard: React.FC = () => {
</div>

<div className="space-y-6">
<RevenueSummary propertyId={selectedProperty} />
{properties.length > 0 && <RevenueSummary propertyId={selectedProperty} />}
</div>
</div>
</div>
Expand Down
16 changes: 3 additions & 13 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;
tenant_id?: string;
total_revenue: string;
currency: string;
reservations_count: number;
}
Expand Down Expand Up @@ -61,7 +62,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 = Number(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 Down Expand Up @@ -102,17 +103,6 @@ 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
10 changes: 10 additions & 0 deletions frontend/src/contexts/AuthContext.new.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
source = 'user_metadata';
}

if (!tenant_id && enhancedUser.tenant_id) {
tenant_id = enhancedUser.tenant_id;
source = 'user';
}

// Add tenant_id as a direct property for backward compatibility
enhancedUser.tenant_id = tenant_id;

Expand Down Expand Up @@ -105,6 +110,11 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
source = 'user_metadata';
}

if (!tenant_id && enhancedUser.tenant_id) {
tenant_id = enhancedUser.tenant_id;
source = 'user';
}

// Add tenant_id as a direct property for backward compatibility
enhancedUser.tenant_id = tenant_id;

Expand Down