Held credit packs: record non-member purchases, notify buyer, auto-grant on join, admin endpoints

This commit is contained in:
inference-bot committed 2026-09-27 21:54:34 -06:00
1 parent df740db283
commit 8a320bfed7
1 file changed
+159 -13
+159 -13
View File
@@ -418,6 +418,23 @@ def get_db() -> sqlite3.Connection:
"created_at TEXT DEFAULT (datetime('now'))"
")"
)
# Held credit packs: purchases by NON-members. Money received but credits
# not granted (a membership is required to use credits). Each hold is
# either refunded manually (OC dashboard, 30-day window) or granted
# automatically when the buyer becomes a member.
conn.execute(
"CREATE TABLE IF NOT EXISTS held_credits ("
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
"slug TEXT NOT NULL, "
"email TEXT, "
"name TEXT, "
"amount REAL NOT NULL, "
"oc_reference TEXT, "
"status TEXT NOT NULL DEFAULT 'held', "
"created_at TEXT DEFAULT (datetime('now')), "
"resolved_at TEXT"
")"
)
# Member-managed API keys: add a fingerprint (last-4 of the key, shown in
# lists) and purge the stored plaintext. Member API keys are revealed ONCE
@@ -991,6 +1008,38 @@ async def provision_member(email: str, name: str, slug: str = "") -> str:
# Subscribe to the member newsletter (single opt-in; members consented by joining).
await listmonk_sync(email, subscribe=True)
# Auto-grant any HELD credit packs for this member (they pre-purchased
# credits before joining — apply them now as part of provisioning).
granted = 0.0
try:
conn = get_db()
rows = conn.execute(
"SELECT id, amount FROM held_credits "
"WHERE status = 'held' AND (LOWER(email) = ? OR LOWER(slug) = ?)",
(email.lower(), slug.lower() if slug else ""),
).fetchall()
if rows:
hold_ids = [r[0] for r in rows]
granted = sum(float(r[1] or 0.0) for r in rows)
conn.execute(
"UPDATE held_credits SET status = 'granted', resolved_at = datetime('now') "
"WHERE id IN (%s)" % ",".join("?" * len(hold_ids)),
hold_ids,
)
conn.commit()
conn.close()
if granted > 0:
new_balance = add_credits(
email, granted,
reason="held credit pack(s) granted on membership",
reference=f"held:{','.join(str(r[0]) for r in rows)}",
)
await sync_team_budget_from_balance(email)
logger.info("Granted %.2f held credits to new member %s (balance %s)",
granted, email, new_balance)
except Exception as e:
logger.warning("Held-credit grant for %s failed: %s", email, e)
await sync_loomio_memberships()
logger.info("Provisioned member %s (user_id=%s)", email, user_id)
return user_id
@@ -1261,31 +1310,88 @@ async def opencollective_webhook(request: Request, token: str):
if "credit" in tier_name.lower() and slug and event_type == "order.processed":
members = await fetch_members()
email = next((m["email"] for m in members if m["slug"] == slug), None)
amount = round(float(amount_cents) / 100.0, 2)
oc_ref = f"oc:{slug}:{data.get('id', '')}"
if email:
credits = round(float(amount_cents) / 100.0, 2)
# Member purchase → credits land immediately.
new_balance = add_credits(
email, credits,
email, amount,
reason="credit pack purchase (Open Collective)",
reference=f"oc:{slug}:{data.get('id', '')}",
reference=oc_ref,
)
# Push the enlarged budget immediately.
await sync_team_budget_from_balance(email)
logger.info("Credit pack: +%s credits for %s (balance %s)",
credits, email, new_balance)
amount, email, new_balance)
return JSONResponse({
"status": "credits_added", "email": email,
"credits_added": credits, "credit_balance": new_balance,
"credits_added": amount, "credit_balance": new_balance,
})
# Non-member credit-pack purchase: credits are held in escrow, NOT
# provisioned. The buyer was warned a membership is required; if
# they later become a member, the sweep/admin can grant these.
logger.warning(
"Credit pack payment from non-member slug %s (%.2f credits HELD, not granted)",
slug, float(amount_cents) / 100.0,
# Non-member purchase → HOLD. Record it, email the buyer with
# their two options (refund or join-and-apply). Credits are NOT
# granted: a membership is required to use them.
buyer_name = name or slug
buyer_email = ""
# The webhook payload strips emails; look up the payer via the
# admin token (bot account sees contributor emails).
try:
q = ('{ collective(slug: "%s") { members(limit: 100) { nodes '
'{ account { name slug ... on Individual { email } } } } } }' % OC_COLLECTIVE_SLUG)
async with httpx.AsyncClient(timeout=15.0) as client:
r = await client.post(OC_GRAPHQL_URL,
headers={"Personal-Token": OC_PERSONAL_TOKEN,
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64)"},
json={"query": q})
if r.status_code == 200:
for n in r.json().get("data", {}).get("collective", {}).get("members", {}).get("nodes", []):
if (n.get("account") or {}).get("slug") == slug:
buyer_email = (n["account"].get("email") or "").strip()
buyer_name = n["account"].get("name") or buyer_name
break
except Exception as e:
logger.warning("Credit-pack hold: payer lookup failed for %s: %s", slug, e)
conn = get_db()
conn.execute(
"INSERT INTO held_credits (slug, email, name, amount, oc_reference) "
"VALUES (?, ?, ?, ?, ?)",
(slug, buyer_email or None, buyer_name, amount, oc_ref),
)
conn.commit()
conn.close()
logger.warning(
"Credit pack HELD from non-member %s (%.2f credits); buyer notified",
slug, amount,
)
if buyer_email:
send_email(
buyer_email,
"Inference Cooperative — about your credit pack",
f"Hi {buyer_name},\n\n"
f"Thank you for your ${amount:.2f} credit pack purchase — we've received it.\n\n"
"One thing to flag: credit packs are for members, and our records\n"
"don't show you as one yet. You have two options:\n\n"
"1. Join the co-op ($10-20/month, sliding scale):\n"
" https://opencollective.com/inference-cooperative/contribute\n"
f" Once you're a member, your ${amount:.2f} in credits will be\n"
" applied to your account automatically.\n\n"
"2. Request a refund:\n"
" Reply to this email or write to info@inference.coop and\n"
" we'll refund you in full.\n\n"
"Sorry for the friction — memberships keep the co-op\n"
"member-governed, which is rather the point of the place.\n\n"
"— The Inference Cooperative\n"
"https://inference.coop",
)
else:
logger.warning("Credit-pack hold: no buyer email found for %s; no notification sent", slug)
return JSONResponse({
"status": "ignored",
"note": "credit pack purchased without membership; membership is required to use credits",
"status": "held",
"note": "credit pack purchased without membership; hold recorded and buyer notified",
})
if slug and first_payment and "membership" in tier_name.lower():
@@ -1588,6 +1694,46 @@ async def admin_credit_ledger(email: str, request: Request):
}
@app.get("/admin/held-credits")
@limiter.limit("30/minute")
async def admin_held_credits(request: Request):
"""List held credit packs (non-member purchases awaiting refund or grant)."""
_verify_admin_token(request)
conn = get_db()
rows = conn.execute(
"SELECT id, slug, email, name, amount, oc_reference, status, created_at, resolved_at "
"FROM held_credits ORDER BY created_at DESC"
).fetchall()
conn.close()
return {
"held": [
{"id": r[0], "slug": r[1], "email": r[2], "name": r[3], "amount": r[4],
"reference": r[5], "status": r[6], "created_at": r[7], "resolved_at": r[8]}
for r in rows
]
}
@app.post("/admin/held-credits/{hold_id}/refund")
@limiter.limit("20/minute")
async def admin_held_refund(hold_id: int, request: Request):
"""Mark a held credit pack as refunded (the refund itself is done manually
in the Open Collective dashboard — this records it and updates the hold)."""
_verify_admin_token(request)
conn = get_db()
cur = conn.execute(
"UPDATE held_credits SET status = 'refunded', resolved_at = datetime('now') "
"WHERE id = ? AND status = 'held'",
(hold_id,),
)
conn.commit()
updated = cur.rowcount
conn.close()
if not updated:
raise HTTPException(404, "Held credit not found (or already resolved)")
return {"status": "refunded", "id": hold_id}
@app.get("/admin/overview")
@limiter.limit("30/minute")
async def admin_overview(request: Request):