Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Python bytecode and caches
__pycache__/
*.py[cod]

13 changes: 13 additions & 0 deletions NOTES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Client-Reported Issues: Priority Order

## P0 — Cross-tenant revenue data exposure

Client B sometimes sees revenue numbers belonging to another company after refreshing.

## P1 — Revenue totals do not match client records

Client A reports that March revenue totals differ from its internal records.

## P2 — Revenue totals are off by a few cents

Finance has observed small discrepancies in some totals.
2 changes: 1 addition & 1 deletion backend/app/services/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ async def get_revenue_summary(property_id: str, tenant_id: str) -> Dict[str, Any
"""
Fetches revenue summary, utilizing caching to improve performance.
"""
cache_key = f"revenue:{property_id}"
cache_key = f"revenue:{tenant_id}:{property_id}"

# Try to get from cache
cached = await redis_client.get(cache_key)
Expand Down
87 changes: 65 additions & 22 deletions backend/app/services/reservations.py
Original file line number Diff line number Diff line change
@@ -1,35 +1,78 @@
from datetime import datetime
from decimal import Decimal
from decimal import Decimal, ROUND_HALF_UP
from typing import Dict, Any, List
from zoneinfo import ZoneInfo

async def calculate_monthly_revenue(property_id: str, month: int, year: int, db_session=None) -> Decimal:
CENT = Decimal("0.01")


def round_currency(amount: Any) -> Decimal:
"""Round a monetary total to cents using conventional financial rounding."""
return Decimal(str(amount)).quantize(CENT, rounding=ROUND_HALF_UP)


async def calculate_monthly_revenue(
property_id: str,
month: int,
year: int,
db_session=None,
tenant_id: str = None,
) -> Decimal:
"""
Calculates revenue for a specific month.
"""

start_date = datetime(year, month, 1)
from sqlalchemy import text
from app.core.database_pool import db_pool

property_timezone_query = text("""
SELECT timezone
FROM properties
WHERE id = :property_id
AND tenant_id = :tenant_id
""")

property_timezone = "UTC"
if month < 12:
end_date = datetime(year, month + 1, 1)
next_month = (year, month + 1)
else:
end_date = datetime(year + 1, 1, 1)

print(f"DEBUG: Querying revenue for {property_id} from {start_date} to {end_date}")
next_month = (year + 1, 1)

# SQL Simulation (This would be executed against the actual DB)
query = """
SELECT SUM(total_amount) as total
query = text("""
SELECT COALESCE(SUM(total_amount), 0) 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
WHERE property_id = :property_id
AND tenant_id = :tenant_id
AND check_in_date >= :start_date
AND check_in_date < :end_date
""")

owns_session = db_session is None
if owns_session:
await db_pool.initialize()
db_session = await db_pool.get_session()

try:
timezone_result = await db_session.execute(property_timezone_query, {
"property_id": property_id,
"tenant_id": tenant_id,
})
timezone_name = timezone_result.scalar_one_or_none() or property_timezone
local_timezone = ZoneInfo(timezone_name)
start_date = datetime(year, month, 1, tzinfo=local_timezone)
end_date = datetime(next_month[0], next_month[1], 1, tzinfo=local_timezone)

result = await db_session.execute(query, {
"property_id": property_id,
"tenant_id": tenant_id,
"start_date": start_date,
"end_date": end_date,
})
row = result.fetchone()
return round_currency(row.total if row else 0)
finally:
if owns_session:
await db_session.close()

async def calculate_total_revenue(property_id: str, tenant_id: str) -> Dict[str, Any]:
"""
Expand Down Expand Up @@ -65,7 +108,7 @@ 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 = round_currency(row.total_revenue)
return {
"property_id": property_id,
"tenant_id": tenant_id,
Expand Down
87 changes: 87 additions & 0 deletions backend/app/tests/test_revenue_data_cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import pytest
from fastapi.testclient import TestClient

from app.services import cache as cache_service
from app.services import reservations

from app.main import app


@pytest.fixture
def client():
with TestClient(app) as test_client:
yield test_client


def login(client, email, password):
response = client.post(
"/api/v1/auth/login",
json={"email": email, "password": password},
)
response.raise_for_status()
return response.json()["access_token"]


@pytest.fixture
def a_token(client):
return login(
client,
"sunset@propertyflow.com",
"client_a_2024"
)


@pytest.fixture
def b_token(client):
return login(
client,
"ocean@propertyflow.com",
"client_b_2024"
)


class FakeRedis:
def __init__(self):
self.values = {}

async def get(self, key):
return self.values.get(key)

async def setex(self, key, ttl, value):
self.values[key] = value


async def fake_revenue(property_id, tenant_id):
totals = {
"tenant-a": ("1000.00", 3),
"tenant-b": ("2500.00", 5),
}

total, count = totals[tenant_id]

return {
"property_id": property_id,
"tenant_id": tenant_id,
"total": total,
"currency": "USD",
"count": count,
}


def test_revenue_isolated_between_tenants(client, a_token, b_token, monkeypatch):
monkeypatch.setattr(cache_service, "redis_client", FakeRedis())
monkeypatch.setattr(reservations, "calculate_total_revenue", fake_revenue)

response_a = client.get(
"/api/v1/dashboard/summary?property_id=prop-001",
headers={"Authorization": f"Bearer {a_token}"},
)
response_b = client.get(
"/api/v1/dashboard/summary?property_id=prop-001",
headers={"Authorization": f"Bearer {b_token}"},
)

assert response_a.status_code == 200
assert response_b.status_code == 200
assert response_a.json()["total_revenue"] == 1000.0
assert response_b.json()["total_revenue"] == 2500.0
75 changes: 75 additions & 0 deletions backend/app/tests/test_revenue_totals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import asyncio
import os
from decimal import Decimal
from types import SimpleNamespace

import pytest
from fastapi.testclient import TestClient
from supabase import create_client

from app.config import settings
from app.main import app
from app.services.reservations import calculate_monthly_revenue


@pytest.fixture
def client():
with TestClient(app) as test_client:
yield test_client


@pytest.fixture
def a_token():
supabase = create_client(
settings.supabase_url,
settings.supabase_anon_key,
)
response = supabase.auth.sign_in_with_password({
"email": "sunset@propertyflow.com",
"password": os.environ["TEST_TENANT_A_PASSWORD"],
})
return response.session.access_token


def test_monthly_revenue_endpoint_counts_march_in_property_timezone(client, a_token):
# prop-001 is Europe/Paris. The res-tz-1 reservation starts at
# 2024-02-29 23:30 UTC, which is 2024-03-01 00:30 local time.
response = client.get(
"/api/v1/revenue/2024/3/?property_id=prop-001",
headers={"Authorization": f"Bearer {a_token}"},
)
assert response.status_code == 200
assert response.json()["total_revenue"] == 3500.0


def test_monthly_revenue_rounds_half_cents_after_aggregation():
class Result:
def __init__(self, *, scalar=None, row=None):
self.scalar = scalar
self.row = row

def scalar_one_or_none(self):
return self.scalar

def fetchone(self):
return self.row

class Session:
def __init__(self):
self.results = iter([
Result(scalar="UTC"),
Result(row=SimpleNamespace(total=Decimal("10.005"))),
])

async def execute(self, _query, _parameters):
return next(self.results)

total = asyncio.run(calculate_monthly_revenue(
property_id="prop-001",
month=3,
year=2024,
db_session=Session(),
tenant_id="tenant-a",
))

assert total == Decimal("10.01")
3 changes: 2 additions & 1 deletion backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ dependencies = [
[dependency-groups]
dev = [
"pre-commit>=4.3.0",
"pytest>=9.1.1",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
testpaths = ["app/tests"]
pythonpath = ["."]
Loading