From b7f05a92bd207881ba478b46c5a4f35476adc172 Mon Sep 17 00:00:00 2001 From: Harry Tanaka Date: Fri, 31 Jul 2026 00:02:42 +0700 Subject: [PATCH] Multi-tenant revenue: caching, rounding, timezone Backend: include tenant_id in cache keys (cache.py) to avoid cross-tenant collisions; return total_revenue as a rounded Decimal string in dashboard API (dashboard.py) using ROUND_HALF_UP for consistent monetary formatting. Reservations service (reservations.py) updated signature to accept tenant_id and sketched timezone-aware start/end datetimes (uses ZoneInfo) to ensure month boundaries respect property timezone. Frontend: make Dashboard tenant-aware (AuthContext) and filter properties by tenant; RevenueSummary improved formatting, typing and uses simulatedTenant when calling SecureAPI. These changes improve multi-tenancy correctness, monetary precision, and timezone handling. --- backend/app/api/v1/dashboard.py | 7 +- backend/app/services/cache.py | 2 +- backend/app/services/reservations.py | 13 +- frontend/src/components/Dashboard.tsx | 28 +-- frontend/src/components/RevenueSummary.tsx | 216 +++++++++++---------- 5 files changed, 145 insertions(+), 121 deletions(-) diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index 1ec352d7e..04a31b2b2 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -2,7 +2,7 @@ from typing import Dict, Any from app.services.cache import get_revenue_summary from app.core.auth import authenticate_request as get_current_user - +from decimal import Decimal, ROUND_HALF_UP router = APIRouter() @router.get("/dashboard/summary") @@ -17,9 +17,12 @@ async def get_dashboard_summary( total_revenue_float = float(revenue_data['total']) + total = 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": str(total), "currency": revenue_data['currency'], "reservations_count": revenue_data['count'] } diff --git a/backend/app/services/cache.py b/backend/app/services/cache.py index b81474957..672e3f9d6 100644 --- a/backend/app/services/cache.py +++ b/backend/app/services/cache.py @@ -10,7 +10,7 @@ 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) diff --git a/backend/app/services/reservations.py b/backend/app/services/reservations.py index 384bd00ab..0872a5893 100644 --- a/backend/app/services/reservations.py +++ b/backend/app/services/reservations.py @@ -1,17 +1,20 @@ +from zoneinfo import ZoneInfo from datetime import datetime 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. """ - - start_date = datetime(year, month, 1) +# 1) load property timezone for (property_id, tenant_id) e.g. Europe/Paris + tz = ZoneInfo(property_timezone) # from DB + start_date = datetime(year, month, 1, tzinfo=tz) if month < 12: - end_date = datetime(year, month + 1, 1) + end_date = datetime(year, month + 1, 1, tzinfo=tz) else: - end_date = datetime(year + 1, 1, 1) + end_date = datetime(year + 1, 1, 1, tzinfo=tz) + print(f"DEBUG: Querying revenue for {property_id} from {start_date} to {end_date}") diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index a21bba404..348954bd0 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -1,17 +1,21 @@ import React, { useState } from "react"; 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' } +import { useAuth } from "../contexts/AuthContext.new"; +const ALL_PROPERTIES = [ + { id: "prop-001", name: "Beach House Alpha", tenantId: "tenant-a" }, + { id: "prop-002", name: "City Apartment Downtown", tenantId: "tenant-a" }, + { id: "prop-003", name: "Country Villa Estate", tenantId: "tenant-a" }, + { id: "prop-004", name: "Lakeside Cottage", tenantId: "tenant-b" }, + { id: "prop-005", name: "Urban Loft Modern", tenantId: "tenant-b" }, + // note: tenant-b also has prop-001 "Mountain Lodge Beta" in seed — + // for video you can add that row if you want Ocean's list complete ]; const Dashboard: React.FC = () => { - const [selectedProperty, setSelectedProperty] = useState('prop-001'); - + const { user } = useAuth(); + const tenantId = user?.tenant_id || user?.app_metadata?.tenant_id || "tenant-a"; + const PROPERTIES = ALL_PROPERTIES.filter((p) => p.tenantId === tenantId); + const [selectedProperty, setSelectedProperty] = useState(PROPERTIES[0]?.id ?? "prop-001"); return (
@@ -22,11 +26,9 @@ const Dashboard: React.FC = () => {

Revenue Overview

-

- Monthly performance insights for your properties -

+

Monthly performance insights for your properties

- + {/* Property Selector */}
diff --git a/frontend/src/components/RevenueSummary.tsx b/frontend/src/components/RevenueSummary.tsx index dbb6d0629..34da7aca3 100644 --- a/frontend/src/components/RevenueSummary.tsx +++ b/frontend/src/components/RevenueSummary.tsx @@ -1,119 +1,135 @@ -import React, { useEffect, useState } from 'react'; -import { SecureAPI } from '../lib/secureApi'; +import React, { useEffect, useState } from "react"; +import { SecureAPI } from "../lib/secureApi"; interface RevenueData { - property_id: string; - total_revenue: number; - currency: string; - reservations_count: number; + property_id: string; + total_revenue: number; + currency: string; + reservations_count: number; } interface RevenueSummaryProps { - propertyId?: string; - debugTenant?: string; - showRaw?: boolean; + propertyId?: string; + debugTenant?: string; + showRaw?: boolean; } -export const RevenueSummary: React.FC = ({ propertyId = 'prop-001', debugTenant, showRaw }) => { - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); +export const RevenueSummary: React.FC = ({ propertyId = "prop-001", debugTenant, showRaw }) => { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); - const activeTenant = debugTenant || 'candidate'; + const activeTenant = debugTenant || "candidate"; - useEffect(() => { - const fetchRevenue = async () => { - setLoading(true); - 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() - }); - setData(response); - } catch (err) { - setError('Failed to load revenue data'); - console.error(err); - } finally { - setLoading(false); - } - }; + useEffect(() => { + const fetchRevenue = async () => { + setLoading(true); + 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(), + }); + setData(response); + } catch (err) { + setError("Failed to load revenue data"); + console.error(err); + } finally { + setLoading(false); + } + }; - fetchRevenue(); - }, [propertyId, activeTenant]); + fetchRevenue(); + }, [propertyId, activeTenant]); - if (loading) { - return ( -
-
-
-
-
-
-
-
-
-
- ); - } - - if (error) return
{error}
; - if (!data) return null; + if (loading) { + return ( +
+
+
+
+
+
+
+
+
+
+ ); + } - const displayTotal = Math.round(data.total_revenue * 100) / 100; + if (error) return
{error}
; + if (!data) return null; - return ( -
- {showRaw && ( -
- Raw API Response -
{JSON.stringify(data, null, 2)}
-
- )} + const displayTotal = Number(data.total_revenue); + return ( +
+ {showRaw && ( +
+ Raw API Response +
{JSON.stringify(data, null, 2)}
+
+ )} -
-
-
-

Total Revenue

-
- - {data.currency} {displayTotal.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} - - {/* Fake trend indicator for premium feel */} - - - 12% - -
-
-
+
+
+
+

Total Revenue

+
+ + {data.currency}{" "} + {displayTotal.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + + {/* Fake trend indicator for premium feel */} + + + 12% + +
+
+
-
-
-

Property ID

-

{data.property_id}

-
-
-

Reservations

-

{data.reservations_count} bookings

-
-
+
+
+

Property ID

+

{data.property_id}

+
+
+

Reservations

+

+ {data.reservations_count} bookings +

+
+
- {/* Precision Warning Area */} -
- {Math.abs(data.total_revenue - displayTotal) > 0.000001 && showRaw && ( -
- - - - Precision Mismatch Detected -
- )} -
+ {/* Precision Warning Area */} +
+ {Math.abs(data.total_revenue - displayTotal) > 0.000001 && showRaw && ( +
+ + + + Precision Mismatch Detected
+ )}
- ); +
+
+ ); };