Skip to content
Merged
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
14 changes: 8 additions & 6 deletions apps/api/core/credits.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
30 changes: 14 additions & 16 deletions apps/api/routers/billing/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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"]

Expand All @@ -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)
Expand Down
7 changes: 4 additions & 3 deletions apps/api/routers/billing/usdc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 8 additions & 4 deletions apps/api/routers/execute.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}


Expand Down
Loading