diff --git a/app/main.py b/app/main.py index c52121a..ac9a523 100644 --- a/app/main.py +++ b/app/main.py @@ -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\": \"\"}\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"]