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.
if "welcome_sent_at" not in cols:
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
# 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()
# --- 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]:
"""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:
"""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
the applied budget. This keeps the portal DB and LiteLLM in sync whenever
a balance changes (top-up, reset, pricing-model change).
Effective budget = monthly allowance + remaining non-expiring credits
(credits drawn only after the allowance is exhausted this cycle). Reads
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)
credits = get_credit_balance(email)
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:
@@ -1133,6 +1231,39 @@ async def opencollective_webhook(request: Request, token: str):
# False so a missing field never triggers a spurious re-provision; the
# nightly sweep backstops any missed first payment.
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:
members = await fetch_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}
@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")
@limiter.limit("30/minute")
async def admin_overview(request: Request):
@@ -1431,6 +1617,12 @@ async def admin_overview(request: Request):
team = teams.get(f"member:{email}", {})
spend = float(team.get("spend") or 0.0)
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(
{
"email": email,
@@ -1438,8 +1630,11 @@ async def admin_overview(request: Request):
"activated": invite_accepted.get(user_id, False),
"active": bool(active),
"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,
"remaining": max((balance or MEMBER_BUDGET) - spend, 0.0),
"remaining": max(eff["max_budget"] - spend, 0.0),
"reset_at": team.get("budget_reset_at"),
"cloudron_user_id": user_id,
}