Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
2b8a523
fix(db): connect revenue pool using database_url
ahmaddev-codex Oct 12, 2026
2873c6a
fix(revenue): remove mock data fallback
ahmaddev-codex Oct 12, 2026
796991d
fix(cache): scope revenue cache key by tenant
ahmaddev-codex Oct 12, 2026
5566a7d
chore(deps): add sqlalchemy asyncio extra to pyproject
ahmaddev-codex Oct 12, 2026
846ce01
chore(git): add gitignore for build and cache files
ahmaddev-codex Oct 12, 2026
71e38ee
fix(revenue): round totals to cents with Decimal
ahmaddev-codex Oct 12, 2026
f9066c8
fix(dashboard): display revenue without float rounding
ahmaddev-codex Oct 12, 2026
6053d2a
chore(git): stop tracking frontend build output
ahmaddev-codex Oct 12, 2026
d75a57c
chore(frontend): replace committed .env with .env.example
ahmaddev-codex Oct 12, 2026
8b21d64
Merge branch 'fix/cache-tenant-isolation' into fix/timezone-monthly-r…
ahmaddev-codex Oct 12, 2026
7818d12
fix(revenue): use property time zone for monthly totals
ahmaddev-codex Oct 12, 2026
789ba87
feat(dashboard): add month and year filter to revenue summary
ahmaddev-codex Oct 12, 2026
58e1935
feat(dashboard): add month picker to revenue overview
ahmaddev-codex Oct 12, 2026
4653a23
fix(dashboard): clear stale error when revenue refetches
ahmaddev-codex Oct 12, 2026
1288bc1
fix(tenant): reject users without a tenant
ahmaddev-codex Oct 12, 2026
fe74418
feat(dashboard): add tenant-scoped properties endpoint
ahmaddev-codex Oct 12, 2026
4e84787
fix(dashboard): list only the signed-in tenant's properties
ahmaddev-codex Oct 12, 2026
8ee2d6b
Merge pull request #1 from ahmaddev-codex/chore/gitignore
ahmaddev-codex Oct 12, 2026
5fbed72
Merge pull request #2 from ahmaddev-codex/fix/cache-tenant-isolation
ahmaddev-codex Oct 12, 2026
75577d0
Merge pull request #3 from ahmaddev-codex/fix/revenue-db-connection
ahmaddev-codex Oct 12, 2026
4a079dd
Merge pull request #4 from ahmaddev-codex/fix/revenue-precision
ahmaddev-codex Oct 12, 2026
e89e93f
Merge remote-tracking branch 'origin/dev' into fix/timezone-monthly-r…
ahmaddev-codex Oct 12, 2026
2f417f8
Merge pull request #5 from ahmaddev-codex/fix/timezone-monthly-revenue
ahmaddev-codex Oct 12, 2026
f3b20e4
Merge pull request #6 from ahmaddev-codex/feat/dashboard-month-filter
ahmaddev-codex Oct 12, 2026
0754de4
Merge pull request #7 from ahmaddev-codex/fix/tenant-scoped-properties
ahmaddev-codex Oct 12, 2026
242d825
fix(tenant): resolve tenant from token claims
ahmaddev-codex Oct 15, 2026
eacf0ef
Merge pull request #8 from ahmaddev-codex/fix/token-tenant-resolution
ahmaddev-codex Oct 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Python
__pycache__/
*.py[cod]
.venv/
.pytest_cache/

# Node
node_modules/

# Env
.env.local
.env.*.local

# OS / editor
.DS_Store
*.log
vite.config.ts.timestamp-*.mjs

# Build output
frontend/dist/

# Local env (see frontend/.env.example)
.env
50 changes: 42 additions & 8 deletions backend/app/api/v1/dashboard.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,59 @@
from fastapi import APIRouter, Depends, HTTPException
from typing import Dict, Any
from fastapi import APIRouter, Depends, HTTPException, Query
from typing import Dict, Any, List, Optional
from app.services.cache import get_revenue_summary
from app.core.auth import authenticate_request as get_current_user

router = APIRouter()


def _require_tenant(current_user) -> str:
tenant_id = getattr(current_user, "tenant_id", None)
if not tenant_id:
raise HTTPException(status_code=403, detail="User is not assigned to a tenant")
return tenant_id


@router.get("/dashboard/properties")
async def get_dashboard_properties(current_user: dict = Depends(get_current_user)) -> List[Dict[str, str]]:
tenant_id = _require_tenant(current_user)

from app.core.database_pool import db_pool
from sqlalchemy import text

try:
await db_pool.initialize()
async with db_pool.get_session() as session:
result = await session.execute(
text("SELECT id, name FROM properties WHERE tenant_id = :tenant_id ORDER BY id"),
{"tenant_id": tenant_id},
)
return [{"id": row.id, "name": row.name} for row in result]
except Exception:
raise HTTPException(status_code=503, detail="Properties are temporarily unavailable")


@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)

total_revenue_float = float(revenue_data['total'])
tenant_id = _require_tenant(current_user)

if (month is None) != (year is None):
raise HTTPException(status_code=400, detail="month and year must be provided together")

try:
revenue_data = await get_revenue_summary(property_id, tenant_id, month, year)
except Exception:
raise HTTPException(status_code=503, detail="Revenue data is temporarily unavailable")

return {
"property_id": revenue_data['property_id'],
"total_revenue": total_revenue_float,
# Sent as a decimal string; floats lose cents.
"total_revenue": revenue_data['total'],
"currency": revenue_data['currency'],
"reservations_count": revenue_data['count']
}
13 changes: 7 additions & 6 deletions backend/app/core/database_pool.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -13,13 +12,15 @@ 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}"
# Async engine needs the asyncpg driver in the URL.
database_url = settings.database_url.replace("postgresql://", "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
Expand All @@ -45,7 +46,7 @@ async def close(self):
if self.engine:
await self.engine.dispose()

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")
Expand Down
32 changes: 29 additions & 3 deletions backend/app/core/tenant_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ def resolve_tenant_from_user(user_data: dict) -> Optional[str]:
return None

@staticmethod
async def resolve_tenant_id(user_id: str, user_email: str, token: Optional[str] = None) -> str:
async def resolve_tenant_id(user_id: str, user_email: str, token: Optional[str] = None) -> Optional[str]:
"""
Resolve tenant ID for a user.

Expand All @@ -87,9 +87,35 @@ async def resolve_tenant_id(user_id: str, user_email: str, token: Optional[str]
return "tenant-b"
if user_email == "candidate@propertyflow.com":
return "tenant-a"

# Extract tenant from token claims if provided
if token:
payload = None
try:
from jose import jwt
payload = jwt.get_unverified_claims(token)
except Exception:
try:
import base64
import json
parts = token.split(".")
if len(parts) >= 2:
payload_b64 = parts[1]
padding = len(payload_b64) % 4
if padding:
payload_b64 += "=" * (4 - padding)
payload = json.loads(base64.urlsafe_b64decode(payload_b64).decode("utf-8"))
except Exception:
payload = None

if payload and isinstance(payload, dict):
tenant_id = TenantResolver.resolve_tenant_from_token(payload)
if tenant_id:
return tenant_id

# Default fallback
return "tenant-a"
# Unknown users get no tenant rather than another company's data.
logger.warning(f"No tenant mapping for {user_email}")
return None
Comment on lines +116 to +118

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The new tenant resolver only works for test emails, causing the auth cache to be invalidated on every request for real users, forcing a re-authentication loop.
Severity: CRITICAL

Suggested Fix

Update tenant_resolver.py to correctly resolve tenant IDs for all production users, not just the hardcoded test emails. This likely involves restoring the previous logic or implementing a proper lookup based on the user's email or other attributes from the database.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: backend/app/core/tenant_resolver.py#L91-L93

Potential issue: The tenant resolution logic in `tenant_resolver.py` was changed to only
return a `tenant_id` for three hardcoded test emails, returning `None` for all other
users. In `auth.py`, the authentication caching logic explicitly checks `if not
cached_user.tenant_id` on a cache hit. If `tenant_id` is `None`, it deletes the cache
entry and forces a full re-authentication. This interaction creates a perpetual
re-authentication loop for every real production user on every API request, completely
bypassing the 30-minute auth cache and placing excessive load on the Supabase
authentication service.

Did we get this right? 👍 / 👎 to inform future reviews.


@staticmethod
async def update_user_tenant_metadata(user_id: str, tenant_id: str) -> None:
Expand Down
21 changes: 14 additions & 7 deletions backend/app/services/cache.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,34 @@
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.
"""
cache_key = f"revenue:{property_id}"
# Property IDs are only unique per tenant, so the key must include the tenant.
cache_key = f"revenue:{tenant_id}:{property_id}"
if month and year:
cache_key += f":{year}-{month:02d}"

# 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)
from app.services.reservations import calculate_monthly_revenue, calculate_total_revenue

if month and year:
result = await calculate_monthly_revenue(property_id, tenant_id, month, year)
else:
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))
Expand Down
94 changes: 47 additions & 47 deletions backend/app/services/reservations.py
Original file line number Diff line number Diff line change
@@ -1,46 +1,63 @@
from datetime import datetime
from decimal import Decimal
from decimal import Decimal, ROUND_HALF_UP
from typing import Dict, Any, List

async def calculate_monthly_revenue(property_id: str, month: int, year: int, db_session=None) -> Decimal:
async def calculate_monthly_revenue(property_id: str, tenant_id: str, month: int, year: int) -> Dict[str, Any]:
"""
Calculates revenue for a specific month.
Calculates revenue for reservations checking in during a month,
using the property's local time zone for the month boundaries.
"""

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
from app.core.database_pool import db_pool
from sqlalchemy import text

await db_pool.initialize()

# Convert check-in to property-local time before comparing with naive month bounds.
# e.g. 2024-02-29 23:30 UTC is 2024-03-01 00:30 in Europe/Paris, so it counts in March.
query = text("""
SELECT
COALESCE(SUM(r.total_amount), 0) AS total_revenue,
COUNT(r.id) 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) >= :start_date
AND (r.check_in_date AT TIME ZONE p.timezone) < :end_date
""")

async with db_pool.get_session() as session:
result = await session.execute(query, {
"property_id": property_id,
"tenant_id": tenant_id,
"start_date": start_date,
"end_date": end_date,
})
row = result.fetchone()

total = Decimal(str(row.total_revenue)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
return {
"property_id": property_id,
"tenant_id": tenant_id,
"total": str(total),
"currency": "USD",
"count": row.reservation_count,
}

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()
# Reuse the shared pool instead of creating one per request.
from app.core.database_pool import db_pool

await db_pool.initialize()

if db_pool.session_factory:
Expand All @@ -65,7 +82,8 @@ async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str,
row = result.fetchone()

if row:
total_revenue = Decimal(str(row.total_revenue))
# Amounts are stored to 3 decimals; round once, to cents, after summing.
total_revenue = Decimal(str(row.total_revenue)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
return {
"property_id": property_id,
"tenant_id": tenant_id,
Expand All @@ -86,24 +104,6 @@ async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str,
raise Exception("Database pool not available")

except Exception as e:
# Fail loudly: returning placeholder figures hides outages behind wrong numbers.
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']
}
raise
2 changes: 1 addition & 1 deletion backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
9 changes: 7 additions & 2 deletions backend/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

File renamed without changes.
Binary file removed frontend/dist/Idmobile.png
Binary file not shown.
Binary file removed frontend/dist/Vector.png
Binary file not shown.
1 change: 0 additions & 1 deletion frontend/dist/assets/charts-RKvbJDHG.js

This file was deleted.

1 change: 0 additions & 1 deletion frontend/dist/assets/data-api-Bj79fh9f.js

This file was deleted.

1 change: 0 additions & 1 deletion frontend/dist/assets/data-query-DIoVICGX.js

This file was deleted.

1 change: 0 additions & 1 deletion frontend/dist/assets/editor-complete-b_xua3o6.js

This file was deleted.

1 change: 0 additions & 1 deletion frontend/dist/assets/index-CLh9TTHC.css

This file was deleted.

2 changes: 0 additions & 2 deletions frontend/dist/assets/index-qvxZ5pjb.js

This file was deleted.

Binary file removed frontend/dist/assets/logo-0e8ybaOJ.png
Binary file not shown.
1 change: 0 additions & 1 deletion frontend/dist/assets/quickSessionCheck-CDz9td7h.js

This file was deleted.

Loading