Membership tied to Membership tier: sweep+webhook filter, credit packs HELD for non-members, MANUAL_MEMBERS for legacy
This commit is contained in:
1 parent
63acea289f
commit
5fa2449037
1 file changed
+40
-11
+40
-11
@@ -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"):
|
||||
|
||||
Reference in new issue
Block a user