diff --git a/src/auth/supabase_entitlement.py b/src/auth/supabase_entitlement.py index ce516c06..0b0d9a87 100644 --- a/src/auth/supabase_entitlement.py +++ b/src/auth/supabase_entitlement.py @@ -218,14 +218,17 @@ class SupabaseEntitlementService: response.status_code, ) row = None + rows: List[Dict[str, object]] = [] else: data = response.json() if response.content else [] - row = self._pick_latest_current_subscription(data, now=now) + rows = [item for item in data if isinstance(item, dict)] if isinstance(data, list) else [] + row = self._pick_latest_current_subscription(rows, now=now) with self._sub_cache_lock: self._sub_cache[user_id] = { "active": bool(row), "row": row, + "rows": rows, "ts": now_ts, } return row @@ -236,6 +239,7 @@ class SupabaseEntitlementService: def _query_active_subscription_rows( self, user_id: str, + bypass_cache: bool = False, ) -> List[Dict[str, object]]: if not user_id: return [] @@ -244,16 +248,17 @@ class SupabaseEntitlementService: return [] now_ts = time.time() - with self._sub_cache_lock: - cached = self._sub_cache.get(user_id) - if cached and now_ts - float(cached.get("ts") or 0) < self.sub_cache_ttl_sec: - rows = cached.get("rows") - if isinstance(rows, list): - return [row for row in rows if isinstance(row, dict)] - row = cached.get("row") - if isinstance(row, dict): - return [row] - return [] + if not bypass_cache: + with self._sub_cache_lock: + cached = self._sub_cache.get(user_id) + if cached and now_ts - float(cached.get("ts") or 0) < self.sub_cache_ttl_sec: + rows = cached.get("rows") + if isinstance(rows, list): + return [row for row in rows if isinstance(row, dict)] + row = cached.get("row") + if isinstance(row, dict): + return [row] + return [] try: now = datetime.now(timezone.utc) @@ -520,10 +525,11 @@ class SupabaseEntitlementService: self, user_id: str, respect_requirement: bool = True, + bypass_cache: bool = False, ) -> Dict[str, object]: if respect_requirement and not self.require_subscription: return {} - rows = self._query_active_subscription_rows(user_id) + rows = self._query_active_subscription_rows(user_id, bypass_cache=bypass_cache) if not rows: return {} diff --git a/tests/test_supabase_entitlement.py b/tests/test_supabase_entitlement.py index 145e16d3..1aff5597 100644 --- a/tests/test_supabase_entitlement.py +++ b/tests/test_supabase_entitlement.py @@ -100,3 +100,43 @@ def test_latest_active_subscription_ignores_future_start(monkeypatch): assert result is not None assert result["plan_code"] == "signup_trial_3d" + + +def test_subscription_window_keeps_queued_renewal_after_current_cache(monkeypatch): + monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co") + monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key") + monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role") + + service = SupabaseEntitlementService() + + now = datetime.now(timezone.utc) + current = { + "id": 1, + "user_id": "user-1", + "status": "active", + "plan_code": "pro_monthly", + "starts_at": (now - timedelta(days=29)).isoformat(), + "expires_at": (now + timedelta(days=1)).isoformat(), + } + queued = { + "id": 2, + "user_id": "user-1", + "status": "active", + "plan_code": "pro_monthly", + "starts_at": (now + timedelta(days=1)).isoformat(), + "expires_at": (now + timedelta(days=31)).isoformat(), + } + + def _fake_get(url, headers=None, params=None, timeout=None): + return _Response(200, [queued, current]) + + monkeypatch.setattr(entitlement_module.requests, "get", _fake_get) + + assert service._query_latest_active_subscription("user-1") == current + + window = service.get_subscription_window("user-1", respect_requirement=False) + + assert window["current"] == current + assert window["total_expires_at"] == queued["expires_at"] + assert window["queued_days"] == 30 + assert window["queued_count"] == 1 diff --git a/web/routes.py b/web/routes.py index e45173a1..dd5ae31a 100644 --- a/web/routes.py +++ b/web/routes.py @@ -1083,6 +1083,7 @@ async def ops_memberships(request: Request, limit: int = 200): subscription_window = SUPABASE_ENTITLEMENT.get_subscription_window( user_id, respect_requirement=False, + bypass_cache=True, ) current_expires_at = item.get("expires_at") total_expires_at = (