Organizational memberships: OC org contributors (type ORGANIZATION) never expose an email, so all three automated paths missed them (webhook email lookup, nightly sweep, /join). Add: members.org_slug link column + fetch_org_backers() (org BACKER/Membership nodes); sweep keeps linked accounts active while the org's dues are active and deactivates on lapse (fail-safe keeps active if the org fetch errors); webhook re-activates linked accounts on org dues and emails the operator for unlinked orgs; POST /admin/org-link (link + full provision incl. Cloudron signup link) and GET /admin/org-links (live dues status); admin overview gains an 'org' field.
This commit is contained in:
1 parent
5425efee1f
commit
72614f8f19
1 file changed
+213
-2
+213
-2
@@ -483,6 +483,14 @@ def get_db() -> sqlite3.Connection:
|
||||
# exhausted within a cycle.
|
||||
if "credit_balance" not in cols:
|
||||
conn.execute("ALTER TABLE members ADD COLUMN credit_balance REAL NOT NULL DEFAULT 0")
|
||||
# Migration: org_slug — for ORGANIZATION-backed memberships. OC orgs never
|
||||
# expose an email to our integration, so the org's designated contact is
|
||||
# linked manually (POST /admin/org-link): org_slug holds the OC org slug
|
||||
# whose Membership-tier dues keep this account active. The nightly sweep
|
||||
# keeps the account active while the org is a BACKER on the Membership
|
||||
# tier and deactivates it when the org's dues lapse.
|
||||
if "org_slug" not in cols:
|
||||
conn.execute("ALTER TABLE members ADD COLUMN org_slug TEXT")
|
||||
# Credit ledger: audit trail for every credit movement (purchases via the
|
||||
# OC webhook, admin adjustments, spends drawn against credits). Money needs
|
||||
# an audit trail.
|
||||
@@ -688,6 +696,52 @@ async def fetch_members() -> list[dict]:
|
||||
return result
|
||||
|
||||
|
||||
async def fetch_org_backers() -> dict[str, dict]:
|
||||
"""Organization-backed memberships on the Membership tier.
|
||||
|
||||
OC Organizations never expose an email to our integration (the
|
||||
`... on Individual { email }` fragment doesn't match them, and OC does not
|
||||
return org emails even to collective admins), so these members are LINKED
|
||||
to portal accounts via members.org_slug (POST /admin/org-link). Keyed by
|
||||
org slug: {slug: {name, role, tier}}.
|
||||
"""
|
||||
if not OC_PERSONAL_TOKEN:
|
||||
return {}
|
||||
query = (
|
||||
'{ collective(slug: "%s") { members(limit: 100) { nodes { role tier { name } account { name slug type } } } } }'
|
||||
% OC_COLLECTIVE_SLUG
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.post(
|
||||
OC_GRAPHQL_URL,
|
||||
headers={"Personal-Token": OC_PERSONAL_TOKEN},
|
||||
json={"query": query},
|
||||
)
|
||||
if r.status_code != 200:
|
||||
logger.warning("OC org-member fetch failed: %s", r.status_code)
|
||||
return {}
|
||||
nodes = r.json().get("data", {}).get("collective", {}).get("members", {}).get("nodes", [])
|
||||
result: dict[str, dict] = {}
|
||||
for n in nodes:
|
||||
role = n.get("role", "")
|
||||
acct = n.get("account", {}) or {}
|
||||
if (acct.get("type") or "") != "ORGANIZATION":
|
||||
continue
|
||||
if role not in ("BACKER", "ADMIN"):
|
||||
continue
|
||||
tier = ((n.get("tier") or {}).get("name") or "").strip()
|
||||
if role != "ADMIN" and "membership" not in tier.lower() and tier != "":
|
||||
continue
|
||||
slug = acct.get("slug") or ""
|
||||
if slug:
|
||||
result[slug] = {
|
||||
"name": acct.get("name") or slug,
|
||||
"role": role,
|
||||
"tier": tier,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def send_email(to: str, subject: str, text_body: str, html_body: str | None = None) -> bool:
|
||||
"""Send an email via Cloudron's sendmail addon (SMTP).
|
||||
|
||||
@@ -1935,6 +1989,48 @@ async def opencollective_webhook(request: Request, token: str):
|
||||
except Exception as e:
|
||||
logger.warning("Webhook: failed to pin reset date for %s: %s",
|
||||
member["email"], e)
|
||||
elif slug:
|
||||
# Organization-backed membership: OC orgs never expose an email
|
||||
# to our integration, so fetch_members can't see them. Linked
|
||||
# orgs (members.org_slug) get their account re-activated on
|
||||
# dues; unlinked orgs ping the operator to link them.
|
||||
conn = get_db()
|
||||
row = conn.execute(
|
||||
"SELECT email, name FROM members WHERE org_slug = ?", (slug,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if row:
|
||||
org_email, org_name = row
|
||||
conn = get_db()
|
||||
conn.execute("UPDATE members SET active = 1 WHERE email = ?", (org_email,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info("Webhook: org dues for %s — linked account %s re-activated",
|
||||
slug, org_email)
|
||||
if first_payment:
|
||||
try:
|
||||
await provision_member(org_email, org_name, slug)
|
||||
except Exception as e:
|
||||
logger.warning("Webhook: org re-provision failed for %s: %s",
|
||||
org_email, e)
|
||||
else:
|
||||
logger.warning(
|
||||
"Webhook: Membership payment from ORGANIZATION %s (%s) — "
|
||||
"no linked account; add via POST /admin/org-link",
|
||||
name, slug,
|
||||
)
|
||||
send_email(
|
||||
"info@inference.coop",
|
||||
"Action needed: organizational membership without a linked account",
|
||||
f"Open Collective reports a Membership-tier payment from the\n"
|
||||
f"organization \"{name}\" (slug: {slug}).\n\n"
|
||||
"Organizations don't expose an email to our integration, so the\n"
|
||||
"membership needs to be linked to a portal account manually:\n\n"
|
||||
" POST /admin/org-link\n"
|
||||
" {\"org_slug\": \"" + slug + "\", \"email\": \"<their preferred email>\"}\n\n"
|
||||
"Until linked, the organization cannot onboard.\n\n"
|
||||
"— The portal",
|
||||
)
|
||||
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.
|
||||
@@ -2249,6 +2345,87 @@ async def admin_provision(request: Request):
|
||||
return {"status": "provisioned", "email": email, "user_id": user_id}
|
||||
|
||||
|
||||
@app.post("/admin/org-link")
|
||||
@limiter.limit("20/minute")
|
||||
async def admin_org_link(request: Request):
|
||||
"""Link an OC organizational membership to a portal account.
|
||||
|
||||
OC Organizations never expose an email to our integration, so org-backed
|
||||
memberships are linked manually: body {"org_slug": "...", "email": "...",
|
||||
"name": "..."}. Creates (or reuses) the members row for the email with
|
||||
org_slug set, and provisions the full pipeline — Cloudron user, LiteLLM
|
||||
key, and the welcome email containing the Cloudron account-setup link, so
|
||||
the organization's contact can complete signup. The nightly sweep keeps
|
||||
the account active while the org holds the Membership tier and deactivates
|
||||
it when the org's dues lapse (dues monitoring, automatic).
|
||||
"""
|
||||
_verify_admin_token(request)
|
||||
|
||||
body = await request.json()
|
||||
org_slug = (body.get("org_slug") or "").strip().lower()
|
||||
email = (body.get("email") or "").strip().lower()
|
||||
name = (body.get("name") or org_slug).strip()
|
||||
if not org_slug or not email or "@" not in email:
|
||||
raise HTTPException(400, "org_slug and a valid email are required")
|
||||
|
||||
org = (await fetch_org_backers()).get(org_slug)
|
||||
|
||||
conn = get_db()
|
||||
existing = conn.execute("SELECT email FROM members WHERE org_slug = ?", (org_slug,)).fetchone()
|
||||
conn.execute(
|
||||
"INSERT INTO members (email, name, slug, active, org_slug) VALUES (?, ?, ?, 1, ?) "
|
||||
"ON CONFLICT(email) DO UPDATE SET org_slug = excluded.org_slug, active = 1",
|
||||
(email, name, org_slug, org_slug),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
provisioned = False
|
||||
if org: # only provision while the org's membership is active
|
||||
try:
|
||||
await provision_member(email, name, org_slug)
|
||||
provisioned = True
|
||||
except Exception as e:
|
||||
logger.warning("org-link: provision failed for %s: %s", email, e)
|
||||
|
||||
return {
|
||||
"status": "linked",
|
||||
"org_slug": org_slug,
|
||||
"email": email,
|
||||
"org_active": org is not None,
|
||||
"tier": (org or {}).get("tier"),
|
||||
"provisioned": provisioned,
|
||||
"replaced_existing_link": bool(existing),
|
||||
"note": "the nightly sweep deactivates this account automatically if the org's dues lapse",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/admin/org-links")
|
||||
@limiter.limit("30/minute")
|
||||
async def admin_org_links(request: Request):
|
||||
"""List organization-linked accounts with their live OC dues status."""
|
||||
_verify_admin_token(request)
|
||||
|
||||
org_backers = await fetch_org_backers()
|
||||
conn = get_db()
|
||||
rows = conn.execute(
|
||||
"SELECT email, name, org_slug, active FROM members WHERE org_slug IS NOT NULL"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
links = []
|
||||
for email, name, org_slug, active in rows:
|
||||
org = org_backers.get(org_slug)
|
||||
links.append({
|
||||
"org_slug": org_slug,
|
||||
"org_name": (org or {}).get("name") or name,
|
||||
"email": email,
|
||||
"account_active": bool(active),
|
||||
"dues_active": org is not None,
|
||||
"tier": (org or {}).get("tier"),
|
||||
})
|
||||
return {"links": links}
|
||||
|
||||
|
||||
@app.post("/admin/set-balance")
|
||||
@limiter.limit("20/minute")
|
||||
async def admin_set_balance(request: Request):
|
||||
@@ -2402,7 +2579,7 @@ async def admin_overview(request: Request):
|
||||
|
||||
conn = get_db()
|
||||
rows = conn.execute(
|
||||
"SELECT email, active, balance, cloudron_user_id FROM members ORDER BY email"
|
||||
"SELECT email, active, balance, cloudron_user_id, org_slug FROM members ORDER BY email"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
|
||||
@@ -2468,7 +2645,7 @@ async def admin_overview(request: Request):
|
||||
admins = []
|
||||
total_spend = 0.0
|
||||
total_all_time = 0.0
|
||||
for email, active, balance, user_id in rows:
|
||||
for email, active, balance, user_id, org_slug in rows:
|
||||
team = teams.get(f"member:{email}", {})
|
||||
spend = float(team.get("spend") or 0.0)
|
||||
member_all_time = float(all_time_spend.get(f"member:{email}", 0.0))
|
||||
@@ -2491,6 +2668,7 @@ async def admin_overview(request: Request):
|
||||
"activated": invite_accepted.get(user_id, False),
|
||||
"active": bool(active),
|
||||
"manual": email.lower() in MANUAL_MEMBERS,
|
||||
"org": org_slug or None,
|
||||
"balance": float(balance) if balance is not None else MEMBER_BUDGET,
|
||||
"credit_balance": credits,
|
||||
"credits_remaining": max(0.0, credits - eff["credits_used"]),
|
||||
@@ -2959,6 +3137,39 @@ async def reconcile_memberships() -> dict:
|
||||
provisioned_count = 0
|
||||
deactivated_count = 0
|
||||
|
||||
# 0. Organization-backed memberships: dues paid by an OC Organization.
|
||||
# Linked accounts (members.org_slug) stay active while their org holds a
|
||||
# BACKER/ADMIN role on the Membership tier; lapsed orgs deactivate like
|
||||
# individuals. Fail-safe: if the org fetch fails, keep linked accounts
|
||||
# active (never deactivate on our own fetch failure).
|
||||
try:
|
||||
org_backers = await fetch_org_backers()
|
||||
org_fetch_ok = True
|
||||
except Exception as e:
|
||||
logger.warning("Sweep: org fetch failed (keeping linked accounts active): %s", e)
|
||||
org_backers = {}
|
||||
org_fetch_ok = False
|
||||
if org_backers or not org_fetch_ok:
|
||||
conn = get_db()
|
||||
org_rows = conn.execute(
|
||||
"SELECT email, name, org_slug FROM members WHERE org_slug IS NOT NULL"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
for org_email, org_name, org_slug in org_rows:
|
||||
org = org_backers.get(org_slug)
|
||||
if org_fetch_ok and not org:
|
||||
continue # org lapsed → falls through to deactivation below
|
||||
if org_email not in active_emails:
|
||||
active.append({
|
||||
"email": org_email,
|
||||
"name": org_name or (org["name"] if org else org_slug),
|
||||
"slug": org_slug,
|
||||
"role": (org or {}).get("role", "BACKER"),
|
||||
"tier": ((org or {}).get("tier", "") or "") + " (org)",
|
||||
})
|
||||
active_emails.add(org_email)
|
||||
logger.info("Sweep: org-linked member %s active via org %s", org_email, org_slug)
|
||||
|
||||
# 1. Provision missing members.
|
||||
for m in active:
|
||||
email = m["email"]
|
||||
|
||||
Reference in new issue
Block a user