Variable-amount credit pack: grant tip-free contribution amount via public OC order API
This commit is contained in:
1 parent
315247e5db
commit
9b1d9edde4
1 file changed
+38
-40
+38
-40
@@ -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', '')}"
|
||||
|
||||
Reference in new issue
Block a user