Non-expiring credits: two-bucket budget model, ledger, OC credit-pack webhook, admin endpoints

This commit is contained in:
inference-bot committed 2026-09-27 19:10:50 -06:00
1 parent 0c3c12999c
commit 63acea289f
1 file changed
+202 -7
+202 -7
View File
@@ -399,6 +399,25 @@ def get_db() -> sqlite3.Connection:
# used to throttle re-invites of members who never activated. # used to throttle re-invites of members who never activated.
if "welcome_sent_at" not in cols: if "welcome_sent_at" not in cols:
conn.execute("ALTER TABLE members ADD COLUMN welcome_sent_at TEXT") conn.execute("ALTER TABLE members ADD COLUMN welcome_sent_at TEXT")
# Migration: credit_balance — purchased, NON-EXPIRING credits (USD).
# Distinct from `balance` (the monthly allowance, which resets): credits
# carry over indefinitely and are spent only after the allowance is
# exhausted within a cycle.
if "credit_balance" not in cols:
conn.execute("ALTER TABLE members ADD COLUMN credit_balance REAL NOT NULL DEFAULT 0")
# Credit ledger: audit trail for every credit movement (purchases via the
# OC webhook, admin adjustments, spends drawn against credits). Money needs
# an audit trail.
conn.execute(
"CREATE TABLE IF NOT EXISTS credit_ledger ("
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
"email TEXT NOT NULL, "
"delta REAL NOT NULL, "
"reason TEXT NOT NULL, "
"reference TEXT, "
"created_at TEXT DEFAULT (datetime('now'))"
")"
)
# Member-managed API keys: add a fingerprint (last-4 of the key, shown in # Member-managed API keys: add a fingerprint (last-4 of the key, shown in
# lists) and purge the stored plaintext. Member API keys are revealed ONCE # lists) and purge the stored plaintext. Member API keys are revealed ONCE
@@ -449,6 +468,78 @@ def set_member_balance(email: str, balance: float) -> None:
conn.close() conn.close()
# --- Credits: purchased, non-expiring usage dollars ---
def get_credit_balance(email: str) -> float:
"""Return the member's non-expiring credit balance (USD, default 0)."""
conn = get_db()
row = conn.execute(
"SELECT credit_balance FROM members WHERE email = ?", (email,)
).fetchone()
conn.close()
if row and row[0] is not None:
return float(row[0])
return 0.0
def add_credits(email: str, delta: float, reason: str, reference: str | None = None) -> float:
"""Adjust a member's credit balance and record the movement in the ledger.
Returns the new balance. Negative deltas are allowed (draws against
credits); the balance is clamped at >= 0.
"""
conn = get_db()
conn.execute(
"UPDATE members SET credit_balance = MAX(0, COALESCE(credit_balance, 0) + ?) "
"WHERE email = ?",
(delta, email),
)
conn.execute(
"INSERT INTO credit_ledger (email, delta, reason, reference) VALUES (?, ?, ?, ?)",
(email, delta, reason, reference),
)
row = conn.execute(
"SELECT credit_balance FROM members WHERE email = ?", (email,)
).fetchone()
conn.commit()
conn.close()
return float(row[0]) if row and row[0] is not None else 0.0
def compute_max_budget(allowance: float, credit_balance: float, team_spend: float | None) -> dict:
"""Compute the effective LiteLLM max_budget from the two buckets.
Model: the member has a monthly ALLOWANCE (resets via LiteLLM's
budget_duration) plus a NON-EXPIRING credit balance. Spend draws the
allowance first; once spend exceeds the allowance within a cycle, credits
cover the overflow.
`team_spend` is the member's spend so far this cycle (from LiteLLM).
Credits already consumed this cycle are excluded from the available pool
so the pushed max_budget reflects exactly what's left:
max_budget = allowance + credits_remaining
Returns {"max_budget", "allowance_used", "credits_used"} for dashboards.
"""
if team_spend is None:
# Unknown spend: be permissive (full allowance + full credits) so we
# never wrongly block a member over a metrics gap.
return {
"max_budget": allowance + credit_balance,
"allowance_used": 0.0,
"credits_used": 0.0,
}
allowance_used = min(max(team_spend, 0.0), allowance)
credits_used = max(0.0, team_spend - allowance)
credits_remaining = max(0.0, credit_balance - credits_used)
return {
"max_budget": allowance + credits_remaining,
"allowance_used": allowance_used,
"credits_used": credits_used,
}
async def fetch_members() -> list[dict]: async def fetch_members() -> list[dict]:
"""Return the full active-member list: email, name, slug, role. """Return the full active-member list: email, name, slug, role.
@@ -949,16 +1040,23 @@ async def litellm_update_team_budget(team_id: str, budget: float) -> None:
async def sync_team_budget_from_balance(email: str) -> float: async def sync_team_budget_from_balance(email: str) -> float:
"""Apply the member's stored balance to their team's max_budget. """Apply the member's effective budget to their LiteLLM team.
Reads the balance from the members table and pushes it to LiteLLM. Returns Effective budget = monthly allowance + remaining non-expiring credits
the applied budget. This keeps the portal DB and LiteLLM in sync whenever (credits drawn only after the allowance is exhausted this cycle). Reads
a balance changes (top-up, reset, pricing-model change). the member's live spend from LiteLLM to determine how much credit has
already been consumed in the current cycle. Returns the applied budget.
""" """
balance = get_member_balance(email) balance = get_member_balance(email)
credits = get_credit_balance(email)
team_id = await litellm_get_or_create_team(email, balance) team_id = await litellm_get_or_create_team(email, balance)
await litellm_update_team_budget(team_id, balance)
return balance # Live spend this cycle (DB path preferred; LiteLLM computes the reset).
team = get_team_budget_from_db(team_id) or {}
team_spend = team.get("spend")
eff = compute_max_budget(balance, credits, team_spend)
await litellm_update_team_budget(team_id, eff["max_budget"])
return eff["max_budget"]
async def litellm_create_key(email: str, budget: float, team_id: str) -> str: async def litellm_create_key(email: str, budget: float, team_id: str) -> str:
@@ -1133,6 +1231,39 @@ async def opencollective_webhook(request: Request, token: str):
# False so a missing field never triggers a spurious re-provision; the # False so a missing field never triggers a spurious re-provision; the
# nightly sweep backstops any missed first payment. # nightly sweep backstops any missed first payment.
first_payment = data.get("firstPayment", False) first_payment = data.get("firstPayment", False)
# Credit-pack detection: one-time contributions to a "Credit pack"
# tier route to the member's NON-EXPIRING credit balance instead of
# provisioning/allowance. Detection: the OC tier name contains
# "credit" (case-insensitive). Recurring subscriptions never route
# here (a credit pack is a one-time purchase).
tier_name = ""
order_tier = (data.get("tier") or data.get("order", {}) or {})
if isinstance(order_tier, dict):
tier_name = (order_tier.get("name") or "").strip()
amount_cents = data.get("amount") or data.get("valueInCents") or 0
if "credit" in tier_name.lower() and slug and event_type == "order.processed":
members = await fetch_members()
email = next((m["email"] for m in members if m["slug"] == slug), None)
if email:
credits = round(float(amount_cents) / 100.0, 2)
new_balance = add_credits(
email, credits,
reason="credit pack purchase (Open Collective)",
reference=f"oc:{slug}:{data.get('id', '')}",
)
# Push the enlarged budget immediately.
await sync_team_budget_from_balance(email)
logger.info("Credit pack: +%s credits for %s (balance %s)",
credits, email, new_balance)
return JSONResponse({
"status": "credits_added", "email": email,
"credits_added": credits, "credit_balance": new_balance,
})
logger.warning("Credit pack payment from unknown slug %s", slug)
return JSONResponse({"status": "ignored", "note": "unknown slug for credit pack"})
if slug and first_payment: if slug and first_payment:
members = await fetch_members() members = await fetch_members()
for m in members: for m in members:
@@ -1373,6 +1504,61 @@ async def admin_set_balance(request: Request):
return {"status": "updated", "email": email, "balance": new_balance, "team_budget": applied} return {"status": "updated", "email": email, "balance": new_balance, "team_budget": applied}
@app.post("/admin/set-credits")
@limiter.limit("20/minute")
async def admin_set_credits(request: Request):
"""Adjust a member's non-expiring credit balance (with ledger entry).
Body: {"email": "...", "add": 10.0, "reason": "goodwill"} → add/subtract
{"email": "...", "set": 0.0, "reason": "correction"} → set absolute
"""
_verify_admin_token(request)
body = await request.json()
email = (body.get("email") or "").strip().lower()
if not email:
raise HTTPException(400, "Missing email")
reason = (body.get("reason") or "admin adjustment").strip()
if "add" in body:
delta = float(body["add"])
new_balance = add_credits(email, delta, reason=reason)
elif "set" in body:
target = float(body["set"])
current = get_credit_balance(email)
new_balance = add_credits(email, target - current, reason=reason)
else:
raise HTTPException(400, "Provide 'add' or 'set'")
applied = await sync_team_budget_from_balance(email)
return {
"status": "updated", "email": email,
"credit_balance": new_balance, "team_budget": applied,
}
@app.get("/admin/credits/{email}")
@limiter.limit("30/minute")
async def admin_credit_ledger(email: str, request: Request):
"""Return a member's credit balance and full ledger (audit trail)."""
_verify_admin_token(request)
conn = get_db()
rows = conn.execute(
"SELECT delta, reason, reference, created_at FROM credit_ledger "
"WHERE email = ? ORDER BY created_at DESC",
(email,),
).fetchall()
conn.close()
return {
"email": email,
"credit_balance": get_credit_balance(email),
"ledger": [
{"delta": r[0], "reason": r[1], "reference": r[2], "created_at": r[3]}
for r in rows
],
}
@app.get("/admin/overview") @app.get("/admin/overview")
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def admin_overview(request: Request): async def admin_overview(request: Request):
@@ -1431,6 +1617,12 @@ async def admin_overview(request: Request):
team = teams.get(f"member:{email}", {}) team = teams.get(f"member:{email}", {})
spend = float(team.get("spend") or 0.0) spend = float(team.get("spend") or 0.0)
total_spend += spend total_spend += spend
credits = get_credit_balance(email)
eff = compute_max_budget(
float(balance) if balance is not None else MEMBER_BUDGET,
credits,
spend,
)
members.append( members.append(
{ {
"email": email, "email": email,
@@ -1438,8 +1630,11 @@ async def admin_overview(request: Request):
"activated": invite_accepted.get(user_id, False), "activated": invite_accepted.get(user_id, False),
"active": bool(active), "active": bool(active),
"balance": float(balance) if balance is not None else MEMBER_BUDGET, "balance": float(balance) if balance is not None else MEMBER_BUDGET,
"credit_balance": credits,
"allowance_used": eff["allowance_used"],
"credits_used": eff["credits_used"],
"spend": spend, "spend": spend,
"remaining": max((balance or MEMBER_BUDGET) - spend, 0.0), "remaining": max(eff["max_budget"] - spend, 0.0),
"reset_at": team.get("budget_reset_at"), "reset_at": team.get("budget_reset_at"),
"cloudron_user_id": user_id, "cloudron_user_id": user_id,
} }