portal: 90% threshold for budget notices; sweep 3x a day

Trigger becomes proportional (90% of the balance used) instead of a $2 floor, so a member on $15 and one on $35 are treated the same. The once-per-crossing latch keys on the same fraction. membership_sweep moves from '0 3 * * *' to '0 3,11,19 * * *' so the notice arrives within hours of crossing rather than up to a day later. BUDGET_NOTICES remains off.
This commit is contained in:
inference-bot committed 2026-10-31 16:49:22 +00:00
1 parent 26be6156ae
commit 7d2507a927
2 files changed
+9 -8

No files matched your search

+1 -1
View File
@@ -15,7 +15,7 @@
"sendmail": {}, "sendmail": {},
"scheduler": { "scheduler": {
"membership_sweep": { "membership_sweep": {
"schedule": "0 3 * * *", "schedule": "0 3,11,19 * * *",
"command": "/app/code/sweep.sh" "command": "/app/code/sweep.sh"
}, },
"rag_refresh": { "rag_refresh": {
+8 -7
View File
@@ -3973,9 +3973,10 @@ async def index():
# Generic on purpose: the balance can be short because the monthly allowance is spent, # 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 # 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 # either way. The trigger is proportional — 90% of the balance used — so it means the same
# thing to a member on a $15 allowance and a member on $35. # thing to a member on $15 and one on $35: the letter arrives when roughly a tenth is left,
BUDGET_LOW_WATER = float(os.environ.get("BUDGET_LOW_WATER", "2.00")) # on whichever sweep follows, rather than at an arbitrary absolute amount.
BUDGET_NOTICE_AT = float(os.environ.get("BUDGET_NOTICE_AT", "0.90"))
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"/>
@@ -4131,10 +4132,10 @@ async def budget_notice_sweep(send: bool = False) -> dict:
continue continue
remaining = float(budget) - spend remaining = float(budget) - spend
if remaining >= BUDGET_LOW_WATER: if spend < BUDGET_NOTICE_AT * float(budget):
# Back above the line — they added funds, or the allowance reset. Clear the latch so # Above the line — they added funds, or the allowance reset. Clear the latch so the
# the NEXT crossing sends a fresh notice. That is what makes it once each time, and # NEXT crossing sends a fresh notice. That is what makes it once each time, and it
# it is why this is a latch rather than a key on the budget cycle. # is why this is a latch rather than a key on the budget cycle.
conn.execute("DELETE FROM budget_notices WHERE email = ?", (email,)) conn.execute("DELETE FROM budget_notices WHERE email = ?", (email,))
conn.commit() conn.commit()
continue continue