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]:
|
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
|
Uses the bot's personal token (admin) so emails are exposed. This is the
|
||||||
authoritative source of truth for reconciliation (provisioning missing
|
authoritative source of truth for reconciliation (provisioning missing
|
||||||
members, deactivating lapsed ones).
|
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:
|
if not OC_PERSONAL_TOKEN:
|
||||||
logger.warning("OC_PERSONAL_TOKEN not set; cannot fetch members")
|
logger.warning("OC_PERSONAL_TOKEN not set; cannot fetch members")
|
||||||
return []
|
return []
|
||||||
query = (
|
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
|
% OC_COLLECTIVE_SLUG
|
||||||
)
|
)
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
@@ -569,12 +574,19 @@ async def fetch_members() -> list[dict]:
|
|||||||
role = n.get("role", "")
|
role = n.get("role", "")
|
||||||
acct = n.get("account", {}) or {}
|
acct = n.get("account", {}) or {}
|
||||||
email = (acct.get("email") or "").strip().lower()
|
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({
|
result.append({
|
||||||
"email": email,
|
"email": email,
|
||||||
"name": acct.get("name") or email.split("@")[0],
|
"name": acct.get("name") or email.split("@")[0],
|
||||||
"slug": acct.get("slug") or "",
|
"slug": acct.get("slug") or "",
|
||||||
"role": role,
|
"role": role,
|
||||||
|
"tier": tier,
|
||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -1232,17 +1244,20 @@ async def opencollective_webhook(request: Request, token: str):
|
|||||||
# nightly sweep backstops any missed first payment.
|
# nightly sweep backstops any missed first payment.
|
||||||
first_payment = data.get("firstPayment", False)
|
first_payment = data.get("firstPayment", False)
|
||||||
|
|
||||||
# Credit-pack detection: one-time contributions to a "Credit pack"
|
# Membership provision: only on payments to the "Membership" tier. A
|
||||||
# tier route to the member's NON-EXPIRING credit balance instead of
|
# one-time donation (no tier, or any other tier) grants BACKER role on
|
||||||
# provisioning/allowance. Detection: the OC tier name contains
|
# OC but is NOT a membership — don't provision it.
|
||||||
# "credit" (case-insensitive). Recurring subscriptions never route
|
|
||||||
# here (a credit pack is a one-time purchase).
|
|
||||||
tier_name = ""
|
tier_name = ""
|
||||||
order_tier = (data.get("tier") or data.get("order", {}) or {})
|
order_tier = (data.get("tier") or data.get("order", {}) or {})
|
||||||
if isinstance(order_tier, dict):
|
if isinstance(order_tier, dict):
|
||||||
tier_name = (order_tier.get("name") or "").strip()
|
tier_name = (order_tier.get("name") or "").strip()
|
||||||
amount_cents = data.get("amount") or data.get("valueInCents") or 0
|
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":
|
if "credit" in tier_name.lower() and slug and event_type == "order.processed":
|
||||||
members = await fetch_members()
|
members = await fetch_members()
|
||||||
email = next((m["email"] for m in members if m["slug"] == slug), None)
|
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,
|
"status": "credits_added", "email": email,
|
||||||
"credits_added": credits, "credit_balance": new_balance,
|
"credits_added": credits, "credit_balance": new_balance,
|
||||||
})
|
})
|
||||||
logger.warning("Credit pack payment from unknown slug %s", slug)
|
# Non-member credit-pack purchase: credits are held in escrow, NOT
|
||||||
return JSONResponse({"status": "ignored", "note": "unknown slug for credit pack"})
|
# 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()
|
members = await fetch_members()
|
||||||
for m in members:
|
for m in members:
|
||||||
if m["slug"] == slug:
|
if m["slug"] == slug:
|
||||||
@@ -1273,6 +1297,11 @@ async def opencollective_webhook(request: Request, token: str):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Webhook: failed to provision %s: %s", m["email"], e)
|
logger.warning("Webhook: failed to provision %s: %s", m["email"], e)
|
||||||
break
|
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})
|
return JSONResponse({"status": "provisioned", "name": name, "slug": slug})
|
||||||
|
|
||||||
if event_type in ("collective.member.deleted", "collective.transaction.deleted"):
|
if event_type in ("collective.member.deleted", "collective.transaction.deleted"):
|
||||||
|
|||||||
Reference in new issue
Block a user