diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..9e71c5ce6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.idea/ +.venv/ +**/.venv/ +__pycache__/ +**/__pycache__/ +*.py[cod] diff --git a/README.md b/README.md new file mode 100644 index 000000000..b209bcc99 --- /dev/null +++ b/README.md @@ -0,0 +1,137 @@ +# Property Revenue Dashboard Debugging Assignment + +## Loom Walkthrough + +I will add the link to my Loom walkthrough here after recording it. + +## SetUp + +I followed the instructions in ASSIGNMENT.md and investigated the clients and their data in the db. + +From the problem description and the client reports, a few possible bugs came to mind: + +- Database misconfiguration +- Invalid cache +- Incorrect date handling +- Rounding errors +- Bugs in the UI + +## Investigation Approach + +I like starting with running the whole test suite, but the project has none. I thus started by installing the project and logging in as both clients. + +I compared what I saw in the UI with the API response and the data in the PostgreSQL Docker service: + +~~~bash +docker compose exec db psql -U postgres -d propertyflow +~~~ + +I then checked the backend logs, Redis, the database queries and finally the code that handled each request. I worked from the data source towards the UI so that each later result was based on real data. + +## What I Discovered and How + +### 1. The application was returning mock revenue + +The first thing I noticed was that Sunset's prop-001 result did not match the records in the db. The logs contained: + +~~~text +Database pool initialization failed: 'Settings' object has no attribute 'supabase_db_user' +~~~ + +I searched for this configuration and found that database_pool.py expected settings that did not exist. I then followed the error into reservations.py and found that the error was caught and replaced with hardcoded revenue data. This made a database failure look like a valid financial result. + +### 2. Revenue cache entries were shared between tenants + +After tracing the revenue request, I checked Redis and found a key in this format: + +~~~text +revenue:prop-001 +~~~ + +Sunset and Ocean both have a property with the ID prop-001. This meant that the first client to load the property populated the value returned to the other client for five minutes. The database query included the tenant, but Redis returned its value before that query ran. + +### 3. The monthly dashboard calculated all-time revenue + +The UI described the result as monthly performance, but the endpoint did not accept a month or year. The active SQL query only filtered by property and tenant. + +I also found an unfinished calculate_monthly_revenue function. It returned zero, was not used, and created date boundaries without using the property's timezone. + +### 4. March was being interpreted in the wrong timezone + +While checking the seeded records, I found this reservation: + +~~~text +UTC: 2024-02-29 23:30 +Europe/Paris: 2024-03-01 00:30 +Amount: 1250.000 +~~~ + +It is a February timestamp in UTC but a March booking for the Paris property. A UTC-only March filter would therefore exclude valid March revenue. + +### 5. Exact database decimals were converted to floats + +The database stores amounts as NUMERIC(10, 3) and the service initially used Decimal. The route converted the value to a Python float, after which the frontend rounded a JavaScript number using Math.round. + +The seeded values 333.333, 333.333 and 333.334 made it clear that the financial rounding rule needed to be explicit and applied in one place. + +### 6. Both clients saw the same hardcoded property list + +While switching accounts, I noticed that both clients saw the same five properties. Ocean's prop-001 was even labelled as Sunset's “Beach House Alpha” instead of “Mountain Lodge Beta.” + +I found that the entire list was hardcoded in Dashboard.tsx. The frontend also sent a simulated tenant header even though the tenant should only come from the authenticated user. + +## Fixes Applied + +I applied the fixes in the same order in which I found the issues: + +### 1. Database connection and mock revenue + +[backend/app/core/database_pool.py](backend/app/core/database_pool.py) now uses the configured DATABASE_URL and an async-compatible pool. [backend/app/main.py](backend/app/main.py) manages that shared pool, while [backend/app/services/reservations.py](backend/app/services/reservations.py) no longer replaces database errors with mock financial data. I also updated [backend/requirements.txt](backend/requirements.txt), [backend/pyproject.toml](backend/pyproject.toml) and [backend/uv.lock](backend/uv.lock) with SQLAlchemy's async dependency. + +### 2. Cross-tenant cache + +[backend/app/services/cache.py](backend/app/services/cache.py) now includes the tenant, property, year and month in every revenue cache key. + +### 3. Monthly revenue + +[backend/app/api/v1/dashboard.py](backend/app/api/v1/dashboard.py) now requires a reporting month and year. [backend/app/services/reservations.py](backend/app/services/reservations.py) filters reservations using that period instead of returning an all-time total. + +### 4. Property timezone + +[backend/app/services/reservations.py](backend/app/services/reservations.py) now builds the calendar month in the property's named timezone and converts the boundaries to UTC before querying. + +### 5. Financial precision + +[backend/app/services/reservations.py](backend/app/services/reservations.py) rounds the final Decimal using ROUND_HALF_UP. [backend/app/api/v1/dashboard.py](backend/app/api/v1/dashboard.py) returns that exact value as a string, and [frontend/src/components/RevenueSummary.tsx](frontend/src/components/RevenueSummary.tsx) displays it without JavaScript rounding. + +### 6. Tenant-specific properties + +[backend/app/api/v1/dashboard.py](backend/app/api/v1/dashboard.py) exposes a tenant-filtered property endpoint and rejects properties outside the authenticated tenant. [frontend/src/lib/secureApi.ts](frontend/src/lib/secureApi.ts) no longer sends a simulated tenant header, while [frontend/src/components/Dashboard.tsx](frontend/src/components/Dashboard.tsx) loads the authenticated client's properties and provides the month selector. + +Finally, [.gitignore](.gitignore) excludes .idea, virtual environments, __pycache__ directories and compiled Python files. + +## How I Will Demonstrate the Fixes + +I will rebuild the application from a clean environment and demonstrate the following: + +1. The backend connects to PostgreSQL and the API result agrees with the db instead of returning mock data. +2. When the database is unavailable, revenue returns a service error rather than a fabricated total. +3. Sunset and Ocean receive separate results for their shared prop-001 identifier. +4. Redis contains separate keys in this format: + +~~~text +revenue:::: +~~~ + +5. Sunset's March 2024 report includes the reservation stored at February 29 23:30 UTC because it is March 1 in Paris. +6. The raw API response contains the same exact two-decimal value displayed in the UI. +7. Sunset sees only Sunset properties and Ocean sees only Ocean properties. +8. A direct request for another tenant's property returns no property or revenue data. + +## Recommendations + +This is what I would do if I had more time: + +1. Add regression tests for tenant cache isolation, local month boundaries, property authorization and decimal rounding. +2. Add structured logs for the tenant, property, reporting period and cache hit or miss without logging tokens or financial data. +3. Add cache invalidation when reservations or property timezone information changes. diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index 1ec352d7e..7b69064b8 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -1,25 +1,69 @@ -from fastapi import APIRouter, Depends, HTTPException -from typing import Dict, Any -from app.services.cache import get_revenue_summary +from typing import Any, Dict, List + +from fastapi import APIRouter, Depends, HTTPException, Query + 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 ( + PropertyNotFound, + RevenueServiceUnavailable, + get_tenant_properties, +) router = APIRouter() + +def _get_tenant_id(current_user: AuthenticatedUser) -> str: + if not current_user.tenant_id: + raise HTTPException(status_code=403, detail="Tenant access is required") + return current_user.tenant_id + + +@router.get("/dashboard/properties") +async def get_dashboard_properties( + current_user: AuthenticatedUser = Depends(get_current_user), +) -> List[Dict[str, str]]: + tenant_id = _get_tenant_id(current_user) + + try: + return await get_tenant_properties(tenant_id) + except RevenueServiceUnavailable as error: + raise HTTPException( + status_code=503, + 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) + year: int = Query(..., ge=2000, le=2100), + month: int = Query(..., ge=1, le=12), + current_user: AuthenticatedUser = 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 = _get_tenant_id(current_user) + + try: + revenue_data = await get_revenue_summary( + property_id, + tenant_id, + month, + year, + ) + except PropertyNotFound as error: + raise HTTPException(status_code=404, detail="Property not found") from error + except RevenueServiceUnavailable as error: + raise HTTPException( + status_code=503, + 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"], + "year": revenue_data["year"], + "month": revenue_data["month"], + "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..8e356d8b4 100644 --- a/backend/app/core/database_pool.py +++ b/backend/app/core/database_pool.py @@ -1,6 +1,5 @@ -import asyncio from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker -from sqlalchemy.pool import QueuePool +from sqlalchemy import text import logging from ..config import settings @@ -13,13 +12,22 @@ def __init__(self): async def initialize(self): """Initialize database connection pool""" + if self.session_factory: + 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 = settings.database_url + if database_url.startswith("postgresql://"): + database_url = database_url.replace( + "postgresql://", "postgresql+asyncpg://", 1 + ) + elif database_url.startswith("postgres://"): + database_url = database_url.replace( + "postgres://", "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 @@ -32,11 +40,18 @@ async def initialize(self): class_=AsyncSession, expire_on_commit=False ) + + # Opening a connection here catches invalid configuration during + # application startup instead of on the first dashboard request. + async with self.engine.connect() as connection: + await connection.execute(text("SELECT 1")) logger.info("✅ Database connection pool initialized") except Exception as e: logger.error(f"❌ Database pool initialization failed: {e}") + if self.engine: + await self.engine.dispose() self.engine = None self.session_factory = None @@ -44,11 +59,13 @@ async def close(self): """Close database connections""" if self.engine: await self.engine.dispose() + self.engine = None + self.session_factory = None - 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") + raise RuntimeError("Database pool not initialized") return self.session_factory() # Global database pool instance diff --git a/backend/app/main.py b/backend/app/main.py index 00734b2fa..5d8f49766 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -15,6 +15,7 @@ import time from app.core.redis_client import redis_client +from app.core.database_pool import db_pool from .api.v1 import ( users_lightning, cities, @@ -90,6 +91,11 @@ async def lifespan(app: FastAPI): # Startup logger.info("Starting up...") + # Initialize the PostgreSQL pool used by revenue reporting. + await db_pool.initialize() + if not db_pool.session_factory: + logger.warning("Revenue database is unavailable") + # Initialize Supabase connection pool try: from .core.supabase_connection_pool import supabase_pool @@ -127,6 +133,9 @@ async def lifespan(app: FastAPI): await async_processor.shutdown() logger.info("Async processor shutdown completed") + await db_pool.close() + logger.info("Revenue database connection pool closed") + # Close connection pool try: from .core.supabase_connection_pool import supabase_pool diff --git a/backend/app/services/cache.py b/backend/app/services/cache.py index b81474957..d4d685650 100644 --- a/backend/app/services/cache.py +++ b/backend/app/services/cache.py @@ -3,25 +3,34 @@ from typing import Dict, Any import os +from app.services.reservations import calculate_monthly_revenue + # 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, + year: int, +) -> 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}:{year}:{month}" # 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) + result = await calculate_monthly_revenue( + property_id, + tenant_id, + month, + year, + ) # Cache the result for 5 minutes await redis_client.setex(cache_key, 300, json.dumps(result)) diff --git a/backend/app/services/reservations.py b/backend/app/services/reservations.py index 384bd00ab..f80004d04 100644 --- a/backend/app/services/reservations.py +++ b/backend/app/services/reservations.py @@ -1,109 +1,157 @@ -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: - """ - 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. - """ +import logging +from datetime import datetime, timezone +from decimal import Decimal, ROUND_HALF_UP +from typing import Any, Dict, List +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from sqlalchemy import text + +from app.core.database_pool import db_pool + +logger = logging.getLogger(__name__) + + +class RevenueServiceUnavailable(Exception): + """Raised when revenue cannot be read from the database.""" + + +class PropertyNotFound(Exception): + """Raised when a property does not belong to the authenticated tenant.""" + + +def _next_month(year: int, month: int) -> tuple[int, int]: + if month == 12: + return year + 1, 1 + return year, month + 1 + + +def _format_currency(value: Decimal) -> str: + return str(value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)) + + +async def calculate_monthly_revenue( + property_id: str, + tenant_id: str, + month: int, + year: int, +) -> Dict[str, Any]: + """Calculate a property's revenue for one local calendar month.""" + if not db_pool.session_factory: + raise RevenueServiceUnavailable("Revenue database is unavailable") + + property_query = text(""" + SELECT timezone + FROM properties + WHERE id = :property_id AND tenant_id = :tenant_id + """) + 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'] + async with db_pool.get_session() as session: + property_result = await session.execute(property_query, { + "property_id": property_id, + "tenant_id": tenant_id, + }) + property_row = property_result.fetchone() + + if not property_row: + raise PropertyNotFound(property_id) + + try: + property_timezone = ZoneInfo(property_row.timezone) + except ZoneInfoNotFoundError as error: + logger.exception( + "Invalid timezone %s for property %s and tenant %s", + property_row.timezone, + property_id, + tenant_id, + ) + raise RevenueServiceUnavailable( + "Property timezone is invalid" + ) from error + + next_year, next_month = _next_month(year, month) + local_start = datetime(year, month, 1, tzinfo=property_timezone) + local_end = datetime( + next_year, + next_month, + 1, + tzinfo=property_timezone, + ) + utc_start = local_start.astimezone(timezone.utc) + utc_end = local_end.astimezone(timezone.utc) + + revenue_query = text(""" + SELECT + COALESCE(SUM(total_amount), 0) AS total_revenue, + COUNT(*) AS reservation_count, + COALESCE(MIN(currency), 'USD') AS currency + FROM reservations + WHERE property_id = :property_id + AND tenant_id = :tenant_id + AND check_in_date >= :utc_start + AND check_in_date < :utc_end + """) + result = await session.execute(revenue_query, { + "property_id": property_id, + "tenant_id": tenant_id, + "utc_start": utc_start, + "utc_end": utc_end, + }) + row = result.fetchone() + except (PropertyNotFound, RevenueServiceUnavailable): + raise + except Exception as error: + logger.exception( + "Revenue database query failed for property %s and tenant %s", + property_id, + tenant_id, + ) + raise RevenueServiceUnavailable( + "Revenue database is unavailable" + ) from error + + total_revenue = Decimal(str(row.total_revenue)) + return { + "property_id": property_id, + "tenant_id": tenant_id, + "year": year, + "month": month, + "total": _format_currency(total_revenue), + "currency": row.currency, + "count": row.reservation_count, + } + + +async def get_tenant_properties(tenant_id: str) -> List[Dict[str, str]]: + """Return only properties owned by the authenticated tenant.""" + if not db_pool.session_factory: + raise RevenueServiceUnavailable("Revenue database is unavailable") + + query = text(""" + SELECT id, name, timezone + FROM properties + WHERE tenant_id = :tenant_id + ORDER BY name + """) + + try: + async with db_pool.get_session() as session: + result = await session.execute(query, {"tenant_id": tenant_id}) + rows = result.fetchall() + except Exception as error: + logger.exception( + "Property database query failed for tenant %s", + tenant_id, + ) + raise RevenueServiceUnavailable( + "Property database is unavailable" + ) from error + + return [ + { + "id": row.id, + "name": row.name, + "timezone": row.timezone, } + for row in rows + ] diff --git a/backend/pyproject.toml b/backend/pyproject.toml index ebaa4bc92..6968736a4 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -27,7 +27,7 @@ dependencies = [ "reportlab>=4.4.3", "requests>=2.32.4", "sendgrid>=6.12.4", - "sqlalchemy>=2.0.0", + "sqlalchemy[asyncio]>=2.0.0", "stripe>=7.0.0", "supabase>=2.18.0", "tenacity>=9.1.2", diff --git a/backend/requirements.txt b/backend/requirements.txt index 6b777d2aa..06cb8bd4a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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] diff --git a/backend/uv.lock b/backend/uv.lock index 01e932715..f63e8c8fd 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -177,7 +177,7 @@ dependencies = [ { name = "reportlab" }, { name = "requests" }, { name = "sendgrid" }, - { name = "sqlalchemy" }, + { name = "sqlalchemy", extra = ["asyncio"] }, { name = "stripe" }, { name = "supabase" }, { name = "tenacity" }, @@ -214,7 +214,7 @@ requires-dist = [ { name = "reportlab", specifier = ">=4.4.3" }, { name = "requests", specifier = ">=2.32.4" }, { name = "sendgrid", specifier = ">=6.12.4" }, - { name = "sqlalchemy", specifier = ">=2.0.0" }, + { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.0" }, { name = "stripe", specifier = ">=7.0.0" }, { name = "supabase", specifier = ">=2.18.0" }, { name = "tenacity", specifier = ">=9.1.2" }, @@ -1863,6 +1863,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/55/ba2546ab09a6adebc521bf3974440dc1d8c06ed342cceb30ed62a8858835/sqlalchemy-2.0.42-py3-none-any.whl", hash = "sha256:defcdff7e661f0043daa381832af65d616e060ddb54d3fe4476f51df7eaa1835", size = 1922072, upload-time = "2025-07-29T13:09:17.061Z" }, ] +[package.optional-dependencies] +asyncio = [ + { name = "greenlet" }, +] + [[package]] name = "starlette" version = "0.47.2" diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index a21bba404..820482b00 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -1,16 +1,46 @@ -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 Property { + 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 [selectedPeriod, setSelectedPeriod] = useState("2024-03"); + const [propertiesLoading, setPropertiesLoading] = useState(true); + const [propertiesError, setPropertiesError] = useState(""); + + useEffect(() => { + const fetchProperties = async () => { + setPropertiesLoading(true); + setPropertiesError(""); + + try { + const response = await SecureAPI.getDashboardProperties(); + setProperties(response); + setSelectedProperty((current) => { + if (response.some((property: Property) => property.id === current)) { + return current; + } + return response[0]?.id || ""; + }); + } catch (error) { + console.error(error); + setPropertiesError("Failed to load properties"); + } finally { + setPropertiesLoading(false); + } + }; + + fetchProperties(); + }, []); + + const [year, month] = selectedPeriod.split("-").map(Number); return (
@@ -26,28 +56,58 @@ const Dashboard: React.FC = () => { Monthly performance insights for your properties

- - {/* Property Selector */} -
- - + +
+
+ + setSelectedPeriod(event.target.value)} + className="block w-full 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 && !propertiesLoading && properties.length === 0 && ( +
+ No properties are available for this account. +
+ )} + + {selectedProperty && selectedPeriod && ( +
+ +
+ )} diff --git a/frontend/src/components/RevenueSummary.tsx b/frontend/src/components/RevenueSummary.tsx index dbb6d0629..4ea58107e 100644 --- a/frontend/src/components/RevenueSummary.tsx +++ b/frontend/src/components/RevenueSummary.tsx @@ -3,34 +3,40 @@ import { SecureAPI } from '../lib/secureApi'; interface RevenueData { property_id: string; - total_revenue: number; + total_revenue: string; currency: string; reservations_count: number; + year: number; + month: number; } interface RevenueSummaryProps { - propertyId?: string; - debugTenant?: string; + propertyId: string; + year: number; + month: number; showRaw?: boolean; } -export const RevenueSummary: React.FC = ({ propertyId = 'prop-001', debugTenant, showRaw }) => { +const formatMoney = (amount: string) => { + const [integerPart, fractionPart = '00'] = amount.split('.'); + const groupedInteger = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, ','); + return `${groupedInteger}.${fractionPart.padEnd(2, '0').slice(0, 2)}`; +}; + +export const RevenueSummary: React.FC = ({ propertyId, year, month, 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() - }); + const response = await SecureAPI.getDashboardSummary( + propertyId, + year, + month + ); setData(response); } catch (err) { setError('Failed to load revenue data'); @@ -41,7 +47,7 @@ export const RevenueSummary: React.FC = ({ propertyId = 'pr }; fetchRevenue(); - }, [propertyId, activeTenant]); + }, [propertyId, year, month]); if (loading) { return ( @@ -61,8 +67,6 @@ export const RevenueSummary: React.FC = ({ propertyId = 'pr if (error) return
{error}
; if (!data) return null; - const displayTotal = Math.round(data.total_revenue * 100) / 100; - return (
{showRaw && ( @@ -78,7 +82,7 @@ export const RevenueSummary: React.FC = ({ propertyId = 'pr

Total Revenue

- {data.currency} {displayTotal.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + {data.currency} {formatMoney(data.total_revenue)} {/* Fake trend indicator for premium feel */} @@ -102,17 +106,6 @@ export const RevenueSummary: React.FC = ({ propertyId = 'pr
- {/* Precision Warning Area */} -
- {Math.abs(data.total_revenue - displayTotal) > 0.000001 && showRaw && ( -
- - - - Precision Mismatch Detected -
- )} -
); diff --git a/frontend/src/lib/secureApi.ts b/frontend/src/lib/secureApi.ts index f85f04c90..e29f3188f 100644 --- a/frontend/src/lib/secureApi.ts +++ b/frontend/src/lib/secureApi.ts @@ -1449,23 +1449,20 @@ export class SecureAPIClient { } // ============= DASHBOARD API ============= - /** - * Get dashboard summary with optional simulation header - */ - 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()); - } + async getDashboardProperties() { + return this.request>( + '/api/v1/dashboard/properties' + ); + } - const requestOptions: RequestInit = {}; - if (options?.simulatedTenant) { - requestOptions.headers = { - 'X-Simulated-Tenant': options.simulatedTenant - }; - } + async getDashboardSummary(propertyId: string, year: number, month: number) { + const queryParams = new URLSearchParams({ + property_id: propertyId, + year: String(year), + month: String(month) + }); - return this.request(`/api/v1/dashboard/summary?${queryParams}`, requestOptions); + return this.request(`/api/v1/dashboard/summary?${queryParams}`); } async uploadCompanyLogo(logo_url: string) {