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:
1 parent
9b1d9edde4
commit
b7d239d9b6
1 file changed
+47
-7
+47
-7
@@ -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.
|
||||||
|
|||||||
Reference in new issue
Block a user