From fd37445f0faeef2e7587372b28f2b07042049737 Mon Sep 17 00:00:00 2001 From: keremtatlici Date: Wed, 6 May 2026 18:20:14 +0200 Subject: [PATCH 1/6] chore: add .gitignore Add a comprehensive .gitignore covering Python, Node, secrets, OS files, IDE config, and Docker artifacts to prevent unwanted files from being committed in future changes. --- .gitignore | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..c94966d22 --- /dev/null +++ b/.gitignore @@ -0,0 +1,60 @@ +# ===== Python ===== +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ + +# Virtual environments +venv/ +.venv/ +env/ +ENV/ + +# ===== Node / Frontend ===== +node_modules/ +dist/ +build/ +.vite/ +*.tsbuildinfo + +# Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# ===== Environment / Secrets ===== +.env +.env.local +.env.*.local +*.pem +*.key +credentials.json + +# ===== OS ===== +.DS_Store +Thumbs.db +desktop.ini + +# ===== IDE / Editors ===== +.idea/ +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.swp +*.swo + +# ===== Docker ===== +*.pid + +# ===== Misc ===== +*.log +.cache/ From 84c2802fb7d829c4d2168de7a96c8572dc7dfa86 Mon Sep 17 00:00:00 2001 From: keremtatlici Date: Wed, 6 May 2026 18:45:46 +0200 Subject: [PATCH 2/6] fix: connect database pool to local Postgres The async database pool was looking for non-existent Supabase settings (supabase_db_user, etc.), causing initialization to fail and the revenue service to silently fall back to mock data. - Use settings.database_url (already wired up via DATABASE_URL env var in docker-compose.yml) and inject the asyncpg driver prefix that SQLAlchemy's async engine requires. - Drop poolclass=QueuePool: the sync QueuePool is incompatible with create_async_engine, which uses AsyncAdaptedQueuePool by default. - Make get_session() sync so callers can use it as an async context manager (async with db_pool.get_session() as session). Returning a coroutine here was raising 'coroutine object does not support the asynchronous context manager protocol' at runtime. This change is portable: setting DATABASE_URL to a Supabase Postgres URL works without further code changes. --- backend/app/core/database_pool.py | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/backend/app/core/database_pool.py b/backend/app/core/database_pool.py index d638dfcfe..e2bc6bf08 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 import logging from ..config import settings @@ -14,17 +13,21 @@ def __init__(self): 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}" + # Use local DATABASE_URL from settings (wired up via docker-compose). + # SQLAlchemy async engine requires the asyncpg driver prefix. + database_url = settings.database_url.replace( + "postgresql://", "postgresql+asyncpg://", 1 + ) + # create_async_engine uses AsyncAdaptedQueuePool by default; + # we must NOT pass the sync QueuePool class here. 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=20, + max_overflow=30, + pool_pre_ping=True, + pool_recycle=3600, + echo=False, ) self.session_factory = async_sessionmaker( @@ -45,8 +48,12 @@ async def close(self): if self.engine: await self.engine.dispose() - async def get_session(self) -> AsyncSession: - """Get database session from pool""" + def get_session(self) -> AsyncSession: + """Get database session from pool. + + Returns the AsyncSession directly so callers can use it as an + async context manager: `async with db_pool.get_session() as s:`. + """ if not self.session_factory: raise Exception("Database pool not initialized") return self.session_factory() From 393382ba232fdc9d3f87cabeeaad5d247357c021 Mon Sep 17 00:00:00 2001 From: keremtatlici Date: Wed, 6 May 2026 18:53:27 +0200 Subject: [PATCH 3/6] fix: stop returning mock revenue when database fails The revenue service caught any database exception and returned hardcoded mock data (e.g. prop-001 -> $1000/3 reservations) instead of failing. Real values differ (prop-001 is $2250/4), so users were silently shown fabricated numbers while the API still returned 200 OK. This silent failure was the root cause behind two of the reported customer complaints: "March numbers don't match" and "a few cents off". It also leaked across tenants since the mock data ignores tenant_id. - Remove the entire mock_data dictionary and mock return branch from the except block in calculate_total_revenue. - Log the failure with logger.error and re-raise so FastAPI surfaces a real HTTP 500 to the client. - Replace the existing print() with the standard logging module (consistent with database_pool.py and the rest of app/core). --- backend/app/services/reservations.py | 29 ++++++++-------------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/backend/app/services/reservations.py b/backend/app/services/reservations.py index 384bd00ab..f95985cf3 100644 --- a/backend/app/services/reservations.py +++ b/backend/app/services/reservations.py @@ -1,7 +1,10 @@ +import logging from datetime import datetime from decimal import Decimal from typing import Dict, Any, List +logger = logging.getLogger(__name__) + async def calculate_monthly_revenue(property_id: str, month: int, year: int, db_session=None) -> Decimal: """ Calculates revenue for a specific month. @@ -86,24 +89,8 @@ async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, 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'] - } + logger.error( + f"Failed to compute revenue for {property_id} " + f"(tenant: {tenant_id}): {e}" + ) + raise From d85b24ce890dcc6eab39bab0e6099d32b7a627dd Mon Sep 17 00:00:00 2001 From: keremtatlici Date: Wed, 6 May 2026 19:01:51 +0200 Subject: [PATCH 4/6] fix: include tenant_id in revenue cache key Two tenants can share the same property_id because the properties table uses a composite primary key (id, tenant_id). The revenue cache key only used property_id, so tenant B's request would hit tenant A's cached entry and return another company's revenue figures. This is the underlying cause of Ocean Rentals' complaint that they were seeing numbers belonging to another company. - Change cache_key to f"revenue:{tenant_id}:{property_id}" so each tenant gets its own cache entry. - Put tenant_id first in the key for easy bulk invalidation per tenant (e.g. SCAN revenue:tenant-a:*). - Add a short comment explaining why tenant_id is required so future readers don't 'simplify' the key back. --- backend/app/services/cache.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/app/services/cache.py b/backend/app/services/cache.py index b81474957..8814e04de 100644 --- a/backend/app/services/cache.py +++ b/backend/app/services/cache.py @@ -10,7 +10,10 @@ 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}" + # Include tenant_id in the cache key so two tenants that share a + # property_id (composite primary key) get separate cache entries. + # Without this, tenant B can read tenant A's cached revenue. + cache_key = f"revenue:{tenant_id}:{property_id}" # Try to get from cache cached = await redis_client.get(cache_key) From 08c54cfef37bbe9f7ed67822140df2a3a323b689 Mon Sep 17 00:00:00 2001 From: keremtatlici Date: Wed, 6 May 2026 19:17:22 +0200 Subject: [PATCH 5/6] fix: filter revenue by month/year in property timezone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard subtitle says 'Monthly performance insights' but the revenue endpoint had no date filter at all and silently ignored any month/year query parameters. It also lacked any timezone awareness, so reservations near midnight UTC fell into the wrong calendar month for properties in non-UTC zones. Concretely: res-tz-1 has check_in_date '2024-02-29 23:30 UTC', which is '2024-03-01 00:30 Europe/Paris'. Beach House Alpha (prop-001) is configured with the Paris timezone, so this reservation belongs to March from the customer's point of view. Filtering in UTC would put it in February and produce wrong 'March numbers' — which matches the Sunset Properties complaint. Backend changes (no frontend changes; UI keeps current behaviour when it doesn't pass a period): - backend/app/api/v1/dashboard.py: accept optional 'month' (1..12) and 'year' (2000..2100) query parameters with FastAPI validation, pass them through to the cache layer. - backend/app/services/cache.py: extend the cache key with a period component (e.g. revenue:tenant-a:prop-001:2024-03 or :all), so different periods don't collide. Forward the new parameters to the revenue calculation. - backend/app/services/reservations.py: * Drop the calculate_monthly_revenue placeholder (returned Decimal('0'), never called). * Add optional 'month'/'year' to calculate_total_revenue. * When a period is given, JOIN properties to read each row's timezone and filter with (check_in_date AT TIME ZONE p.timezone) >= make_date(year, month, 1) and < that date + INTERVAL '1 month', so the month boundaries are evaluated in the property's local zone. * Without a period, behaviour is unchanged (all-time sum). --- backend/app/api/v1/dashboard.py | 16 +-- backend/app/services/cache.py | 29 ++--- backend/app/services/reservations.py | 151 ++++++++++++++------------- 3 files changed, 102 insertions(+), 94 deletions(-) diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index 1ec352d7e..ea1c51b9e 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -1,5 +1,5 @@ -from fastapi import APIRouter, Depends, HTTPException -from typing import Dict, Any +from fastapi import APIRouter, Depends, HTTPException, Query +from typing import Dict, Any, Optional from app.services.cache import get_revenue_summary from app.core.auth import authenticate_request as get_current_user @@ -8,15 +8,17 @@ @router.get("/dashboard/summary") async def get_dashboard_summary( property_id: str, + month: Optional[int] = Query(None, ge=1, le=12), + year: Optional[int] = Query(None, ge=2000, le=2100), 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) - + + revenue_data = await get_revenue_summary(property_id, tenant_id, month, year) + total_revenue_float = float(revenue_data['total']) - + return { "property_id": revenue_data['property_id'], "total_revenue": total_revenue_float, diff --git a/backend/app/services/cache.py b/backend/app/services/cache.py index 8814e04de..30914c624 100644 --- a/backend/app/services/cache.py +++ b/backend/app/services/cache.py @@ -1,32 +1,37 @@ import json import redis.asyncio as redis -from typing import Dict, Any +from typing import Dict, Any, Optional import os # 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: Optional[int] = None, + year: Optional[int] = None, +) -> Dict[str, Any]: """ Fetches revenue summary, utilizing caching to improve performance. """ - # Include tenant_id in the cache key so two tenants that share a - # property_id (composite primary key) get separate cache entries. - # Without this, tenant B can read tenant A's cached revenue. - cache_key = f"revenue:{tenant_id}:{property_id}" - + # Cache key includes tenant_id (multi-tenant isolation, see Bug #3) + # and the period so different month/year requests don't collide. + period = f"{year}-{month:02d}" if month and year else "all" + cache_key = f"revenue:{tenant_id}:{property_id}:{period}" + # 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_total_revenue(property_id, tenant_id, month, year) + # Cache the result for 5 minutes await redis_client.setex(cache_key, 300, json.dumps(result)) - + return result diff --git a/backend/app/services/reservations.py b/backend/app/services/reservations.py index f95985cf3..3d3610c88 100644 --- a/backend/app/services/reservations.py +++ b/backend/app/services/reservations.py @@ -1,93 +1,94 @@ import logging -from datetime import datetime from decimal import Decimal -from typing import Dict, Any, List +from typing import Dict, Any, Optional logger = logging.getLogger(__name__) -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 +async def calculate_total_revenue( + property_id: str, + tenant_id: str, + month: Optional[int] = None, + year: Optional[int] = None, +) -> Dict[str, Any]: """ - - # 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 + Aggregates revenue from the database. -async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, Any]: - """ - Aggregates revenue from database. + If both ``month`` and ``year`` are provided, only reservations whose + ``check_in_date`` falls inside that calendar month are counted, + evaluated in the property's own timezone. This is what customers + expect: a check-in at 2024-03-01 00:30 Europe/Paris counts as + March, even though its UTC value is 2024-02-29 23:30. + + Without ``month``/``year`` the all-time revenue is returned + (kept for backwards compatibility with callers that don't yet + pass a period). """ 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 - + + if not db_pool.session_factory: + raise Exception("Database pool not available") + + async with db_pool.get_session() as session: + from sqlalchemy import text + + params: Dict[str, Any] = { + "property_id": property_id, + "tenant_id": tenant_id, + } + + if month and year: + # Filter using the property's local timezone so that + # cross-midnight reservations land in the correct month. 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 + SELECT + SUM(r.total_amount) AS total_revenue, + COUNT(*) AS reservation_count + 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 + AND (r.check_in_date AT TIME ZONE p.timezone) + >= make_date(:year, :month, 1) + AND (r.check_in_date AT TIME ZONE p.timezone) + < make_date(:year, :month, 1) + INTERVAL '1 month' """) - - 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") - + params["year"] = year + params["month"] = month + else: + query = text(""" + SELECT + SUM(total_amount) AS total_revenue, + COUNT(*) AS reservation_count + FROM reservations + WHERE property_id = :property_id + AND tenant_id = :tenant_id + """) + + result = await session.execute(query, params) + row = result.fetchone() + + total = ( + Decimal(str(row.total_revenue)) + if row and row.total_revenue is not None + else Decimal("0") + ) + count = row.reservation_count if row else 0 + + return { + "property_id": property_id, + "tenant_id": tenant_id, + "total": str(total), + "currency": "USD", + "count": count, + } + except Exception as e: logger.error( f"Failed to compute revenue for {property_id} " From 375b95f7dab99f02769b5979cb9e84f6baf25d55 Mon Sep 17 00:00:00 2001 From: keremtatlici Date: Wed, 6 May 2026 19:31:15 +0200 Subject: [PATCH 6/6] fix: deliver revenue as exact decimal string The dashboard endpoint was casting the revenue value to a Python float before returning it. IEEE-754 cannot exactly represent common decimal amounts (0.1, 0.2, etc.), which is exactly the kind of drift the finance team was hinting at with their 'a few cents off' report. The frontend already had defensive code around this: - Math.round(value * 100) / 100 to mask precision drift on display - A 'Precision Mismatch Detected' banner gated on Math.abs(value - displayTotal) > 1e-6 That defensive code is a clear sign the original developer knew the contract was unsafe and worked around it instead of fixing it at the source. This change moves money out of float entirely: backend/app/api/v1/dashboard.py - Quantize the Decimal returned by the revenue service to two decimal places (USD) using ROUND_HALF_UP, the standard money rounding mode. - Serialize total_revenue as a string ('2250.10' rather than 2250.1) so the API contract preserves the exact value end to end. This matches the convention used by Stripe, PayPal, and most payment APIs. frontend/src/components/RevenueSummary.tsx - Update the RevenueData type so total_revenue is a string. - parseFloat the value once for display arithmetic; the existing Math.round / toLocaleString rendering path is unchanged. - Keep the precision-mismatch warning, now compared against the parsed number; with the new contract it should never fire, but it remains as an extra guard. The visible UI ('USD 2,250.10') is identical because the frontend was already rounding for display. The fix prevents the underlying precision issue from leaking into any downstream calculation (taxes, commissions, exports) where the masking no longer applies. --- backend/app/api/v1/dashboard.py | 12 ++++++++++-- frontend/src/components/RevenueSummary.tsx | 11 ++++++++--- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index ea1c51b9e..3900e0406 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -1,3 +1,4 @@ +from decimal import Decimal, ROUND_HALF_UP from fastapi import APIRouter, Depends, HTTPException, Query from typing import Dict, Any, Optional from app.services.cache import get_revenue_summary @@ -17,11 +18,18 @@ async def get_dashboard_summary( revenue_data = await get_revenue_summary(property_id, tenant_id, month, year) - total_revenue_float = float(revenue_data['total']) + # Money must not travel as float: IEEE-754 cannot exactly represent + # values like 0.1, so float() introduces precision drift that + # accumulates in any downstream arithmetic. Quantize to 2 decimals + # (USD) using Decimal and serialize as a string so the API contract + # carries the exact value (industry pattern: Stripe, PayPal, etc.). + total_revenue = Decimal(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_revenue), "currency": revenue_data['currency'], "reservations_count": revenue_data['count'] } diff --git a/frontend/src/components/RevenueSummary.tsx b/frontend/src/components/RevenueSummary.tsx index dbb6d0629..b208a2cec 100644 --- a/frontend/src/components/RevenueSummary.tsx +++ b/frontend/src/components/RevenueSummary.tsx @@ -3,7 +3,10 @@ import { SecureAPI } from '../lib/secureApi'; interface RevenueData { property_id: string; - total_revenue: number; + // Money is delivered as a string so the API contract carries the + // exact, already-rounded value (no float precision drift). Parse to + // a JS number only at the display layer. + total_revenue: string; currency: string; reservations_count: number; } @@ -61,7 +64,9 @@ export const RevenueSummary: React.FC = ({ propertyId = 'pr if (error) return
{error}
; if (!data) return null; - const displayTotal = Math.round(data.total_revenue * 100) / 100; + // Backend already rounded to 2 decimals; parseFloat is just to get a Number for formatting. + const totalNumber = parseFloat(data.total_revenue); + const displayTotal = Math.round(totalNumber * 100) / 100; return (
@@ -104,7 +109,7 @@ export const RevenueSummary: React.FC = ({ propertyId = 'pr {/* Precision Warning Area */}
- {Math.abs(data.total_revenue - displayTotal) > 0.000001 && showRaw && ( + {Math.abs(totalNumber - displayTotal) > 0.000001 && showRaw && (