portal: budget notice -> generic low-balance wording, $2 floor, username, once per crossing

Wording no longer names an allowance or a credit pack: it says balance, so it is true whether the member is short on allowance, on purchased credit, or both. Trigger is an absolute $2 floor on remaining budget rather than a percentage, so it means the same at any budget size. Recipient greeting uses members.slug (the username) instead of the email local part, which produced 'Hi n,'. The once-only latch now clears when the balance rises back above the floor, so it fires once per crossing rather than once per budget cycle. BUDGET_NOTICES stays off.
This commit is contained in:
inference-bot committed 2026-10-31 16:35:35 +00:00
1 parent 49e79aadcc
commit d5fbb2ed84
2 files changed
+55 -55

No files matched your search

+1 -1
View File
@@ -31,5 +31,5 @@
"manifestVersion": 2, "manifestVersion": 2,
"website": "https://inference.coop", "website": "https://inference.coop",
"contactEmail": "info@inference.coop", "contactEmail": "info@inference.coop",
"dockerImage": "registry.inference.coop/co-op/member-portal:20261031-87e7c5b" "dockerImage": "registry.inference.coop/co-op/member-portal:49e79aa"
} }
+54 -54
View File
@@ -3971,7 +3971,11 @@ async def index():
# link today; the Open Collective contribution page is what actually credits a # link today; the Open Collective contribution page is what actually credits a
# member (the webhook settles contributions into credits). # member (the webhook settles contributions into credits).
BUDGET_WARN_AT = 0.80 # fraction of the cap that triggers the warning # Generic on purpose: the balance can be short because the monthly allowance is spent,
# because purchased credit is spent, or a mix — the letter says "balance" so it is true
# either way. The trigger is an absolute floor, not a percentage, so it means the same
# thing to a member on a $15 allowance and a member on $35.
BUDGET_LOW_WATER = float(os.environ.get("BUDGET_LOW_WATER", "2.00"))
PINE_LOGO_SVG = """<svg width="56" height="56" viewBox="0 0 130 130" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"> PINE_LOGO_SVG = """<svg width="56" height="56" viewBox="0 0 130 130" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<circle cx="65" cy="14" r="5" fill="#5b8c5a"/> <circle cx="65" cy="14" r="5" fill="#5b8c5a"/>
@@ -4021,38 +4025,34 @@ def _ensure_budget_notices_table(conn) -> None:
def budget_notice_email( def budget_notice_email(
name: str, level: str, spend: float, budget: float, pack_url: str name: str, spend: float, budget: float, remaining: float, pack_url: str
) -> tuple[str, str, str]: ) -> tuple[str, str, str]:
"""Build a budget notice (subject, text, html). """Build the low-balance notice (subject, text, html).
Deliberately the same shell as welcome_email(): cream background, white card, Deliberately generic about where the balance came from: a member may be short
forest-green button, inline pine-tree mark, table-based layout, no external because the monthly allowance is spent, because credit they bought is spent, or
assets. Only the words and the call to action differ, so a member sees one a mix of both. The wording says "balance" so it is true either way, and it names
consistent co-op identity rather than a system notice bolted on beside it. no mechanism a member would have to reconcile against their own dashboard.
Same shell as welcome_email(): cream background, white card, forest-green
button, inline pine-tree mark, table-based layout, no external assets.
""" """
pct = (spend / budget * 100.0) if budget else 0.0 subject = "Your Inference Cooperative balance is running low"
if level == "exhausted": heading = "Your balance is running low"
subject = "Your monthly Inference Cooperative credit is used up" lede = (f"You have ${remaining:.2f} of your ${budget:.2f} balance left. When it "
heading = "You've used your monthly credit" "runs out, chat and API requests stop until you add more. Nothing else "
lede = ("Your full monthly credit is used, so chat and API requests will stop " "about your account changes.")
"until your credit resets or you add more.")
else:
subject = f"You've used {pct:.0f}% of your monthly Inference Cooperative credit"
heading = f"You're at {pct:.0f}% of your monthly credit"
lede = ("You're getting close to the end of your monthly credit. When it runs "
"out, chat and API requests stop until it resets.")
money = f"${spend:.2f} of ${budget:.2f} used" money = f"${remaining:.2f} remaining"
text = ( text = (
f"Hi {name},\n\n" f"Hi {name},\n\n"
f"{lede}\n\n" f"{lede}\n\n"
f"{money}\n\n" f"{money}\n\n"
"If you'd like more, credit packs add to your account on top of the monthly " "If you'd like to keep going, you can add to your balance here:\n\n"
"credit. They never expire, carry over month to month, and are only drawn on "
"once your monthly credit is used:\n\n"
f"{pack_url}\n\n" f"{pack_url}\n\n"
"Nothing else changes — your membership, your data and your account stay as " "This is the only notice of its kind you'll get until your balance goes back "
"they are. Questions? Reply to this email or write to info@inference.coop.\n\n" "above the line and runs low again. Questions? Reply to this email or write to "
"info@inference.coop.\n\n"
"— The Inference Cooperative\n" "— The Inference Cooperative\n"
) )
html = f"""<!DOCTYPE html> html = f"""<!DOCTYPE html>
@@ -4073,13 +4073,13 @@ def budget_notice_email(
<h1 style="margin:0 0 12px;font-size:24px;font-weight:700;color:#2d3327;">{heading}</h1> <h1 style="margin:0 0 12px;font-size:24px;font-weight:700;color:#2d3327;">{heading}</h1>
<p style="margin:0 0 8px;font-size:16px;color:#6b7a62;line-height:1.5;">{lede}</p> <p style="margin:0 0 8px;font-size:16px;color:#6b7a62;line-height:1.5;">{lede}</p>
<p style="margin:0 0 24px;font-size:15px;color:#2d3327;line-height:1.5;"><strong>{money}</strong></p> <p style="margin:0 0 24px;font-size:15px;color:#2d3327;line-height:1.5;"><strong>{money}</strong></p>
<a href="{pack_url}" style="display:inline-block;background:#5b8c5a;color:#ffffff;text-decoration:none;padding:14px 36px;border-radius:999px;font-size:16px;font-weight:600;">Add credits</a> <a href="{pack_url}" style="display:inline-block;background:#5b8c5a;color:#ffffff;text-decoration:none;padding:14px 36px;border-radius:999px;font-size:16px;font-weight:600;">Add to your balance</a>
<p style="margin:28px 0 0;font-size:13px;color:#94a08c;line-height:1.5;">Credit packs never expire, carry over month to month, and are drawn on only after your monthly credit is used.</p> <p style="margin:28px 0 0;font-size:13px;color:#94a08c;line-height:1.5;">This is the only notice of its kind you'll get until your balance goes back above the line and runs low again.</p>
</td> </td>
</tr> </tr>
<tr> <tr>
<td align="center" style="padding-top:24px;font-size:13px;color:#94a08c;line-height:1.6;"> <td align="center" style="padding-top:24px;font-size:13px;color:#94a08c;line-height:1.6;">
Nothing else changes — your membership, your data and your account stay as they are.<br> Nothing else about your account changes.<br>
Questions? Reply to this email or write to <a href="mailto:info@inference.coop" style="color:#6b7a62;">info@inference.coop</a>.<br> Questions? Reply to this email or write to <a href="mailto:info@inference.coop" style="color:#6b7a62;">info@inference.coop</a>.<br>
<span style="color:#2d3327;">The Inference Cooperative</span> &middot; inference.coop <span style="color:#2d3327;">The Inference Cooperative</span> &middot; inference.coop
</td> </td>
@@ -4094,11 +4094,11 @@ def budget_notice_email(
async def budget_notice_sweep(send: bool = False) -> dict: async def budget_notice_sweep(send: bool = False) -> dict:
"""Find members near or over their monthly cap, and optionally email them. """Find members whose balance has fallen below the low-water mark, and optionally email.
`send=False` is a preview: it computes exactly who would be written to, with `send=False` is a preview: it computes exactly who would be written to, with the
the numbers, and sends nothing. `send=True` emails and records the send — the numbers, and sends nothing. `send=True` emails and records the send — the caller
caller is responsible for requiring BUDGET_NOTICES=on first. is responsible for requiring BUDGET_NOTICES=on first.
""" """
mode = budget_notices_enabled() mode = budget_notices_enabled()
if send and mode != "on": if send and mode != "on":
@@ -4107,15 +4107,18 @@ async def budget_notice_sweep(send: bool = False) -> dict:
conn = get_db() conn = get_db()
_ensure_budget_notices_table(conn) _ensure_budget_notices_table(conn)
cols = {r[1] for r in conn.execute("PRAGMA table_info(members)")} cols = {r[1] for r in conn.execute("PRAGMA table_info(members)")}
name_col = "name" if "name" in cols else ("display_name" if "display_name" in cols else None) # There is no name column in `members` — `slug` is the username (e.g. ntnsndr). Falling
sel = "SELECT email" + (f", {name_col}" if name_col else "") + " FROM members WHERE active = 1" # back to the email local part produced "Hi n," for the operator, which is how this was
# caught. Prefer the username; say "there" rather than inventing a name.
ident = "slug" if "slug" in cols else None
sel = "SELECT email" + (f", {ident}" if ident else "") + " FROM members WHERE active = 1"
rows = conn.execute(sel).fetchall() rows = conn.execute(sel).fetchall()
pack = credit_pack_url() pack = credit_pack_url()
preview, sent, skipped = [], 0, 0 preview, sent, skipped = [], 0, 0
for row in rows: for row in rows:
email = row[0] email = row[0]
name = (row[1] if len(row) > 1 and row[1] else str(email).split("@")[0]) name = ((row[1] or "").strip() if len(row) > 1 else "") or "there"
team_id = await litellm_team_id_for_email(email) team_id = await litellm_team_id_for_email(email)
if not team_id: if not team_id:
skipped += 1 skipped += 1
@@ -4124,27 +4127,26 @@ async def budget_notice_sweep(send: bool = False) -> dict:
budget = stats.get("max_budget") budget = stats.get("max_budget")
spend = float(stats.get("spend") or 0.0) spend = float(stats.get("spend") or 0.0)
if not budget or budget <= 0: if not budget or budget <= 0:
skipped += 1 # unlimited: nothing to warn about skipped += 1 # no cap configured: nothing to warn about
continue continue
cycle = str(stats.get("budget_reset_at") or stats.get("reset_at") or "cycle")[:19]
if spend >= budget: remaining = float(budget) - spend
level = "exhausted" if remaining >= BUDGET_LOW_WATER:
elif spend >= BUDGET_WARN_AT * budget: # Back above the line — they added funds, or the allowance reset. Clear the latch so
level = "warning" # the NEXT crossing sends a fresh notice. That is what makes it once each time, and
else: # it is why this is a latch rather than a key on the budget cycle.
conn.execute("DELETE FROM budget_notices WHERE email = ?", (email,))
conn.commit()
continue continue
already = conn.execute( if conn.execute("SELECT 1 FROM budget_notices WHERE email = ?", (email,)).fetchone():
"SELECT 1 FROM budget_notices WHERE email = ? AND level = ? AND cycle = ?", skipped += 1 # already told them for this crossing
(email, level, cycle),
).fetchone()
if already:
skipped += 1
continue continue
subject, text_body, html_body = budget_notice_email(name, level, spend, budget, pack)
subject, text_body, html_body = budget_notice_email(name, spend, budget, remaining, pack)
entry = { entry = {
"email": email, "level": level, "email": email, "remaining": round(remaining, 2),
"spend": round(spend, 2), "budget": round(budget, 2), "spend": round(spend, 2), "budget": round(budget, 2),
"pct": round(spend / budget * 100, 1), "cycle": cycle, "resets": str(stats.get("budget_reset_at") or stats.get("reset_at") or "")[:19],
} }
if send: if send:
ok = send_email(email, subject, text_body, html_body) ok = send_email(email, subject, text_body, html_body)
@@ -4152,16 +4154,14 @@ async def budget_notice_sweep(send: bool = False) -> dict:
if ok: if ok:
conn.execute( conn.execute(
"INSERT OR REPLACE INTO budget_notices" "INSERT OR REPLACE INTO budget_notices"
" (email, level, cycle, sent_at, detail) VALUES (?, ?, ?, datetime('now'), ?)", " (email, level, cycle, sent_at, detail) VALUES (?, 'low', ?, datetime('now'), ?)",
(email, level, cycle, f"spend={spend:.4f} budget={budget:.4f}"), (email, entry["resets"] or "crossing", f"remaining={remaining:.4f}"),
) )
conn.commit() conn.commit()
sent += 1 sent += 1
else: else:
entry["subject"] = subject entry["subject"] = subject
preview.append(entry) preview.append(entry)
continue
preview.append(entry)
conn.close() conn.close()
if send: if send:
return {"mode": mode, "sent": sent, "considered": len(rows), return {"mode": mode, "sent": sent, "considered": len(rows),