From d5fbb2ed8409c2bb35d172e9764409b0e5aed718 Mon Sep 17 00:00:00 2001 From: Inferencebot Date: Sat, 31 Oct 2026 16:35:35 +0000 Subject: [PATCH] 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. --- CloudronManifest.json | 2 +- app/main.py | 108 +++++++++++++++++++++--------------------- 2 files changed, 55 insertions(+), 55 deletions(-) diff --git a/CloudronManifest.json b/CloudronManifest.json index 48768e6..40c23e2 100644 --- a/CloudronManifest.json +++ b/CloudronManifest.json @@ -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" } diff --git a/app/main.py b/app/main.py index 337be5b..4b2aa8c 100644 --- a/app/main.py +++ b/app/main.py @@ -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 = """

{heading}

{lede}

{money}

- Add credits -

Credit packs never expire, carry over month to month, and are drawn on only after your monthly credit is used.

+ Add to your balance +

This is the only notice of its kind you'll get until your balance goes back above the line and runs low again.

- Nothing else changes — your membership, your data and your account stay as they are.
+ Nothing else about your account changes.
Questions? Reply to this email or write to info@inference.coop.
The Inference Cooperative · inference.coop @@ -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: