Membership tied to Membership tier: sweep+webhook filter, credit packs HELD for non-members, MANUAL_MEMBERS for legacy

This commit is contained in:
inference-bot committed 2026-09-27 21:17:03 -06:00
1 parent 63acea289f
commit 5fa2449037
1 file changed
+40 -11
+40 -11
View File
@@ -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"):