Held credit packs: record non-member purchases, notify buyer, auto-grant on join, admin endpoints
This commit is contained in:
1 parent
df740db283
commit
8a320bfed7
1 file changed
+159
-13
+159
-13
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user