diff --git a/app/main.py b/app/main.py index 6081e8b..0c94865 100644 --- a/app/main.py +++ b/app/main.py @@ -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, }