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.
|
# 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,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user