Non-expiring credits: two-bucket budget model, ledger, OC credit-pack webhook, admin endpoints
This commit is contained in:
1 parent
0c3c12999c
commit
63acea289f
1 file changed
+202
-7
+202
-7
@@ -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,
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user