diff --git a/app/main.py b/app/main.py index 29b7e16..a29ee80 100644 --- a/app/main.py +++ b/app/main.py @@ -4171,16 +4171,55 @@ async def budget_notice_sweep(send: bool = False) -> dict: "would_notify": len(preview), "notices": preview} +async def budget_notice_test(dest: str) -> dict: + """Email one sample low-balance letter to `dest`, using that member's real numbers. + + Deliberately does NOT write to budget_notices: a test must not consume the real + once-per-crossing latch, or reviewing the copy would silence the actual notice. + Works while BUDGET_NOTICES is off, so copy can be reviewed before arming anything. + """ + conn = get_db() + row = conn.execute("SELECT slug FROM members WHERE email = ?", (dest,)).fetchone() + name = ((row[0] or "").strip() if row else "") or "there" + conn.close() + + budget, spend = 15.0, 13.34 # sample numbers if they aren't a member + if row: + team_id = await litellm_team_id_for_email(dest) + stats = (get_team_budget_from_db(team_id) or {}) if team_id else {} + if stats.get("max_budget"): + budget = float(stats["max_budget"]) + spend = float(stats.get("spend") or 0.0) + + remaining = budget - spend + subject, text_body, html_body = budget_notice_email( + name, spend, budget, remaining, credit_pack_url() + ) + ok = send_email(dest, subject, text_body, html_body) + return { + "mode": "test", "to": dest, "sent": bool(ok), "subject": subject, + "rendered": {"name": name, "spend": round(spend, 2), "budget": round(budget, 2), + "remaining": round(remaining, 2)}, + "note": "no ledger row written — the real once-per-crossing latch is untouched", + } + + @app.post("/admin/budget-notices") @limiter.limit("10/minute") -async def admin_budget_notices(request: Request, mode: str = "dryrun"): - """Preview (default) or send the monthly budget notices. +async def admin_budget_notices(request: Request, mode: str = "dryrun", to: str | None = None): + """Preview (default), send, or email a single sample. - Authenticated by X-Admin-Token. `mode=dryrun` never emails; `mode=send` - requires BUDGET_NOTICES=on and refuses otherwise, so a preview can be run at - any time without arming the sender. + Authenticated by X-Admin-Token. `mode=dryrun` never emails; `mode=send` requires + BUDGET_NOTICES=on and refuses otherwise; `mode=test&to=
` emails one sample + using that member's real numbers so the copy can be read in a mail client first. + A test works while the flag is off and leaves no trace in the ledger. """ _verify_admin_token(request) + if mode == "test": + dest = (to or "").strip() + if not dest: + return {"refused": "mode=test needs &to="} + return await budget_notice_test(dest) if mode == "send": if budget_notices_enabled() != "on": return {"refused": f"BUDGET_NOTICES is '{budget_notices_enabled()}', not 'on'"