diff --git a/app/main.py b/app/main.py index 0c94865..6902a5b 100644 --- a/app/main.py +++ b/app/main.py @@ -541,17 +541,22 @@ def compute_max_budget(allowance: float, credit_balance: float, team_spend: floa async def fetch_members() -> list[dict]: - """Return the full active-member list: email, name, slug, role. + """Return the full active-member list: email, name, slug, role, tier. Uses the bot's personal token (admin) so emails are exposed. This is the authoritative source of truth for reconciliation (provisioning missing members, deactivating lapsed ones). + + Membership is tied to the "Membership" TIER — a one-time donation grants + BACKER role on Open Collective but is NOT a membership. So this returns + only users whose tier name contains "membership" (case-insensitive) or + who have no tier (legacy: contributors who predate the tier system). """ if not OC_PERSONAL_TOKEN: logger.warning("OC_PERSONAL_TOKEN not set; cannot fetch members") return [] query = ( - '{ collective(slug: "%s") { members(limit: 100) { nodes { role account { name slug ... on Individual { email } } } } } }' + '{ collective(slug: "%s") { members(limit: 100) { nodes { role tier { name } account { name slug ... on Individual { email } } } } } }' % OC_COLLECTIVE_SLUG ) async with httpx.AsyncClient() as client: @@ -569,12 +574,19 @@ async def fetch_members() -> list[dict]: role = n.get("role", "") acct = n.get("account", {}) or {} email = (acct.get("email") or "").strip().lower() - if email and role in ("BACKER", "ADMIN"): + tier = ((n.get("tier") or {}).get("name") or "").strip() + if not email or role not in ("BACKER", "ADMIN"): + continue + # Admins (the co-op itself) always count. BACKERs must be on the + # Membership tier; no-tier BACKERs are legacy contributors from before + # tiers existed — grandfathered as members. + if role == "ADMIN" or "membership" in tier.lower() or tier == "": result.append({ "email": email, "name": acct.get("name") or email.split("@")[0], "slug": acct.get("slug") or "", "role": role, + "tier": tier, }) return result @@ -1232,17 +1244,20 @@ async def opencollective_webhook(request: Request, token: str): # nightly sweep backstops any missed first payment. first_payment = data.get("firstPayment", False) - # Credit-pack detection: one-time contributions to a "Credit pack" - # tier route to the member's NON-EXPIRING credit balance instead of - # provisioning/allowance. Detection: the OC tier name contains - # "credit" (case-insensitive). Recurring subscriptions never route - # here (a credit pack is a one-time purchase). + # Membership provision: only on payments to the "Membership" tier. A + # one-time donation (no tier, or any other tier) grants BACKER role on + # OC but is NOT a membership — don't provision it. tier_name = "" order_tier = (data.get("tier") or data.get("order", {}) or {}) if isinstance(order_tier, dict): tier_name = (order_tier.get("name") or "").strip() amount_cents = data.get("amount") or data.get("valueInCents") or 0 + # Credit-pack detection: one-time contributions to a "Credit pack" + # tier route to the member's NON-EXPIRING credit balance instead of + # provisioning/allowance. Detection: the OC tier name contains + # "credit" (case-insensitive). Recurring subscriptions never route + # here (a credit pack is a one-time purchase). 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) @@ -1261,10 +1276,19 @@ async def opencollective_webhook(request: Request, token: str): "status": "credits_added", "email": email, "credits_added": credits, "credit_balance": new_balance, }) - logger.warning("Credit pack payment from unknown slug %s", slug) - return JSONResponse({"status": "ignored", "note": "unknown slug for credit pack"}) + # 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, + ) + return JSONResponse({ + "status": "ignored", + "note": "credit pack purchased without membership; membership is required to use credits", + }) - if slug and first_payment: + if slug and first_payment and "membership" in tier_name.lower(): members = await fetch_members() for m in members: if m["slug"] == slug: @@ -1273,6 +1297,11 @@ async def opencollective_webhook(request: Request, token: str): except Exception as e: logger.warning("Webhook: failed to provision %s: %s", m["email"], e) break + elif slug and first_payment and event_type == "order.processed": + # One-time donation (no tier or non-membership tier): NOT a + # membership. Log it so we can see it, but don't provision. + logger.info("One-time donation from slug %s (tier=%r) — not a membership, not provisioning", + slug, tier_name) return JSONResponse({"status": "provisioned", "name": name, "slug": slug}) if event_type in ("collective.member.deleted", "collective.transaction.deleted"):