Tier resolution via OC tiers API (TierId->face value+name, cached); credits at face value, not total-with-tip; remove debug payload log
This commit is contained in:
1 parent
f163a5b375
commit
315247e5db
1 file changed
+60
-15
+60
-15
@@ -85,6 +85,42 @@ WEBHOOK_TOKEN = os.environ.get("WEBHOOK_TOKEN", "")
|
|||||||
OC_GRAPHQL_URL = "https://opencollective.com/api/graphql/v2"
|
OC_GRAPHQL_URL = "https://opencollective.com/api/graphql/v2"
|
||||||
OC_COLLECTIVE_SLUG = os.environ.get("OC_COLLECTIVE_SLUG", "inference-cooperative")
|
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
|
# 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
|
# the collective. Authenticated as an admin, the GraphQL API exposes member
|
||||||
# emails (which are hidden from anonymous access). We use this to match a
|
# 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")
|
raise HTTPException(401, "Invalid webhook token")
|
||||||
|
|
||||||
payload = await request.json()
|
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", "")
|
event_type = payload.get("type", "")
|
||||||
data = payload.get("data", {})
|
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.
|
# OC but is NOT a membership — don't provision it.
|
||||||
#
|
#
|
||||||
# Tier detection: the webhook payload carries only `data.order.TierId`
|
# Tier detection: the webhook payload carries only `data.order.TierId`
|
||||||
# (numeric) — NO tier name. The order description reliably embeds the
|
# (numeric) — NO tier name or face amount. Resolve both via the
|
||||||
# tier name in parens ("Financial contribution to Inference
|
# collective's tiers query (cached); the order description is the
|
||||||
# Cooperative (Credit pack)"), so we parse it from there.
|
# fallback for the name ("... (Credit pack)").
|
||||||
order_desc = (data.get("order") or {}).get("description") or ""
|
order = data.get("order") or {}
|
||||||
m = re.search(r"\(([^)]+)\)\s*$", order_desc.strip())
|
tier_id = order.get("TierId")
|
||||||
tier_name = m.group(1).strip() if m else ""
|
face_cents, tier_name = await get_tier_face(tier_id)
|
||||||
if not tier_name:
|
if not tier_name:
|
||||||
# Fallback: description without parens IS the tier name in some
|
order_desc = order.get("description") or ""
|
||||||
# OC webhook shapes ("Financial contribution to X: TierName").
|
m = re.search(r"\(([^)]+)\)\s*$", order_desc.strip())
|
||||||
m2 = re.search(r":\s*([^:]+)$", order_desc.strip())
|
tier_name = m.group(1).strip() if m else ""
|
||||||
tier_name = m2.group(1).strip() if m2 else ""
|
|
||||||
amount_cents = data.get("amount") or 0
|
amount_cents = data.get("amount") or 0
|
||||||
if not amount_cents:
|
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"
|
# Credit-pack detection: one-time contributions to a "Credit pack"
|
||||||
# tier route to the member's NON-EXPIRING credit balance instead of
|
# 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":
|
if "credit" in tier_name.lower() and slug and event_type == "order.processed":
|
||||||
members = await fetch_members()
|
members = await fetch_members()
|
||||||
email = next((m["email"] for m in members if m["slug"] == slug), None)
|
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', '')}"
|
oc_ref = f"oc:{slug}:{data.get('id', '')}"
|
||||||
|
|
||||||
if email:
|
if email:
|
||||||
|
|||||||
Reference in new issue
Block a user