From b7d239d9b6002a10030d856c9f7b506a0e58bff0 Mon Sep 17 00:00:00 2001 From: inference-bot Date: Sun, 27 Sep 2026 22:38:05 -0600 Subject: [PATCH] Reset dates follow real billing: webhook re-pins budget_reset_at on every Membership payment (+30d); DB write (API drops the field) --- app/main.py | 54 ++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/app/main.py b/app/main.py index aeb681c..708a3f1 100644 --- a/app/main.py +++ b/app/main.py @@ -20,12 +20,13 @@ import re import sqlite3 import secrets import smtplib -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart import httpx import jwt +import asyncio from fastapi import FastAPI, Request, Response, HTTPException from fastapi.responses import JSONResponse, StreamingResponse from slowapi import Limiter @@ -1136,6 +1137,34 @@ async def litellm_update_team_budget(team_id: str, budget: float) -> None: 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: """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", }) - 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() - for m in members: - if m["slug"] == slug: + member = next((m for m in members if m["slug"] == slug), None) + if member: + if first_payment: try: - await provision_member(m["email"], m["name"], m["slug"]) + await provision_member(member["email"], member["name"], member["slug"]) except Exception as e: - logger.warning("Webhook: failed to provision %s: %s", m["email"], e) - break + logger.warning("Webhook: failed to provision %s: %s", member["email"], e) + 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": # One-time donation (no tier or non-membership tier): NOT a # membership. Log it so we can see it, but don't provision.