From 8a320bfed7d6a92e89dc4ee3aca87efdafc4714d Mon Sep 17 00:00:00 2001 From: inference-bot Date: Sun, 27 Sep 2026 21:54:34 -0600 Subject: [PATCH] Held credit packs: record non-member purchases, notify buyer, auto-grant on join, admin endpoints --- app/main.py | 172 ++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 159 insertions(+), 13 deletions(-) diff --git a/app/main.py b/app/main.py index 311e984..5801d8e 100644 --- a/app/main.py +++ b/app/main.py @@ -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):