diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..8d2f30037e --- /dev/null +++ b/.gitignore @@ -0,0 +1,113 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual Environment +.env +.venv +env/ +venv/ +ENV/ + +# Coverage +.coverage +.coverage.* +.cache +coverage.xml +*.cover +htmlcov/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +.DS_Store + +# Logs +*.log +logs/ +log/ + +# Database +*.db +*.sqlite3 + +# Local development +.env.local +.env.*.local +*.env + +# Docker +.docker/ + +# Test +.pytest_cache/ +.hypothesis/ +# --- FEATURE[apple_pay] START --- +.apple_pay_test_credentials/ +# --- FEATURE[apple_pay] END --- + +# Alembic +versions/__pycache__/ +versions/*.pyc + +# Distribution +dist/ +build/ + +# Poetry +# poetry.lock + +# vs code workspace +*.code-workspace + +# Additional common patterns +__pypackages__/ +# --- FEATURE[celery] START --- +celerybeat-schedule +celerybeat.pid +# --- FEATURE[celery] END --- +*.sage.py +.ipynb_checkpoints +profile_default/ +ipython_config.py +.pdm.toml +.mypy_cache/ +.ruff_cache/ +.dmypy.json +dmypy.json + +# OS specific +Thumbs.db +ehthumbs.db +Desktop.ini +$RECYCLE.BIN/ +*.lnk + +# Backend specific +media/ +static/uploads/ +*.pem +node_modules/ +.sass-cache/ +tmp/ +temp/ diff --git a/FINDINGS.md b/FINDINGS.md new file mode 100644 index 0000000000..22ee6dafa5 --- /dev/null +++ b/FINDINGS.md @@ -0,0 +1,235 @@ +# Findings: Property Revenue Dashboard + +This document lists the bugs behind the three reported problems, how each one was found, the root cause, the fix, and the test that now covers it. The last part lists recommended next steps for the codebase. + +Line references marked **before** point to the original code at commit `f8badb754`. You can open them with `git show f8badb754:`. References marked **after** point to the current code. + +## Summary + +Nine bugs were found and fixed, one commit each. A new backend test suite has 58 tests, all passing. + +| Reported problem | Caused by | +|---|---| +| Client A (Sunset Properties): "March totals don't match our records" | Bug 1 (mock data instead of the database), bug 4 (month boundaries in UTC instead of the property's timezone), bug 9 (no way to choose a month in the dashboard) | +| Client B (Ocean Rentals): "sometimes we see revenue that belongs to another company" | Bug 2 (cache key without tenant), bug 3 (unknown users assigned to Sunset's tenant), bug 6 (every tenant's property names shown to everyone) | +| Finance: "totals slightly off by a few cents" | Bug 5 (sub-cent amounts rounded through floats), bug 8 (amounts in different currencies would be added together) | +| Not reported, found during review | Bug 7 (fake "+12%" growth badge shown next to real revenue) | + +## Bugs + +### 1. The dashboard never read the database and showed hardcoded mock numbers + +- **Symptom:** every client saw the same figures, for example prop-001 = 1000.00 from 3 bookings, for both tenants. The real value for Sunset is 2250.00 from 4 bookings. +- **How found:** called `/api/v1/dashboard/summary` as both clients. The backend log showed `Database pool not available` on every request. +- **Root cause (before):** + - `backend/app/core/database_pool.py:18` built the URL from `settings.supabase_db_*` fields. Those fields do not exist in `config.py`, so initialization failed and the error was swallowed. + - `database_pool.py:22` used `QueuePool`, which cannot be used with an async engine. + - `database_pool.py:48` defined `get_session` as `async def`, but callers used it with `async with`, which fails. + - `backend/app/services/reservations.py:43` created a new pool on every request, and never disposed it. + - `reservations.py:93` caught every error and returned a hardcoded `mock_data` dictionary keyed only by property ID. + - `requirements.txt` did not install `greenlet`, which the SQLAlchemy asyncio extension needs. +- **Fix (after):** + - `database_pool.py:9` builds the URL from `settings.database_url`, using the asyncpg driver. + - `database_pool.py:23` creates one shared pool, once per process, protected by a lock. + - `database_pool.py:63` makes `get_session` a plain method that returns a session usable with `async with`. + - The mock fallback is removed. A database error is logged and the endpoint returns 503. + - `requirements.txt` now has `sqlalchemy[asyncio]`. +- **Commits:** `a261ade22`, dependency alignment in `pyproject.toml` and `uv.lock` in `991462286`. +- **Tests:** `test_revenue_db_integration.py::test_all_time_total_uses_real_data_not_mock`, `test_dashboard_api.py::test_summary_returns_503_when_data_unavailable`. + +### 2. The revenue cache mixed up tenants + +- **Symptom:** after Sunset loaded prop-001, Ocean saw Sunset's 2250.00 for its own prop-001 for 5 minutes. Which tenant saw wrong data depended on who loaded first, which explains "sometimes, when we refresh". +- **How found:** after bug 1 was fixed, the same request as Ocean returned Sunset's total. The Redis keys showed only `revenue:prop-001`. +- **Root cause (before):** `backend/app/services/cache.py:13` used the key `revenue:{property_id}`. Property IDs are unique only within a tenant, and `prop-001` exists in both tenants (`database/seed.sql`). +- **Fix (after):** `cache.py:24` uses `revenue:{tenant_id}:{property_id}:{period}`. `cache.py:19` refuses a missing tenant, so a key without a tenant cannot be built. +- **Commit:** `03f3c9a5e`. +- **Tests:** `test_revenue_cache.py`, 6 tests. When the old key was put back temporarily, 3 of them failed. + +### 3. Users without a tenant were given Sunset's tenant + +- **Symptom:** a valid login for any email other than the two demo clients saw Sunset Properties' revenue. +- **How found:** while testing a "no tenant" case, a token with an unknown email returned HTTP 200 with Sunset's numbers. The auth log showed `tenant=tenant-a`. +- **Root cause (before):** + - `backend/app/core/tenant_resolver.py:92` ended with `return "tenant-a"` after checking three hardcoded emails. It also ignored the `tenant_id` claim already present in the signed token. + - `backend/app/api/v1/dashboard.py:14` fell back to a shared `"default_tenant"` value when the tenant was missing. +- **Fix (after):** + - `tenant_resolver.py:89` reads the tenant claim from the token first. + - `tenant_resolver.py:106` returns `None` when no tenant is found. + - `dashboard.py:12-18` (`_require_tenant`) returns 403 when there is no tenant. +- **Commit:** `129f08ee5`. +- **Tests:** `test_tenant_resolver.py`, 9 tests. `test_dashboard_api.py::test_summary_without_tenant_is_forbidden`. + +### 4. Month boundaries used UTC instead of the property's timezone + +- **Symptom:** Sunset's March total for Beach House Alpha did not match their records. +- **How found:** seed reservation `res-tz-1` checks in at `2024-02-29 23:30 UTC`. For a property in `Europe/Paris` that is `2024-03-01 00:30`, which is March. A UTC month boundary gives March = 1000.000 from 3 bookings. The correct local-time answer is 2250.00 from 4 bookings. +- **Root cause (before):** `backend/app/services/reservations.py:5` (`calculate_monthly_revenue`) built naive UTC dates, had no tenant parameter, and was a stub that always returned `Decimal('0')` (line 32). Nothing called it. +- **Fix (after):** + - `reservations.py:37` implements the monthly query. + - `reservations.py:71` compares `check_in_date AT TIME ZONE p.timezone` against the local month start and the next month start, joining `properties` on `(id, tenant_id)`. + - The summary endpoint accepts optional `month` and `year` (both or neither, otherwise 422). + - The cache key includes the period. +- **Commit:** `f3c82697c`. +- **Tests:** `test_revenue_db_integration.py::test_booking_late_on_feb_29_utc_counts_as_march_in_paris`, plus the March 2024 table for all six tenant and property pairs. + +### 5. Cent rounding went through floats + +- **Symptom:** totals could be off by a cent. +- **Root cause (before):** + - `total_amount` is `NUMERIC(10,3)`, so it holds sub-cent values like `333.333`. + - The service returned the raw sum. `backend/app/api/v1/dashboard.py:18` converted it with `float()`. + - The frontend rounded the float with `Math.round(x * 100) / 100` (`RevenueSummary.tsx:64`). + - Floats cannot represent most decimal amounts exactly, for example `round(2.675, 2)` gives `2.67`. +- **Fix (after):** + - `reservations.py:11` (`_to_cents`) rounds the exact database sum once, with `Decimal` and `ROUND_HALF_UP`. + - `dashboard.py:66` also returns `total_revenue_str`, the exact amount as a string. The numeric field stays for the existing frontend. +- **Commit:** `4743cb491`. +- **Tests:** `test_money_rounding.py`, 11 tests, including `2.675` rounding to `2.68`. + +### 6. Every tenant saw every tenant's property names + +- **Symptom:** Sunset's dropdown listed Ocean's properties, and the other way round. It also labelled Ocean's `prop-001` as "Beach House Alpha", although for Ocean it is "Mountain Lodge Beta". +- **Root cause (before):** + - `frontend/src/components/Dashboard.tsx:4-10` hardcoded all five properties. + - `RevenueSummary.tsx:22,31` sent a leftover `X-Simulated-Tenant: candidate` header. The backend ignored it, but it suggested the client could choose its own tenant. +- **Fix (after):** + - New endpoint `GET /api/v1/dashboard/properties` (`dashboard.py:22`), backed by `list_tenant_properties` (`reservations.py:159`). + - The dashboard loads the list on start. + - The simulated-tenant header is removed. +- **Commit:** `1191677f4`. +- **Tests:** `test_dashboard_api.py::test_properties_lists_only_callers_tenant`, `test_revenue_db_integration.py::test_property_list_is_scoped_to_tenant`. + +### 7. A fake "+12%" growth badge was shown next to real revenue + +- **Root cause (before):** `RevenueSummary.tsx:83-88` rendered a hardcoded green "+12%" badge. The code comment said `Fake trend indicator for premium feel`. Clients preparing board reports saw a growth figure that no data supports. +- **Fix:** removed. +- **Commit:** `801c5eb61`. + +### 8. The currency was hardcoded and mixed currencies would be added together + +- **Root cause (before):** the `reservations.currency` column exists, and the brief mentions "various amounts and currencies". Both revenue paths still returned `"currency": "USD"`, and `SUM(total_amount)` would add EUR and USD amounts as if they were the same money. All seed data is USD, so this was not visible yet. +- **Fix (after):** + - `reservations.py:25-35` returns the stored currency. It raises `MixedCurrencyError` when one total would cover more than one currency. + - `dashboard.py:52` returns 409 in that case. +- **Commit:** `99c90111f`. +- **Tests:** `test_revenue_db_integration.py::test_mixed_currencies_are_not_summed` (adds a temporary EUR booking and removes it), `test_dashboard_api.py::test_summary_returns_409_for_mixed_currencies`. + +### 9. The dashboard could not show a single month + +- **Symptom:** the backend could compute March (bug 4), but the UI only showed all-time totals, so Sunset could not compare its March figure in the dashboard. +- **Fix:** + - `Dashboard.tsx` has a month picker labelled "Month (property local time)", with an "All time" reset. + - `RevenueSummary` sends `month` and `year` and shows the period under the total. +- **Commit:** `cd513f57e`. + +## Expected numbers (March 2024, property local time) + +| Tenant | Property | Total | Bookings | +|---|---|---|---| +| Sunset (tenant-a) | prop-001 Beach House Alpha | 2,250.00 USD | 4 | +| Sunset (tenant-a) | prop-002 City Apartment Downtown | 4,975.50 USD | 4 | +| Sunset (tenant-a) | prop-003 Country Villa Estate | 6,100.50 USD | 2 | +| Ocean (tenant-b) | prop-001 Mountain Lodge Beta | 0.00 USD | 0 | +| Ocean (tenant-b) | prop-004 Lakeside Cottage | 1,776.50 USD | 4 | +| Ocean (tenant-b) | prop-005 Urban Loft Modern | 3,256.00 USD | 3 | + +Before the fixes, Sunset's prop-001 showed 1,000.00 from 3 bookings (mock data). A query with UTC month boundaries gives the same wrong 1,000.000 from 3 bookings. + +## How to verify + +```bash +docker compose up --build -d +docker compose exec backend pip install -r requirements-dev.txt +docker compose exec backend pytest # 58 tests +docker compose exec backend pytest -m integration # 17 tests against the seeded Postgres +``` + +Then open http://localhost:3000: + +1. Log in as Sunset. The dropdown shows only Sunset's three properties. +2. Pick 2024-03: Beach House Alpha shows 2,250.00 USD from 4 bookings. Pick 2024-02: it shows 0.00. +3. Log out and log in as Ocean. Its prop-001 is "Mountain Lodge Beta" with 0.00, and Lakeside Cottage shows 1,776.50 for March. + +The dev dependencies are not part of the image. Install them again after any `docker compose up --build`. + +## Recommendations + +These items are outside the scope of a debugging exercise ("do not rebuild the system"), but they are the changes that would stop this class of bug from coming back. They are ordered by priority. + +### Now + +**A. Move the backend to a layered architecture.** Today one module mixes concerns: `services/reservations.py` opens database sessions and writes raw SQL, `services/cache.py` creates its own Redis client, and `api/v1/dashboard.py` maps every exception by hand. All three tenant bugs happened in places where one layer had to remember the tenant on its own. A structure like the one in [eslam5464/Fastapi-Template](https://github.com/eslam5464/Fastapi-Template) makes the tenant a required input at every boundary: + +``` +api/v1/dashboard.py routes only: parse input, call the service, return a schema +api/deps.py get_current_tenant, get_session, get_revenue_service +services/revenue_service.py business rules: local-time months, cents, currency +repositories/ + reservation_repository.py all SQL; every method takes tenant_id + property_repository.py +models/ SQLAlchemy Property, Reservation +schemas/ Pydantic RevenueSummaryOut (Decimal money, currency) +core/exceptions.py TenantRequiredError, MixedCurrencyError, DataUnavailableError + mapped to 403 / 409 / 503 in one exception handler +``` + +Further steps in the same direction: + +- Put caching in one wrapper around the service, with one key builder that requires a tenant. +- Replace `database/schema.sql` and the nine `backend/lightning_optimization_*.sql` variants with Alembic migrations. +- Enforce the layer rules with tach or import-linter, for example "api must not import repositories". +- Migrate step by step: move the revenue slice first, which the 58 tests already cover, then the other routers. + +**B. Enforce tenant isolation in the database too.** + +- Row-level security is enabled on `properties` and `reservations` (`database/schema.sql:35-36`), but no policies exist. Add `USING (tenant_id = current_setting('app.tenant_id'))` and set `app.tenant_id` on each session. Then a query that forgets `WHERE tenant_id` returns nothing instead of another tenant's data. +- Pass the verified JWT payload to `TenantResolver`, instead of reading the claims again with `jwt.get_unverified_claims` (`tenant_resolver.py:89`). +- Remove the email-to-tenant mapping once every token carries the claim. + +**C. Security hygiene.** + +- Demo passwords are hardcoded in `backend/app/api/v1/login.py:43,73`. +- `secret_key` defaults to `"debug_challenge_secret"` (`config.py:14`). Startup should fail outside development when the default is in use. +- `frontend/.env` is committed. +- The authentication cache is an in-process dictionary (`core/auth.py:20`) kept for 30 minutes. Each worker has its own copy, and a token cannot be revoked. Move it to Redis with a token blacklist, and add rate limiting on login. Both are optional features of the template above. + +### Next + +**D. Model money and time explicitly.** + +- Add a `Money(amount: Decimal, currency: str)` value object, and return per-currency totals or converted totals instead of 409. +- Validate property timezones against the IANA list. +- Agree with finance on the revenue recognition rule. Today revenue counts in the month of check-in. A stay that crosses the end of a month may need to be split by nights. + +**E. Cache correctness.** + +- Invalidate `revenue:{tenant}:{property}:*` when a reservation is created or changed, or use versioned keys. Today a new booking is invisible for up to 5 minutes. +- Use the shared client in `core/redis_client.py` instead of the second client created in `services/cache.py:7`. + +**F. Repository and delivery hygiene.** + +- 72,348 `node_modules` files and the `frontend/dist` build output are committed. Remove them and rely on the lockfiles. +- The backend Dockerfile runs `uvicorn --reload`, which is for development only. +- CI runs only pre-commit. Add pytest with a coverage gate, ruff, mypy and a security scan. +- Install dev dependencies in a separate image stage, so a rebuild keeps pytest. +- Remove the obsolete `version:` key from `docker-compose.yml`. + +### Later + +**G. Code health.** + +- `api/v1/city_access_fast.py` and `api/v1/city_access_fixed.py` are two near-duplicate implementations of about 500 lines each. +- `frontend/src/lib/secureApi.ts` is 2,483 lines; split it by domain. +- Replace the remaining `print(` calls in the backend with logging. +- When Supabase is not configured, startup logs `Failed to initialize Supabase connection pool`, and the login page logs `SessionRecovery` errors. Both are noise that hides real errors. + +**H. Observability.** + +- Add structured logs with `tenant_id` and a request ID. +- Add alerts on the rate of 503 and 409 responses. +- Keep an audit log of which user viewed which tenant's revenue, so a leak can be detected and measured. + +## Known leftovers + +Items G and H were not changed, because the brief asks to fix the reported issues and not rebuild the system. The Supabase and `SessionRecovery` messages existed before these changes and do not affect the dashboard. diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index 1ec352d7e3..750ddb031e 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 fastapi import APIRouter, Depends, HTTPException, Query +from decimal import Decimal +from typing import Dict, Any, List, Optional from app.services.cache import get_revenue_summary +from app.services.reservations import MixedCurrencyError, list_tenant_properties from app.core.auth import authenticate_request as get_current_user +from app.models.auth import AuthenticatedUser router = APIRouter() + +def _require_tenant(current_user: AuthenticatedUser) -> str: + """Return the user's tenant, or refuse when there is none.""" + # A user without a tenant must not fall into a shared bucket that other + # tenant-less users could read; refuse instead. + tenant_id = getattr(current_user, "tenant_id", None) + if not tenant_id: + raise HTTPException(status_code=403, detail="No tenant associated with this account") + return tenant_id + + +@router.get("/dashboard/properties") +async def get_dashboard_properties( + current_user: AuthenticatedUser = Depends(get_current_user) +) -> List[Dict[str, Any]]: + """Properties of the caller's tenant, for the dashboard selector.""" + tenant_id = _require_tenant(current_user) + + try: + return await list_tenant_properties(tenant_id) + except Exception: + raise HTTPException(status_code=503, detail="Property list temporarily unavailable") + + @router.get("/dashboard/summary") async def get_dashboard_summary( property_id: str, - current_user: dict = Depends(get_current_user) + month: Optional[int] = Query(None, ge=1, le=12, description="Calendar month in the property's timezone"), + year: Optional[int] = Query(None, ge=2000, le=2100), + 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']) - + + if (month is None) != (year is None): + raise HTTPException(status_code=422, detail="month and year must be given together") + + tenant_id = _require_tenant(current_user) + + try: + revenue_data = await get_revenue_summary(property_id, tenant_id, month, year) + except MixedCurrencyError: + raise HTTPException( + status_code=409, + detail="Reservations use more than one currency; totals cannot be added", + ) + except Exception: + raise HTTPException(status_code=503, detail="Revenue data temporarily unavailable") + + # 'total' is already rounded to cents as an exact decimal string. + # total_revenue keeps the numeric field the frontend reads; the string + # field carries the exact amount for consumers that must not use floats. + total_revenue = Decimal(revenue_data['total']) + return { "property_id": revenue_data['property_id'], - "total_revenue": total_revenue_float, + "total_revenue": float(total_revenue), + "total_revenue_str": str(total_revenue), "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 d638dfcfe3..7fae3d70d0 100644 --- a/backend/app/core/database_pool.py +++ b/backend/app/core/database_pool.py @@ -1,29 +1,44 @@ import asyncio from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker -from sqlalchemy.pool import QueuePool import logging from ..config import settings logger = logging.getLogger(__name__) + +def _async_database_url(url: str) -> str: + """Use the asyncpg driver for a plain postgresql:// URL.""" + for prefix in ("postgresql+asyncpg://", "postgresql://", "postgres://"): + if url.startswith(prefix): + return "postgresql+asyncpg://" + url[len(prefix):] + return url + + class DatabasePool: def __init__(self): self.engine = None self.session_factory = None - + self._init_lock = asyncio.Lock() + async def initialize(self): - """Initialize database connection pool""" + """Initialize database connection pool (once per process)""" + if self.session_factory: + return + async with self._init_lock: + if self.session_factory: + return + await self._create_engine() + + async def _create_engine(self): 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}" - + # The async engine uses AsyncAdaptedQueuePool by default. + # QueuePool is not valid for asyncio engines. self.engine = create_async_engine( - database_url, - poolclass=QueuePool, - pool_size=20, # Number of connections to maintain - max_overflow=30, # Additional connections when needed + _async_database_url(settings.database_url), + pool_size=settings.database_pool_size, + max_overflow=settings.database_max_overflow, pool_pre_ping=True, # Validate connections - pool_recycle=3600, # Recycle connections every hour + pool_recycle=settings.database_pool_recycle, echo=False # Set to True for SQL debugging ) @@ -45,7 +60,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") diff --git a/backend/app/core/tenant_resolver.py b/backend/app/core/tenant_resolver.py index db09a46290..fa4056ed60 100644 --- a/backend/app/core/tenant_resolver.py +++ b/backend/app/core/tenant_resolver.py @@ -4,6 +4,8 @@ from typing import Optional import logging +from jose import jwt, JWTError + logger = logging.getLogger(__name__) @@ -69,17 +71,27 @@ 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. - + Args: user_id: User ID user_email: User email - + token: JWT already verified by the caller; its tenant claim wins + Returns: - Tenant ID + Tenant ID, or None when the user has no tenant """ + # The signed token is the source of truth for the tenant. + if token: + try: + tenant_id = TenantResolver.resolve_tenant_from_token(jwt.get_unverified_claims(token)) + if tenant_id: + return tenant_id + except JWTError: + logger.warning(f"Could not read tenant claim from token for {user_email}") + # Fallback mapping by known user email. if user_email == "sunset@propertyflow.com": return "tenant-a" @@ -87,9 +99,11 @@ 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" - - # Default fallback - return "tenant-a" + + # No default: an unknown user must never be placed in a real tenant, + # or they would see that tenant's data. + logger.warning(f"No tenant found for user {user_email} ({user_id})") + return None @staticmethod async def update_user_tenant_metadata(user_id: str, tenant_id: str) -> None: diff --git a/backend/app/services/cache.py b/backend/app/services/cache.py index b81474957f..d04467abea 100644 --- a/backend/app/services/cache.py +++ b/backend/app/services/cache.py @@ -1,27 +1,41 @@ 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}" - + if not tenant_id: + raise ValueError("tenant_id is required for revenue lookups") + + # Property IDs are only unique per tenant (prop-001 exists in several + # tenants), so the tenant must be part of the key to keep data isolated. + 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 - + from app.services.reservations import calculate_monthly_revenue, calculate_total_revenue + # Calculate revenue - result = await calculate_total_revenue(property_id, tenant_id) + 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)) diff --git a/backend/app/services/reservations.py b/backend/app/services/reservations.py index 384bd00abe..43800cd73f 100644 --- a/backend/app/services/reservations.py +++ b/backend/app/services/reservations.py @@ -1,48 +1,108 @@ +import logging 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: +logger = logging.getLogger(__name__) + +CENT = Decimal("0.01") + + +def _to_cents(value) -> Decimal: """ - Calculates revenue for a specific month. + Round a money amount to cents. + + total_amount is NUMERIC(10,3), so sums can carry sub-cent digits. + Sum exactly in the database, then round once here with ROUND_HALF_UP + (never through float, which cannot represent most decimal values). """ + return Decimal(str(value if value is not None else 0)).quantize(CENT, rounding=ROUND_HALF_UP) + + +DEFAULT_CURRENCY = "USD" + + +class MixedCurrencyError(Exception): + """Reservations in different currencies cannot be summed into one total.""" + +def _currency_of(row, property_id: str, tenant_id: str) -> str: + """Currency of the summed reservations; refuse to mix currencies.""" + if row.currency_count and row.currency_count > 1: + raise MixedCurrencyError( + f"{property_id} (tenant: {tenant_id}) has reservations in {row.currency_count} currencies" + ) + return row.currency or DEFAULT_CURRENCY + +async def calculate_monthly_revenue(property_id: str, tenant_id: str, month: int, year: int) -> Dict[str, Any]: + """ + Calculates revenue for a calendar month in the property's own timezone. + + check_in_date is stored in UTC. A booking at 2024-02-29 23:30 UTC is + 2024-03-01 00:30 in Europe/Paris, so for a Paris property it belongs to + March. Month boundaries are therefore applied to the local check-in time. + """ + # Naive local-time bounds; compared against check_in_date converted to the + # property's timezone (timestamptz AT TIME ZONE tz -> local timestamp). 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() + if not db_pool.session_factory: + raise Exception("Database pool not available") + + query = text(""" + SELECT + COALESCE(SUM(r.total_amount), 0) AS total_revenue, + COUNT(r.id) AS reservation_count, + COUNT(DISTINCT r.currency) AS currency_count, + MIN(r.currency) AS currency + 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 + """) + + try: + 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() + except Exception as e: + logger.error(f"Database error for {property_id} (tenant: {tenant_id}, {year}-{month:02d}): {e}") + raise + + return { + "property_id": property_id, + "tenant_id": tenant_id, + "total": str(_to_cents(row.total_revenue)), + "currency": _currency_of(row, property_id, tenant_id), + "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 process-wide pool instead of creating one per request + from app.core.database_pool import db_pool + await db_pool.initialize() - + if db_pool.session_factory: async with db_pool.get_session() as session: # Use SQLAlchemy text for raw SQL @@ -52,7 +112,9 @@ async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, SELECT property_id, SUM(total_amount) as total_revenue, - COUNT(*) as reservation_count + COUNT(*) as reservation_count, + COUNT(DISTINCT currency) as currency_count, + MIN(currency) as currency FROM reservations WHERE property_id = :property_id AND tenant_id = :tenant_id GROUP BY property_id @@ -65,12 +127,12 @@ 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)) + total_revenue = _to_cents(row.total_revenue) return { "property_id": property_id, "tenant_id": tenant_id, "total": str(total_revenue), - "currency": "USD", + "currency": _currency_of(row, property_id, tenant_id), "count": row.reservation_count } else: @@ -79,31 +141,45 @@ async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, "property_id": property_id, "tenant_id": tenant_id, "total": "0.00", - "currency": "USD", + "currency": DEFAULT_CURRENCY, "count": 0 } else: raise Exception("Database pool not available") + except MixedCurrencyError: + raise + except Exception as e: + # Never return made-up figures: a financial dashboard must fail loudly + # instead of showing numbers that belong to no tenant. + logger.error(f"Database error for {property_id} (tenant: {tenant_id}): {e}") + raise + + +async def list_tenant_properties(tenant_id: str) -> List[Dict[str, Any]]: + """ + Returns the properties that belong to one tenant. + """ + 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") + + query = text(""" + SELECT id, name, timezone + FROM properties + WHERE tenant_id = :tenant_id + ORDER BY id + """) + + try: + async with db_pool.get_session() as session: + result = await session.execute(query, {"tenant_id": tenant_id}) + rows = result.fetchall() 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"Database error listing properties (tenant: {tenant_id}): {e}") + raise + + return [{"id": r.id, "name": r.name, "timezone": r.timezone} for r in rows] diff --git a/backend/pyproject.toml b/backend/pyproject.toml index ebaa4bc92d..3bb020dbe0 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -7,6 +7,7 @@ requires-python = ">=3.11" dependencies = [ "aiohttp>=3.12.15", "aioredis>=2.0.0", + "asyncpg>=0.27.0", "beautifulsoup4>=4.13.4", "cachetools>=6.2.0", "cryptography>=46.0.2", @@ -27,7 +28,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", @@ -38,6 +39,8 @@ dependencies = [ [dependency-groups] dev = [ "pre-commit>=4.3.0", + "pytest>=9.2.0", + "pytest-asyncio>=1.4.1.post1", ] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000000..c1aa1129d7 --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest>=9.2.0 +pytest-asyncio>=1.4.1.post1 diff --git a/backend/requirements.txt b/backend/requirements.txt index 6b777d2aaa..06cb8bd4a6 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/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000000..85f22db670 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,32 @@ +import pytest + + +def pytest_configure(config): + config.addinivalue_line( + "markers", + "integration: needs the seeded Postgres from docker-compose (skipped when unreachable)", + ) + + +class FakeRedis: + """In-memory stand-in for the async Redis client used by the revenue cache.""" + + def __init__(self): + self.store = {} + self.ttls = {} + + async def get(self, key): + return self.store.get(key) + + async def setex(self, key, ttl, value): + self.store[key] = value + self.ttls[key] = ttl + + +@pytest.fixture +def fake_redis(monkeypatch): + from app.services import cache + + fake = FakeRedis() + monkeypatch.setattr(cache, "redis_client", fake) + return fake diff --git a/backend/tests/test_dashboard_api.py b/backend/tests/test_dashboard_api.py new file mode 100644 index 0000000000..6821419119 --- /dev/null +++ b/backend/tests/test_dashboard_api.py @@ -0,0 +1,162 @@ +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.api.v1 import dashboard +from app.core.auth import authenticate_request +from app.models.auth import AuthenticatedUser +from app.services.reservations import MixedCurrencyError + + +def _user(tenant_id): + return AuthenticatedUser( + id="user-1", + email="user@example.test", + permissions=[], + cities=[], + is_admin=False, + tenant_id=tenant_id, + ) + + +@pytest.fixture +def make_client(): + """Build a client for the dashboard router only (no app startup hooks).""" + + def _make(tenant_id): + app = FastAPI() + app.include_router(dashboard.router, prefix="/api/v1") + app.dependency_overrides[authenticate_request] = lambda: _user(tenant_id) + return TestClient(app) + + return _make + + +@pytest.fixture +def summary_calls(monkeypatch): + calls = [] + + async def fake_summary(property_id, tenant_id, month=None, year=None): + calls.append((property_id, tenant_id, month, year)) + return {"property_id": property_id, "tenant_id": tenant_id, "total": "2250.00", "currency": "USD", "count": 4} + + monkeypatch.setattr(dashboard, "get_revenue_summary", fake_summary) + return calls + + +def test_summary_uses_tenant_from_auth(make_client, summary_calls): + response = make_client("tenant-a").get("/api/v1/dashboard/summary", params={"property_id": "prop-001"}) + + assert response.status_code == 200 + assert summary_calls == [("prop-001", "tenant-a", None, None)] + + +def test_summary_ignores_simulated_tenant_header(make_client, summary_calls): + response = make_client("tenant-a").get( + "/api/v1/dashboard/summary", + params={"property_id": "prop-001"}, + headers={"X-Simulated-Tenant": "tenant-b"}, + ) + + assert response.status_code == 200 + assert summary_calls[0][1] == "tenant-a" + + +def test_summary_returns_cents_as_number_and_exact_string(make_client, summary_calls): + body = make_client("tenant-a").get("/api/v1/dashboard/summary", params={"property_id": "prop-001"}).json() + + assert body == { + "property_id": "prop-001", + "total_revenue": 2250.0, + "total_revenue_str": "2250.00", + "currency": "USD", + "reservations_count": 4, + } + + +@pytest.mark.parametrize("tenant_id", [None, ""]) +def test_summary_without_tenant_is_forbidden(make_client, summary_calls, tenant_id): + # Regression: this used to fall back to a shared "default_tenant". + response = make_client(tenant_id).get("/api/v1/dashboard/summary", params={"property_id": "prop-001"}) + + assert response.status_code == 403 + assert summary_calls == [] + + +def test_summary_passes_month_and_year(make_client, summary_calls): + response = make_client("tenant-a").get( + "/api/v1/dashboard/summary", params={"property_id": "prop-001", "month": 3, "year": 2024} + ) + + assert response.status_code == 200 + assert summary_calls == [("prop-001", "tenant-a", 3, 2024)] + + +@pytest.mark.parametrize( + "params", + [ + {"month": 3}, + {"year": 2024}, + {"month": 13, "year": 2024}, + {"month": 0, "year": 2024}, + ], +) +def test_summary_rejects_bad_period(make_client, summary_calls, params): + response = make_client("tenant-a").get( + "/api/v1/dashboard/summary", params={"property_id": "prop-001", **params} + ) + + assert response.status_code == 422 + assert summary_calls == [] + + +def test_summary_returns_503_when_data_unavailable(make_client, monkeypatch): + # Regression: DB errors used to be hidden behind hardcoded mock totals. + async def failing_summary(*args, **kwargs): + raise RuntimeError("database down") + + monkeypatch.setattr(dashboard, "get_revenue_summary", failing_summary) + response = make_client("tenant-a").get("/api/v1/dashboard/summary", params={"property_id": "prop-001"}) + + assert response.status_code == 503 + + +def test_summary_returns_409_for_mixed_currencies(make_client, monkeypatch): + async def mixed_summary(*args, **kwargs): + raise MixedCurrencyError("prop-002 has reservations in 2 currencies") + + monkeypatch.setattr(dashboard, "get_revenue_summary", mixed_summary) + response = make_client("tenant-a").get("/api/v1/dashboard/summary", params={"property_id": "prop-002"}) + + assert response.status_code == 409 + + +def test_properties_lists_only_callers_tenant(make_client, monkeypatch): + seen = [] + + async def fake_list(tenant_id): + seen.append(tenant_id) + return [{"id": "prop-004", "name": "Lakeside Cottage", "timezone": "America/New_York"}] + + monkeypatch.setattr(dashboard, "list_tenant_properties", fake_list) + response = make_client("tenant-b").get("/api/v1/dashboard/properties") + + assert response.status_code == 200 + assert seen == ["tenant-b"] + assert response.json()[0]["id"] == "prop-004" + + +def test_properties_without_tenant_is_forbidden(make_client, monkeypatch): + async def fake_list(tenant_id): + raise AssertionError("must not query without a tenant") + + monkeypatch.setattr(dashboard, "list_tenant_properties", fake_list) + assert make_client(None).get("/api/v1/dashboard/properties").status_code == 403 + + +def test_properties_returns_503_when_data_unavailable(make_client, monkeypatch): + async def failing_list(tenant_id): + raise RuntimeError("database down") + + monkeypatch.setattr(dashboard, "list_tenant_properties", failing_list) + assert make_client("tenant-a").get("/api/v1/dashboard/properties").status_code == 503 diff --git a/backend/tests/test_money_rounding.py b/backend/tests/test_money_rounding.py new file mode 100644 index 0000000000..aaeabad443 --- /dev/null +++ b/backend/tests/test_money_rounding.py @@ -0,0 +1,37 @@ +from decimal import Decimal + +import pytest + +from app.services.reservations import _to_cents + + +@pytest.mark.parametrize( + "value, expected", + [ + ("2250.000", "2250.00"), + ("999.999", "1000.00"), + ("1776.505", "1776.51"), + ("333.333", "333.33"), + ("0.005", "0.01"), + ("0.004", "0.00"), + (Decimal("1000.000"), "1000.00"), + (0, "0.00"), + (None, "0.00"), + ], +) +def test_to_cents_rounds_half_up_to_two_places(value, expected): + result = _to_cents(value) + assert isinstance(result, Decimal) + assert str(result) == expected + + +def test_to_cents_avoids_float_rounding_error(): + # 2.675 has no exact binary float; float rounding gives 2.67. + assert round(2.675, 2) == 2.67 + assert str(_to_cents("2.675")) == "2.68" + + +def test_sum_then_round_matches_seed_data(): + # The three seed reservations for prop-001 (tenant-a) in mid-March. + amounts = [Decimal("333.333"), Decimal("333.333"), Decimal("333.334")] + assert str(_to_cents(sum(amounts))) == "1000.00" diff --git a/backend/tests/test_revenue_cache.py b/backend/tests/test_revenue_cache.py new file mode 100644 index 0000000000..17231c14c5 --- /dev/null +++ b/backend/tests/test_revenue_cache.py @@ -0,0 +1,84 @@ +import json + +import pytest + +from app.services import cache +from app.services import reservations + + +def _result(property_id, tenant_id, total, count): + return {"property_id": property_id, "tenant_id": tenant_id, "total": total, "currency": "USD", "count": count} + + +@pytest.fixture +def calc_calls(monkeypatch): + """Replace the DB-backed calculators with fakes that record their calls.""" + calls = [] + totals = {("tenant-a", "prop-001"): "2250.00", ("tenant-b", "prop-001"): "0.00"} + + async def fake_total(property_id, tenant_id): + calls.append(("total", tenant_id, property_id)) + return _result(property_id, tenant_id, totals.get((tenant_id, property_id), "0.00"), 0) + + async def fake_monthly(property_id, tenant_id, month, year): + calls.append(("monthly", tenant_id, property_id, month, year)) + return _result(property_id, tenant_id, "123.45", 1) + + monkeypatch.setattr(reservations, "calculate_total_revenue", fake_total) + monkeypatch.setattr(reservations, "calculate_monthly_revenue", fake_monthly) + return calls + + +@pytest.mark.asyncio +async def test_same_property_id_is_cached_per_tenant(fake_redis, calc_calls): + # Regression: the key was revenue:{property_id}, so tenant-b got tenant-a's + # cached prop-001 total. + a = await cache.get_revenue_summary("prop-001", "tenant-a") + b = await cache.get_revenue_summary("prop-001", "tenant-b") + + assert a["total"] == "2250.00" + assert b["total"] == "0.00" + assert set(fake_redis.store) == { + "revenue:tenant-a:prop-001:all", + "revenue:tenant-b:prop-001:all", + } + assert len(calc_calls) == 2 + + +@pytest.mark.asyncio +async def test_cache_hit_returns_cached_value_without_recalculating(fake_redis, calc_calls): + await cache.get_revenue_summary("prop-001", "tenant-a") + again = await cache.get_revenue_summary("prop-001", "tenant-a") + + assert again["total"] == "2250.00" + assert len(calc_calls) == 1 + assert fake_redis.ttls["revenue:tenant-a:prop-001:all"] == 300 + + +@pytest.mark.asyncio +async def test_cached_entry_of_other_tenant_is_never_returned(fake_redis, calc_calls): + fake_redis.store["revenue:tenant-a:prop-001:all"] = json.dumps(_result("prop-001", "tenant-a", "2250.00", 4)) + + b = await cache.get_revenue_summary("prop-001", "tenant-b") + + assert b["tenant_id"] == "tenant-b" + assert b["total"] == "0.00" + + +@pytest.mark.asyncio +async def test_monthly_and_all_time_use_separate_keys(fake_redis, calc_calls): + await cache.get_revenue_summary("prop-001", "tenant-a") + monthly = await cache.get_revenue_summary("prop-001", "tenant-a", month=3, year=2024) + + assert monthly["total"] == "123.45" + assert "revenue:tenant-a:prop-001:2024-03" in fake_redis.store + assert calc_calls[-1] == ("monthly", "tenant-a", "prop-001", 3, 2024) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("tenant_id", ["", None]) +async def test_missing_tenant_is_rejected(fake_redis, calc_calls, tenant_id): + with pytest.raises(ValueError): + await cache.get_revenue_summary("prop-001", tenant_id) + assert fake_redis.store == {} + assert calc_calls == [] diff --git a/backend/tests/test_revenue_db_integration.py b/backend/tests/test_revenue_db_integration.py new file mode 100644 index 0000000000..31c19f42ec --- /dev/null +++ b/backend/tests/test_revenue_db_integration.py @@ -0,0 +1,148 @@ +""" +Integration tests against the seeded Postgres from docker-compose +(database/schema.sql + database/seed.sql). Skipped when the DB is unreachable. + +Run inside the backend container: + docker compose exec backend pytest -m integration +""" +import pytest +import pytest_asyncio + +from app.core.database_pool import db_pool +from app.services.reservations import ( + MixedCurrencyError, + calculate_monthly_revenue, + calculate_total_revenue, + list_tenant_properties, +) + +pytestmark = [pytest.mark.integration, pytest.mark.asyncio] + + +@pytest_asyncio.fixture(autouse=True) +async def fresh_pool(): + """ + The pool is process-wide, but each test runs in its own event loop and + asyncpg connections cannot move between loops. Start and end every test + with a fresh engine. + """ + await _reset_pool() + await db_pool.initialize() + try: + async with db_pool.get_session() as session: + from sqlalchemy import text + + await session.execute(text("SELECT 1")) + except Exception as e: # pragma: no cover - environment dependent + await _reset_pool() + pytest.skip(f"Postgres not reachable: {e}") + yield + await _reset_pool() + + +async def _reset_pool(): + if db_pool.engine: + await db_pool.engine.dispose() + db_pool.engine = None + db_pool.session_factory = None + + +async def test_all_time_total_uses_real_data_not_mock(): + # Mock fallback used to return 1000.00 / 3 for prop-001. + result = await calculate_total_revenue("prop-001", "tenant-a") + assert result["total"] == "2250.00" + assert result["count"] == 4 + + +async def test_same_property_id_is_isolated_per_tenant(): + a = await calculate_total_revenue("prop-001", "tenant-a") + b = await calculate_total_revenue("prop-001", "tenant-b") + assert a["total"] == "2250.00" + assert b["total"] == "0.00" + assert b["count"] == 0 + + +async def test_tenant_cannot_read_other_tenants_property(): + result = await calculate_total_revenue("prop-004", "tenant-a") + assert result["total"] == "0.00" + assert result["count"] == 0 + + +@pytest.mark.parametrize( + "property_id, tenant_id, total, count", + [ + ("prop-001", "tenant-a", "2250.00", 4), + ("prop-002", "tenant-a", "4975.50", 4), + ("prop-003", "tenant-a", "6100.50", 2), + ("prop-004", "tenant-b", "1776.50", 4), + ("prop-005", "tenant-b", "3256.00", 3), + ("prop-001", "tenant-b", "0.00", 0), + ], +) +async def test_march_2024_totals_per_property(property_id, tenant_id, total, count): + result = await calculate_monthly_revenue(property_id, tenant_id, 3, 2024) + assert (result["total"], result["count"]) == (total, count) + + +async def test_booking_late_on_feb_29_utc_counts_as_march_in_paris(): + # res-tz-1: 2024-02-29 23:30 UTC == 2024-03-01 00:30 Europe/Paris. + # A UTC month boundary gives March = 1000.00 / 3 and February = 1250.00 / 1. + march = await calculate_monthly_revenue("prop-001", "tenant-a", 3, 2024) + february = await calculate_monthly_revenue("prop-001", "tenant-a", 2, 2024) + + assert (march["total"], march["count"]) == ("2250.00", 4) + assert (february["total"], february["count"]) == ("0.00", 0) + + +async def test_december_rolls_over_to_next_year(): + result = await calculate_monthly_revenue("prop-001", "tenant-a", 12, 2023) + assert (result["total"], result["count"]) == ("0.00", 0) + + +async def test_totals_always_have_two_decimals(): + for property_id, tenant_id in [("prop-001", "tenant-a"), ("prop-004", "tenant-b")]: + result = await calculate_total_revenue(property_id, tenant_id) + assert len(result["total"].split(".")[1]) == 2 + + +@pytest.mark.parametrize( + "tenant_id, expected", + [ + ("tenant-a", [("prop-001", "Beach House Alpha"), ("prop-002", "City Apartment Downtown"), ("prop-003", "Country Villa Estate")]), + ("tenant-b", [("prop-001", "Mountain Lodge Beta"), ("prop-004", "Lakeside Cottage"), ("prop-005", "Urban Loft Modern")]), + ("tenant-unknown", []), + ], +) +async def test_property_list_is_scoped_to_tenant(tenant_id, expected): + properties = await list_tenant_properties(tenant_id) + assert [(p["id"], p["name"]) for p in properties] == expected + + +async def test_currency_comes_from_stored_reservations(): + total = await calculate_total_revenue("prop-002", "tenant-a") + monthly = await calculate_monthly_revenue("prop-002", "tenant-a", 3, 2024) + assert total["currency"] == "USD" + assert monthly["currency"] == "USD" + + +async def _exec(sql, params=None): + from sqlalchemy import text + + async with db_pool.get_session() as session: + await session.execute(text(sql), params or {}) + await session.commit() + + +async def test_mixed_currencies_are_not_summed(): + # A EUR booking must not be added to USD bookings as if it were dollars. + await _exec( + "INSERT INTO reservations (id, property_id, tenant_id, check_in_date, check_out_date, total_amount, currency) " + "VALUES ('res-test-eur', 'prop-002', 'tenant-a', '2024-03-10 12:00:00+00', '2024-03-12 10:00:00+00', 500.000, 'EUR')" + ) + try: + with pytest.raises(MixedCurrencyError): + await calculate_total_revenue("prop-002", "tenant-a") + with pytest.raises(MixedCurrencyError): + await calculate_monthly_revenue("prop-002", "tenant-a", 3, 2024) + finally: + await _exec("DELETE FROM reservations WHERE id = 'res-test-eur'") diff --git a/backend/tests/test_tenant_resolver.py b/backend/tests/test_tenant_resolver.py new file mode 100644 index 0000000000..13bb19f26c --- /dev/null +++ b/backend/tests/test_tenant_resolver.py @@ -0,0 +1,63 @@ +import pytest +from jose import jwt + +from app.core.tenant_resolver import TenantResolver + + +def _token(claims): + return jwt.encode(claims, "test-secret", algorithm="HS256") + + +@pytest.mark.asyncio +async def test_unknown_user_without_tenant_gets_none(): + # Regression: this used to fall back to "tenant-a" and expose its data. + tenant = await TenantResolver.resolve_tenant_id( + user_id="user-x", user_email="nobody@example.test", token=_token({"email": "nobody@example.test"}) + ) + assert tenant is None + + +@pytest.mark.asyncio +async def test_unknown_user_without_token_gets_none(): + assert await TenantResolver.resolve_tenant_id(user_id="user-x", user_email="nobody@example.test") is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("location", ["app_metadata", "user_metadata", "root"]) +async def test_tenant_claim_in_token_is_used(location): + claims = {"email": "someone@example.test"} + if location == "root": + claims["tenant_id"] = "tenant-b" + else: + claims[location] = {"tenant_id": "tenant-b"} + + tenant = await TenantResolver.resolve_tenant_id( + user_id="user-x", user_email="someone@example.test", token=_token(claims) + ) + assert tenant == "tenant-b" + + +@pytest.mark.asyncio +async def test_token_claim_wins_over_email_mapping(): + token = _token({"app_metadata": {"tenant_id": "tenant-b"}}) + tenant = await TenantResolver.resolve_tenant_id( + user_id="user-sunset", user_email="sunset@propertyflow.com", token=token + ) + assert tenant == "tenant-b" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "email, expected", + [("sunset@propertyflow.com", "tenant-a"), ("ocean@propertyflow.com", "tenant-b")], +) +async def test_known_emails_still_map_without_claim(email, expected): + assert await TenantResolver.resolve_tenant_id(user_id="u", user_email=email) == expected + + +@pytest.mark.asyncio +async def test_malformed_token_falls_back_to_email_mapping(): + tenant = await TenantResolver.resolve_tenant_id( + user_id="u", user_email="ocean@propertyflow.com", token="not-a-jwt" + ) + assert tenant == "tenant-b" diff --git a/backend/uv.lock b/backend/uv.lock index 01e9327152..0f4a4d53c2 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -141,6 +141,77 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, ] +[[package]] +name = "asyncpg" +version = "0.32.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/80/4e/59dc964f962f09e3ed472e5d2d3ba670a41a2be25080dc62ab3db507ff5e/asyncpg-0.32.0.tar.gz", hash = "sha256:45e64e56714d888330b884aad1dfb363d0bf43fb343e3d1a8968525f3bade478", size = 1075156, upload-time = "2026-10-06T20:32:40.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/27/1a7970f1ece6c205b03c79f45b89420dee9655ffb66bd2c11be8f40c248a/asyncpg-0.32.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5789340b9bcdab94a19eb8ff119322a09991e3626d131b55828535b373e285d4", size = 686071, upload-time = "2026-10-06T20:30:39.115Z" }, + { url = "https://files.pythonhosted.org/packages/2b/47/085934d0290806a92789eee860109c44bea71ff8bc7850a9d3a30da7a819/asyncpg-0.32.0-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:057ed2455e4e14ad9949f1ac1829112c7d0454c9810b124f36de1486febe6824", size = 692193, upload-time = "2026-10-06T20:30:40.563Z" }, + { url = "https://files.pythonhosted.org/packages/b4/2c/d92524b9e860aecd119c0ebe43f3b9eca26dc2b75c4dfe1be3e999e3f6b1/asyncpg-0.32.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c938c4da9166ac1ef330475e314e2b94c68bde2795be0f4e8a1e00ccd806cadd", size = 3196713, upload-time = "2026-10-06T20:30:42.123Z" }, + { url = "https://files.pythonhosted.org/packages/85/b5/3ac7cb86aa287e5bbceaeb783ee6e4f51cd2a001f1747ef4f1236a20bde6/asyncpg-0.32.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:968c570c5913b7ce0995953d7239bd2367142d1af4359f87699f7a6ca75c4382", size = 3260618, upload-time = "2026-10-06T20:30:43.552Z" }, + { url = "https://files.pythonhosted.org/packages/e3/08/618ac36b2970b437d45523f50b5580dba0c34756bbf2153306f82a2697e5/asyncpg-0.32.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96c8226d2026e025852facb5a05035ea5e11b14bebb6b42e4e43948ef8f0d075", size = 3132973, upload-time = "2026-10-06T20:30:45.147Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e6/54db41b3d5fe26b0401a49327ffce439195c5f6073d8afbbdc9758cb35c3/asyncpg-0.32.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d3f745f4947df9004e2637753ff81d52f305f790f49d67f72e1677db12b07a7b", size = 3251612, upload-time = "2026-10-06T20:30:46.923Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e0/ed1e7536ce949896de29ee955b473659b3daa7887e7081030dba2b15ea5d/asyncpg-0.32.0-cp311-cp311-win32.whl", hash = "sha256:469e6520a839957304582eb8a708d874985914500b64517155f80e6fec00e742", size = 538739, upload-time = "2026-10-06T20:30:48.355Z" }, + { url = "https://files.pythonhosted.org/packages/df/eb/52c4bddad17ff1bee485ae83e08c752a998ef04ac5df76f03fef6430d0ed/asyncpg-0.32.0-cp311-cp311-win_amd64.whl", hash = "sha256:6a1e671e67f4b0bef3c03f37a896d61706f769a83922c119070f1f04e415dc17", size = 610534, upload-time = "2026-10-06T20:30:50.003Z" }, + { url = "https://files.pythonhosted.org/packages/85/c7/9af12f2b3300c425a151ef8f85f47c0db76135827c549031858954805ff7/asyncpg-0.32.0-cp311-cp311-win_arm64.whl", hash = "sha256:901bc87b94539f32853bd73a9b02fa78f7feed4cf628824caad3093ec6662f58", size = 574363, upload-time = "2026-10-06T20:30:51.489Z" }, + { url = "https://files.pythonhosted.org/packages/73/06/d5f956db9c936c90cd3289cf948a86c3efc9849e26354356c23da29f6a2d/asyncpg-0.32.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7cb31f7a8472ddc6b6f5c9da1290e901d5c77c8441c7213bd13b13ef6fe6359c", size = 681566, upload-time = "2026-10-06T20:30:52.779Z" }, + { url = "https://files.pythonhosted.org/packages/09/93/ea55f3b26fd40ec90e5b6d6c53b9ff52633cf6b87a468d9c033a727832f4/asyncpg-0.32.0-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:643d8d6e955a355045dddfe827d74f4f0d1dc4a18e06963a08260af838fbf093", size = 704359, upload-time = "2026-10-06T20:30:54.608Z" }, + { url = "https://files.pythonhosted.org/packages/46/2c/a3704e8675d37b168f3584661fc9f64f3021659c9b94e51cf9ab957b2bc5/asyncpg-0.32.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:14ff79ca2574182ce258159c48978a086f9026fc121d935017b5d10c64fa3c72", size = 3707008, upload-time = "2026-10-06T20:30:56.326Z" }, + { url = "https://files.pythonhosted.org/packages/30/30/4fd8d1155b3d7a32a2c241dcb9c5d9e9bd74a59ae71ed25ef8ddb8e038e1/asyncpg-0.32.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:54851411bee2aa51a30d0911524201fbb05f82cc0f7c248b140203db637c723d", size = 3810163, upload-time = "2026-10-06T20:30:58.114Z" }, + { url = "https://files.pythonhosted.org/packages/c1/25/5b0992d45661e1488aba775cf17a2e6c82c7d1d7e10acc71efd394760a00/asyncpg-0.32.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8592f0ed9c315b2117dbdc707cf3292f09a89d5b07661016a84dd881326965cf", size = 3600446, upload-time = "2026-10-06T20:30:59.946Z" }, + { url = "https://files.pythonhosted.org/packages/ea/88/1c82c6feacec813423401b5aef1a43baea951694157f4d405b2d14e80e6d/asyncpg-0.32.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4dbe0982cb3ded878de0867dfaeae3116faf471d484ea28b3e3da942f01fb778", size = 3764563, upload-time = "2026-10-06T20:31:01.462Z" }, + { url = "https://files.pythonhosted.org/packages/84/f5/5a3796088f0c3f7d22aaf7c48536f40b27e44b7c9603d4d7abfeca2ed97e/asyncpg-0.32.0-cp312-cp312-win32.whl", hash = "sha256:fbe1f8c788fb5df18ea8a5432dfa2473fd8f7f088025fb83d089a7c7b37e37b0", size = 551810, upload-time = "2026-10-06T20:31:03.248Z" }, + { url = "https://files.pythonhosted.org/packages/af/42/f4d333a3f67b0e7cf58ea855f9d5d9104ce38c21f2a2f22bf7dce524428c/asyncpg-0.32.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd7157a86817730c3239bc687abf8186a471525d695e225c187b9a523a808a98", size = 626763, upload-time = "2026-10-06T20:31:04.927Z" }, + { url = "https://files.pythonhosted.org/packages/a8/82/9d82e16e1d0b4e2a639a2db649d4b444b8a479cd52553a9c36ba0d6320a8/asyncpg-0.32.0-cp312-cp312-win_arm64.whl", hash = "sha256:9509e21fc526f1fc27cf80ad9f9b8dde3f3e21935d46be66d649635321d3407c", size = 577288, upload-time = "2026-10-06T20:31:06.776Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ee/b6b5870b51e004880d9a216313ea7d4f180961c5869f32e58e8cb9b71e96/asyncpg-0.32.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c032869fd9c3c9fd1a86ad67e53f63906159068087c2674dd1e19be3cffff571", size = 683362, upload-time = "2026-10-06T20:31:08.078Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8b/1f450742bc6eab0c015cae26aef94fac2ff29433e3f18a019126c3912c49/asyncpg-0.32.0-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:0c764dce865b41878396e736d4d2c6c6ce3a8e1b61d1f6bb292e30d265ae7ca6", size = 706652, upload-time = "2026-10-06T20:31:09.524Z" }, + { url = "https://files.pythonhosted.org/packages/05/dc/13f3c0ef7e867bafdccd470e5cfae1f2fd9a7085c771546bd4b94018e043/asyncpg-0.32.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:925ce1cc54419d468bfb77632d91e5e2be5be0fdf9d43680c68fe7cedf87051a", size = 3698244, upload-time = "2026-10-06T20:31:10.894Z" }, + { url = "https://files.pythonhosted.org/packages/1f/64/b00ef3fc0d861c28a1937f08d2c7f6e6119c152b414d50fa800c3aee83b5/asyncpg-0.32.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4cec40b66a36b14921c155db78631cd96ed00e225fdf38dd5532e9aef350a498", size = 3801314, upload-time = "2026-10-06T20:31:12.964Z" }, + { url = "https://files.pythonhosted.org/packages/de/1b/215067d97a13206ce1565da920ddbefe5a1e5f89903e6de862fdd0a034a1/asyncpg-0.32.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1fba43a9a230ce4d2b4593b761b8e03630c613c282b24566e27c7f53695273b1", size = 3598650, upload-time = "2026-10-06T20:31:14.797Z" }, + { url = "https://files.pythonhosted.org/packages/37/45/2bfcb5c9b04df3f17fd367647c9f3ee9fe64ea0612b509a6b1832afcedae/asyncpg-0.32.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7a8f7fa8304f757e23cccb8ffef6a6fce0b6320ffc565a884ee3cd0dfad1ac5", size = 3762739, upload-time = "2026-10-06T20:31:17.186Z" }, + { url = "https://files.pythonhosted.org/packages/08/45/e6b37756e6c8979fe070e9821654244f38319493f5b0589e549d9a40c001/asyncpg-0.32.0-cp313-cp313-win32.whl", hash = "sha256:d809399022e244eb86bb532a4ae9a45746e0f6dc5154fd6aa2f6ad63fa3f5373", size = 551065, upload-time = "2026-10-06T20:31:18.812Z" }, + { url = "https://files.pythonhosted.org/packages/ee/46/0a4e92f4310da644b28595b22ef2fff1ffd3dab84953dc8b4c5eef72b764/asyncpg-0.32.0-cp313-cp313-win_amd64.whl", hash = "sha256:38640b106705fef8b0f46cdb5fd9dcf6a638eed5cadb0f441714a21405ca8a0a", size = 625571, upload-time = "2026-10-06T20:31:20.571Z" }, + { url = "https://files.pythonhosted.org/packages/35/f4/48ed4b580b99b1fabc480c707229bb8f1e4ba0f5b24a50822b339efe1e48/asyncpg-0.32.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78145adedfe51dc2fda623e6602cf816dabc2eafcff693bd50484321a1c9034", size = 576342, upload-time = "2026-10-06T20:31:22.29Z" }, + { url = "https://files.pythonhosted.org/packages/25/25/a30ca6417f9142c6a63a7caf5f33717902b2d0ca8a8ff8fc72c6cc2fa77d/asyncpg-0.32.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ac18d9ee7a8ca70aed276f79b249d9f37e4d55e3525db1002b5f0b62ddec4f5", size = 691699, upload-time = "2026-10-06T20:31:24.168Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b5/59f10f2381a073c199cd868fce0d8f7aa448b08412de4dc4dbe4118bcee9/asyncpg-0.32.0-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:e1120ef2ae3a5e514c9ea9fce83519ba692710ea5f38434eadbbf12789073dfe", size = 715194, upload-time = "2026-10-06T20:31:25.969Z" }, + { url = "https://files.pythonhosted.org/packages/54/59/79a5aebd58250bedefa6dcd43b22b037d9cf0054ceb4c718c53ebf04e63f/asyncpg-0.32.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fa68acb42f22436597016e5d7feef7b0b5c49b4c56aece3fdb3ba0da2326cb2", size = 3729978, upload-time = "2026-10-06T20:31:27.541Z" }, + { url = "https://files.pythonhosted.org/packages/68/db/fc91b503b3ec66cf242d83c799388285ea5f0ee238435d53dd9c1a8648a9/asyncpg-0.32.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63417b8f7369c54f6754c1fbd5a2968fbe632ff55bfbedd56a0177b6a96bd251", size = 3794539, upload-time = "2026-10-06T20:31:29.617Z" }, + { url = "https://files.pythonhosted.org/packages/40/bd/7359320499fdb2733206191b8fd15b7ec602656cbc1444bff7a8c66a365c/asyncpg-0.32.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2c6366841a792d0a4d16991de240a8053b7c4772a18a5f27fa6fad09c0e359fb", size = 3632884, upload-time = "2026-10-06T20:31:31.298Z" }, + { url = "https://files.pythonhosted.org/packages/18/75/dd3c3dd99f1db55b9736d23a44da29501f07f852bf4df91507f37b156fb1/asyncpg-0.32.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3ef1dfd11919280e011ffd1c873323c5088a94fd2c3f77946a5250cf306e2eb", size = 3764931, upload-time = "2026-10-06T20:31:32.916Z" }, + { url = "https://files.pythonhosted.org/packages/38/4f/161b275759725a774d170a383c1208996865ebad50d6891e60d35461a3e6/asyncpg-0.32.0-cp314-cp314-win32.whl", hash = "sha256:77cf9d7023f063ae6f9e443077b55af0dc1807dd9afff1ae656b93ee0cddedc9", size = 557690, upload-time = "2026-10-06T20:31:34.856Z" }, + { url = "https://files.pythonhosted.org/packages/b5/03/880d0db1faedf8b740a57a7ba50e115651a0f05c5905140195813879b086/asyncpg-0.32.0-cp314-cp314-win_amd64.whl", hash = "sha256:2f87452025b47ce80dcc3a0be2b5d1f8aab5deec2516d266f1643d4e53cc40d5", size = 634859, upload-time = "2026-10-06T20:31:36.512Z" }, + { url = "https://files.pythonhosted.org/packages/79/bb/2e86b462a2a2a795eaa7838266db019876b8e7a12c465b903517a4e87fd0/asyncpg-0.32.0-cp314-cp314-win_arm64.whl", hash = "sha256:d0e4508a3d62b0f42d7a99c030c364050b11e75f61c9dd4861e5fdda7cb60636", size = 594013, upload-time = "2026-10-06T20:31:37.91Z" }, + { url = "https://files.pythonhosted.org/packages/20/1d/5369c4438496e654121cbda75be2e8043d1fcae3552b856d44011a19b723/asyncpg-0.32.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:afec11e0b9c001e69966becacd2f948cc8949b4916ec4c0f4dc9b52e47de4528", size = 743832, upload-time = "2026-10-06T20:31:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/60/b0/4b92582c2339a164275a6418ccaeeb0453b72f2e0d7003702379cb50e852/asyncpg-0.32.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:418d266a553e932bf961bb43bfd610ee6c5425fb1b9a599a5828fd12bae8f5c4", size = 769568, upload-time = "2026-10-06T20:31:40.691Z" }, + { url = "https://files.pythonhosted.org/packages/3d/88/919d9ff7ca3c3b96aa404b88b6a53e142b4422623c5ee5a69c4b733240ce/asyncpg-0.32.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b1666e1b747ebbc75c87cb31972704ae8a3ca15b950f94456e97d26781c67d10", size = 3948962, upload-time = "2026-10-06T20:31:42.456Z" }, + { url = "https://files.pythonhosted.org/packages/27/8b/e9f412ae9a3e3f0eb23415249e8d5933e7aeb01068b4083fc86714043d1f/asyncpg-0.32.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83510bb25d38f0415e155aa3a7af78621369891f5ecd8730d012d9cb26143ffc", size = 3874815, upload-time = "2026-10-06T20:31:44.094Z" }, + { url = "https://files.pythonhosted.org/packages/08/71/24364e9ff7bb9860548452513f295306b12f5b24e8fb0b78f1605c443946/asyncpg-0.32.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:87957755d11639cf248c6aaa094eee9d150f07065866d1710c9427e02dfc0790", size = 3762465, upload-time = "2026-10-06T20:31:45.908Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/33cb7e805ec6806b196473e2c7a2ba9d5af3ad2928930aa06359c8eeef87/asyncpg-0.32.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:764227423bf30a3001d3da6df90e82d30a2a097d762e4ee5fa074236eda262f4", size = 3797285, upload-time = "2026-10-06T20:31:47.53Z" }, + { url = "https://files.pythonhosted.org/packages/be/e7/85eb86d6040725f5c191fd6af9f10769c60ed971634b47f4b4bcab293d44/asyncpg-0.32.0-cp314-cp314t-win32.whl", hash = "sha256:f2342b1f3e87b2096320a77edcbb830fbd23b1d4d4842c57567764430b95e4fc", size = 594006, upload-time = "2026-10-06T20:31:49.197Z" }, + { url = "https://files.pythonhosted.org/packages/f9/aa/ea75defe55718457bcf41cde42248db5bbee65fce8c6f0a0e43d9eca1723/asyncpg-0.32.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5c3a48908cb0a02393e5bdab7fa92aefd700f2a93212bf91f04aa9657b4f554d", size = 674647, upload-time = "2026-10-06T20:31:50.547Z" }, + { url = "https://files.pythonhosted.org/packages/0d/0b/078d362872c6c72dd5d11c214dde8dac65b1c87ece96fd2fc2f786a8f66c/asyncpg-0.32.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f8eadd207c26850a2e15f3c2a1096b5d051ea6758a26f2f3e65ce16f84297ed8", size = 624589, upload-time = "2026-10-06T20:31:52.291Z" }, + { url = "https://files.pythonhosted.org/packages/5c/83/e0145d19197b965438693179c88dd99cfc69bc1bf954815f44762ab88843/asyncpg-0.32.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:58975b1a51a100c4716ebf22f84c249d27140f7b9385b64ad9b676836f1db9ab", size = 689708, upload-time = "2026-10-06T20:31:55.809Z" }, + { url = "https://files.pythonhosted.org/packages/2f/13/f394919a59f104288b1b17fb6c7a3ac4738b8c555690a63caf603f91ca83/asyncpg-0.32.0-cp315-cp315-macosx_11_0_x86_64.whl", hash = "sha256:6b95fc2ebdb4af072bfa8b64c6d0397b49242d17bef1c0337857904f9267dab2", size = 714408, upload-time = "2026-10-06T20:31:57.504Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3d/1123cf41bff78fdfd80e6fd143cc86bf1ef2875af8f5d8742c03f471e913/asyncpg-0.32.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a759f98c5652443db501b20041aeee548e9a04fe7ae939067321acd207218447", size = 3733440, upload-time = "2026-10-06T20:31:59.308Z" }, + { url = "https://files.pythonhosted.org/packages/de/24/ff4b045e85d7bdf6f61f67c285800abd6e82f26319671d7f0dfadadc1aa0/asyncpg-0.32.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ceea1064500d0d7a46c092cdbe9752064c23b720ab0e0bff83d1030fffe7a50a", size = 3824312, upload-time = "2026-10-06T20:32:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/12/63/1ec7eb6e20f7e8ae120a41aad9669044cce964f39773baf644897a046aee/asyncpg-0.32.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:543f02790d086244c7cdc849e4b671b6c2048be0242b78d943494da6e80c0001", size = 3637212, upload-time = "2026-10-06T20:32:02.699Z" }, + { url = "https://files.pythonhosted.org/packages/79/68/528e362eb5adbc1a7defe4c5f157756a031346d3efa9920467b245e4ce41/asyncpg-0.32.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f24d20a68f0e37ca6fc490388e7eeb48abab3da0dbf06248135ed6179f5f521d", size = 3791355, upload-time = "2026-10-06T20:32:04.415Z" }, + { url = "https://files.pythonhosted.org/packages/38/e3/22f443f456bf93d1806f43a820da8ee463dfe9b93a9d77a3f00fedcdaad6/asyncpg-0.32.0-cp315-cp315-win32.whl", hash = "sha256:110f72d33c8b944ab421ca383db0b8849cfeb861547fee6cbb61f65a6bcd0985", size = 557457, upload-time = "2026-10-06T20:32:06.52Z" }, + { url = "https://files.pythonhosted.org/packages/54/d5/ccb76555a333f543c4d6ad6422b616efc0811dbbde5054fda071e249c7bf/asyncpg-0.32.0-cp315-cp315-win_amd64.whl", hash = "sha256:6d1d1cd1348ebb9b204b5f56f977c5d4380674c25cc094064bf32bd9c3b7273d", size = 635573, upload-time = "2026-10-06T20:32:08.197Z" }, + { url = "https://files.pythonhosted.org/packages/38/70/dff17e837ba0eb4347bb33da33f54df87230d3d176793d4bb2ad7786b1b8/asyncpg-0.32.0-cp315-cp315-win_arm64.whl", hash = "sha256:cd5d16b3a5db37c1e6e445e362952b4af569f85f94e162f947bfa8ea25a45fa5", size = 594218, upload-time = "2026-10-06T20:32:09.717Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b8/c5506dbde0cfb213963210fd0c80e60036ddaaa883ac0d3c55d05a10ebe8/asyncpg-0.32.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4ea1a72a00fe705b68a9727c3d538c4c56690af9bb1cbbf3c089f5d3ddcccea0", size = 741693, upload-time = "2026-10-06T20:32:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/23/98/9f998c651aa5d66b59ab6c13da71a15d74ccb1ddc4d65290ea5e2e5aedc1/asyncpg-0.32.0-cp315-cp315t-macosx_11_0_x86_64.whl", hash = "sha256:ed3ae4c3659aea1fb0e3a6c1061fc4c64d9b7a2a8f4a27443dc43d74fa84cf03", size = 768101, upload-time = "2026-10-06T20:32:12.948Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ce/d8c63a71e908f5d80de1a3a057c8407aaea07cf19980d4b24ab624943c99/asyncpg-0.32.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db69b9cf879bddeea41210c80b8c8877bfe2709e2bee9d18d5a5c00e7eb75972", size = 3940715, upload-time = "2026-10-06T20:32:14.544Z" }, + { url = "https://files.pythonhosted.org/packages/b9/a5/5d2b17682e297e39206eda1dfe0120fc239e84d3440b39ff7c9cc7ec83db/asyncpg-0.32.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bee7bb5394bf55fc3bf4144625c33f298949961acdb1e0d67e60f958ac9a2e6", size = 3907504, upload-time = "2026-10-06T20:32:16.212Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/38ec7277f31f26267a0a0547d0997d936850d05007d1e0e1041bf8070e1d/asyncpg-0.32.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:d74eabd68e68861333e3fcb92b520a2a851f6485abf4b723887590399d4980c1", size = 3750324, upload-time = "2026-10-06T20:32:18.061Z" }, + { url = "https://files.pythonhosted.org/packages/dc/74/089e80eda7d543a49875687a84121e2ad61a7c69698963623ee77372c4e9/asyncpg-0.32.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6af2af292a93d5ef800007c8f8f66b85af2a49b49e4b56a10685a0dc24a6af83", size = 3826457, upload-time = "2026-10-06T20:32:19.757Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3c/38104e60cda6131977f95b634d45536ddc1cde53ef8bc765f9056e3e17ee/asyncpg-0.32.0-cp315-cp315t-win32.whl", hash = "sha256:d148cb6a9081ed999ca3cd0d95fb9eaf79bf17d885bba93c83de52273d2fe0af", size = 592437, upload-time = "2026-10-06T20:32:21.668Z" }, + { url = "https://files.pythonhosted.org/packages/95/09/85cba249db0910708826ea428b32a4a05630df993621c369bdb8d42c73c5/asyncpg-0.32.0-cp315-cp315t-win_amd64.whl", hash = "sha256:e101801b4124e905da0732cf2b0d838f682a9ea5273d7cced3d54bdbe744e6f7", size = 672417, upload-time = "2026-10-06T20:32:23.147Z" }, + { url = "https://files.pythonhosted.org/packages/38/11/ec5f7f306dd361aa9558f002cbb6acfa1e9ba32fa59b8f53135fbdfa14f1/asyncpg-0.32.0-cp315-cp315t-win_arm64.whl", hash = "sha256:3bbf08c08e31f43be858255614518e78cdfb343571e557e818e9fe736334f4c8", size = 622767, upload-time = "2026-10-06T20:32:24.64Z" }, +] + [[package]] name = "attrs" version = "25.3.0" @@ -157,6 +228,7 @@ source = { virtual = "." } dependencies = [ { name = "aiohttp" }, { name = "aioredis" }, + { name = "asyncpg" }, { name = "beautifulsoup4" }, { name = "cachetools" }, { name = "cryptography" }, @@ -177,7 +249,7 @@ dependencies = [ { name = "reportlab" }, { name = "requests" }, { name = "sendgrid" }, - { name = "sqlalchemy" }, + { name = "sqlalchemy", extra = ["asyncio"] }, { name = "stripe" }, { name = "supabase" }, { name = "tenacity" }, @@ -188,12 +260,15 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, ] [package.metadata] requires-dist = [ { name = "aiohttp", specifier = ">=3.12.15" }, { name = "aioredis", specifier = ">=2.0.0" }, + { name = "asyncpg", specifier = ">=0.27.0" }, { name = "beautifulsoup4", specifier = ">=4.13.4" }, { name = "cachetools", specifier = ">=6.2.0" }, { name = "cryptography", specifier = ">=46.0.2" }, @@ -214,7 +289,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" }, @@ -223,7 +298,11 @@ requires-dist = [ ] [package.metadata.requires-dev] -dev = [{ name = "pre-commit", specifier = ">=4.3.0" }] +dev = [ + { name = "pre-commit", specifier = ">=4.3.0" }, + { name = "pytest", specifier = ">=9.2.0" }, + { name = "pytest-asyncio", specifier = ">=1.4.1.post1" }, +] [[package]] name = "beautifulsoup4" @@ -819,6 +898,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, +] + [[package]] name = "jiter" version = "0.11.0" @@ -1303,6 +1391,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" }, ] +[[package]] +name = "pluggy" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" }, +] + [[package]] name = "postgrest" version = "1.1.1" @@ -1592,6 +1689,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" }, ] +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + [[package]] name = "pyhumps" version = "3.8.0" @@ -1610,6 +1716,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, ] +[[package]] +name = "pytest" +version = "9.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/42/bb4641652e58dedeac2ec51abc1f8cae4fee4ec63899a0999016229a0f2a/pytest-9.2.0.tar.gz", hash = "sha256:97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b", size = 1797555, upload-time = "2026-10-27T21:30:20.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/39/1f5d8c8877a0fa327db959c19341d4be61660de1ab6b7391f9eb805bcd67/pytest-9.2.0-py3-none-any.whl", hash = "sha256:07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588", size = 411776, upload-time = "2026-10-27T21:30:19.131Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.1.post1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/7f/c60a4338420b33453d4f814ac0c930abd399f162b9bdd21af4b9e363037f/pytest_asyncio-1.4.1.post1.tar.gz", hash = "sha256:f582d9ea4a1c082195abe8bd29a1fe106aeb1cd435e3dad2d3bc2749935c36a2", size = 145360, upload-time = "2026-11-01T18:26:03.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/de/adbc7578959cfab07e5e7c05cf6d54224a51e7667ea2c07458b6bc571e75/pytest_asyncio-1.4.1.post1-py3-none-any.whl", hash = "sha256:0755e488cc0a2b9bd2eab7292cc6390d4cbb8390f1020745647bae5a708e0165", size = 17140, upload-time = "2026-11-01T18:26:02.168Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1863,6 +1998,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 a21bba404e..dbf0f99bc0 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -1,16 +1,39 @@ -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'); + // Properties come from the backend for the logged-in tenant only, so one + // client never sees another client's property names. + const [properties, setProperties] = useState([]); + const [selectedProperty, setSelectedProperty] = useState(''); + const [propertiesError, setPropertiesError] = useState(''); + // "YYYY-MM" from , or "" for all time. + const [period, setPeriod] = useState(''); + const [periodYear, periodMonth] = period ? period.split('-').map(Number) : [undefined, undefined]; + + useEffect(() => { + let cancelled = false; + SecureAPI.getDashboardProperties() + .then((list) => { + if (cancelled) return; + setProperties(list); + setSelectedProperty(list[0]?.id ?? ''); + }) + .catch((err) => { + console.error(err); + if (!cancelled) setPropertiesError('Failed to load properties'); + }); + return () => { + cancelled = true; + }; + }, []); return (
@@ -27,6 +50,32 @@ const Dashboard: React.FC = () => {

+
+ {/* Month filter (calendar month in each property's own timezone) */} +
+ +
+ setPeriod(e.target.value)} + className="block 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" + /> + {period && ( + + )} +
+
+ {/* Property Selector */}
@@ -35,18 +84,24 @@ const Dashboard: React.FC = () => { onChange={(e) => setSelectedProperty(e.target.value)} className="block w-full sm:w-auto min-w-[200px] 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" > - {PROPERTIES.map((property) => ( + {properties.map((property) => ( ))}
+
- + {propertiesError && ( +
{propertiesError}
+ )} + {selectedProperty && ( + + )}
diff --git a/frontend/src/components/RevenueSummary.tsx b/frontend/src/components/RevenueSummary.tsx index dbb6d0629d..b8678ed3ef 100644 --- a/frontend/src/components/RevenueSummary.tsx +++ b/frontend/src/components/RevenueSummary.tsx @@ -9,26 +9,33 @@ interface RevenueData { } interface RevenueSummaryProps { - propertyId?: string; - debugTenant?: string; + propertyId: string; + // Calendar month (1-12) and year; both unset means all time. + month?: number; + year?: number; showRaw?: boolean; } -export const RevenueSummary: React.FC = ({ propertyId = 'prop-001', debugTenant, showRaw }) => { +const periodLabel = (month?: number, year?: number) => + month && year + ? new Date(year, month - 1, 1).toLocaleString(undefined, { month: 'long', year: 'numeric' }) + : 'All time'; + +export const RevenueSummary: React.FC = ({ propertyId, month, year, 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); + setError(''); try { - // Use SecureAPI to handle authentication automatically - // We pass the simulatedTenant option which SecureAPI will attach as a header + // SecureAPI attaches the auth token; the backend takes the + // tenant from that token only. const response = await SecureAPI.getDashboardSummary(propertyId, { - simulatedTenant: activeTenant, + month, + year, timestamp: Date.now() }); setData(response); @@ -41,7 +48,7 @@ export const RevenueSummary: React.FC = ({ propertyId = 'pr }; fetchRevenue(); - }, [propertyId, activeTenant]); + }, [propertyId, month, year]); if (loading) { return ( @@ -76,17 +83,11 @@ export const RevenueSummary: React.FC = ({ propertyId = 'pr

Total Revenue

+

{periodLabel(month, year)}

{data.currency} {displayTotal.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} - {/* Fake trend indicator for premium feel */} - - - 12% -
diff --git a/frontend/src/lib/secureApi.ts b/frontend/src/lib/secureApi.ts index f85f04c90a..fb83055bfa 100644 --- a/frontend/src/lib/secureApi.ts +++ b/frontend/src/lib/secureApi.ts @@ -1450,22 +1450,30 @@ export class SecureAPIClient { // ============= DASHBOARD API ============= /** - * Get dashboard summary with optional simulation header + * Get dashboard summary. The tenant always comes from the auth token. */ - async getDashboardSummary(propertyId: string, options?: { simulatedTenant?: string, timestamp?: number }) { + async getDashboardSummary( + propertyId: string, + options?: { month?: number; year?: number; timestamp?: number } + ) { const queryParams = new URLSearchParams({ property_id: propertyId }); + // The backend needs both or neither; month is in the property's timezone. + if (options?.month && options?.year) { + queryParams.append('month', options.month.toString()); + queryParams.append('year', options.year.toString()); + } if (options?.timestamp) { queryParams.append('_t', options.timestamp.toString()); } - const requestOptions: RequestInit = {}; - if (options?.simulatedTenant) { - requestOptions.headers = { - 'X-Simulated-Tenant': options.simulatedTenant - }; - } + return this.request(`/api/v1/dashboard/summary?${queryParams}`); + } - return this.request(`/api/v1/dashboard/summary?${queryParams}`, requestOptions); + /** + * Get the properties of the logged-in user's tenant + */ + async getDashboardProperties() { + return this.request>('/api/v1/dashboard/properties'); } async uploadCompanyLogo(logo_url: string) {