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

+54 -54
View File
@@ -3971,7 +3971,11 @@ async def index():
# link today; the Open Collective contribution page is what actually credits a
# 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">
<circle cx="65" cy="14" r="5" fill="#5b8c5a"/>
@@ -4021,38 +4025,34 @@ def _ensure_budget_notices_table(conn) -> None:
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]:
"""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,
forest-green button, inline pine-tree mark, table-based layout, no external
assets. Only the words and the call to action differ, so a member sees one
consistent co-op identity rather than a system notice bolted on beside it.
Deliberately generic about where the balance came from: a member may be short
because the monthly allowance is spent, because credit they bought is spent, or
a mix of both. The wording says "balance" so it is true either way, and it names
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
if level == "exhausted":
subject = "Your monthly Inference Cooperative credit is used up"
heading = "You've used your monthly credit"
lede = ("Your full monthly credit is used, so chat and API requests will stop "
"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.")
subject = "Your Inference Cooperative balance is running low"
heading = "Your balance is running low"
lede = (f"You have ${remaining:.2f} of your ${budget:.2f} balance left. When it "
"runs out, chat and API requests stop until you add more. Nothing else "
"about your account changes.")
money = f"${spend:.2f} of ${budget:.2f} used"
money = f"${remaining:.2f} remaining"
text = (
f"Hi {name},\n\n"
f"{lede}\n\n"
f"{money}\n\n"
"If you'd like more, credit packs add to your account on top of the monthly "
"credit. They never expire, carry over month to month, and are only drawn on "
"once your monthly credit is used:\n\n"
"If you'd like to keep going, you can add to your balance here:\n\n"
f"{pack_url}\n\n"
"Nothing else changes — your membership, your data and your account stay as "
"they are. Questions? Reply to this email or write to info@inference.coop.\n\n"
"This is the only notice of its kind you'll get until your balance goes back "
"above the line and runs low again. Questions? Reply to this email or write to "
"info@inference.coop.\n\n"
"— The Inference Cooperative\n"
)
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>
<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>
<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>
<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>
<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;">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>
</tr>
<tr>
<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>
<span style="color:#2d3327;">The Inference Cooperative</span> &middot; inference.coop
</td>
@@ -4094,11 +4094,11 @@ def budget_notice_email(
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
the numbers, and sends nothing. `send=True` emails and records the send — the
caller is responsible for requiring BUDGET_NOTICES=on first.
`send=False` is a preview: it computes exactly who would be written to, with the
numbers, and sends nothing. `send=True` emails and records the send — the caller
is responsible for requiring BUDGET_NOTICES=on first.
"""
mode = budget_notices_enabled()
if send and mode != "on":
@@ -4107,15 +4107,18 @@ async def budget_notice_sweep(send: bool = False) -> dict:
conn = get_db()
_ensure_budget_notices_table(conn)
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)
sel = "SELECT email" + (f", {name_col}" if name_col else "") + " FROM members WHERE active = 1"
# There is no name column in `members` — `slug` is the username (e.g. ntnsndr). Falling
# 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()
pack = credit_pack_url()
preview, sent, skipped = [], 0, 0
for row in rows:
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)
if not team_id:
skipped += 1
@@ -4124,27 +4127,26 @@ async def budget_notice_sweep(send: bool = False) -> dict:
budget = stats.get("max_budget")
spend = float(stats.get("spend") or 0.0)
if not budget or budget <= 0:
skipped += 1 # unlimited: nothing to warn about
skipped += 1 # no cap configured: nothing to warn about
continue
cycle = str(stats.get("budget_reset_at") or stats.get("reset_at") or "cycle")[:19]
if spend >= budget:
level = "exhausted"
elif spend >= BUDGET_WARN_AT * budget:
level = "warning"
else:
remaining = float(budget) - spend
if remaining >= BUDGET_LOW_WATER:
# Back above the line — they added funds, or the allowance reset. Clear the latch so
# the NEXT crossing sends a fresh notice. That is what makes it once each time, and
# 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
already = conn.execute(
"SELECT 1 FROM budget_notices WHERE email = ? AND level = ? AND cycle = ?",
(email, level, cycle),
).fetchone()
if already:
skipped += 1
if conn.execute("SELECT 1 FROM budget_notices WHERE email = ?", (email,)).fetchone():
skipped += 1 # already told them for this crossing
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 = {
"email": email, "level": level,
"email": email, "remaining": round(remaining, 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:
ok = send_email(email, subject, text_body, html_body)
@@ -4152,15 +4154,13 @@ async def budget_notice_sweep(send: bool = False) -> dict:
if ok:
conn.execute(
"INSERT OR REPLACE INTO budget_notices"
" (email, level, cycle, sent_at, detail) VALUES (?, ?, ?, datetime('now'), ?)",
(email, level, cycle, f"spend={spend:.4f} budget={budget:.4f}"),
" (email, level, cycle, sent_at, detail) VALUES (?, 'low', ?, datetime('now'), ?)",
(email, entry["resets"] or "crossing", f"remaining={remaining:.4f}"),
)
conn.commit()
sent += 1
else:
entry["subject"] = subject
preview.append(entry)
continue
preview.append(entry)
conn.close()
if send: