From c452da6c7df38c38f14ffe2501937af0900b90c2 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 23 Mar 2026 15:10:32 +0100 Subject: [PATCH 1/3] Allow credit-backed accounts past weekly quota --- app/core/usage/quota.py | 44 ++++++++++++++++++++++++++---- app/modules/proxy/load_balancer.py | 18 ++++++++++++ tests/unit/test_load_balancer.py | 40 +++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 6 deletions(-) diff --git a/app/core/usage/quota.py b/app/core/usage/quota.py index dc76079e..1516fcdf 100644 --- a/app/core/usage/quota.py +++ b/app/core/usage/quota.py @@ -15,6 +15,9 @@ def apply_usage_quota( runtime_reset: float | None, secondary_used: float | None, secondary_reset: int | None, + credits_has: bool | None = None, + credits_unlimited: bool | None = None, + credits_balance: float | None = None, ) -> tuple[AccountStatus, float | None, float | None]: used_percent = primary_used reset_at = runtime_reset @@ -22,15 +25,29 @@ def apply_usage_quota( if status in (AccountStatus.DEACTIVATED, AccountStatus.PAUSED): return status, used_percent, reset_at + has_credit_override = _has_credit_override( + credits_has=credits_has, + credits_unlimited=credits_unlimited, + credits_balance=credits_balance, + ) + if secondary_used is not None: if secondary_used >= 100.0: - status = AccountStatus.QUOTA_EXCEEDED - used_percent = 100.0 - if secondary_reset is not None: - reset_at = secondary_reset - return status, used_percent, reset_at + if has_credit_override: + if status == AccountStatus.QUOTA_EXCEEDED: + status = AccountStatus.ACTIVE + reset_at = None + else: + status = AccountStatus.QUOTA_EXCEEDED + used_percent = 100.0 + if secondary_reset is not None: + reset_at = secondary_reset + return status, used_percent, reset_at if status == AccountStatus.QUOTA_EXCEEDED: - if runtime_reset and runtime_reset > time.time(): + if has_credit_override: + status = AccountStatus.ACTIVE + reset_at = None + elif runtime_reset and runtime_reset > time.time(): reset_at = runtime_reset else: status = AccountStatus.ACTIVE @@ -62,3 +79,18 @@ def _fallback_primary_reset(primary_window_minutes: int | None) -> float | None: if not window_minutes: return None return time.time() + float(window_minutes) * 60.0 + + +def _has_credit_override( + *, + credits_has: bool | None, + credits_unlimited: bool | None, + credits_balance: float | None, +) -> bool: + if credits_unlimited is True: + return True + if credits_has is True: + return True + if credits_balance is not None and credits_balance > 0: + return True + return False diff --git a/app/modules/proxy/load_balancer.py b/app/modules/proxy/load_balancer.py index 7ed83afa..29ed120d 100644 --- a/app/modules/proxy/load_balancer.py +++ b/app/modules/proxy/load_balancer.py @@ -311,6 +311,21 @@ def _state_from_account( secondary_used = effective_secondary_entry.used_percent if effective_secondary_entry else None secondary_reset = effective_secondary_entry.reset_at if effective_secondary_entry else None + credits_has = ( + primary_entry.credits_has + if primary_entry and primary_entry.credits_has is not None + else (effective_secondary_entry.credits_has if effective_secondary_entry else None) + ) + credits_unlimited = ( + primary_entry.credits_unlimited + if primary_entry and primary_entry.credits_unlimited is not None + else (effective_secondary_entry.credits_unlimited if effective_secondary_entry else None) + ) + credits_balance = ( + primary_entry.credits_balance + if primary_entry and primary_entry.credits_balance is not None + else (effective_secondary_entry.credits_balance if effective_secondary_entry else None) + ) # Use account.reset_at from DB as the authoritative source for runtime reset # and to survive process restarts. @@ -325,6 +340,9 @@ def _state_from_account( runtime_reset=effective_runtime_reset, secondary_used=secondary_used, secondary_reset=secondary_reset, + credits_has=credits_has, + credits_unlimited=credits_unlimited, + credits_balance=credits_balance, ) return AccountState( diff --git a/tests/unit/test_load_balancer.py b/tests/unit/test_load_balancer.py index 3be4201a..82ad027b 100644 --- a/tests/unit/test_load_balancer.py +++ b/tests/unit/test_load_balancer.py @@ -326,3 +326,43 @@ def test_apply_usage_quota_resets_to_active_if_runtime_reset_expired(monkeypatch assert status == AccountStatus.ACTIVE assert used_percent == 50.0 assert reset_at is None + + +def test_apply_usage_quota_allows_secondary_100_when_credits_exist(): + status, used_percent, reset_at = apply_usage_quota( + status=AccountStatus.ACTIVE, + primary_used=11.0, + primary_reset=None, + primary_window_minutes=None, + runtime_reset=None, + secondary_used=100.0, + secondary_reset=1_700_010_000, + credits_has=True, + credits_unlimited=False, + credits_balance=959.0, + ) + assert status == AccountStatus.ACTIVE + assert used_percent == 11.0 + assert reset_at is None + + +def test_apply_usage_quota_clears_quota_exceeded_when_credits_balance_positive(monkeypatch): + now = 1_700_000_000.0 + future = now + 3600.0 + monkeypatch.setattr("app.core.usage.quota.time.time", lambda: now) + + status, used_percent, reset_at = apply_usage_quota( + status=AccountStatus.QUOTA_EXCEEDED, + primary_used=20.0, + primary_reset=None, + primary_window_minutes=None, + runtime_reset=future, + secondary_used=100.0, + secondary_reset=1_700_010_000, + credits_has=None, + credits_unlimited=None, + credits_balance=1.0, + ) + assert status == AccountStatus.ACTIVE + assert used_percent == 20.0 + assert reset_at is None From d822994a4046edb142294df7a7eb77afa0c90fe8 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 23 Mar 2026 15:10:32 +0100 Subject: [PATCH 2/3] Show live account credits in the dashboard --- app/modules/accounts/mappers.py | 25 +++++++++++++++++++ app/modules/accounts/schemas.py | 3 +++ frontend/src/features/accounts/schemas.ts | 7 ++++++ .../components/account-card.test.tsx | 12 +++++++++ .../dashboard/components/account-card.tsx | 15 +++++++++++ frontend/src/test/mocks/factories.ts | 7 ++++++ 6 files changed, 69 insertions(+) diff --git a/app/modules/accounts/mappers.py b/app/modules/accounts/mappers.py index 8f1c38c8..cf671f95 100644 --- a/app/modules/accounts/mappers.py +++ b/app/modules/accounts/mappers.py @@ -86,6 +86,12 @@ def _account_to_summary( secondary_used_percent, capacity_secondary, ) + credits_has, credits_unlimited, credits_balance = _extract_credit_status( + effective_primary_usage, + effective_secondary_usage, + primary_usage, + secondary_usage, + ) return AccountSummary( account_id=account.id, email=account.email, @@ -105,6 +111,9 @@ def _account_to_summary( remaining_credits_primary=remaining_credits_primary, capacity_credits_secondary=capacity_secondary, remaining_credits_secondary=remaining_credits_secondary, + credits_has=credits_has, + credits_unlimited=credits_unlimited, + credits_balance=credits_balance, deactivation_reason=account.deactivation_reason, auth=auth_status, ) @@ -182,6 +191,22 @@ def _normalize_used_percent(entry: UsageHistory | None) -> float | None: return entry.used_percent +def _extract_credit_status( + *entries: UsageHistory | None, +) -> tuple[bool | None, bool | None, float | None]: + for entry in entries: + if entry is None: + continue + if ( + entry.credits_has is None + and entry.credits_unlimited is None + and entry.credits_balance is None + ): + continue + return entry.credits_has, entry.credits_unlimited, entry.credits_balance + return None, None, None + + def build_account_usage_trends( buckets: list[UsageTrendBucket], since_epoch: int, diff --git a/app/modules/accounts/schemas.py b/app/modules/accounts/schemas.py index 52bc6abb..a07f123e 100644 --- a/app/modules/accounts/schemas.py +++ b/app/modules/accounts/schemas.py @@ -50,6 +50,9 @@ class AccountSummary(DashboardModel): remaining_credits_primary: float | None = None capacity_credits_secondary: float | None = None remaining_credits_secondary: float | None = None + credits_has: bool | None = None + credits_unlimited: bool | None = None + credits_balance: float | None = None deactivation_reason: str | None = None auth: AccountAuthStatus | None = None diff --git a/frontend/src/features/accounts/schemas.ts b/frontend/src/features/accounts/schemas.ts index c655405b..8caae3b3 100644 --- a/frontend/src/features/accounts/schemas.ts +++ b/frontend/src/features/accounts/schemas.ts @@ -37,6 +37,13 @@ export const AccountSummarySchema = z.object({ resetAtSecondary: z.string().datetime({ offset: true }).nullable().optional(), windowMinutesPrimary: z.number().nullable().optional(), windowMinutesSecondary: z.number().nullable().optional(), + capacityCreditsPrimary: z.number().nullable().optional(), + remainingCreditsPrimary: z.number().nullable().optional(), + capacityCreditsSecondary: z.number().nullable().optional(), + remainingCreditsSecondary: z.number().nullable().optional(), + creditsHas: z.boolean().nullable().optional(), + creditsUnlimited: z.boolean().nullable().optional(), + creditsBalance: z.number().nullable().optional(), auth: AccountAuthSchema.nullable().optional(), }); diff --git a/frontend/src/features/dashboard/components/account-card.test.tsx b/frontend/src/features/dashboard/components/account-card.test.tsx index bfdd621b..62202117 100644 --- a/frontend/src/features/dashboard/components/account-card.test.tsx +++ b/frontend/src/features/dashboard/components/account-card.test.tsx @@ -29,4 +29,16 @@ describe("AccountCard", () => { expect(screen.queryByText("Primary")).not.toBeInTheDocument(); expect(screen.getByText("Secondary")).toBeInTheDocument(); }); + + it("renders the credits row", () => { + const account = createAccountSummary({ + creditsBalance: 959, + remainingCreditsSecondary: 0, + }); + + render(); + + expect(screen.getByText("Credits:")).toBeInTheDocument(); + expect(screen.getByText("959.00")).toBeInTheDocument(); + }); }); diff --git a/frontend/src/features/dashboard/components/account-card.tsx b/frontend/src/features/dashboard/components/account-card.tsx index 2c8b7641..8b6db853 100644 --- a/frontend/src/features/dashboard/components/account-card.tsx +++ b/frontend/src/features/dashboard/components/account-card.tsx @@ -69,6 +69,14 @@ export function AccountCard({ account, showAccountId = false, onAction }: Accoun const primaryRemaining = account.usage?.primaryRemainingPercent ?? null; const secondaryRemaining = account.usage?.secondaryRemainingPercent ?? null; const weeklyOnly = account.windowMinutesPrimary == null && account.windowMinutesSecondary != null; + const displayCredits = account.creditsBalance ?? ( + weeklyOnly + ? account.remainingCreditsSecondary + : (account.remainingCreditsSecondary ?? account.remainingCreditsPrimary) + ); + const creditsLabel = account.creditsUnlimited ? "Unlimited" : ( + displayCredits === null || displayCredits === undefined ? "-" : displayCredits.toFixed(2) + ); const primaryReset = formatQuotaResetLabel(account.resetAtPrimary ?? null); const secondaryReset = formatQuotaResetLabel(account.resetAtSecondary ?? null); @@ -103,6 +111,13 @@ export function AccountCard({ account, showAccountId = false, onAction }: Accoun +
+ Credits:{" "} + + {creditsLabel} + +
+ {/* Actions */}