From edf22c8a231f515f1610b7f5341094ae95580f63 Mon Sep 17 00:00:00 2001 From: MytelligentPRV Date: Sun, 21 Jun 2026 00:14:05 -0700 Subject: [PATCH] fix(billing): one user-facing "remaining" everywhere = spendable balance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the billing-display class. (b) Removed the duplicate credits_remaining key in /account/credits — dropped the earlier allotment-remaining assignment so the authoritative balance is returned by design, not by dict-ordering luck. (a) Every USER-FACING "remaining"/balance surface now reads the authoritative spendable credits_balance (hold-aware), so users see one consistent number: - GET /account/credits (credits_remaining + calls_remaining) - GET /account/alerts (low-credit thresholds fire off balance; the forecast's daily burn now uses the ledger) - GET /account/analytics ("remaining" = balance; "used this cycle" stays ledger-derived) - GET /billing/settings (credits_remaining + calls_remaining) - POST /execute (batch) (response credits_remaining = balance after run) /billing/balance + /auth/me already read credits_balance. Allotment-remaining (plan allotment − settled cycle spend) stays as INTERNAL quota math only — compute_calls_remaining() is retained (ledger-derived) for internal quota/warning logic and is no longer surfaced to users. It differs from balance by in-flight reserve holds, which belong under the hood. Removed the now-unused compute_calls_remaining imports from the display routers. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/core/credits.py | 14 ++++++++------ apps/api/routers/billing/account.py | 30 ++++++++++++++--------------- apps/api/routers/billing/usdc.py | 7 ++++--- apps/api/routers/execute.py | 12 ++++++++---- 4 files changed, 34 insertions(+), 29 deletions(-) diff --git a/apps/api/core/credits.py b/apps/api/core/credits.py index 2835436..88c69f5 100644 --- a/apps/api/core/credits.py +++ b/apps/api/core/credits.py @@ -277,12 +277,14 @@ async def credits_used_this_cycle(conn, user_id: str) -> int: async def compute_calls_remaining(conn, api_key_id: str) -> int: - """Credits remaining in the cycle allotment — DERIVED from the ledger. - - = plan allotment − credits_used_this_cycle (ledger sum), per USER (not per - key), so it captures all spend including NULL-key /search debits. No longer - reads api_keys.monthly_calls_count, which drifted low and over-stated - remaining (the pre-money quota under-count).""" + """INTERNAL quota math — allotment remaining this cycle. NOT for user display. + + = plan allotment − credits_used_this_cycle (ledger sum), per USER. Differs from + the spendable balance by any in-flight reserve hold, so it must NOT be shown to + users (every user-facing "remaining"/balance surface reads + user_credits.credits_balance instead). Kept for internal quota logic (e.g. + usage-warning thresholds). Ledger-derived — never reads the drift-prone + api_keys.monthly_calls_count.""" row = await conn.fetchrow( "SELECT user_id, tier FROM api_keys WHERE id = $1::uuid", api_key_id, diff --git a/apps/api/routers/billing/account.py b/apps/api/routers/billing/account.py index eae37df..7605fc6 100644 --- a/apps/api/routers/billing/account.py +++ b/apps/api/routers/billing/account.py @@ -12,7 +12,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request from core.auth import resolve_dashboard_caller -from core.credits import PLANS, CREDITS_PER_CALL, ROUTING_FEE, PAYMENT_MULTIPLIERS, compute_calls_remaining, credits_used_this_cycle +from core.credits import PLANS, CREDITS_PER_CALL, ROUTING_FEE, PAYMENT_MULTIPLIERS, credits_used_this_cycle from core.db import get_db from core.rate_limit import limiter from core.tier_gates import require_tier, _get_redis @@ -360,19 +360,15 @@ async def account_credits(request: Request, db=Depends(get_db)): pkg_tier = credits['package_tier'] if credits else 'free' tier = _credits_to_tier(lifetime, pkg_tier) - calls_remaining = ( - await compute_calls_remaining(db, str(caller["api_key_id"])) - if caller["api_key_id"] else 0 - ) - + # User-facing "remaining" = the authoritative spendable balance (hold-aware) — + # NOT allotment-remaining (compute_calls_remaining), which is internal quota + # math and differs by in-flight reserve holds. One consistent number everywhere. return { "plan": tier, - "credits_remaining": calls_remaining, + "credits_remaining": balance, "credits_included": PLANS.get(tier, PLANS["free"])["calls_included"], - "calls_remaining": calls_remaining, # backward compat + "calls_remaining": balance, # backward compat alias of credits_remaining "calls_included": PLANS.get(tier, PLANS["free"])["calls_included"], # backward compat - # Dashboard-only credit detail (not shown in public docs) - "credits_remaining": balance, "credits_total": lifetime, "tier": tier, "email": caller["email"], @@ -524,7 +520,9 @@ def _find_v2(slug: str): plan_def = PLANS.get(plan_tier, PLANS["free"]) credits_included = plan_def["calls_included"] # calls_included == monthly_credits after fix credits_used = await credits_used_this_cycle(db, user_id) - credits_remaining = max(0, credits_included - credits_used) + # User-facing "remaining" = authoritative spendable balance (hold-aware), the + # same number shown everywhere — not allotment-remaining (included − used). + credits_remaining = credits["credits_balance"] if credits else 0 reset_at_str = ( caller["monthly_calls_reset_at"].date().isoformat() if caller["monthly_calls_reset_at"] else reset.isoformat() @@ -1105,10 +1103,9 @@ async def account_alerts(request: Request, db=Depends(get_db)): pkg_tier = credits["package_tier"] if credits else "free" tier = _credits_to_tier(balance, pkg_tier) - calls_remaining = ( - await compute_calls_remaining(db, str(caller["api_key_id"])) - if caller["api_key_id"] else 0 - ) + # Low-credit alerts fire off the authoritative spendable balance (hold-aware), + # not allotment-remaining — a user "running low" is about real spendable credits. + calls_remaining = balance plan_def = PLANS.get(tier, PLANS["free"]) calls_included = plan_def["calls_included"] @@ -1117,7 +1114,8 @@ async def account_alerts(request: Request, db=Depends(get_db)): will_exhaust = False days_remaining = None - monthly_count = caller.get("monthly_calls_count") or 0 + # Daily burn from the ledger (settled cycle spend), not the drift-prone counter. + monthly_count = await credits_used_this_cycle(db, caller["user_id"]) monthly_reset_at = caller.get("monthly_calls_reset_at") if monthly_reset_at and monthly_count > 0: now_utc = datetime.now(timezone.utc) diff --git a/apps/api/routers/billing/usdc.py b/apps/api/routers/billing/usdc.py index b21e54b..7aafe41 100644 --- a/apps/api/routers/billing/usdc.py +++ b/apps/api/routers/billing/usdc.py @@ -633,7 +633,6 @@ async def topup_usdc(request: Request, db=Depends(get_db)): @limiter.limit("30/minute") async def get_billing_settings(request: Request, db=Depends(get_db)): """Return current billing permission settings for the authenticated API key.""" - from core.credits import compute_calls_remaining # Session-OR-key (PR #25 pattern): the dashboard billing-settings page uses the # wf_session cookie. Resolve the caller, then read their primary key row. caller = await resolve_dashboard_caller(request, db) @@ -671,8 +670,10 @@ async def get_billing_settings(request: Request, db=Depends(get_db)): "monthly_topup_spent_usd": round(spent, 2), "monthly_topup_remaining_usd": round(limit - spent, 2), "monthly_topup_reset_at": reset_at.date().isoformat() if reset_at else None, - "credits_remaining": await compute_calls_remaining(db, str(key_record["id"])), - "calls_remaining": await compute_calls_remaining(db, str(key_record["id"])), # backward compat + # User-facing remaining = authoritative spendable balance (hold-aware), + # not allotment-remaining (internal quota math). + "credits_remaining": balance, + "calls_remaining": balance, # backward compat "plan": tier, "payment_rail": payment_rail, "usdc_bonus_rate": 0.05, diff --git a/apps/api/routers/execute.py b/apps/api/routers/execute.py index e8f7c1a..36c44f5 100644 --- a/apps/api/routers/execute.py +++ b/apps/api/routers/execute.py @@ -24,7 +24,6 @@ _increment_calls, _maybe_dispatch_credits_low, check_and_deduct_credits, - compute_calls_remaining, ROUTING_FEE, ) from core.db import get_db @@ -1626,13 +1625,18 @@ async def execute_batch(request: Request, db=Depends(get_db)): ) total_ms = round((_time_mod.time() - batch_start) * 1000) - calls_remaining = await compute_calls_remaining(db, str(_api_key_id)) + # User-facing remaining = authoritative spendable balance after the batch + # (hold-aware), not allotment-remaining (internal quota math). + _bal_row = await db.fetchrow( + "SELECT credits_balance FROM user_credits WHERE user_id = $1::uuid", str(user_id) + ) + credits_remaining = _bal_row["credits_balance"] if _bal_row else 0 return { "results": list(results), "total_execution_ms": total_ms, - "credits_remaining": calls_remaining, - "calls_remaining": calls_remaining, # backward compat + "credits_remaining": credits_remaining, + "calls_remaining": credits_remaining, # backward compat }