diff --git a/app/main.py b/app/main.py index dec5919..ee86e3a 100644 --- a/app/main.py +++ b/app/main.py @@ -85,6 +85,42 @@ WEBHOOK_TOKEN = os.environ.get("WEBHOOK_TOKEN", "") OC_GRAPHQL_URL = "https://opencollective.com/api/graphql/v2" OC_COLLECTIVE_SLUG = os.environ.get("OC_COLLECTIVE_SLUG", "inference-cooperative") +# Cache of TierId -> (face_value_cents, name), fetched from the OC GraphQL +# API (tiers scope is allowed on our personal token; orders is not). +_tier_face_cache: dict[int, tuple[int, str]] = {} + + +async def get_tier_face(tier_id: int | None) -> tuple[int | None, str]: + """Look up a tier's face value and name by numeric legacyId. + + Returns (face_cents, name); (None, "") if unknown. The webhook payload + carries only TierId — no name, no face amount — so we resolve via the + collective's tiers query and cache the mapping. + """ + if not tier_id: + return None, "" + if tier_id in _tier_face_cache: + return _tier_face_cache[tier_id] + try: + query = ('{ collective(slug: "%s") { tiers { nodes { legacyId name ' + "amount { valueInCents } } } } }" % OC_COLLECTIVE_SLUG) + async with httpx.AsyncClient(timeout=15.0) as client: + r = await client.post(OC_GRAPHQL_URL, + headers={"Personal-Token": OC_PERSONAL_TOKEN, + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64)"}, + json={"query": query}) + for t in r.json().get("data", {}).get("collective", {}).get("tiers", {}).get("nodes", []): + lid = t.get("legacyId") + if lid is not None: + _tier_face_cache[int(lid)] = ( + (t.get("amount") or {}).get("valueInCents") or 0, + t.get("name") or "", + ) + except Exception as e: + logger.warning("Tier face lookup failed for %s: %s", tier_id, e) + return _tier_face_cache.get(tier_id, (None, "")) + + # Personal token for the "Inference Co-op Bot" account, which is an admin of # the collective. Authenticated as an admin, the GraphQL API exposes member # emails (which are hidden from anonymous access). We use this to match a @@ -1262,9 +1298,6 @@ async def opencollective_webhook(request: Request, token: str): raise HTTPException(401, "Invalid webhook token") payload = await request.json() - # DEBUG (temporary): log the full payload once to learn the real shape of - # tier fields on order.processed. Remove after tier routing is verified. - logger.info("OC webhook payload: %s", json.dumps(payload)[:1500]) event_type = payload.get("type", "") data = payload.get("data", {}) @@ -1301,20 +1334,19 @@ async def opencollective_webhook(request: Request, token: str): # OC but is NOT a membership — don't provision it. # # Tier detection: the webhook payload carries only `data.order.TierId` - # (numeric) — NO tier name. The order description reliably embeds the - # tier name in parens ("Financial contribution to Inference - # Cooperative (Credit pack)"), so we parse it from there. - order_desc = (data.get("order") or {}).get("description") or "" - m = re.search(r"\(([^)]+)\)\s*$", order_desc.strip()) - tier_name = m.group(1).strip() if m else "" + # (numeric) — NO tier name or face amount. Resolve both via the + # collective's tiers query (cached); the order description is the + # fallback for the name ("... (Credit pack)"). + order = data.get("order") or {} + tier_id = order.get("TierId") + face_cents, tier_name = await get_tier_face(tier_id) if not tier_name: - # Fallback: description without parens IS the tier name in some - # OC webhook shapes ("Financial contribution to X: TierName"). - m2 = re.search(r":\s*([^:]+)$", order_desc.strip()) - tier_name = m2.group(1).strip() if m2 else "" + order_desc = order.get("description") or "" + m = re.search(r"\(([^)]+)\)\s*$", order_desc.strip()) + tier_name = m.group(1).strip() if m else "" amount_cents = data.get("amount") or 0 if not amount_cents: - amount_cents = ((data.get("order") or {}).get("totalAmount")) or 0 + amount_cents = order.get("totalAmount") or 0 # Credit-pack detection: one-time contributions to a "Credit pack" # tier route to the member's NON-EXPIRING credit balance instead of @@ -1324,7 +1356,20 @@ async def opencollective_webhook(request: Request, token: str): 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) - amount = round(float(amount_cents) / 100.0, 2) + # Credits are granted at the tier's FACE VALUE (what the pack + # buys), not the total charged — the total can include a platform + # tip the co-op never receives. Fall back to the charged amount + # if the face value is unavailable. + if face_cents: + amount = round(face_cents / 100.0, 2) + else: + amount = round(float(amount_cents) / 100.0, 2) + charged = round(float(amount_cents) / 100.0, 2) + if charged > amount: + logger.info( + "Credit pack: charged $%.2f but face value $%.2f (tip/fees); granting face value", + charged, amount, + ) oc_ref = f"oc:{slug}:{data.get('id', '')}" if email: