diff --git a/app/core/clients/rate_limit_reset_credits.py b/app/core/clients/rate_limit_reset_credits.py index 9c922d6ca..e0eecfd11 100644 --- a/app/core/clients/rate_limit_reset_credits.py +++ b/app/core/clients/rate_limit_reset_credits.py @@ -17,6 +17,10 @@ ) from app.core.clients.headers import build_chatgpt_auth_headers from app.core.clients.http import lease_retry_client +from app.core.clients.usage import ( + _retry_delay_seconds, + _safe_codex_json, +) from app.core.config.settings import get_settings from app.core.types import JsonObject from app.core.upstream_proxy import ResolvedUpstreamRoute @@ -125,7 +129,7 @@ async def fetch_reset_credits( try: if route is not None: - return await _fetch_reset_credits_via_codex( + data = await _fetch_reset_credits_via_codex( url=url, route=route, headers=headers, @@ -133,31 +137,32 @@ async def fetch_reset_credits( retries=retries, codex_client=codex_client, ) - async with lease_retry_client(client) as retry_client: - async with retry_client.request( - "GET", - url, - headers=headers, - timeout=timeout, - retry_options=retry_options, - ) as resp: - data = await _safe_json(resp) - if resp.status >= 400: - code = _extract_error_code(data) - message = _extract_error_message(data) or f"Reset credits fetch failed ({resp.status})" - logger.warning( - "Reset credits fetch failed request_id=%s status=%s code=%s message=%s", - get_request_id(), - resp.status, - code, - message, - ) - raise ResetCreditFetchError(resp.status, message, code=code) - try: - return ResetCreditsResponse.model_validate(_success_payload(data)) - except (ValueError, ValidationError) as exc: - logger.warning("Reset credits fetch invalid payload request_id=%s", get_request_id()) - raise ResetCreditFetchError(502, "Invalid reset credits payload") from exc + else: + async with lease_retry_client(client) as retry_client: + async with retry_client.request( + "GET", + url, + headers=headers, + timeout=timeout, + retry_options=retry_options, + ) as resp: + data = await _safe_json(resp) + if resp.status >= 400: + code = _extract_error_code(data) + message = _extract_error_message(data) or f"Reset credits fetch failed ({resp.status})" + logger.warning( + "Reset credits fetch failed request_id=%s status=%s code=%s message=%s", + get_request_id(), + resp.status, + code, + message, + ) + raise ResetCreditFetchError(resp.status, message, code=code) + try: + return ResetCreditsResponse.model_validate(_success_payload(data)) + except (ValueError, ValidationError) as exc: + logger.warning("Reset credits fetch invalid payload request_id=%s", get_request_id()) + raise ResetCreditFetchError(502, "Invalid reset credits payload") from exc except (aiohttp.ClientError, asyncio.TimeoutError, CodexTransportError) as exc: logger.warning("Reset credits fetch error request_id=%s error=%s", get_request_id(), exc) raise ResetCreditFetchError(0, f"Reset credits fetch failed: {exc}") from exc @@ -180,6 +185,8 @@ async def consume_reset_credit( usage_base = base_url or settings.upstream_base_url url = _consume_url(usage_base) timeout = aiohttp.ClientTimeout(total=timeout_seconds or settings.usage_fetch_timeout_seconds) + # Consume is non-idempotent, so omitted max_retries must not inherit the + # fetch retry budget and risk replaying a successful upstream redemption. retries = max_retries if max_retries is not None else 0 headers = build_chatgpt_auth_headers( access_token, @@ -237,123 +244,6 @@ async def consume_reset_credit( raise ConsumeResetCreditError(0, f"Reset credits consume failed: {exc}") from exc -async def _fetch_reset_credits_via_codex( - *, - url: str, - route: ResolvedUpstreamRoute, - headers: dict[str, str], - timeout_seconds: float, - retries: int, - codex_client: CodexClient | None, -) -> ResetCreditsResponse: - attempts = max(1, retries + 1) - owns_codex_client = codex_client is None - active_codex_client = codex_client or CodexClient(create_codex_session()) - try: - for attempt in range(attempts): - try: - data, status = await _request_json_via_codex( - active_codex_client, - "GET", - url, - route=route, - headers=headers, - timeout_seconds=timeout_seconds, - ) - except CodexTransportError: - if attempt < attempts - 1: - await asyncio.sleep(_retry_delay_seconds(attempt)) - continue - raise - if status in RETRYABLE_STATUS and attempt < attempts - 1: - await asyncio.sleep(_retry_delay_seconds(attempt)) - continue - if status >= 400: - code = _extract_error_code(data) - message = _extract_error_message(data) or f"Reset credits fetch failed ({status})" - raise ResetCreditFetchError(status, message, code=code) - try: - return ResetCreditsResponse.model_validate(_success_payload(data)) - except (ValueError, ValidationError) as exc: - raise ResetCreditFetchError(502, "Invalid reset credits payload") from exc - finally: - if owns_codex_client: - close = getattr(active_codex_client, "close", None) - if callable(close): - await close() - raise RuntimeError("unreachable reset credits fetch retry state") - - -async def _consume_reset_credit_via_codex( - *, - url: str, - route: ResolvedUpstreamRoute, - headers: dict[str, str], - body: JsonObject, - timeout_seconds: float, - retries: int, - codex_client: CodexClient | None, -) -> ConsumeResetCreditResponse: - attempts = max(1, retries + 1) - owns_codex_client = codex_client is None - active_codex_client = codex_client or CodexClient(create_codex_session()) - try: - for attempt in range(attempts): - try: - data, status = await _request_json_via_codex( - active_codex_client, - "POST", - url, - route=route, - headers=headers, - json_body=body, - timeout_seconds=timeout_seconds, - ) - except CodexTransportError: - if attempt < attempts - 1: - await asyncio.sleep(_retry_delay_seconds(attempt)) - continue - raise - if status in RETRYABLE_STATUS and attempt < attempts - 1: - await asyncio.sleep(_retry_delay_seconds(attempt)) - continue - if status >= 400: - code = _extract_error_code(data) - message = _extract_error_message(data) or f"Reset credits consume failed ({status})" - raise ConsumeResetCreditError(status, message, code=code) - try: - return ConsumeResetCreditResponse.model_validate(_success_payload(data)) - except (ValueError, ValidationError) as exc: - raise ConsumeResetCreditError(502, "Invalid reset credits consume payload") from exc - finally: - if owns_codex_client: - close = getattr(active_codex_client, "close", None) - if callable(close): - await close() - raise RuntimeError("unreachable reset credits consume retry state") - - -async def _request_json_via_codex( - codex_client: CodexClient, - method: str, - url: str, - *, - route: ResolvedUpstreamRoute, - headers: dict[str, str], - timeout_seconds: float, - json_body: JsonObject | None = None, -) -> tuple[JsonObject, int]: - request_kwargs: dict[str, object] = { - "route": route, - "headers": headers, - "timeout": timeout_seconds, - } - if json_body is not None: - request_kwargs["json"] = json_body - resp = await codex_client.request(method, url, **request_kwargs) - return await _safe_codex_json(resp), _codex_response_status(resp) - - def build_snapshot(response: ResetCreditsResponse) -> RateLimitResetCreditsSnapshot: """Project an upstream list response into the cached snapshot shape.""" nearest = _nearest_available_expires_at(response.credits) @@ -382,41 +272,6 @@ def _consume_url(base_url: str) -> str: return f"{_reset_credits_url(base_url)}/consume" -def _codex_response_status(response: object) -> int: - value = getattr(response, "status_code", getattr(response, "status", None)) - if value is None: - return 0 - return int(value) - - -async def _safe_codex_json(response: object) -> JsonObject: - try: - json_method = getattr(response, "json", None) - if callable(json_method): - data = json_method() - if asyncio.iscoroutine(data): - data = await data - return data if isinstance(data, dict) else {"error": {"message": str(data)}} - except Exception: - pass - content = getattr(response, "content", None) - if isinstance(content, bytes): - return {"error": {"message": content.decode("utf-8", errors="replace").strip()}} - if isinstance(content, str): - return {"error": {"message": content.strip()}} - text_method = getattr(response, "text", None) - if callable(text_method): - try: - text = text_method() - if asyncio.iscoroutine(text): - text = await text - if isinstance(text, str): - return {"error": {"message": text.strip()}} - except Exception: - pass - return {"error": {"message": ""}} - - async def _safe_json(resp: aiohttp.ClientResponse) -> JsonObject: try: data = await resp.json(content_type=None) @@ -478,5 +333,106 @@ def _retry_options(attempts: int) -> ExponentialRetry: ) -def _retry_delay_seconds(attempt: int) -> float: - return min(RETRY_MAX_TIMEOUT, RETRY_START_TIMEOUT * (2.0**attempt)) +async def _fetch_reset_credits_via_codex( + *, + url: str, + route: ResolvedUpstreamRoute, + headers: dict[str, str], + timeout_seconds: float, + retries: int, + codex_client: CodexClient | None, +) -> JsonObject: + attempts = max(1, retries + 1) + owns_codex_client = codex_client is None + active_codex_client = codex_client or CodexClient(create_codex_session()) + try: + for attempt in range(attempts): + try: + resp = await active_codex_client.request( + "GET", + url, + route=route, + headers=headers, + timeout=timeout_seconds, + ) + except CodexTransportError: + if attempt < attempts - 1: + await asyncio.sleep(_retry_delay_seconds(attempt)) + continue + raise + + data = await _safe_codex_json(resp) + status = _codex_response_status(resp) + if status in RETRYABLE_STATUS and attempt < attempts - 1: + await asyncio.sleep(_retry_delay_seconds(attempt)) + continue + if status >= 400: + code = _extract_error_code(data) + message = _extract_error_message(data) or f"Reset credits fetch failed ({status})" + raise ResetCreditFetchError(status, message, code=code) + return data if isinstance(data, dict) else {"error": {"message": str(data)}} + finally: + if owns_codex_client: + close = getattr(active_codex_client, "close", None) + if callable(close): + await close() + raise RuntimeError("unreachable reset credits fetch retry state") + + +async def _consume_reset_credit_via_codex( + *, + url: str, + route: ResolvedUpstreamRoute, + headers: dict[str, str], + body: dict[str, str], + timeout_seconds: float, + retries: int, + codex_client: CodexClient | None, +) -> ConsumeResetCreditResponse: + attempts = max(1, retries + 1) + owns_codex_client = codex_client is None + active_codex_client = codex_client or CodexClient(create_codex_session()) + try: + for attempt in range(attempts): + try: + resp = await active_codex_client.request( + "POST", + url, + route=route, + headers=headers, + json=body, + timeout=timeout_seconds, + ) + except CodexTransportError: + if attempt < attempts - 1: + await asyncio.sleep(_retry_delay_seconds(attempt)) + continue + raise + + data = await _safe_codex_json(resp) + status = _codex_response_status(resp) + if status in RETRYABLE_STATUS and attempt < attempts - 1: + await asyncio.sleep(_retry_delay_seconds(attempt)) + continue + if status >= 400: + code = _extract_error_code(data) + message = _extract_error_message(data) or f"Reset credits consume failed ({status})" + raise ConsumeResetCreditError(status, message, code=code) + try: + return ConsumeResetCreditResponse.model_validate(_success_payload(data)) + except (ValueError, ValidationError) as exc: + logger.warning("Reset credits consume invalid payload request_id=%s", get_request_id()) + raise ConsumeResetCreditError(502, "Invalid reset credits consume payload") from exc + finally: + if owns_codex_client: + close = getattr(active_codex_client, "close", None) + if callable(close): + await close() + raise RuntimeError("unreachable reset credits consume retry state") + + +def _codex_response_status(response: object) -> int: + value = getattr(response, "status_code", getattr(response, "status", None)) + if value is None: + return 0 + return int(value) diff --git a/app/core/usage/models.py b/app/core/usage/models.py index f37c08f1e..1a531e517 100644 --- a/app/core/usage/models.py +++ b/app/core/usage/models.py @@ -27,6 +27,12 @@ class CreditsPayload(BaseModel): balance: str | None = None +class RateLimitResetCreditsSummary(BaseModel): + model_config = ConfigDict(extra="ignore") + + available_count: int | None = None + + class AdditionalRateLimitPayload(BaseModel): model_config = ConfigDict(extra="ignore") @@ -44,4 +50,5 @@ class UsagePayload(BaseModel): seat_type: str | None = None rate_limit: RateLimitPayload | None = None credits: CreditsPayload | None = None + rate_limit_reset_credits: RateLimitResetCreditsSummary | None = None additional_rate_limits: list[AdditionalRateLimitPayload] | None = None diff --git a/app/core/usage/reset_credits_refresh_scheduler.py b/app/core/usage/reset_credits_refresh_scheduler.py index 093f985a4..8ac6d9980 100644 --- a/app/core/usage/reset_credits_refresh_scheduler.py +++ b/app/core/usage/reset_credits_refresh_scheduler.py @@ -7,13 +7,14 @@ from dataclasses import dataclass, field from app.core.clients.rate_limit_reset_credits import ( + ResetCreditFetchError, ResetCreditsResponse, build_snapshot, fetch_reset_credits, ) from app.core.config.settings import get_settings from app.core.crypto import TokenEncryptor -from app.core.upstream_proxy import ResolvedUpstreamRoute, UpstreamProxyRouteError, resolve_upstream_route +from app.core.upstream_proxy import ResolvedUpstreamRoute, UpstreamProxyRouteError from app.db.models import Account, AccountStatus from app.db.session import get_background_session from app.modules.accounts.repository import AccountsRepository @@ -21,13 +22,16 @@ RateLimitResetCreditsStore, get_rate_limit_reset_credits_store, ) +from app.modules.usage.updater import _resolve_upstream_route_for_account logger = logging.getLogger(__name__) -_RESET_CREDITS_SKIP_STATUSES = frozenset({AccountStatus.PAUSED, AccountStatus.DEACTIVATED}) +_RESET_CREDITS_SKIP_STATUSES = frozenset( + {AccountStatus.PAUSED, AccountStatus.REAUTH_REQUIRED, AccountStatus.DEACTIVATED} +) ResetCreditsFetchFn = Callable[..., Awaitable[ResetCreditsResponse]] -UpstreamRouteResolver = Callable[[Account], Awaitable[ResolvedUpstreamRoute | None]] +ResolveRouteFn = Callable[[Account], Awaitable[ResolvedUpstreamRoute | None]] @dataclass(slots=True) @@ -71,7 +75,7 @@ async def _refresh_once(self) -> None: encryptor=TokenEncryptor(), store=get_rate_limit_reset_credits_store(), fetch_fn=fetch_reset_credits, - route_resolver=_resolve_upstream_route_for_account, + resolve_route=_resolve_reset_credits_refresh_route, ) except Exception: logger.exception("Reset credits refresh loop failed") @@ -83,7 +87,7 @@ async def refresh_reset_credits_for_accounts( encryptor: TokenEncryptor, store: RateLimitResetCreditsStore, fetch_fn: ResetCreditsFetchFn = fetch_reset_credits, - route_resolver: UpstreamRouteResolver | None = None, + resolve_route: ResolveRouteFn | None = None, ) -> None: """Refresh the cached reset-credits snapshot for each eligible account. @@ -93,7 +97,10 @@ async def refresh_reset_credits_for_accounts( stays owned by usage refresh. One account failing must not abort the loop. """ for account in accounts: - if not _is_reset_credits_refresh_eligible(account): + if account.status in _RESET_CREDITS_SKIP_STATUSES: + await store.invalidate(account.id) + continue + if not account.chatgpt_account_id: await store.invalidate(account.id) continue await _refresh_account_reset_credits( @@ -101,12 +108,12 @@ async def refresh_reset_credits_for_accounts( encryptor=encryptor, store=store, fetch_fn=fetch_fn, - route_resolver=route_resolver, + resolve_route=resolve_route, ) -def _is_reset_credits_refresh_eligible(account: Account) -> bool: - return account.status not in _RESET_CREDITS_SKIP_STATUSES and bool(account.chatgpt_account_id) +async def _resolve_reset_credits_refresh_route(account: Account) -> ResolvedUpstreamRoute | None: + return await _resolve_upstream_route_for_account(account, operation="usage_refresh") async def _refresh_account_reset_credits( @@ -115,32 +122,43 @@ async def _refresh_account_reset_credits( encryptor: TokenEncryptor, store: RateLimitResetCreditsStore, fetch_fn: ResetCreditsFetchFn, - route_resolver: UpstreamRouteResolver | None, + resolve_route: ResolveRouteFn | None = None, ) -> None: snapshot_generation = store.generation(account.id) + route: ResolvedUpstreamRoute | None = None + if resolve_route is not None: + try: + route = await resolve_route(account) + except UpstreamProxyRouteError as exc: + logger.warning( + "Reset credits refresh upstream proxy route unavailable account_id=%s reason=%s", + account.id, + exc.reason, + ) + return try: access_token = encryptor.decrypt(account.access_token_encrypted) - route = await route_resolver(account) if route_resolver is not None else None response = await fetch_fn( access_token, account.chatgpt_account_id, route=route, allow_direct_egress=route is None, ) - except UpstreamProxyRouteError as exc: + except ResetCreditFetchError as exc: logger.warning( - "Reset credits route resolution failed account_id=%s reason=%s", + "Reset credits refresh failed account_id=%s error=%s", account.id, - exc.reason, + exc, ) return - except Exception as exc: # scheduler must never crash the loop or mutate account status + except Exception as exc: logger.warning( "Reset credits refresh failed account_id=%s error=%s", account.id, exc, ) return + snapshot = build_snapshot(response) stored = await store.set_if_generation(account.id, snapshot, snapshot_generation) if not stored: @@ -150,16 +168,6 @@ async def _refresh_account_reset_credits( ) -async def _resolve_upstream_route_for_account(account: Account) -> ResolvedUpstreamRoute | None: - async with get_background_session() as session: - return await resolve_upstream_route( - session, - account_id=account.id, - operation="reset_credits_refresh", - scope="account", - ) - - def build_rate_limit_reset_credits_scheduler() -> RateLimitResetCreditsRefreshScheduler: settings = get_settings() return RateLimitResetCreditsRefreshScheduler( diff --git a/app/modules/accounts/mappers.py b/app/modules/accounts/mappers.py index 2c751502c..a99339f44 100644 --- a/app/modules/accounts/mappers.py +++ b/app/modules/accounts/mappers.py @@ -30,7 +30,9 @@ from app.modules.usage.mappers import usage_history_to_window_row _ACCOUNT_ROUTING_POLICIES = frozenset({"burn_first", "normal", "preserve"}) -_RESET_CREDITS_INELIGIBLE_STATUSES = frozenset({AccountStatus.PAUSED, AccountStatus.DEACTIVATED}) +_RESET_CREDITS_INELIGIBLE_STATUSES = frozenset( + {AccountStatus.PAUSED, AccountStatus.REAUTH_REQUIRED, AccountStatus.DEACTIVATED} +) _DEFAULT_USAGE_REFRESH_INTERVAL_SECONDS = 60 diff --git a/app/modules/accounts/schemas.py b/app/modules/accounts/schemas.py index 93ba9da3b..68bd4c3c5 100644 --- a/app/modules/accounts/schemas.py +++ b/app/modules/accounts/schemas.py @@ -115,10 +115,8 @@ class AccountSummary(DashboardModel): # surface a "delete older" action without requiring the operator to # group rows by email themselves. See codex-lb #787 (B). is_email_duplicate: bool = False - # Banked rate-limit reset credits joined from the in-memory snapshot - # (refreshed by each replica's reset-credits scheduler). ``0`` / ``null`` - # when no snapshot is cached yet (e.g. right after restart); the dashboard - # hides all reset affordances in that case. + # Banked rate-limit reset credits from the in-memory snapshot when cached, + # otherwise the latest persisted primary usage_history count from /wham/usage. available_reset_credits: int = 0 reset_credit_nearest_expires_at: datetime | None = None diff --git a/app/modules/rate_limit_reset_credits/api.py b/app/modules/rate_limit_reset_credits/api.py index 3adffb1b7..a8b398715 100644 --- a/app/modules/rate_limit_reset_credits/api.py +++ b/app/modules/rate_limit_reset_credits/api.py @@ -1,23 +1,31 @@ from __future__ import annotations import asyncio +import logging +from collections.abc import Awaitable, Callable +from dataclasses import dataclass from datetime import datetime, timezone -from typing import Awaitable, Callable -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Request from pydantic import Field +from app.core.audit.service import AuditService from app.core.auth.dependencies import ( require_dashboard_write_access, set_dashboard_error_format, validate_dashboard_session, ) +from app.core.auth.refresh import RefreshError from app.core.clients.rate_limit_reset_credits import ( ConsumeResetCreditError, ConsumeResetCreditResponse, RateLimitResetCreditsSnapshot, + ResetCreditFetchError, ResetCreditItem, + ResetCreditsResponse, + build_snapshot, consume_reset_credit, + fetch_reset_credits, ) from app.core.crypto import TokenEncryptor from app.core.exceptions import ( @@ -27,14 +35,20 @@ DashboardPermissionError, DashboardServiceUnavailableError, ) -from app.core.upstream_proxy import ResolvedUpstreamRoute, UpstreamProxyRouteError, resolve_upstream_route +from app.core.upstream_proxy import ResolvedUpstreamRoute, UpstreamProxyRouteError +from app.core.usage.reset_credits_refresh_scheduler import _refresh_account_reset_credits from app.db.models import Account, AccountStatus from app.dependencies import AccountsContext, get_accounts_context +from app.modules.accounts.auth_manager import AuthManager +from app.modules.proxy.account_cache import get_account_selection_cache from app.modules.rate_limit_reset_credits.store import ( RateLimitResetCreditsStore, get_rate_limit_reset_credits_store, ) from app.modules.shared.schemas import DashboardModel +from app.modules.usage.updater import _resolve_upstream_route_for_account + +logger = logging.getLogger(__name__) router = APIRouter( prefix="/api/accounts", @@ -42,10 +56,17 @@ dependencies=[Depends(validate_dashboard_session), Depends(set_dashboard_error_format)], ) +FetchFn = Callable[..., Awaitable[ResetCreditsResponse]] ConsumeFn = Callable[..., Awaitable[ConsumeResetCreditResponse]] +RefreshUsageFn = Callable[[Account], Awaitable[None]] +ResolveRouteFn = Callable[[Account], Awaitable[ResolvedUpstreamRoute | None]] + +_NON_REDEEMABLE_STATUSES = frozenset( + {AccountStatus.PAUSED, AccountStatus.REAUTH_REQUIRED, AccountStatus.DEACTIVATED} +) + _redeem_locks: dict[str, asyncio.Lock] = {} _redeem_locks_registry_lock = asyncio.Lock() -_RESET_CREDITS_INELIGIBLE_STATUSES = frozenset({AccountStatus.PAUSED, AccountStatus.DEACTIVATED}) class ResetCreditItemResponse(DashboardModel): @@ -72,15 +93,42 @@ class ConsumeResetCreditResponseSchema(DashboardModel): redeemed_at: datetime | None = None +@dataclass(frozen=True, slots=True) +class _RedeemResetCreditOutcome: + response: ConsumeResetCreditResponseSchema + available_count_before: int + available_count_after: int + + @router.get( "/{account_id}/rate-limit-reset-credits", response_model=RateLimitResetCreditsSnapshotResponse | None, ) async def get_rate_limit_reset_credits( account_id: str, + context: AccountsContext = Depends(get_accounts_context), ) -> RateLimitResetCreditsSnapshotResponse | None: - snapshot = get_rate_limit_reset_credits_store().get(account_id) - return _snapshot_to_response(snapshot) + store = get_rate_limit_reset_credits_store() + account = await context.repository.get_by_id(account_id) + if account is None: + await store.invalidate(account_id) + return None + if account.status in _NON_REDEEMABLE_STATUSES or not account.chatgpt_account_id: + await store.invalidate(account_id) + return None + + snapshot = store.get(account_id) + if snapshot is not None: + return _snapshot_to_response(snapshot) + + await _refresh_account_reset_credits( + account, + encryptor=TokenEncryptor(), + store=store, + fetch_fn=fetch_reset_credits, + resolve_route=_resolve_reset_credit_route, + ) + return _snapshot_to_response(store.get(account_id)) @router.post( @@ -88,6 +136,7 @@ async def get_rate_limit_reset_credits( response_model=ConsumeResetCreditResponseSchema, ) async def consume_rate_limit_reset_credit( + request: Request, account_id: str, _write_access=Depends(require_dashboard_write_access), context: AccountsContext = Depends(get_accounts_context), @@ -95,32 +144,43 @@ async def consume_rate_limit_reset_credit( account = await context.repository.get_by_id(account_id) if account is None: raise DashboardNotFoundError("Account not found", code="account_not_found") + store = get_rate_limit_reset_credits_store() - if not _account_can_redeem_reset_credit(account): - await store.invalidate(account.id) - raise DashboardConflictError( - "Account is not eligible to redeem reset credits", - code="reset_credit_account_ineligible", - ) + try: - route = await resolve_upstream_route( - context.session, - account_id=account.id, - operation="reset_credits_consume", - scope="account", + outcome = await _redeem_soonest_reset_credit( + account=account, + store=store, + encryptor=TokenEncryptor(), + auth_manager=context.service._auth_manager, + refresh_usage=_build_refresh_usage_callback(context), + resolve_route=_resolve_reset_credit_route, ) + except RefreshError as exc: + if exc.is_permanent: + get_account_selection_cache().invalidate() + raise DashboardConflictError( + f"Reset credit consume could not refresh account credentials: {exc.message}", + code="account_reset_credit_refresh_failed", + ) from exc except UpstreamProxyRouteError as exc: raise DashboardServiceUnavailableError( - "Unable to resolve upstream proxy route for reset-credit consume", - code=exc.reason, + f"Reset credit consume upstream proxy route unavailable: {exc.reason}", + code="account_reset_credit_upstream_route_unavailable", ) from exc - return await _redeem_soonest_reset_credit( - account=account, - store=store, - encryptor=TokenEncryptor(), - consume_fn=consume_reset_credit, - route=route, + + AuditService.log_async( + "account_rate_limit_reset_credit_consumed", + actor_ip=request.client.host if request.client else None, + details={ + "account_id": account_id, + "consume_code": outcome.response.code, + "windows_reset": outcome.response.windows_reset, + "available_reset_credits_before": outcome.available_count_before, + "available_reset_credits_after": outcome.available_count_after, + }, ) + return outcome.response async def _redeem_soonest_reset_credit( @@ -128,37 +188,169 @@ async def _redeem_soonest_reset_credit( account: Account, store: RateLimitResetCreditsStore, encryptor: TokenEncryptor, - consume_fn: ConsumeFn, - route: ResolvedUpstreamRoute | None = None, -) -> ConsumeResetCreditResponseSchema: + fetch_fn: FetchFn | None = None, + consume_fn: ConsumeFn | None = None, + auth_manager: AuthManager | None = None, + refresh_usage: RefreshUsageFn | None = None, + resolve_route: ResolveRouteFn | None = None, +) -> _RedeemResetCreditOutcome: + _assert_account_can_redeem_reset_credit(account) + effective_fetch_fn = fetch_fn or fetch_reset_credits + effective_consume_fn = consume_fn or consume_reset_credit + lock = await get_reset_credit_redeem_lock(account.id) async with lock: - snapshot = store.get(account.id) - credit = _select_soonest_available_credit(snapshot) - if credit is None: - raise DashboardConflictError("No available reset credit", code="no_available_reset_credit") - access_token = encryptor.decrypt(account.access_token_encrypted) + return await _redeem_soonest_reset_credit_locked( + account=account, + store=store, + encryptor=encryptor, + effective_fetch_fn=effective_fetch_fn, + effective_consume_fn=effective_consume_fn, + auth_manager=auth_manager, + refresh_usage=refresh_usage, + resolve_route=resolve_route, + ) + + +async def _redeem_soonest_reset_credit_locked( + *, + account: Account, + store: RateLimitResetCreditsStore, + encryptor: TokenEncryptor, + effective_fetch_fn: FetchFn, + effective_consume_fn: ConsumeFn, + auth_manager: AuthManager | None, + refresh_usage: RefreshUsageFn | None, + resolve_route: ResolveRouteFn | None, +) -> _RedeemResetCreditOutcome: + redeem_account = account + if auth_manager is not None: + redeem_account = await auth_manager.ensure_fresh(account, force=False) + + access_token = encryptor.decrypt(redeem_account.access_token_encrypted) + route: ResolvedUpstreamRoute | None = None + if resolve_route is not None: + route = await resolve_route(redeem_account) + + try: + credits_response = await effective_fetch_fn( + access_token, + redeem_account.chatgpt_account_id, + route=route, + allow_direct_egress=route is None, + ) + except ResetCreditFetchError as exc: + raise _translate_fetch_error(exc) from exc + + credit = _select_soonest_available_credit_from_response(credits_response) + if credit is None: + await store.set(account.id, build_snapshot(credits_response)) + raise DashboardConflictError("No available reset credit", code="no_available_reset_credit") + + try: + result = await effective_consume_fn( + access_token, + redeem_account.chatgpt_account_id, + credit.id, + route=route, + allow_direct_egress=route is None, + ) + except ConsumeResetCreditError as exc: + raise _translate_consume_error(exc) from exc + + redeemed_at = result.credit.redeemed_at if result.credit else None + available_count_after = max(0, credits_response.available_count - 1) + await store.invalidate(account.id) + + if refresh_usage is not None: try: - result = await consume_fn( - access_token, - account.chatgpt_account_id, - credit.id, - route=route, - allow_direct_egress=route is None, + await refresh_usage(redeem_account) + except Exception: + logger.warning( + "Reset credit consume succeeded but usage refresh failed account_id=%s", + account.id, + exc_info=True, + ) + await _try_restore_reset_credits_snapshot_after_consume( + account=account, + redeem_account=redeem_account, + encryptor=encryptor, + store=store, + fetch_fn=effective_fetch_fn, + resolve_route=resolve_route, ) - except ConsumeResetCreditError as exc: - raise _translate_consume_error(exc) from exc - redeemed_at = result.credit.redeemed_at if result.credit else None - await store.invalidate(account.id) - return ConsumeResetCreditResponseSchema( + + return _RedeemResetCreditOutcome( + response=ConsumeResetCreditResponseSchema( code=result.code, windows_reset=result.windows_reset, redeemed_at=redeemed_at, + ), + available_count_before=credits_response.available_count, + available_count_after=available_count_after, + ) + + +def _assert_account_can_redeem_reset_credit(account: Account) -> None: + if account.status in _NON_REDEEMABLE_STATUSES or not account.chatgpt_account_id: + msg = ( + f"Account is {account.status.value} and cannot redeem a reset credit" + if account.status in _NON_REDEEMABLE_STATUSES + else "Account has no ChatGPT account ID and cannot redeem a reset credit" + ) + raise DashboardConflictError( + msg, + code="account_not_reset_credit_applicable", ) -def _account_can_redeem_reset_credit(account: Account) -> bool: - return account.status not in _RESET_CREDITS_INELIGIBLE_STATUSES and bool(account.chatgpt_account_id) +def _build_refresh_usage_callback(context: AccountsContext) -> RefreshUsageFn | None: + usage_updater = context.service._usage_updater + if usage_updater is None: + return None + + async def refresh_usage(account: Account) -> None: + refreshed = await usage_updater.force_refresh(account) + if not refreshed: + raise RuntimeError(f"Forced usage refresh returned no update for account {account.id}") + get_account_selection_cache().invalidate() + + return refresh_usage + + +async def _resolve_reset_credit_route(account: Account) -> ResolvedUpstreamRoute | None: + return await _resolve_upstream_route_for_account(account, operation="rate_limit_reset_consume") + + +async def _try_restore_reset_credits_snapshot_after_consume( + *, + account: Account, + redeem_account: Account, + encryptor: TokenEncryptor, + store: RateLimitResetCreditsStore, + fetch_fn: FetchFn, + resolve_route: ResolveRouteFn | None, +) -> None: + """Best-effort cache repopulation when usage refresh fails after a successful consume.""" + try: + access_token = encryptor.decrypt(redeem_account.access_token_encrypted) + route: ResolvedUpstreamRoute | None = None + if resolve_route is not None: + route = await resolve_route(redeem_account) + credits_response = await fetch_fn( + access_token, + redeem_account.chatgpt_account_id, + route=route, + allow_direct_egress=route is None, + ) + except Exception: + logger.warning( + "Reset credit consume post-refresh re-fetch failed account_id=%s", + account.id, + exc_info=True, + ) + return + await store.set(account.id, build_snapshot(credits_response)) async def get_reset_credit_redeem_lock(account_id: str) -> asyncio.Lock: @@ -173,6 +365,16 @@ async def get_reset_credit_redeem_lock(account_id: str) -> asyncio.Lock: return lock +def _translate_fetch_error(exc: ResetCreditFetchError) -> Exception: + if exc.status_code == 401: + return DashboardAuthError(exc.message, code=exc.code) + if exc.status_code == 403: + return DashboardPermissionError(exc.message, code=exc.code) + if exc.status_code == 409: + return DashboardConflictError(exc.message, code=exc.code) + return DashboardServiceUnavailableError(exc.message, code=exc.code) + + def _translate_consume_error(exc: ConsumeResetCreditError) -> Exception: if exc.status_code == 401: return DashboardAuthError(exc.message, code=exc.code) @@ -188,9 +390,22 @@ def _select_soonest_available_credit( ) -> ResetCreditItem | None: if snapshot is None: return None - if snapshot.available_count <= 0: + return _select_soonest_available_credit_from_items(snapshot.credits, snapshot.available_count) + + +def _select_soonest_available_credit_from_response( + response: ResetCreditsResponse, +) -> ResetCreditItem | None: + return _select_soonest_available_credit_from_items(response.credits, response.available_count) + + +def _select_soonest_available_credit_from_items( + credits: list[ResetCreditItem], + available_count: int, +) -> ResetCreditItem | None: + if available_count <= 0: return None - available = [credit for credit in snapshot.credits if credit.status == "available"] + available = [credit for credit in credits if credit.status == "available"] if not available: return None far_future = datetime.max.replace(tzinfo=timezone.utc) diff --git a/frontend/src/components/layout/app-header.test.tsx b/frontend/src/components/layout/app-header.test.tsx index 5c45a3c2c..60d04eab8 100644 --- a/frontend/src/components/layout/app-header.test.tsx +++ b/frontend/src/components/layout/app-header.test.tsx @@ -44,6 +44,25 @@ describe("AppHeader", () => { expect(await screen.findAllByText("99+")).not.toHaveLength(0); }); + it("sums reset-credit badge across accounts and treats missing counts as zero", async () => { + server.use( + http.get("/api/accounts", () => + HttpResponse.json({ + accounts: [ + createAccountSummary({ availableResetCredits: 5 }), + createAccountSummary({ accountId: "acc-2" }), + createAccountSummary({ accountId: "acc-3", availableResetCredits: null }), + createAccountSummary({ accountId: "acc-4", availableResetCredits: 3 }), + ], + }), + ), + ); + + renderHeader(); + + expect(await screen.findAllByText("8")).not.toHaveLength(0); + }); + it("hides the Accounts reset-credit badge when no resets are available", async () => { server.use( http.get("/api/accounts", () => diff --git a/frontend/src/features/accounts/components/account-actions.test.tsx b/frontend/src/features/accounts/components/account-actions.test.tsx index b08f8c1b9..54df9afc1 100644 --- a/frontend/src/features/accounts/components/account-actions.test.tsx +++ b/frontend/src/features/accounts/components/account-actions.test.tsx @@ -187,6 +187,41 @@ describe("AccountActions", () => { expect(onResetCredit).toHaveBeenCalledWith(account.accountId); }); + it.each(["paused", "deactivated", "reauth_required"] as const)( + "disables reset action for %s accounts", + async (status) => { + const user = userEvent.setup(); + const onResetCredit = vi.fn(); + const account = createAccountSummary({ + status, + availableResetCredits: 2, + resetCreditNearestExpiresAt: "2026-01-03T12:00:00.000Z", + }); + + render( + , + ); + + const button = screen.getByRole("button", { name: "Reset (2)" }); + expect(button).toBeDisabled(); + await user.click(button); + expect(onResetCredit).not.toHaveBeenCalled(); + }, + ); + it("hides reset action when no reset credits are available", () => { const account = createAccountSummary({ availableResetCredits: 0, diff --git a/frontend/src/features/accounts/components/account-actions.tsx b/frontend/src/features/accounts/components/account-actions.tsx index ab096ed61..a66f73ac0 100644 --- a/frontend/src/features/accounts/components/account-actions.tsx +++ b/frontend/src/features/accounts/components/account-actions.tsx @@ -69,6 +69,11 @@ export function AccountActions({ : null; const availableResetCredits = account.availableResetCredits ?? 0; const hasResetCredits = availableResetCredits > 0; + const resetCreditDisabled = + busy || + readOnly || + account.status === "paused" || + showOperatorRecoveryAction; return (
@@ -207,7 +212,7 @@ export function AccountActions({ variant="outline" className="relative h-8 gap-1.5 pr-8 text-xs" onClick={() => onResetCredit(account.accountId)} - disabled={busy || readOnly} + disabled={resetCreditDisabled} > {`Reset (${availableResetCredits})`} diff --git a/frontend/src/features/accounts/components/accounts-page.tsx b/frontend/src/features/accounts/components/accounts-page.tsx index c97b47d89..48b6cc1d1 100644 --- a/frontend/src/features/accounts/components/accounts-page.tsx +++ b/frontend/src/features/accounts/components/accounts-page.tsx @@ -54,7 +54,8 @@ export function AccountsPage() { const importDialog = useDialogState(); const oauthDialog = useDialogState(); const deleteDialog = useDialogState(); - const resetCreditDialog = useDialogState(); + type ResetCreditDialogTarget = { accountId: string; availableResetCredits: number }; + const resetCreditDialog = useDialogState(); const exportDialog = useDialogState(); const [deleteHistory, setDeleteHistory] = useState(false); @@ -180,7 +181,13 @@ export function AccountsPage() { .then((result) => exportDialog.show(result)) .catch(() => null); }} - onResetCredit={(accountId) => resetCreditDialog.show(accountId)} + onResetCredit={(accountId) => { + const account = accountsQuery.data?.find((item) => item.accountId === accountId); + resetCreditDialog.show({ + accountId, + availableResetCredits: account?.availableResetCredits ?? 0, + }); + }} onLimitWarmupChange={(accountId, enabled) => void limitWarmupMutation.mutateAsync({ accountId, enabled }) } @@ -241,7 +248,8 @@ export function AccountsPage() { {resetCreditDialog.data ? ( ) : null} diff --git a/frontend/src/features/accounts/components/reset-credit-confirm-dialog.test.tsx b/frontend/src/features/accounts/components/reset-credit-confirm-dialog.test.tsx index deecb5655..ba87e34a2 100644 --- a/frontend/src/features/accounts/components/reset-credit-confirm-dialog.test.tsx +++ b/frontend/src/features/accounts/components/reset-credit-confirm-dialog.test.tsx @@ -93,8 +93,10 @@ describe("ResetCreditConfirmDialog", () => { await vi.waitFor(() => expect(toastSuccess).toHaveBeenCalledWith("Rate-limit window reset (1)"), ); - expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["accounts"] }); - expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["dashboard"] }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["accounts", "list"] }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["accounts", "trends"] }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["dashboard", "overview"] }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["dashboard", "projections"] }); expect(onOpenChange).toHaveBeenCalledWith(false); }); @@ -132,12 +134,75 @@ describe("ResetCreditConfirmDialog", () => { await vi.waitFor(() => expect(toastError).toHaveBeenCalledWith("No reset credit available"), ); - expect(invalidateSpy).not.toHaveBeenCalledWith({ queryKey: ["accounts"] }); - expect(invalidateSpy).not.toHaveBeenCalledWith({ queryKey: ["dashboard"] }); + expect(invalidateSpy).not.toHaveBeenCalledWith({ queryKey: ["accounts", "list"] }); + expect(invalidateSpy).not.toHaveBeenCalledWith({ queryKey: ["dashboard", "overview"] }); // Failure leaves the dialog open for retry. expect(onOpenChange).not.toHaveBeenCalledWith(false); }); + it("shows a loading state while the reset-credit snapshot is fetching", () => { + server.use( + http.get(SNAPSHOT_URL, async () => { + await new Promise(() => {}); + return snapshotResponse(); + }), + ); + + renderWithClient( + , + ); + + expect(screen.getByText("Loading reset credit details...")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Redeem credit" })).toBeDisabled(); + }); + + it("shows an error message and keeps confirm disabled when the snapshot fetch fails", async () => { + server.use( + http.get(SNAPSHOT_URL, () => + HttpResponse.json( + { + error: { + code: "service_unavailable", + message: "Reset credits unavailable", + }, + }, + { status: 503 }, + ), + ), + ); + + renderWithClient( + , + ); + + expect(await screen.findByText("Reset credits unavailable")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Redeem credit" })).toBeDisabled(); + }); + + it("handles a null snapshot response without allowing redeem", async () => { + server.use(http.get(SNAPSHOT_URL, () => HttpResponse.json(null))); + + renderWithClient( + , + ); + + expect(await screen.findByText("0 free rate limit resets")).toBeInTheDocument(); + expect(screen.getByText("Reset credit details are not available yet.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Redeem credit" })).toBeDisabled(); + }); + it("allows redeeming an available credit when expiry is null", async () => { const user = userEvent.setup(); const consumeCalled = vi.fn(); @@ -186,4 +251,36 @@ describe("ResetCreditConfirmDialog", () => { await vi.waitFor(() => expect(consumeCalled).toHaveBeenCalledTimes(1)); }); + + it("enables redeem when GET cache is empty but summary reports available credits", async () => { + const user = userEvent.setup(); + const consumeCalled = vi.fn(); + server.use( + http.get(SNAPSHOT_URL, () => HttpResponse.json(null)), + http.post(CONSUME_URL, () => { + consumeCalled(); + return HttpResponse.json({ + code: "rate_limit_reset", + windowsReset: 1, + redeemedAt: "2026-01-01T12:00:00.000Z", + }); + }), + ); + + renderWithClient( + , + ); + + expect(await screen.findByText("2 free rate limit resets")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Redeem credit" })).toBeEnabled(); + + await user.click(screen.getByRole("button", { name: "Redeem credit" })); + + await vi.waitFor(() => expect(consumeCalled).toHaveBeenCalledTimes(1)); + }); }); diff --git a/frontend/src/features/accounts/components/reset-credit-confirm-dialog.tsx b/frontend/src/features/accounts/components/reset-credit-confirm-dialog.tsx index c6a1becc4..b1de8c07c 100644 --- a/frontend/src/features/accounts/components/reset-credit-confirm-dialog.tsx +++ b/frontend/src/features/accounts/components/reset-credit-confirm-dialog.tsx @@ -5,12 +5,15 @@ import { } from "@/features/accounts/hooks/use-accounts"; import type { RateLimitResetCreditItem } from "@/features/accounts/schemas"; import { cn } from "@/lib/utils"; +import { getErrorMessage } from "@/utils/errors"; import { formatLocalDateTimeSeconds, formatSingleUnitRemaining } from "@/utils/formatters"; export type ResetCreditConfirmDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; accountId: string | null; + /** Count from account summary when the per-account cache GET has not populated yet. */ + summaryAvailableCount?: number; }; function pickSoonestAvailableCredit( @@ -52,7 +55,7 @@ function CreditExpiryLine({ return (

{label}{" "} - {formatLocalDateTimeSeconds(expiresAt)} + {formatLocalDateTimeSeconds(expiresAt)}{" "} c.status === "available" && c.id !== soonest?.id, ); - const availableCount = snapshotQuery.data?.availableCount ?? 0; + const availableCount = + snapshotQuery.data != null + ? snapshotQuery.data.availableCount + : summaryAvailableCount; const pending = resetCreditConsumeMutation.isPending; + const confirmDisabled = + pending || !accountId || snapshotLoading || snapshotError || availableCount <= 0; const handleConfirm = () => { if (!accountId || pending) { @@ -111,36 +126,48 @@ export function ResetCreditConfirmDialog({ description="This redeems the soonest-expiring banked reset credit for this account." confirmLabel={pending ? "Redeeming..." : "Redeem credit"} cancelLabel="Cancel" - confirmDisabled={pending || !accountId || !soonest} + confirmDisabled={confirmDisabled} onOpenChange={handleOpenChange} onConfirm={handleConfirm} >

-

- {availableCount} free rate limit reset{availableCount !== 1 ? "s" : ""} -

- {soonest ? ( -
- - {otherCredits.map((credit) => ( - - ))} - {!soonest.expiresAt && otherCredits.length === 0 ? ( -

No upcoming expiry data available.

+ {snapshotLoading ? ( +

Loading reset credit details...

+ ) : snapshotError ? ( +

{snapshotErrorMessage}

+ ) : ( + <> +

+ {availableCount} free rate limit reset{availableCount !== 1 ? "s" : ""} +

+ {soonest ? ( +
+ + {otherCredits.map((credit) => ( + + ))} + {!soonest.expiresAt && otherCredits.length === 0 ? ( +

No upcoming expiry data available.

+ ) : null} +
+ ) : availableCount > 0 ? ( +

No upcoming expiry data available.

+ ) : snapshotQuery.data === null ? ( +

+ Reset credit details are not available yet. +

) : null} -
- ) : availableCount > 0 ? ( -

No upcoming expiry data available.

- ) : null} + + )}
); diff --git a/frontend/src/features/accounts/hooks/use-accounts.ts b/frontend/src/features/accounts/hooks/use-accounts.ts index 3672be4ec..616063bdf 100644 --- a/frontend/src/features/accounts/hooks/use-accounts.ts +++ b/frontend/src/features/accounts/hooks/use-accounts.ts @@ -182,12 +182,15 @@ export function useAccountMutations() { const resetCreditConsumeMutation = useMutation({ mutationFn: (accountId: string) => consumeRateLimitResetCredit(accountId), onSuccess: (data) => { - const resetCount = data.windowsReset; + const resetCount = data.windowsReset ?? 0; toast.success( `Rate-limit window${resetCount === 1 ? "" : "s"} reset (${resetCount})`, ); - void queryClient.invalidateQueries({ queryKey: ["accounts"] }); - void queryClient.invalidateQueries({ queryKey: ["dashboard"] }); + void queryClient.invalidateQueries({ queryKey: ["accounts", "list"] }); + void queryClient.invalidateQueries({ queryKey: ["accounts", "trends"] }); + void queryClient.invalidateQueries({ queryKey: ["accounts", "reset-credits"] }); + void queryClient.invalidateQueries({ queryKey: ["dashboard", "overview"] }); + void queryClient.invalidateQueries({ queryKey: ["dashboard", "projections"] }); }, onError: (error: Error) => { toast.error(error.message || "Reset credit redeem failed"); diff --git a/frontend/src/features/accounts/schemas.test.ts b/frontend/src/features/accounts/schemas.test.ts index 900755b00..014586d91 100644 --- a/frontend/src/features/accounts/schemas.test.ts +++ b/frontend/src/features/accounts/schemas.test.ts @@ -214,52 +214,25 @@ describe("RateLimitResetCreditsSnapshotSchema", () => { }); describe("ConsumeRateLimitResetCreditResponseSchema", () => { - it("parses successful consume responses with optional redeemedAt", () => { + it("parses consume responses when nullable backend fields are omitted or null", () => { expect( ConsumeRateLimitResetCreditResponseSchema.parse({ - code: "rate_limit_reset", - windowsReset: 1, redeemedAt: ISO, }), ).toMatchObject({ - code: "rate_limit_reset", - windowsReset: 1, redeemedAt: ISO, }); expect( ConsumeRateLimitResetCreditResponseSchema.parse({ - code: "rate_limit_reset", - windowsReset: 1, + code: null, + windowsReset: null, redeemedAt: null, }), ).toMatchObject({ - code: "rate_limit_reset", - windowsReset: 1, + code: null, + windowsReset: null, redeemedAt: null, }); - - expect( - ConsumeRateLimitResetCreditResponseSchema.parse({ - code: "rate_limit_reset", - windowsReset: 1, - }), - ).toMatchObject({ - code: "rate_limit_reset", - windowsReset: 1, - }); - - expect(() => - ConsumeRateLimitResetCreditResponseSchema.parse({ - redeemedAt: ISO, - }), - ).toThrow(); - - expect(() => - ConsumeRateLimitResetCreditResponseSchema.parse({ - code: null, - windowsReset: null, - }), - ).toThrow(); }); }); diff --git a/frontend/src/features/accounts/schemas.ts b/frontend/src/features/accounts/schemas.ts index a62af06e4..378b9cca3 100644 --- a/frontend/src/features/accounts/schemas.ts +++ b/frontend/src/features/accounts/schemas.ts @@ -114,9 +114,9 @@ export const RateLimitResetCreditsSnapshotSchema = z.object({ }); export const ConsumeRateLimitResetCreditResponseSchema = z.object({ - code: z.string(), - windowsReset: z.number(), - redeemedAt: z.iso.datetime({ offset: true }).nullable().optional(), + code: z.string().nullable().optional(), + windowsReset: z.number().nullable().optional(), + redeemedAt: z.iso.datetime({ offset: true }).nullable(), }); export const AccountTrendsResponseSchema = z.object({ diff --git a/frontend/src/features/auth/components/auth-gate.test.tsx b/frontend/src/features/auth/components/auth-gate.test.tsx index 41133d17c..1251f2688 100644 --- a/frontend/src/features/auth/components/auth-gate.test.tsx +++ b/frontend/src/features/auth/components/auth-gate.test.tsx @@ -1,5 +1,5 @@ import { render, screen, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; import { AuthGate } from "@/features/auth/components/auth-gate"; import { useAuthStore } from "@/features/auth/hooks/use-auth"; @@ -24,11 +24,16 @@ function setAuthState( describe("AuthGate", () => { beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); setAuthState({ refreshSession: vi.fn().mockResolvedValue(undefined), }); }); + afterEach(() => { + vi.useRealTimers(); + }); + it("shows login form when unauthenticated", async () => { const refreshSession = vi.fn().mockResolvedValue(undefined); setAuthState({ diff --git a/frontend/src/features/dashboard/components/account-card.test.tsx b/frontend/src/features/dashboard/components/account-card.test.tsx index ad3c47c1d..06cae0835 100644 --- a/frontend/src/features/dashboard/components/account-card.test.tsx +++ b/frontend/src/features/dashboard/components/account-card.test.tsx @@ -1,5 +1,6 @@ import { act, render, screen } from "@testing-library/react"; -import { afterEach, describe, expect, it } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { AccountCard } from "@/features/dashboard/components/account-card"; import { usePrivacyStore } from "@/hooks/use-privacy"; @@ -127,4 +128,24 @@ describe("AccountCard", () => { expect(screen.queryByRole("button", { name: /Reset \(/ })).not.toBeInTheDocument(); }); + + it("disables reset action for paused accounts", async () => { + const user = userEvent.setup(); + const onAction = vi.fn(); + const account = createAccountSummary({ + accountId: "acc-paused", + displayName: "Paused Account", + status: "paused", + availableResetCredits: 1, + resetCreditNearestExpiresAt: "2026-01-03T12:00:00.000Z", + }); + + render(); + + const resetButton = screen.getByRole("button", { name: "Reset (1)" }); + expect(resetButton).toBeDisabled(); + + await user.click(resetButton); + expect(onAction).not.toHaveBeenCalledWith(account, "reset-credit"); + }); }); diff --git a/frontend/src/features/dashboard/components/account-card.tsx b/frontend/src/features/dashboard/components/account-card.tsx index 99adcb867..9e9cb19b3 100644 --- a/frontend/src/features/dashboard/components/account-card.tsx +++ b/frontend/src/features/dashboard/components/account-card.tsx @@ -113,9 +113,20 @@ export function AccountCard({ account, showAccountId = false, readOnly = false, : "No attempts"; const availableResetCredits = account.availableResetCredits ?? 0; const hasResetCredits = availableResetCredits > 0; + const resetCreditDisabled = + readOnly || status === "paused" || status === "reauth" || status === "deactivated"; const resetCountdown = account.resetCreditNearestExpiresAt ? formatSingleUnitRemaining(account.resetCreditNearestExpiresAt) : null; + const resetButtonTitle = resetCreditDisabled + ? status === "paused" + ? "Resume account to redeem reset credits" + : status === "reauth" || status === "deactivated" + ? "Re-authenticate account to redeem reset credits" + : "Reset credits unavailable" + : resetCountdown + ? `Reset (${availableResetCredits}) · ${resetCountdown.label}` + : `Reset (${availableResetCredits})`; return (
@@ -201,7 +212,8 @@ export function AccountCard({ account, showAccountId = false, readOnly = false, size="sm" variant="ghost" className="relative h-7 gap-1.5 rounded-lg pr-8 text-xs text-muted-foreground hover:text-foreground" - disabled={readOnly} + title={resetButtonTitle} + disabled={resetCreditDisabled} onClick={() => onAction?.(account, "reset-credit")} > diff --git a/frontend/src/features/dashboard/components/account-list.test.tsx b/frontend/src/features/dashboard/components/account-list.test.tsx index f3fdc9f48..a30491261 100644 --- a/frontend/src/features/dashboard/components/account-list.test.tsx +++ b/frontend/src/features/dashboard/components/account-list.test.tsx @@ -61,15 +61,18 @@ describe("AccountList", () => { render(); + const resetButton = screen.getByRole("button", { name: "Redeem reset credit for Paused Account" }); + expect(resetButton).toBeDisabled(); + await user.click(screen.getByRole("button", { name: "View details for Paused Account" })); - await user.click(screen.getByRole("button", { name: "Redeem reset credit for Paused Account" })); + await user.click(resetButton); await user.click(screen.getByRole("button", { name: "Enable limit warm-up for Paused Account" })); await user.click(screen.getByRole("button", { name: "Resume Paused Account" })); expect(onAction).toHaveBeenNthCalledWith(1, account, "details"); - expect(onAction).toHaveBeenNthCalledWith(2, account, "reset-credit"); - expect(onAction).toHaveBeenNthCalledWith(3, account, "warmup-toggle"); - expect(onAction).toHaveBeenNthCalledWith(4, account, "resume"); + expect(onAction).toHaveBeenNthCalledWith(2, account, "warmup-toggle"); + expect(onAction).toHaveBeenNthCalledWith(3, account, "resume"); + expect(onAction).not.toHaveBeenCalledWith(account, "reset-credit"); }); it("blurs list identity text when privacy mode is enabled", () => { diff --git a/frontend/src/features/dashboard/components/account-list.tsx b/frontend/src/features/dashboard/components/account-list.tsx index 7fd1f0f86..73223c094 100644 --- a/frontend/src/features/dashboard/components/account-list.tsx +++ b/frontend/src/features/dashboard/components/account-list.tsx @@ -314,9 +314,20 @@ export function AccountList({ accounts, readOnly = false, onAction }: AccountLis : "No attempts"; const availableResetCredits = account.availableResetCredits ?? 0; const hasResetCredits = availableResetCredits > 0; + const resetCreditDisabled = + readOnly || status === "paused" || status === "reauth" || status === "deactivated"; const resetCountdown = account.resetCreditNearestExpiresAt ? formatSingleUnitRemaining(account.resetCreditNearestExpiresAt) : null; + const resetButtonTitle = resetCreditDisabled + ? status === "paused" + ? "Resume account to redeem reset credits" + : status === "reauth" || status === "deactivated" + ? "Re-authenticate account to redeem reset credits" + : "Reset credits unavailable" + : resetCountdown + ? `Reset (${availableResetCredits}) · ${resetCountdown.label}` + : `Reset (${availableResetCredits})`; return (
onAction?.(account, "reset-credit")} >