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:
inference-bot committed 2026-10-20 22:45:37 -06:00
1 parent 5425efee1f
commit 72614f8f19
1 file changed
+213 -2
+213 -2
View File
@@ -483,6 +483,14 @@ def get_db() -> sqlite3.Connection:
# exhausted within a cycle. # exhausted within a cycle.
if "credit_balance" not in cols: if "credit_balance" not in cols:
conn.execute("ALTER TABLE members ADD COLUMN credit_balance REAL NOT NULL DEFAULT 0") 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 # Credit ledger: audit trail for every credit movement (purchases via the
# OC webhook, admin adjustments, spends drawn against credits). Money needs # OC webhook, admin adjustments, spends drawn against credits). Money needs
# an audit trail. # an audit trail.
@@ -688,6 +696,52 @@ async def fetch_members() -> list[dict]:
return result 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: 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). """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: except Exception as e:
logger.warning("Webhook: failed to pin reset date for %s: %s", logger.warning("Webhook: failed to pin reset date for %s: %s",
member["email"], e) 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": elif slug and first_payment and event_type == "order.processed":
# One-time donation (no tier or non-membership tier): NOT a # One-time donation (no tier or non-membership tier): NOT a
# membership. Log it so we can see it, but don't provision. # 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} 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") @app.post("/admin/set-balance")
@limiter.limit("20/minute") @limiter.limit("20/minute")
async def admin_set_balance(request: Request): async def admin_set_balance(request: Request):
@@ -2402,7 +2579,7 @@ async def admin_overview(request: Request):
conn = get_db() conn = get_db()
rows = conn.execute( 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() ).fetchall()
conn.close() conn.close()
@@ -2468,7 +2645,7 @@ async def admin_overview(request: Request):
admins = [] admins = []
total_spend = 0.0 total_spend = 0.0
total_all_time = 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}", {}) team = teams.get(f"member:{email}", {})
spend = float(team.get("spend") or 0.0) spend = float(team.get("spend") or 0.0)
member_all_time = float(all_time_spend.get(f"member:{email}", 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), "activated": invite_accepted.get(user_id, False),
"active": bool(active), "active": bool(active),
"manual": email.lower() in MANUAL_MEMBERS, "manual": email.lower() in MANUAL_MEMBERS,
"org": org_slug or None,
"balance": float(balance) if balance is not None else MEMBER_BUDGET, "balance": float(balance) if balance is not None else MEMBER_BUDGET,
"credit_balance": credits, "credit_balance": credits,
"credits_remaining": max(0.0, credits - eff["credits_used"]), "credits_remaining": max(0.0, credits - eff["credits_used"]),
@@ -2959,6 +3137,39 @@ async def reconcile_memberships() -> dict:
provisioned_count = 0 provisioned_count = 0
deactivated_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. # 1. Provision missing members.
for m in active: for m in active:
email = m["email"] email = m["email"]