230 lines
7.6 KiB
Python
230 lines
7.6 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Admin utility: reconcile a payment transaction to a user subscription.
|
|
|
|
Usage:
|
|
docker compose exec -T polyweather python scripts/reconcile_payment_tx.py \
|
|
--user-id <supabase_user_id> --tx-hash <0x...>
|
|
|
|
Optional emergency fallback (manual grant if no matching intent):
|
|
docker compose exec -T polyweather python scripts/reconcile_payment_tx.py \
|
|
--user-id <supabase_user_id> --tx-hash <0x...> --manual-grant
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import importlib
|
|
import json
|
|
import os
|
|
import sys
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from web3 import Web3
|
|
|
|
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
if PROJECT_ROOT not in sys.path:
|
|
sys.path.insert(0, PROJECT_ROOT)
|
|
|
|
_contract_checkout = importlib.import_module("src.payments.contract_checkout")
|
|
PAYMENT_CHECKOUT = _contract_checkout.PAYMENT_CHECKOUT
|
|
PaymentCheckoutError = _contract_checkout.PaymentCheckoutError
|
|
|
|
|
|
def _list_recent_intents(user_id: str, limit: int = 20) -> List[Dict[str, Any]]:
|
|
rows = PAYMENT_CHECKOUT._rest(
|
|
"GET",
|
|
"payment_intents",
|
|
params={
|
|
"select": (
|
|
"id,user_id,plan_code,status,tx_hash,order_id_hex,"
|
|
"amount_units,token_address,created_at,updated_at"
|
|
),
|
|
"user_id": f"eq.{user_id}",
|
|
"order": "created_at.desc",
|
|
"limit": str(limit),
|
|
},
|
|
allowed_status=[200],
|
|
)
|
|
return rows if isinstance(rows, list) else []
|
|
|
|
|
|
def _find_intent_by_tx(user_id: str, tx_hash: str) -> Optional[Dict[str, Any]]:
|
|
tx_norm = str(tx_hash or "").strip().lower()
|
|
if not tx_norm:
|
|
return None
|
|
rows = PAYMENT_CHECKOUT._rest(
|
|
"GET",
|
|
"payment_intents",
|
|
params={
|
|
"select": "id,user_id",
|
|
"tx_hash": f"eq.{tx_norm}",
|
|
"limit": "1",
|
|
},
|
|
allowed_status=[200],
|
|
)
|
|
row = rows[0] if isinstance(rows, list) and rows else None
|
|
if not isinstance(row, dict):
|
|
return None
|
|
if str(row.get("user_id") or "").strip() != str(user_id or "").strip():
|
|
return None
|
|
return row
|
|
|
|
|
|
def _find_intent_by_order_id(user_id: str, order_id_hex: str) -> Optional[Dict[str, Any]]:
|
|
normalized_user_id = str(user_id or "").strip()
|
|
normalized_order_id = str(order_id_hex or "").strip().lower()
|
|
if not normalized_user_id or not normalized_order_id:
|
|
return None
|
|
rows = PAYMENT_CHECKOUT._rest(
|
|
"GET",
|
|
"payment_intents",
|
|
params={
|
|
"select": "id,user_id",
|
|
"order_id_hex": f"eq.{normalized_order_id}",
|
|
"limit": "1",
|
|
},
|
|
allowed_status=[200],
|
|
)
|
|
row = rows[0] if isinstance(rows, list) and rows else None
|
|
if not isinstance(row, dict):
|
|
return None
|
|
if str(row.get("user_id") or "").strip() != normalized_user_id:
|
|
return None
|
|
return row
|
|
|
|
|
|
def _decode_pay_call(tx_hash: str) -> Optional[Dict[str, Any]]:
|
|
w3 = PAYMENT_CHECKOUT._get_web3()
|
|
if not w3.is_connected():
|
|
raise RuntimeError("payment rpc not connected")
|
|
|
|
tx = w3.eth.get_transaction(tx_hash)
|
|
receiver = PAYMENT_CHECKOUT.receiver_contract
|
|
if str(tx.get("to") or "").lower() != str(receiver or "").lower():
|
|
return {
|
|
"is_pay_call": False,
|
|
"reason": f"tx.to={tx.get('to')} (expected checkout contract {receiver})",
|
|
}
|
|
|
|
contract = PAYMENT_CHECKOUT._get_contract(receiver)
|
|
fn, args = contract.decode_function_input(tx["input"])
|
|
if str(getattr(fn, "fn_name", "")) != "pay":
|
|
return {
|
|
"is_pay_call": False,
|
|
"reason": f"contract call is {getattr(fn, 'fn_name', '')}, not pay",
|
|
}
|
|
|
|
return {
|
|
"is_pay_call": True,
|
|
"order_id_hex": Web3.to_hex(args["orderId"]).lower(),
|
|
"plan_id": int(args["planId"]),
|
|
"amount_units": int(args["amount"]),
|
|
"token_address": Web3.to_checksum_address(args["token"]).lower(),
|
|
"from_address": str(tx.get("from") or "").lower(),
|
|
"to_address": str(tx.get("to") or "").lower(),
|
|
}
|
|
|
|
|
|
def _print_json(label: str, payload: Any) -> None:
|
|
print(f"{label}: {json.dumps(payload, ensure_ascii=False, default=str)}")
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--user-id", required=True, help="Supabase user id")
|
|
parser.add_argument("--tx-hash", required=True, help="0x transaction hash")
|
|
parser.add_argument(
|
|
"--manual-grant",
|
|
action="store_true",
|
|
help="Emergency: grant monthly subscription when no matching intent",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
user_id = str(args.user_id).strip()
|
|
tx_hash = str(args.tx_hash).strip().lower()
|
|
if not (tx_hash.startswith("0x") and len(tx_hash) == 66):
|
|
print("ERROR: invalid --tx-hash")
|
|
return 2
|
|
|
|
print("=== reconcile payment tx ===")
|
|
print("user_id:", user_id)
|
|
print("tx_hash:", tx_hash)
|
|
|
|
target_intent = _find_intent_by_tx(user_id, tx_hash)
|
|
if target_intent:
|
|
_print_json("intent_matched_by_tx", target_intent)
|
|
else:
|
|
print("intent_matched_by_tx: null")
|
|
|
|
decode_info: Optional[Dict[str, Any]] = None
|
|
if not target_intent:
|
|
try:
|
|
decode_info = _decode_pay_call(tx_hash)
|
|
_print_json("tx_decode", decode_info)
|
|
except Exception as exc:
|
|
print(f"tx_decode_error: {exc}")
|
|
|
|
if decode_info and decode_info.get("is_pay_call"):
|
|
order_id_hex = str(decode_info.get("order_id_hex") or "").lower()
|
|
if order_id_hex:
|
|
target_intent = _find_intent_by_order_id(user_id, order_id_hex)
|
|
if target_intent:
|
|
_print_json("intent_matched_by_order_id", target_intent)
|
|
|
|
if target_intent:
|
|
intent_id = str(target_intent.get("id") or "")
|
|
try:
|
|
result = PAYMENT_CHECKOUT.confirm_intent_tx(
|
|
user_id=user_id,
|
|
intent_id=intent_id,
|
|
tx_hash=tx_hash,
|
|
)
|
|
_print_json("confirm_result", result.get("intent", {}))
|
|
_print_json("subscription", result.get("subscription", {}))
|
|
_print_json("points_redemption", result.get("points_redemption", {}))
|
|
print("DONE: confirmed")
|
|
return 0
|
|
except PaymentCheckoutError as exc:
|
|
print(f"confirm_error: status={exc.status_code} detail={exc.detail}")
|
|
return 1
|
|
|
|
print("NO_MATCHING_INTENT: cannot auto-confirm this tx for this user.")
|
|
print("Likely reasons:")
|
|
print("- tx is a direct token transfer (not checkout contract pay())")
|
|
print("- tx belongs to another account's intent")
|
|
print("- tx hash typo / wrong chain")
|
|
|
|
recent = _list_recent_intents(user_id, limit=5)
|
|
_print_json("recent_intents", recent)
|
|
|
|
if not args.manual_grant:
|
|
print("ABORTED: use --manual-grant only if you verified payment manually.")
|
|
return 1
|
|
|
|
print("manual_grant: enabled, creating emergency monthly subscription...")
|
|
try:
|
|
plan = PAYMENT_CHECKOUT._select_plan("pro_monthly")
|
|
payload = {
|
|
"manual_grant": True,
|
|
"reason": "operator_reconcile_no_intent",
|
|
"tx_hash": tx_hash,
|
|
}
|
|
sub = PAYMENT_CHECKOUT._grant_subscription(
|
|
user_id=user_id,
|
|
plan_code=plan["plan_code"],
|
|
duration_days=int(plan["duration_days"]),
|
|
tx_hash=tx_hash,
|
|
payload=payload,
|
|
)
|
|
_print_json("manual_subscription", sub)
|
|
print("DONE: manual grant created")
|
|
return 0
|
|
except Exception as exc:
|
|
print(f"manual_grant_error: {exc}")
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|