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:
1 parent
49e79aadcc
commit
d5fbb2ed84
2 files changed
+55
-55
No files matched your search
@@ -31,5 +31,5 @@
|
||||
"manifestVersion": 2,
|
||||
"website": "https://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
@@ -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> · 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:
|
||||
|
||||
Reference in new issue
Block a user