Reset dates follow real billing: webhook re-pins budget_reset_at on every Membership payment (+30d); DB write (API drops the field)

This commit is contained in:
inference-bot committed 2026-09-27 22:38:05 -06:00
1 parent 9b1d9edde4
commit b7d239d9b6
1 file changed
+47 -7
+47 -7
View File
@@ -20,12 +20,13 @@ import re
import sqlite3 import sqlite3
import secrets import secrets
import smtplib import smtplib
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
import httpx import httpx
import jwt import jwt
import asyncio
from fastapi import FastAPI, Request, Response, HTTPException from fastapi import FastAPI, Request, Response, HTTPException
from fastapi.responses import JSONResponse, StreamingResponse from fastapi.responses import JSONResponse, StreamingResponse
from slowapi import Limiter from slowapi import Limiter
@@ -1136,6 +1137,34 @@ async def litellm_update_team_budget(team_id: str, budget: float) -> None:
r.raise_for_status() r.raise_for_status()
async def litellm_pin_reset_date(team_id: str, reset_at: datetime) -> None:
"""Pin a team's allowance reset to an exact moment (next OC payment date).
LiteLLM's /team/update silently DROPS budget_reset_at (verified v1.74.0),
so this writes the authoritative DB directly. Budgets reset to zero spend
at the pinned instant; the pinned date is honored across restarts.
"""
def _write() -> None:
conn = _pg_connect()
try:
cur = conn.cursor()
cur.execute(
'UPDATE "LiteLLM_TeamTable" SET budget_reset_at = %s WHERE team_id = %s',
(reset_at, team_id),
)
conn.commit()
finally:
conn.close()
await asyncio.get_event_loop().run_in_executor(None, _write)
logger.info("Pinned budget reset for %s to %s", team_id, reset_at.isoformat())
def next_reset_from_now() -> datetime:
"""The member's next allowance reset: 30 days from now, on the hour."""
return datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0) + timedelta(days=30)
async def sync_team_budget_from_balance(email: str) -> float: async def sync_team_budget_from_balance(email: str) -> float:
"""Apply the member's effective budget to their LiteLLM team. """Apply the member's effective budget to their LiteLLM team.
@@ -1451,15 +1480,26 @@ async def opencollective_webhook(request: Request, token: str):
"note": "credit pack purchased without membership; hold recorded and buyer notified", "note": "credit pack purchased without membership; hold recorded and buyer notified",
}) })
if slug and first_payment and "membership" in tier_name.lower(): if slug and "membership" in tier_name.lower() and event_type == "order.processed":
# Every Membership-tier payment (first or recurring) re-pins the
# member's allowance reset to payment + 30 days — the reset
# follows the member's actual billing rhythm, not a global
# calendar (all members landing on Oct 1 otherwise).
members = await fetch_members() members = await fetch_members()
for m in members: member = next((m for m in members if m["slug"] == slug), None)
if m["slug"] == slug: if member:
if first_payment:
try: try:
await provision_member(m["email"], m["name"], m["slug"]) await provision_member(member["email"], member["name"], member["slug"])
except Exception as e: except Exception as e:
logger.warning("Webhook: failed to provision %s: %s", m["email"], e) logger.warning("Webhook: failed to provision %s: %s", member["email"], e)
break try:
team_id = await litellm_get_or_create_team(
member["email"], get_member_balance(member["email"]))
await litellm_pin_reset_date(team_id, next_reset_from_now())
except Exception as e:
logger.warning("Webhook: failed to pin reset date for %s: %s",
member["email"], e)
elif slug and first_payment and event_type == "order.processed": elif slug and first_payment and event_type == "order.processed":
# One-time donation (no tier or non-membership tier): NOT a # One-time donation (no tier or non-membership tier): NOT a
# membership. Log it so we can see it, but don't provision. # membership. Log it so we can see it, but don't provision.