diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..fc43a4aba --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# Python runtime artifacts produced by the bind-mounted backend container. +__pycache__/ +*.py[cod] +.pytest_cache/ +*.tsbuildinfo diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index 1ec352d7e..f3e3f35a3 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -1,25 +1,78 @@ -from fastapi import APIRouter, Depends, HTTPException -from typing import Dict, Any -from app.services.cache import get_revenue_summary +import logging +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.exc import SQLAlchemyError + from app.core.auth import authenticate_request as get_current_user +from app.models.auth import AuthenticatedUser +from app.services.cache import get_revenue_summary +from app.services.reservations import PropertyNotFoundError, list_tenant_properties +logger = logging.getLogger(__name__) router = APIRouter() + +def _tenant_id(current_user: AuthenticatedUser) -> str: + """Require tenant context instead of silently using a shared fallback.""" + if not current_user.tenant_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="A tenant context is required for dashboard data", + ) + return current_user.tenant_id + + +@router.get("/dashboard/properties") +async def get_dashboard_properties( + current_user: AuthenticatedUser = Depends(get_current_user), +) -> dict[str, list[dict[str, str]]]: + """List only the current tenant's selectable dashboard properties.""" + try: + properties = await list_tenant_properties(_tenant_id(current_user)) + return {"items": properties} + except SQLAlchemyError as error: + logger.exception("Unable to load dashboard properties: %s", error) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Property data is temporarily unavailable", + ) from error + + @router.get("/dashboard/summary") async def get_dashboard_summary( property_id: str, - 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']) - + year: int = Query(default=2024, ge=2000, le=2100), + month: int = Query(default=3, ge=1, le=12), + current_user: AuthenticatedUser = Depends(get_current_user), +) -> dict[str, Any]: + """Return exact monthly revenue for one tenant-owned property.""" + try: + revenue_data = await get_revenue_summary( + property_id, + _tenant_id(current_user), + year=year, + month=month, + ) + except PropertyNotFoundError as error: + # Do not reveal whether the property exists for another tenant. + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Property not found") from error + except SQLAlchemyError as error: + logger.exception("Unable to load dashboard revenue: %s", error) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Revenue data is temporarily unavailable", + ) from error + 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"], + "property_name": revenue_data["property_name"], + "year": revenue_data["year"], + "month": revenue_data["month"], + "timezone": revenue_data["timezone"], + # A decimal string avoids introducing a binary floating-point value at + # the API boundary. + "total_revenue": revenue_data["total"], + "currency": revenue_data["currency"], + "reservations_count": revenue_data["count"], } diff --git a/backend/app/core/database_pool.py b/backend/app/core/database_pool.py index d638dfcfe..a1fe47ec0 100644 --- a/backend/app/core/database_pool.py +++ b/backend/app/core/database_pool.py @@ -1,60 +1,78 @@ import asyncio -from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker -from sqlalchemy.pool import QueuePool import logging + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + from ..config import settings logger = logging.getLogger(__name__) +def _async_database_url(database_url: str) -> str: + """Return a SQLAlchemy URL that uses the asyncpg driver.""" + if database_url.startswith("postgresql+asyncpg://"): + return database_url + if database_url.startswith("postgresql://"): + return database_url.replace("postgresql://", "postgresql+asyncpg://", 1) + if database_url.startswith("postgres://"): + return database_url.replace("postgres://", "postgresql+asyncpg://", 1) + return database_url + + class DatabasePool: - def __init__(self): + """Lazily initialized async SQLAlchemy session factory.""" + + def __init__(self) -> None: self.engine = None - self.session_factory = None - - 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}" - + self.session_factory: async_sessionmaker[AsyncSession] | None = None + self._initialization_lock = asyncio.Lock() + + async def initialize(self) -> None: + """Initialize the shared pool once using the configured DATABASE_URL.""" + if self.session_factory is not None: + return + + async with self._initialization_lock: + if self.session_factory is not None: + return + + database_url = _async_database_url(settings.database_url) 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 - pool_recycle=3600, # Recycle connections every hour - echo=False # Set to True for SQL debugging + pool_size=settings.database_pool_size, + max_overflow=settings.database_max_overflow, + pool_timeout=settings.database_pool_timeout, + pool_pre_ping=True, + pool_recycle=settings.database_pool_recycle, + echo=False, ) - self.session_factory = async_sessionmaker( bind=self.engine, class_=AsyncSession, - expire_on_commit=False + expire_on_commit=False, ) - - logger.info("✅ Database connection pool initialized") - - except Exception as e: - logger.error(f"❌ Database pool initialization failed: {e}") - self.engine = None - self.session_factory = None - - async def close(self): - """Close database connections""" - if self.engine: - await self.engine.dispose() - - async def get_session(self) -> AsyncSession: - """Get database session from pool""" - if not self.session_factory: - raise Exception("Database pool not initialized") + logger.info("Database connection pool initialized") + + async def close(self) -> None: + """Dispose of the database engine during application shutdown.""" + engine = self.engine + self.engine = None + self.session_factory = None + if engine is not None: + await engine.dispose() + + def get_session(self) -> AsyncSession: + """Create a session from the initialized factory.""" + if self.session_factory is None: + raise RuntimeError("Database pool has not been initialized") return self.session_factory() -# Global database pool instance + +# One shared pool prevents a new engine from being created for every request. db_pool = DatabasePool() -async def get_db_session() -> AsyncSession: - """Dependency to get database session""" + +async def get_db_session(): + """FastAPI dependency that yields an initialized database session.""" + await db_pool.initialize() async with db_pool.get_session() as session: yield session diff --git a/backend/app/main.py b/backend/app/main.py index 00734b2fa..877ace42d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -100,6 +100,14 @@ async def lifespan(app: FastAPI): logger.error(f"❌ Supabase connection pool initialization failed: {e}") # Continue startup - fallback to direct connections + try: + from .core.database_pool import db_pool + + await db_pool.initialize() + logger.info("✅ Revenue database pool initialized") + except Exception as e: + logger.error(f"❌ Revenue database pool initialization failed: {e}") + # Initialize Redis connection with timeout try: await redis_client.initialize() @@ -136,6 +144,14 @@ async def lifespan(app: FastAPI): except Exception as e: logger.warning(f"⚠️ Error closing connection pool: {e}") + try: + from .core.database_pool import db_pool + + await db_pool.close() + logger.info("✅ Revenue database pool closed") + except Exception as e: + logger.warning(f"⚠️ Error closing revenue database pool: {e}") + app = FastAPI( title="Auth Skeleton API", diff --git a/backend/app/services/cache.py b/backend/app/services/cache.py index b81474957..0b575f343 100644 --- a/backend/app/services/cache.py +++ b/backend/app/services/cache.py @@ -1,29 +1,55 @@ import json -import redis.asyncio as redis -from typing import Dict, Any +import logging import os +from typing import Any + +import redis.asyncio as redis -# Initialize Redis client (typically configured centrally). +from app.services.reservations import calculate_monthly_revenue, get_tenant_property +from app.services.revenue_reporting import revenue_cache_key + +logger = logging.getLogger(__name__) + +# Redis is an optimization only; revenue always remains available from Postgres +# when a cache operation fails. 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. + +async def get_revenue_summary( + property_id: str, + tenant_id: str, + *, + year: int, + month: int, +) -> dict[str, Any]: + """Fetch an authorized, property-local monthly revenue summary. + + The ownership check deliberately happens before the cache lookup. That makes + a cached value inaccessible to a tenant that does not own the property. """ - cache_key = f"revenue:{property_id}" - - # Try to get from cache - cached = await redis_client.get(cache_key) - if cached: - return json.loads(cached) - - # 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) - - # Cache the result for 5 minutes - await redis_client.setex(cache_key, 300, json.dumps(result)) - + property_record = await get_tenant_property(property_id, tenant_id) + cache_key = revenue_cache_key(tenant_id, property_id, year, month) + + try: + cached = await redis_client.get(cache_key) + if cached: + return json.loads(cached) + except Exception as error: # Cache availability must not break reporting. + logger.warning("Revenue cache read failed: %s", error) + + result = await calculate_monthly_revenue( + property_id, + tenant_id, + month, + year, + property_timezone=property_record["timezone"], + ) + result["property_name"] = property_record["name"] + result["timezone"] = property_record["timezone"] + + try: + await redis_client.setex(cache_key, 300, json.dumps(result)) + except Exception as error: # Cache availability must not break reporting. + logger.warning("Revenue cache write failed: %s", error) + return result diff --git a/backend/app/services/reservations.py b/backend/app/services/reservations.py index 384bd00ab..fe66fca45 100644 --- a/backend/app/services/reservations.py +++ b/backend/app/services/reservations.py @@ -1,109 +1,106 @@ -from datetime import datetime +"""Tenant-scoped revenue reporting queries.""" + 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}") - - # 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]: - """ - Aggregates revenue from database. - """ - try: - # Import database pool - from app.core.database_pool import DatabasePool - - # 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 - - 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 - """) - - result = await session.execute(query, { - "property_id": property_id, - "tenant_id": tenant_id - }) - row = result.fetchone() - - if row: - total_revenue = Decimal(str(row.total_revenue)) - return { - "property_id": property_id, - "tenant_id": tenant_id, - "total": str(total_revenue), - "currency": "USD", - "count": row.reservation_count - } - else: - # No reservations found for this property - return { - "property_id": property_id, - "tenant_id": tenant_id, - "total": "0.00", - "currency": "USD", - "count": 0 - } - else: - raise Exception("Database pool not available") - - 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'] - } +from typing import Any + +from sqlalchemy import text + +from app.core.database_pool import db_pool +from app.services.revenue_reporting import format_money, month_bounds_utc + + +class PropertyNotFoundError(LookupError): + """Raised when a property is not available to the current tenant.""" + + +async def list_tenant_properties(tenant_id: str) -> list[dict[str, str]]: + """Return only the properties owned by the authenticated tenant.""" + await db_pool.initialize() + async with db_pool.get_session() as session: + result = await session.execute( + text( + """ + SELECT id, name, timezone + FROM properties + WHERE tenant_id = :tenant_id + ORDER BY name + """ + ), + {"tenant_id": tenant_id}, + ) + return [dict(row) for row in result.mappings().all()] + + +async def get_tenant_property(property_id: str, tenant_id: str) -> dict[str, str]: + """Resolve a property only when it belongs to the current tenant.""" + await db_pool.initialize() + async with db_pool.get_session() as session: + result = await session.execute( + text( + """ + SELECT id, name, timezone + FROM properties + WHERE id = :property_id AND tenant_id = :tenant_id + """ + ), + {"property_id": property_id, "tenant_id": tenant_id}, + ) + row = result.mappings().one_or_none() + + if row is None: + # Return the same response for an unknown property and a different + # tenant's property so ownership information is not disclosed. + raise PropertyNotFoundError("Property was not found for this tenant") + + return dict(row) + + +async def calculate_monthly_revenue( + property_id: str, + tenant_id: str, + month: int, + year: int, + *, + property_timezone: str | None = None, +) -> dict[str, Any]: + """Calculate one property's revenue for a property-local calendar month.""" + if property_timezone is None: + property = await get_tenant_property(property_id, tenant_id) + property_timezone = property["timezone"] + + start_at, end_at = month_bounds_utc(year, month, property_timezone) + await db_pool.initialize() + + async with db_pool.get_session() as session: + result = await session.execute( + text( + """ + SELECT + COALESCE(SUM(total_amount), 0) AS total_revenue, + COUNT(*) AS reservation_count + FROM reservations + WHERE property_id = :property_id + AND tenant_id = :tenant_id + AND check_in_date >= :start_at + AND check_in_date < :end_at + """ + ), + { + "property_id": property_id, + "tenant_id": tenant_id, + "start_at": start_at, + "end_at": end_at, + }, + ) + row = result.mappings().one() + + total = Decimal(str(row["total_revenue"])) + return { + "property_id": property_id, + "tenant_id": tenant_id, + "total": format_money(total), + "currency": "USD", + "count": int(row["reservation_count"] or 0), + "year": year, + "month": month, + } diff --git a/backend/app/services/revenue_reporting.py b/backend/app/services/revenue_reporting.py new file mode 100644 index 000000000..5fba2a4ec --- /dev/null +++ b/backend/app/services/revenue_reporting.py @@ -0,0 +1,32 @@ +"""Pure helpers shared by tenant-scoped revenue reporting.""" + +from datetime import datetime, timezone +from decimal import Decimal, ROUND_HALF_UP +from zoneinfo import ZoneInfo + +MONEY_QUANTUM = Decimal("0.01") + + +def month_bounds_utc(year: int, month: int, property_timezone: str) -> tuple[datetime, datetime]: + """Build a property's local calendar-month range as UTC timestamps.""" + zone = ZoneInfo(property_timezone) + start_local = datetime(year, month, 1, tzinfo=zone) + if month == 12: + end_local = datetime(year + 1, 1, 1, tzinfo=zone) + else: + end_local = datetime(year, month + 1, 1, tzinfo=zone) + + return ( + start_local.astimezone(timezone.utc), + end_local.astimezone(timezone.utc), + ) + + +def format_money(amount: Decimal) -> str: + """Round a completed aggregate once and serialize it exactly to cents.""" + return format(amount.quantize(MONEY_QUANTUM, rounding=ROUND_HALF_UP), ".2f") + + +def revenue_cache_key(tenant_id: str, property_id: str, year: int, month: int) -> str: + """Include all response-affecting dimensions in a revenue cache key.""" + return f"revenue:{tenant_id}:{property_id}:{year:04d}-{month:02d}" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index ebaa4bc92..e28ec26ab 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -31,6 +31,7 @@ dependencies = [ "stripe>=7.0.0", "supabase>=2.18.0", "tenacity>=9.1.2", + "tzdata>=2024.1", "uvicorn[standard]>=0.35.0", "wheel==0.45.1", ] diff --git a/backend/requirements.txt b/backend/requirements.txt index 6b777d2aa..908e5ea36 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -21,6 +21,7 @@ sendgrid sqlalchemy>=2.0.0 supabase tenacity>=8.0.0 +tzdata>=2024.1 uvicorn[standard] wheel==0.45.1 loguru diff --git a/backend/tests/test_revenue_reporting.py b/backend/tests/test_revenue_reporting.py new file mode 100644 index 000000000..c1617bccb --- /dev/null +++ b/backend/tests/test_revenue_reporting.py @@ -0,0 +1,42 @@ +from datetime import datetime, timezone +from decimal import Decimal +import unittest + +from app.services.revenue_reporting import format_money, month_bounds_utc, revenue_cache_key + + +class RevenueReportingTests(unittest.TestCase): + def test_paris_month_bounds_include_the_utc_february_boundary_reservation(self) -> None: + start_at, end_at = month_bounds_utc(2024, 3, "Europe/Paris") + + self.assertEqual(start_at, datetime(2024, 2, 29, 23, 0, tzinfo=timezone.utc)) + self.assertEqual(end_at, datetime(2024, 3, 31, 22, 0, tzinfo=timezone.utc)) + + # res-tz-1: 2024-02-29 23:30 UTC is March 1 in Paris. + reservation_check_in = datetime(2024, 2, 29, 23, 30, tzinfo=timezone.utc) + self.assertGreaterEqual(reservation_check_in, start_at) + self.assertLess(reservation_check_in, end_at) + + def test_decimal_amounts_are_summed_before_rounding_to_cents(self) -> None: + march_total = sum( + [ + Decimal("1250.000"), + Decimal("333.333"), + Decimal("333.333"), + Decimal("333.334"), + ] + ) + + self.assertEqual(format_money(march_total), "2250.00") + self.assertEqual(format_money(Decimal("333.333") * 3), "1000.00") + + def test_revenue_cache_is_partitioned_by_tenant_and_reporting_period(self) -> None: + sunset_key = revenue_cache_key("tenant-a", "prop-001", 2024, 3) + ocean_key = revenue_cache_key("tenant-b", "prop-001", 2024, 3) + april_key = revenue_cache_key("tenant-a", "prop-001", 2024, 4) + + self.assertNotEqual(sunset_key, ocean_key) + self.assertNotEqual(sunset_key, april_key) + +if __name__ == "__main__": + unittest.main() diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index a21bba404..801bfbc62 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -1,16 +1,53 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; + import { RevenueSummary } from "./RevenueSummary"; +import { SecureAPI } from "../lib/secureApi"; -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' } -]; +interface DashboardProperty { + id: string; + name: string; + timezone: string; +} const Dashboard: React.FC = () => { - const [selectedProperty, setSelectedProperty] = useState('prop-001'); + const [properties, setProperties] = useState([]); + const [selectedProperty, setSelectedProperty] = useState(""); + const [reportMonth, setReportMonth] = useState("2024-03"); + const [loadingProperties, setLoadingProperties] = useState(true); + const [propertiesError, setPropertiesError] = useState(""); + + useEffect(() => { + let isCurrent = true; + + const loadProperties = async () => { + setLoadingProperties(true); + setPropertiesError(""); + + try { + const response = await SecureAPI.getDashboardProperties(); + if (!isCurrent) return; + + const tenantProperties = response.items || []; + setProperties(tenantProperties); + setSelectedProperty((current) => + tenantProperties.some((property) => property.id === current) + ? current + : tenantProperties[0]?.id || "" + ); + } catch (error) { + if (!isCurrent) return; + console.error(error); + setPropertiesError("Failed to load your properties."); + } finally { + if (isCurrent) setLoadingProperties(false); + } + }; + + loadProperties(); + return () => { + isCurrent = false; + }; + }, []); return (
@@ -26,28 +63,58 @@ const Dashboard: React.FC = () => { Monthly performance insights for your properties

- - {/* Property Selector */} -
- - + +
+
+ + +
+ +
+ + setReportMonth(event.target.value)} + className="block w-full sm:w-auto 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" + /> +
-
- -
+ {propertiesError && ( +
{propertiesError}
+ )} + + {!propertiesError && !loadingProperties && !selectedProperty && ( +
No properties are assigned to this tenant.
+ )} + + {selectedProperty && ( +
+ +
+ )} diff --git a/frontend/src/components/RevenueSummary.tsx b/frontend/src/components/RevenueSummary.tsx index dbb6d0629..36b7edc21 100644 --- a/frontend/src/components/RevenueSummary.tsx +++ b/frontend/src/components/RevenueSummary.tsx @@ -1,119 +1,136 @@ -import React, { useEffect, useState } from 'react'; -import { SecureAPI } from '../lib/secureApi'; +import React, { useEffect, useMemo, useState } from "react"; + +import { SecureAPI } from "../lib/secureApi"; interface RevenueData { - property_id: string; - total_revenue: number; - currency: string; - reservations_count: number; + property_id: string; + property_name: string; + year: number; + month: number; + timezone: string; + total_revenue: string; + currency: string; + reservations_count: number; } interface RevenueSummaryProps { - propertyId?: string; - debugTenant?: string; - showRaw?: boolean; + propertyId: string; + reportMonth: string; } -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'; - - 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]); - - if (loading) { - return ( -
-
-
-
-
-
-
-
-
-
- ); - } +function formatExactMoney(amount: string): string { + const [rawWhole, rawFraction = ""] = amount.split("."); + const isNegative = rawWhole.startsWith("-"); + const whole = (isNegative ? rawWhole.slice(1) : rawWhole) || "0"; + const groupedWhole = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ","); + const fraction = rawFraction.padEnd(2, "0").slice(0, 2); + + return `${isNegative ? "-" : ""}${groupedWhole}.${fraction}`; +} + +function formatReportMonth(value: string): string { + const [year, month] = value.split("-").map(Number); + if (!year || !month) return "Selected month"; + + return new Intl.DateTimeFormat("en-US", { + month: "long", + year: "numeric", + timeZone: "UTC" + }).format(new Date(Date.UTC(year, month - 1, 1))); +} + +export const RevenueSummary: React.FC = ({ propertyId, reportMonth }) => { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + const period = useMemo(() => { + const [year, month] = reportMonth.split("-").map(Number); + return { year, month }; + }, [reportMonth]); - if (error) return
{error}
; - if (!data) return null; + useEffect(() => { + let isCurrent = true; - const displayTotal = Math.round(data.total_revenue * 100) / 100; + const fetchRevenue = async () => { + if (!period.year || !period.month) { + setData(null); + setError("Choose a valid report month."); + setLoading(false); + return; + } + setLoading(true); + setError(""); + try { + const response = await SecureAPI.getDashboardSummary(propertyId, period); + if (isCurrent) setData(response); + } catch (requestError) { + if (isCurrent) { + console.error(requestError); + setData(null); + setError("Failed to load revenue data."); + } + } finally { + if (isCurrent) setLoading(false); + } + }; + + fetchRevenue(); + return () => { + isCurrent = false; + }; + }, [period, propertyId]); + + if (loading) { 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% - -
-
-
- -
-
-

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 -
- )} -
-
+
+
+
+
+
+
+
+
+
); + } + + if (error) return
{error}
; + if (!data) return null; + + return ( +
+
+
+
+

+ {formatReportMonth(reportMonth)} Revenue +

+

Property-local reporting time: {data.timezone}

+
+ + {data.currency} {formatExactMoney(data.total_revenue)} + +
+
+
+ +
+
+

Property

+

{data.property_name}

+

{data.property_id}

+
+
+

Reservations

+

+ {data.reservations_count} bookings +

+
+
+
+
+ ); }; diff --git a/frontend/src/lib/secureApi.ts b/frontend/src/lib/secureApi.ts index f85f04c90..45347f1c4 100644 --- a/frontend/src/lib/secureApi.ts +++ b/frontend/src/lib/secureApi.ts @@ -1449,23 +1449,24 @@ export class SecureAPIClient { } // ============= DASHBOARD API ============= + async getDashboardProperties(): Promise<{ + items: Array<{ id: string; name: string; timezone: string }>; + }> { + return this.request('/api/v1/dashboard/properties'); + } + /** - * Get dashboard summary with optional simulation header + * Get a property-local monthly dashboard summary. Tenant identity comes only + * from the authenticated token; it must never be accepted from the browser. */ - async getDashboardSummary(propertyId: string, options?: { simulatedTenant?: string, timestamp?: number }) { - const queryParams = new URLSearchParams({ property_id: propertyId }); - if (options?.timestamp) { - queryParams.append('_t', options.timestamp.toString()); - } - - const requestOptions: RequestInit = {}; - if (options?.simulatedTenant) { - requestOptions.headers = { - 'X-Simulated-Tenant': options.simulatedTenant - }; - } + async getDashboardSummary(propertyId: string, period: { year: number; month: number }) { + const queryParams = new URLSearchParams({ + property_id: propertyId, + year: period.year.toString(), + month: period.month.toString() + }); - return this.request(`/api/v1/dashboard/summary?${queryParams}`, requestOptions); + return this.request(`/api/v1/dashboard/summary?${queryParams}`); } async uploadCompanyLogo(logo_url: string) {