Repository navigation
Fix revenue dashboard: tenant isolation, timezone months, cent rounding #241
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
izemhsn
wants to merge
1
commit into
Base360-AI:main
Choose a base branch
from
izemhsn:fix/revenue-dashboard-bugs
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,25 +1,55 @@ | ||
| from fastapi import APIRouter, Depends, HTTPException | ||
| from typing import Dict, Any | ||
| from decimal import Decimal, ROUND_HALF_UP | ||
| from typing import Dict, Any, List | ||
| from app.services.cache import get_revenue_summary | ||
| from app.services.reservations import get_tenant_properties | ||
| from app.core.auth import authenticate_request as get_current_user | ||
|
|
||
| router = APIRouter() | ||
|
|
||
|
|
||
| def _require_tenant(current_user) -> str: | ||
| """Every request must belong to a tenant. Never fall back to a shared default tenant.""" | ||
| tenant_id = getattr(current_user, "tenant_id", None) | ||
| if not tenant_id: | ||
| raise HTTPException(status_code=403, detail="No tenant assigned to this user") | ||
| return tenant_id | ||
|
|
||
|
|
||
| @router.get("/dashboard/properties") | ||
| async def get_dashboard_properties( | ||
| current_user: dict = Depends(get_current_user) | ||
| ) -> List[Dict[str, Any]]: | ||
| """Lists only the properties of the logged-in user's tenant.""" | ||
| tenant_id = _require_tenant(current_user) | ||
| try: | ||
| return await get_tenant_properties(tenant_id) | ||
| except Exception as e: | ||
| print(f"Database error listing properties (tenant: {tenant_id}): {e}") | ||
| raise HTTPException(status_code=503, detail="Property data is temporarily unavailable") | ||
|
|
||
|
|
||
| @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']) | ||
|
|
||
|
|
||
| tenant_id = _require_tenant(current_user) | ||
|
|
||
| try: | ||
| revenue_data = await get_revenue_summary(property_id, tenant_id) | ||
| except Exception as e: | ||
| print(f"Database error for {property_id} (tenant: {tenant_id}): {e}") | ||
| raise HTTPException(status_code=503, detail="Revenue data is temporarily unavailable") | ||
|
|
||
| # Round once, on the final total, to whole cents (amounts are stored with 3 decimals). | ||
| # Decimal keeps the math exact; only the rounded value is turned into a JSON number. | ||
| 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": float(total_revenue), | ||
| "currency": revenue_data['currency'], | ||
| "reservations_count": revenue_data['count'] | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,109 +1,156 @@ | ||
| from datetime import datetime | ||
| from datetime import datetime, timezone as dt_timezone | ||
| from decimal import Decimal | ||
| from typing import Dict, Any, List | ||
| from typing import Dict, Any, List, Optional, Tuple | ||
| from zoneinfo import ZoneInfo | ||
|
|
||
| async def calculate_monthly_revenue(property_id: str, month: int, year: int, db_session=None) -> Decimal: | ||
|
|
||
| def get_month_bounds_utc(year: int, month: int, timezone: str = "UTC") -> Tuple[datetime, datetime]: | ||
| """ | ||
| Calculates revenue for a specific month. | ||
| Returns [start, end) of a month in the property's local time, converted to UTC. | ||
| Example: March in Europe/Paris starts at 2024-02-29 23:00 UTC, not 2024-03-01 00:00 UTC. | ||
| """ | ||
| local_tz = ZoneInfo(timezone) | ||
|
|
||
| start_date = datetime(year, month, 1) | ||
| # Midnight on the 1st of this month and of next month, in the property's time zone | ||
| start_local = datetime(year, month, 1, tzinfo=local_tz) | ||
| if month < 12: | ||
| end_date = datetime(year, month + 1, 1) | ||
| end_local = datetime(year, month + 1, 1, tzinfo=local_tz) | ||
| else: | ||
| end_date = datetime(year + 1, 1, 1) | ||
|
|
||
| print(f"DEBUG: Querying revenue for {property_id} from {start_date} to {end_date}") | ||
| end_local = datetime(year + 1, 1, 1, tzinfo=local_tz) | ||
|
|
||
| # 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 | ||
| # check_in_date is stored in UTC, so compare against UTC bounds | ||
| return start_local.astimezone(dt_timezone.utc), end_local.astimezone(dt_timezone.utc) | ||
|
|
||
|
|
||
| async def calculate_monthly_revenue( | ||
| property_id: str, | ||
| tenant_id: str, | ||
| month: int, | ||
| year: int, | ||
| db_session=None, | ||
| timezone: Optional[str] = None, | ||
| ) -> Decimal: | ||
| """ | ||
|
|
||
| # 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 | ||
| Calculates revenue for one property, for one tenant, for one month. | ||
| - db_session: a SQLAlchemy AsyncSession. If None, one is opened from DatabasePool. | ||
| - timezone: the property's time zone. If None, it is read from the properties table. | ||
| """ | ||
| if db_session is not None: | ||
| return await _fetch_monthly_revenue(db_session, property_id, tenant_id, month, year, timezone) | ||
|
|
||
| # No session given: open one from the shared pool | ||
| from app.core.database_pool import db_pool | ||
|
|
||
| await db_pool.initialize() | ||
| if not db_pool.session_factory: | ||
| raise Exception("Database pool not available") | ||
|
|
||
| async with db_pool.get_session() as session: | ||
| return await _fetch_monthly_revenue(session, property_id, tenant_id, month, year, timezone) | ||
|
|
||
|
|
||
| async def _fetch_monthly_revenue(session, property_id: str, tenant_id: str, month: int, year: int, timezone: Optional[str]) -> Decimal: | ||
| """Runs the monthly revenue query on an open session.""" | ||
| from sqlalchemy import text | ||
|
|
||
| # Look up the property's time zone if the caller didn't pass it | ||
| if timezone is None: | ||
| tz_result = await session.execute( | ||
| text("SELECT timezone FROM properties WHERE id = :property_id AND tenant_id = :tenant_id"), | ||
| {"property_id": property_id, "tenant_id": tenant_id}, | ||
| ) | ||
| timezone = tz_result.scalar() | ||
| if timezone is None: | ||
| # This tenant has no such property, so it has no revenue for it | ||
| return Decimal("0") | ||
|
|
||
| start_date, end_date = get_month_bounds_utc(year, month, timezone) | ||
|
|
||
| print(f"DEBUG: Querying revenue for {property_id} (tenant: {tenant_id}) from {start_date} to {end_date}") | ||
|
|
||
| # Filter by tenant too: the same property_id can exist for several tenants | ||
| query = text(""" | ||
| SELECT SUM(total_amount) AS total | ||
| FROM reservations | ||
| WHERE property_id = :property_id | ||
| AND tenant_id = :tenant_id | ||
| AND check_in_date >= :start_date | ||
| AND check_in_date < :end_date | ||
| """) | ||
|
|
||
| result = await session.execute(query, { | ||
| "property_id": property_id, | ||
| "tenant_id": tenant_id, | ||
| "start_date": start_date, | ||
| "end_date": end_date, | ||
| }) | ||
|
|
||
| # SUM() returns NULL when there are no reservations, so fall back to 0 | ||
| total = result.scalar() | ||
| return total if total is not None else Decimal("0") | ||
|
|
||
| async def get_tenant_properties(tenant_id: str) -> List[Dict[str, Any]]: | ||
| """Returns only this tenant's properties (id, name, timezone).""" | ||
| from app.core.database_pool import db_pool | ||
| from sqlalchemy import text | ||
|
|
||
| await db_pool.initialize() | ||
| if not db_pool.session_factory: | ||
| raise Exception("Database pool not available") | ||
|
|
||
| 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 id"), | ||
| {"tenant_id": tenant_id}, | ||
| ) | ||
| return [{"id": row.id, "name": row.name, "timezone": row.timezone} for row in result] | ||
|
|
||
|
|
||
| async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, Any]: | ||
| """ | ||
| Aggregates revenue from database. | ||
| Errors are raised, never replaced by fake numbers: showing wrong revenue is worse than showing an error. | ||
| """ | ||
| 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}) | ||
|
|
||
| from app.core.database_pool import db_pool | ||
| from sqlalchemy import text | ||
|
|
||
| # Reuse the one shared pool (creating a new pool per request leaks connections) | ||
| await db_pool.initialize() | ||
| if not db_pool.session_factory: | ||
| raise Exception("Database pool not available") | ||
|
|
||
| async with db_pool.get_session() as session: | ||
| 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 | ||
| } | ||
|
|
||
| # No reservations found for this property (for this tenant) | ||
| return { | ||
| "property_id": property_id, | ||
| "tenant_id": tenant_id, | ||
| "total": mock_property_data['total'], | ||
| "tenant_id": tenant_id, | ||
| "total": "0.00", | ||
| "currency": "USD", | ||
| "count": mock_property_data['count'] | ||
| "count": 0 | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug:
calculate_monthly_revenueis implemented but has no callers, leaving/dashboard/summaryreturning all-time revenue totals instead of monthly figures.Severity: MEDIUM
Suggested Fix
Add year and month parameters to
/dashboard/summary, update the caching layer to include month scope, and update the endpoint to callcalculate_monthly_revenueinstead ofcalculate_total_revenue.Prompt for AI Agent
Did we get this right? 👍 / 👎 to inform future reviews.