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,
|
"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
@@ -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> · inference.coop
|
<span style="color:#2d3327;">The Inference Cooperative</span> · 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,15 +4154,13 @@ 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)
|
|
||||||
continue
|
|
||||||
preview.append(entry)
|
preview.append(entry)
|
||||||
conn.close()
|
conn.close()
|
||||||
if send:
|
if send:
|
||||||
|
|||||||
Reference in new issue
Block a user