diff --git a/app/main.py b/app/main.py index ee86e3a..aeb681c 100644 --- a/app/main.py +++ b/app/main.py @@ -85,40 +85,40 @@ 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]] = {} +# Cache of order idV2 -> tip-free contribution amount in cents, resolved via +# OC's PUBLIC GraphQL API (no token needed; our personal token lacks the +# orders scope but unauthenticated reads of public orders work). +_order_amount_cache: dict[str, int] = {} -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. +async def get_order_amount(order_id_v2: str | None) -> int | None: + """Look up an order's contribution amount (EXCLUDING the platform tip). - 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. + The webhook's `totalAmount` includes the buyer's optional OC tip, which + the co-op never receives — credits must be granted on the tip-free + amount. Returns cents, or None if unresolvable. """ - if not tier_id: - return None, "" - if tier_id in _tier_face_cache: - return _tier_face_cache[tier_id] + if not order_id_v2: + return None + if order_id_v2 in _order_amount_cache: + return _order_amount_cache[order_id_v2] try: - query = ('{ collective(slug: "%s") { tiers { nodes { legacyId name ' - "amount { valueInCents } } } } }" % OC_COLLECTIVE_SLUG) + query = ('{ order(order: {id: "%s"}) { amount { valueInCents } ' + 'platformTipAmount { valueInCents } } }' % order_id_v2) async with httpx.AsyncClient(timeout=15.0) as client: r = await client.post(OC_GRAPHQL_URL, - headers={"Personal-Token": OC_PERSONAL_TOKEN, + headers={"Content-Type": "application/json", "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 "", - ) + o = r.json().get("data", {}).get("order") or {} + cents = (o.get("amount") or {}).get("valueInCents") + if cents is not None: + _order_amount_cache[order_id_v2] = int(cents) + return int(cents) except Exception as e: - logger.warning("Tier face lookup failed for %s: %s", tier_id, e) - return _tier_face_cache.get(tier_id, (None, "")) + logger.warning("Order amount lookup failed for %s: %s", order_id_v2, e) + return None + # Personal token for the "Inference Co-op Bot" account, which is an admin of @@ -1334,16 +1334,12 @@ 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 or face amount. Resolve both via the - # collective's tiers query (cached); the order description is the - # fallback for the name ("... (Credit pack)"). + # (numeric) — NO tier name. The order description embeds it + # ("Financial contribution to Inference Cooperative (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: - order_desc = order.get("description") or "" - m = re.search(r"\(([^)]+)\)\s*$", order_desc.strip()) - tier_name = m.group(1).strip() if m 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 = order.get("totalAmount") or 0 @@ -1356,18 +1352,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) - # 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) + # The tier is VARIABLE-amount — no fixed face value. Credits are + # granted on the contribution amount EXCLUDING the OC platform + # tip (totalAmount - tip = amount), which we resolve via OC's + # public order API. Fallback: webhook totalAmount (slightly + # overgrants if a tip was included; logged). + tip_free_cents = await get_order_amount(order.get("idV2")) + if tip_free_cents is not None: + amount = round(tip_free_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", + "Credit pack: charged $%.2f including tip; granting tip-free $%.2f", charged, amount, ) oc_ref = f"oc:{slug}:{data.get('id', '')}"