3167 lines
119 KiB
Python
3167 lines
119 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import secrets
|
|
import threading
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timedelta, timezone
|
|
from decimal import Decimal, InvalidOperation, ROUND_FLOOR
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
import requests
|
|
from eth_account import Account
|
|
from eth_account.messages import encode_defunct
|
|
from web3 import Web3
|
|
|
|
from src.auth.supabase_entitlement import SUPABASE_ENTITLEMENT
|
|
from src.auth.telegram_group_pricing import TelegramGroupPricing
|
|
from src.database.db_manager import DBManager
|
|
|
|
DEFAULT_POLYGON_CHAIN_ID = 137
|
|
DEFAULT_ETHEREUM_CHAIN_ID = 1
|
|
DEFAULT_ETHEREUM_USDC_ADDRESS = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
|
|
DEFAULT_USDC_E_ADDRESS = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
|
|
DEFAULT_NATIVE_USDC_ADDRESS = "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359"
|
|
DEFAULT_USDT_ADDRESS = "0xc2132d05d31c914a87c6611c10748aeb04b58e8f"
|
|
|
|
PAYMENT_CONTRACT_ABI = [
|
|
{
|
|
"inputs": [
|
|
{"internalType": "bytes32", "name": "orderId", "type": "bytes32"},
|
|
{"internalType": "uint256", "name": "planId", "type": "uint256"},
|
|
{"internalType": "uint256", "name": "amount", "type": "uint256"},
|
|
{"internalType": "address", "name": "token", "type": "address"},
|
|
],
|
|
"name": "pay",
|
|
"outputs": [],
|
|
"stateMutability": "nonpayable",
|
|
"type": "function",
|
|
},
|
|
{
|
|
"anonymous": False,
|
|
"inputs": [
|
|
{
|
|
"indexed": True,
|
|
"internalType": "bytes32",
|
|
"name": "orderId",
|
|
"type": "bytes32",
|
|
},
|
|
{
|
|
"indexed": True,
|
|
"internalType": "address",
|
|
"name": "payer",
|
|
"type": "address",
|
|
},
|
|
{
|
|
"indexed": True,
|
|
"internalType": "uint256",
|
|
"name": "planId",
|
|
"type": "uint256",
|
|
},
|
|
{
|
|
"indexed": False,
|
|
"internalType": "address",
|
|
"name": "token",
|
|
"type": "address",
|
|
},
|
|
{
|
|
"indexed": False,
|
|
"internalType": "uint256",
|
|
"name": "amount",
|
|
"type": "uint256",
|
|
},
|
|
],
|
|
"name": "OrderPaid",
|
|
"type": "event",
|
|
},
|
|
]
|
|
|
|
ERC20_TRANSFER_EVENT_ABI = {
|
|
"anonymous": False,
|
|
"inputs": [
|
|
{"indexed": True, "name": "from", "type": "address"},
|
|
{"indexed": True, "name": "to", "type": "address"},
|
|
{"indexed": False, "name": "value", "type": "uint256"},
|
|
],
|
|
"name": "Transfer",
|
|
"type": "event",
|
|
}
|
|
|
|
DEFAULT_PLAN_CATALOG: Dict[str, Dict[str, Any]] = {
|
|
"pro_monthly": {"plan_id": 101, "amount_usdc": "10", "duration_days": 30},
|
|
}
|
|
|
|
|
|
def _env_bool(name: str, default: bool = False) -> bool:
|
|
raw = os.getenv(name)
|
|
if raw is None:
|
|
return default
|
|
return str(raw).strip().lower() in {"1", "true", "yes", "on"}
|
|
|
|
|
|
def _env_int(name: str, default: int) -> int:
|
|
raw = os.getenv(name)
|
|
if raw is None:
|
|
return default
|
|
try:
|
|
return int(raw)
|
|
except Exception:
|
|
return default
|
|
|
|
|
|
def _normalize_address(address: Any) -> str:
|
|
text = str(address or "").strip()
|
|
if not text or not Web3.is_address(text):
|
|
return ""
|
|
return Web3.to_checksum_address(text).lower()
|
|
|
|
|
|
def _normalize_order_id_hex(order_id_hex: Any) -> str:
|
|
text = str(order_id_hex or "").strip().lower()
|
|
if not text:
|
|
return ""
|
|
if not text.startswith("0x"):
|
|
text = f"0x{text}"
|
|
if len(text) != 66:
|
|
return ""
|
|
try:
|
|
int(text[2:], 16)
|
|
except Exception:
|
|
return ""
|
|
return text
|
|
|
|
|
|
def _now_utc() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
def _to_iso(dt: datetime) -> str:
|
|
return dt.astimezone(timezone.utc).isoformat()
|
|
|
|
|
|
def _parse_decimal(value: Any, default: Decimal = Decimal("0")) -> Decimal:
|
|
try:
|
|
return Decimal(str(value))
|
|
except (InvalidOperation, ValueError, TypeError):
|
|
return default
|
|
|
|
|
|
def _decimal_to_units(amount: Decimal, decimals: int) -> int:
|
|
q = Decimal(10) ** Decimal(max(0, int(decimals)))
|
|
normalized = (amount * q).quantize(Decimal("1"))
|
|
return int(normalized)
|
|
|
|
|
|
def _units_to_decimal(units: int, decimals: int) -> Decimal:
|
|
q = Decimal(10) ** Decimal(max(0, int(decimals)))
|
|
return Decimal(int(units)) / q
|
|
|
|
|
|
def _format_decimal(value: Decimal, places: int = 6) -> str:
|
|
raw = f"{value:.{places}f}"
|
|
return raw.rstrip("0").rstrip(".") or "0"
|
|
|
|
|
|
def _parse_plan_catalog(raw: str) -> Dict[str, Dict[str, Any]]:
|
|
if not raw:
|
|
return dict(DEFAULT_PLAN_CATALOG)
|
|
try:
|
|
parsed = json.loads(raw)
|
|
except Exception:
|
|
return dict(DEFAULT_PLAN_CATALOG)
|
|
if not isinstance(parsed, dict):
|
|
return dict(DEFAULT_PLAN_CATALOG)
|
|
|
|
out: Dict[str, Dict[str, Any]] = {}
|
|
for plan_code, row in parsed.items():
|
|
code = str(plan_code or "").strip().lower()
|
|
if not code or not isinstance(row, dict):
|
|
continue
|
|
plan_id = int(row.get("plan_id") or 0)
|
|
duration_days = int(row.get("duration_days") or 0)
|
|
amount_usdc = _parse_decimal(row.get("amount_usdc"), Decimal("0"))
|
|
if plan_id <= 0 or duration_days <= 0 or amount_usdc <= 0:
|
|
continue
|
|
out[code] = {
|
|
"plan_id": plan_id,
|
|
"duration_days": duration_days,
|
|
"amount_usdc": _format_decimal(amount_usdc),
|
|
}
|
|
return out or dict(DEFAULT_PLAN_CATALOG)
|
|
|
|
|
|
def _parse_allowed_plan_codes(raw: str) -> List[str]:
|
|
text = str(raw or "").strip()
|
|
if not text:
|
|
return ["pro_monthly"]
|
|
out: List[str] = []
|
|
for part in text.split(","):
|
|
code = str(part or "").strip().lower()
|
|
if code and code not in out:
|
|
out.append(code)
|
|
return out or ["pro_monthly"]
|
|
|
|
|
|
@dataclass
|
|
class WalletBindingRecord:
|
|
chain_id: int
|
|
address: str
|
|
status: str
|
|
is_primary: bool
|
|
verified_at: Optional[str]
|
|
|
|
|
|
@dataclass
|
|
class PaymentTokenConfig:
|
|
code: str
|
|
symbol: str
|
|
name: str
|
|
address: str
|
|
decimals: int
|
|
chain_id: int
|
|
chain_code: str
|
|
chain_name: str
|
|
receiver_contract: str
|
|
direct_receiver_address: str
|
|
rpc_urls: List[str]
|
|
explorer_tx_url: str
|
|
confirmations: Optional[int]
|
|
supports_contract_checkout: bool
|
|
supports_direct_transfer: bool
|
|
is_default: bool
|
|
|
|
|
|
@dataclass
|
|
class PaymentIntentRecord:
|
|
intent_id: str
|
|
order_id_hex: str
|
|
plan_code: str
|
|
plan_id: int
|
|
chain_id: int
|
|
amount_units: int
|
|
amount_usdc: str
|
|
token_address: str
|
|
token_decimals: int
|
|
token_symbol: str
|
|
receiver_address: str
|
|
status: str
|
|
payment_mode: str
|
|
allowed_wallet: Optional[str]
|
|
expires_at: str
|
|
tx_hash: Optional[str]
|
|
metadata: Dict[str, Any]
|
|
|
|
|
|
class PaymentCheckoutError(Exception):
|
|
def __init__(self, status_code: int, detail: str):
|
|
self.status_code = int(status_code)
|
|
self.detail = str(detail)
|
|
super().__init__(self.detail)
|
|
|
|
|
|
class PaymentContractCheckoutService:
|
|
def __init__(self):
|
|
self.enabled = _env_bool("POLYWEATHER_PAYMENT_ENABLED", False)
|
|
self.supabase_url = str(os.getenv("SUPABASE_URL") or "").strip().rstrip("/")
|
|
self.supabase_service_role_key = str(
|
|
os.getenv("SUPABASE_SERVICE_ROLE_KEY") or ""
|
|
).strip()
|
|
self.chain_id = _env_int(
|
|
"POLYWEATHER_PAYMENT_CHAIN_ID", DEFAULT_POLYGON_CHAIN_ID
|
|
)
|
|
self.token_decimals = _env_int("POLYWEATHER_PAYMENT_TOKEN_DECIMALS", 6)
|
|
self.rpc_url = str(os.getenv("POLYWEATHER_PAYMENT_RPC_URL") or "").strip()
|
|
self.rpc_urls = self._load_rpc_urls(
|
|
os.getenv("POLYWEATHER_PAYMENT_RPC_URLS") or self.rpc_url
|
|
)
|
|
legacy_receiver_contract = _normalize_address(
|
|
os.getenv("POLYWEATHER_PAYMENT_RECEIVER_CONTRACT") or ""
|
|
)
|
|
legacy_direct_receiver_address = (
|
|
_normalize_address(
|
|
os.getenv("POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS") or ""
|
|
)
|
|
or legacy_receiver_contract
|
|
)
|
|
legacy_token_address = (
|
|
os.getenv("POLYWEATHER_PAYMENT_TOKEN_ADDRESS")
|
|
or DEFAULT_NATIVE_USDC_ADDRESS
|
|
)
|
|
self.supported_tokens = self._load_supported_tokens(
|
|
os.getenv("POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON") or "",
|
|
fallback_receiver_contract=legacy_receiver_contract,
|
|
fallback_direct_receiver_address=legacy_direct_receiver_address,
|
|
fallback_token_address=legacy_token_address,
|
|
fallback_token_decimals=self.token_decimals,
|
|
)
|
|
self.default_token_key = next(
|
|
(
|
|
key
|
|
for key, token in self.supported_tokens.items()
|
|
if bool(token.is_default)
|
|
),
|
|
"",
|
|
)
|
|
if not self.default_token_key and self.supported_tokens:
|
|
self.default_token_key = next(iter(self.supported_tokens.keys()))
|
|
default_token = self.supported_tokens.get(self.default_token_key)
|
|
self.default_chain_id = int(default_token.chain_id) if default_token else self.chain_id
|
|
self.default_token_address = default_token.address if default_token else ""
|
|
self.token_address = default_token.address if default_token else ""
|
|
self.receiver_contract = (
|
|
default_token.receiver_contract if default_token else ""
|
|
)
|
|
self.direct_receiver_address = (
|
|
default_token.direct_receiver_address if default_token else ""
|
|
)
|
|
self.token_decimals = (
|
|
int(default_token.decimals) if default_token else int(self.token_decimals)
|
|
)
|
|
self.rpc_urls_by_chain = self._load_rpc_urls_by_chain(
|
|
os.getenv("POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON") or "",
|
|
default_chain_id=self.chain_id,
|
|
default_rpc_urls=self.rpc_urls,
|
|
)
|
|
for token in self.supported_tokens.values():
|
|
if token.rpc_urls:
|
|
self.rpc_urls_by_chain.setdefault(int(token.chain_id), [])
|
|
for rpc_url in token.rpc_urls:
|
|
if rpc_url not in self.rpc_urls_by_chain[int(token.chain_id)]:
|
|
self.rpc_urls_by_chain[int(token.chain_id)].append(rpc_url)
|
|
self.intent_ttl_sec = max(
|
|
300, _env_int("POLYWEATHER_PAYMENT_INTENT_TTL_SEC", 1800)
|
|
)
|
|
self.challenge_ttl_sec = max(
|
|
60, _env_int("POLYWEATHER_PAYMENT_WALLET_CHALLENGE_TTL_SEC", 600)
|
|
)
|
|
self.confirmations = max(1, _env_int("POLYWEATHER_PAYMENT_CONFIRMATIONS", 2))
|
|
self.timeout_sec = max(5, _env_int("POLYWEATHER_PAYMENT_HTTP_TIMEOUT_SEC", 10))
|
|
self.poll_interval_sec = max(
|
|
2, _env_int("POLYWEATHER_PAYMENT_POLL_INTERVAL_SEC", 4)
|
|
)
|
|
self.max_wait_sec = max(10, _env_int("POLYWEATHER_PAYMENT_MAX_WAIT_SEC", 50))
|
|
self.plan_catalog = _parse_plan_catalog(
|
|
os.getenv("POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON") or ""
|
|
)
|
|
self.allowed_plan_codes = _parse_allowed_plan_codes(
|
|
os.getenv("POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES") or ""
|
|
)
|
|
filtered_catalog = {
|
|
code: row
|
|
for code, row in self.plan_catalog.items()
|
|
if code in self.allowed_plan_codes
|
|
}
|
|
if filtered_catalog:
|
|
self.plan_catalog = filtered_catalog
|
|
elif "pro_monthly" in self.plan_catalog:
|
|
self.plan_catalog = {"pro_monthly": self.plan_catalog["pro_monthly"]}
|
|
elif self.plan_catalog:
|
|
first_code = sorted(self.plan_catalog.keys())[0]
|
|
self.plan_catalog = {first_code: self.plan_catalog[first_code]}
|
|
self.notify_telegram = _env_bool(
|
|
"POLYWEATHER_PAYMENT_TELEGRAM_NOTIFY_ENABLED", True
|
|
)
|
|
self.points_enabled = _env_bool("POLYWEATHER_PAYMENT_POINTS_ENABLED", True)
|
|
self.points_per_usdc = max(
|
|
1, _env_int("POLYWEATHER_PAYMENT_POINTS_PER_USDC", 500)
|
|
)
|
|
self.points_max_discount_usdc = max(
|
|
0, _env_int("POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC", 3)
|
|
)
|
|
self._w3_lock = threading.Lock()
|
|
self._w3: Optional[Web3] = None
|
|
self._w3_url: str = ""
|
|
self._w3_by_chain: Dict[int, Web3] = {}
|
|
self._w3_url_by_chain: Dict[int, str] = {}
|
|
self._event_topic = Web3.keccak(
|
|
text="OrderPaid(bytes32,address,uint256,address,uint256)"
|
|
).hex()
|
|
self._db = DBManager()
|
|
|
|
@property
|
|
def configured(self) -> bool:
|
|
has_valid_token_routes = bool(
|
|
self.supported_tokens
|
|
and all(
|
|
token.address
|
|
and token.direct_receiver_address
|
|
and (token.receiver_contract or token.supports_direct_transfer)
|
|
for token in self.supported_tokens.values()
|
|
)
|
|
)
|
|
has_rpc_for_token_chains = bool(
|
|
self.supported_tokens
|
|
and all(
|
|
bool(self.rpc_urls_by_chain.get(int(token.chain_id)))
|
|
for token in self.supported_tokens.values()
|
|
)
|
|
)
|
|
return bool(
|
|
self.supabase_url
|
|
and self.supabase_service_role_key
|
|
and has_rpc_for_token_chains
|
|
and has_valid_token_routes
|
|
)
|
|
|
|
def _ensure_enabled(self) -> None:
|
|
if not self.enabled:
|
|
raise PaymentCheckoutError(503, "payment feature disabled")
|
|
if not self.configured:
|
|
raise PaymentCheckoutError(
|
|
503,
|
|
(
|
|
"payment feature not configured: require SUPABASE + RPC + "
|
|
"POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON"
|
|
),
|
|
)
|
|
|
|
def _load_rpc_urls(self, raw: str) -> List[str]:
|
|
out: List[str] = []
|
|
if isinstance(raw, list):
|
|
parts = raw
|
|
else:
|
|
parts = str(raw or "").split(",")
|
|
for part in parts:
|
|
url = str(part or "").strip()
|
|
if url and url not in out:
|
|
out.append(url)
|
|
return out
|
|
|
|
def _load_rpc_urls_by_chain(
|
|
self,
|
|
raw: str,
|
|
*,
|
|
default_chain_id: int,
|
|
default_rpc_urls: List[str],
|
|
) -> Dict[int, List[str]]:
|
|
out: Dict[int, List[str]] = {}
|
|
if default_rpc_urls:
|
|
out[int(default_chain_id)] = list(default_rpc_urls)
|
|
text = str(raw or "").strip()
|
|
if not text:
|
|
return out
|
|
try:
|
|
parsed = json.loads(text)
|
|
except Exception:
|
|
return out
|
|
if not isinstance(parsed, dict):
|
|
return out
|
|
for chain_id_raw, value in parsed.items():
|
|
try:
|
|
chain_id = int(chain_id_raw)
|
|
except Exception:
|
|
continue
|
|
urls = self._load_rpc_urls(value)
|
|
if not urls:
|
|
continue
|
|
out.setdefault(chain_id, [])
|
|
for url in urls:
|
|
if url not in out[chain_id]:
|
|
out[chain_id].append(url)
|
|
return out
|
|
|
|
def _token_key(self, chain_id: int, token_address: str) -> str:
|
|
return f"{int(chain_id)}:{_normalize_address(token_address)}"
|
|
|
|
def _chain_code_for(self, chain_id: int) -> str:
|
|
if int(chain_id) == DEFAULT_ETHEREUM_CHAIN_ID:
|
|
return "ethereum"
|
|
if int(chain_id) == DEFAULT_POLYGON_CHAIN_ID:
|
|
return "polygon"
|
|
return f"chain_{int(chain_id)}"
|
|
|
|
def _chain_name_for(self, chain_id: int) -> str:
|
|
if int(chain_id) == DEFAULT_ETHEREUM_CHAIN_ID:
|
|
return "Ethereum Mainnet"
|
|
if int(chain_id) == DEFAULT_POLYGON_CHAIN_ID:
|
|
return "Polygon"
|
|
return f"Chain ID {int(chain_id)}"
|
|
|
|
def _native_currency_for(self, chain_id: int) -> str:
|
|
if int(chain_id) == DEFAULT_ETHEREUM_CHAIN_ID:
|
|
return "ETH"
|
|
if int(chain_id) == DEFAULT_POLYGON_CHAIN_ID:
|
|
return "POL"
|
|
return "ETH"
|
|
|
|
def _explorer_base_for(self, chain_id: int) -> str:
|
|
if int(chain_id) == DEFAULT_ETHEREUM_CHAIN_ID:
|
|
return "https://etherscan.io"
|
|
if int(chain_id) == DEFAULT_POLYGON_CHAIN_ID:
|
|
return "https://polygonscan.com"
|
|
return ""
|
|
|
|
def _explorer_tx_url_for(self, chain_id: int) -> str:
|
|
base = self._explorer_base_for(chain_id)
|
|
return f"{base}/tx/{{tx_hash}}" if base else ""
|
|
|
|
def _chain_ids(self) -> List[int]:
|
|
ids = {int(token.chain_id) for token in self.supported_tokens.values()}
|
|
ids.update(int(chain_id) for chain_id in self.rpc_urls_by_chain.keys())
|
|
if self.default_chain_id:
|
|
ids.add(int(self.default_chain_id))
|
|
return sorted(ids)
|
|
|
|
def _chain_label_for(self, chain_id: int) -> str:
|
|
return self._chain_code_for(chain_id)
|
|
|
|
def _tokens_for_chain(self, chain_id: int) -> List[PaymentTokenConfig]:
|
|
return [
|
|
token
|
|
for token in self.supported_tokens.values()
|
|
if int(token.chain_id) == int(chain_id)
|
|
]
|
|
|
|
def _find_token_by_address(
|
|
self, token_address: str, chain_id: Optional[int] = None
|
|
) -> Optional[PaymentTokenConfig]:
|
|
normalized = _normalize_address(token_address)
|
|
if not normalized:
|
|
return None
|
|
for token in self.supported_tokens.values():
|
|
if token.address != normalized:
|
|
continue
|
|
if chain_id is not None and int(token.chain_id) != int(chain_id):
|
|
continue
|
|
return token
|
|
return None
|
|
|
|
def _default_token_meta(self, address: str) -> Dict[str, str]:
|
|
normalized = _normalize_address(address)
|
|
if normalized == _normalize_address(DEFAULT_ETHEREUM_USDC_ADDRESS):
|
|
return {"code": "usdc", "symbol": "USDC", "name": "USDC"}
|
|
if normalized == _normalize_address(DEFAULT_NATIVE_USDC_ADDRESS):
|
|
return {"code": "usdc", "symbol": "USDC", "name": "Native USDC"}
|
|
if normalized == _normalize_address(DEFAULT_USDT_ADDRESS):
|
|
return {"code": "usdt", "symbol": "USDT", "name": "USDT"}
|
|
if normalized == _normalize_address(DEFAULT_USDC_E_ADDRESS):
|
|
return {"code": "usdc_e", "symbol": "USDC.e", "name": "USDC.e (PoS)"}
|
|
short = f"{normalized[:6]}...{normalized[-4:]}"
|
|
return {"code": f"token_{short}", "symbol": short, "name": short}
|
|
|
|
def _to_token_config(
|
|
self,
|
|
row: Dict[str, Any],
|
|
fallback_receiver_contract: str,
|
|
fallback_direct_receiver_address: str,
|
|
fallback_token_decimals: int,
|
|
) -> Optional[PaymentTokenConfig]:
|
|
if not isinstance(row, dict):
|
|
return None
|
|
try:
|
|
chain_id = int(row.get("chain_id") or row.get("network_id") or self.chain_id)
|
|
except Exception:
|
|
chain_id = int(self.chain_id)
|
|
chain_code = str(
|
|
row.get("chain_code") or row.get("network") or self._chain_code_for(chain_id)
|
|
).strip().lower()
|
|
chain_name = str(
|
|
row.get("chain_name") or row.get("network_name") or self._chain_name_for(chain_id)
|
|
).strip()
|
|
address = _normalize_address(
|
|
row.get("address") or row.get("token_address") or row.get("contract")
|
|
)
|
|
if not address:
|
|
return None
|
|
receiver_contract = _normalize_address(
|
|
row.get("receiver_contract")
|
|
or row.get("checkout_contract")
|
|
or row.get("contract_address")
|
|
or fallback_receiver_contract
|
|
)
|
|
direct_receiver_address = _normalize_address(
|
|
row.get("direct_receiver_address")
|
|
or row.get("direct_receiver")
|
|
or fallback_direct_receiver_address
|
|
or receiver_contract
|
|
)
|
|
if not receiver_contract and not direct_receiver_address:
|
|
return None
|
|
default_meta = self._default_token_meta(address)
|
|
code = str(row.get("code") or default_meta["code"]).strip().lower()
|
|
symbol = str(row.get("symbol") or default_meta["symbol"]).strip()
|
|
name = str(row.get("name") or default_meta["name"]).strip()
|
|
if not code:
|
|
code = default_meta["code"]
|
|
if not symbol:
|
|
symbol = default_meta["symbol"]
|
|
if not name:
|
|
name = default_meta["name"]
|
|
try:
|
|
decimals = int(
|
|
row.get("decimals")
|
|
or row.get("token_decimals")
|
|
or fallback_token_decimals
|
|
)
|
|
except Exception:
|
|
decimals = int(fallback_token_decimals)
|
|
decimals = max(0, decimals)
|
|
is_default = bool(row.get("is_default"))
|
|
rpc_urls = self._load_rpc_urls(row.get("rpc_urls") or row.get("rpc_url") or "")
|
|
explorer_tx_url = str(
|
|
row.get("explorer_tx_url") or self._explorer_tx_url_for(chain_id)
|
|
).strip()
|
|
try:
|
|
confirmations_raw = row.get("confirmations")
|
|
confirmations = (
|
|
int(confirmations_raw) if confirmations_raw is not None else None
|
|
)
|
|
except Exception:
|
|
confirmations = None
|
|
supports_direct_transfer = bool(row.get("supports_direct_transfer", True))
|
|
supports_contract_checkout = bool(
|
|
row.get("supports_contract_checkout", row.get("supports_contract", chain_id == self.chain_id))
|
|
)
|
|
return PaymentTokenConfig(
|
|
code=code,
|
|
symbol=symbol,
|
|
name=name,
|
|
address=address,
|
|
decimals=decimals,
|
|
chain_id=chain_id,
|
|
chain_code=chain_code or self._chain_code_for(chain_id),
|
|
chain_name=chain_name or self._chain_name_for(chain_id),
|
|
receiver_contract=receiver_contract,
|
|
direct_receiver_address=direct_receiver_address or receiver_contract,
|
|
rpc_urls=rpc_urls,
|
|
explorer_tx_url=explorer_tx_url,
|
|
confirmations=confirmations,
|
|
supports_contract_checkout=supports_contract_checkout,
|
|
supports_direct_transfer=supports_direct_transfer,
|
|
is_default=is_default,
|
|
)
|
|
|
|
def _load_supported_tokens(
|
|
self,
|
|
raw: str,
|
|
*,
|
|
fallback_receiver_contract: str,
|
|
fallback_direct_receiver_address: str,
|
|
fallback_token_address: str,
|
|
fallback_token_decimals: int,
|
|
) -> Dict[str, PaymentTokenConfig]:
|
|
parsed_rows: List[Dict[str, Any]] = []
|
|
text = str(raw or "").strip()
|
|
if text:
|
|
try:
|
|
parsed = json.loads(text)
|
|
except Exception:
|
|
parsed = None
|
|
if isinstance(parsed, list):
|
|
parsed_rows = [row for row in parsed if isinstance(row, dict)]
|
|
elif isinstance(parsed, dict):
|
|
if isinstance(parsed.get("tokens"), list):
|
|
parsed_rows = [
|
|
row
|
|
for row in parsed.get("tokens") or []
|
|
if isinstance(row, dict)
|
|
]
|
|
else:
|
|
for key, value in parsed.items():
|
|
if isinstance(value, dict):
|
|
row = dict(value)
|
|
row.setdefault("code", str(key))
|
|
parsed_rows.append(row)
|
|
|
|
out: Dict[str, PaymentTokenConfig] = {}
|
|
for row in parsed_rows:
|
|
token = self._to_token_config(
|
|
row,
|
|
fallback_receiver_contract=fallback_receiver_contract,
|
|
fallback_direct_receiver_address=fallback_direct_receiver_address,
|
|
fallback_token_decimals=fallback_token_decimals,
|
|
)
|
|
if not token:
|
|
continue
|
|
out[self._token_key(token.chain_id, token.address)] = token
|
|
|
|
if out:
|
|
return out
|
|
|
|
fallback_address = _normalize_address(fallback_token_address)
|
|
if not (fallback_address and fallback_receiver_contract):
|
|
return {}
|
|
fallback_meta = self._default_token_meta(fallback_address)
|
|
fallback_token = PaymentTokenConfig(
|
|
code=fallback_meta["code"],
|
|
symbol=fallback_meta["symbol"],
|
|
name=fallback_meta["name"],
|
|
address=fallback_address,
|
|
decimals=max(0, int(fallback_token_decimals)),
|
|
chain_id=int(self.chain_id),
|
|
chain_code=self._chain_code_for(self.chain_id),
|
|
chain_name=self._chain_name_for(self.chain_id),
|
|
receiver_contract=fallback_receiver_contract,
|
|
direct_receiver_address=fallback_direct_receiver_address
|
|
or fallback_receiver_contract,
|
|
rpc_urls=[],
|
|
explorer_tx_url=self._explorer_tx_url_for(self.chain_id),
|
|
confirmations=None,
|
|
supports_contract_checkout=True,
|
|
supports_direct_transfer=True,
|
|
is_default=True,
|
|
)
|
|
return {self._token_key(fallback_token.chain_id, fallback_token.address): fallback_token}
|
|
|
|
def _resolve_supported_token(
|
|
self,
|
|
token_address: Optional[str] = None,
|
|
chain_id: Optional[int] = None,
|
|
) -> PaymentTokenConfig:
|
|
selected_chain_id = int(chain_id) if chain_id is not None else None
|
|
normalized = _normalize_address(token_address or "")
|
|
if normalized:
|
|
token = self._find_token_by_address(normalized, selected_chain_id)
|
|
if token:
|
|
return token
|
|
available = ", ".join(
|
|
f"{item.chain_code}/{item.symbol}:{item.address}"
|
|
for item in self.supported_tokens.values()
|
|
)
|
|
raise PaymentCheckoutError(
|
|
400,
|
|
f"token_address not supported: {normalized}. available={available}",
|
|
)
|
|
if selected_chain_id is not None:
|
|
chain_tokens = self._tokens_for_chain(selected_chain_id)
|
|
default_for_chain = next(
|
|
(token for token in chain_tokens if bool(token.is_default)),
|
|
chain_tokens[0] if chain_tokens else None,
|
|
)
|
|
if default_for_chain:
|
|
return default_for_chain
|
|
raise PaymentCheckoutError(
|
|
400, f"payment chain_id not supported: {selected_chain_id}"
|
|
)
|
|
default_token = self.supported_tokens.get(self.default_token_key)
|
|
if default_token:
|
|
return default_token
|
|
raise PaymentCheckoutError(503, "no supported payment token configured")
|
|
|
|
def _token_decimals_for(
|
|
self, token_address: str, chain_id: Optional[int] = None
|
|
) -> int:
|
|
token = self._find_token_by_address(token_address, chain_id)
|
|
if token:
|
|
return int(token.decimals)
|
|
return int(self.token_decimals)
|
|
|
|
def _token_symbol_for(
|
|
self, token_address: str, chain_id: Optional[int] = None
|
|
) -> str:
|
|
token = self._find_token_by_address(token_address, chain_id)
|
|
if token and token.symbol:
|
|
return str(token.symbol)
|
|
normalized = _normalize_address(token_address)
|
|
if normalized:
|
|
return f"{normalized[:6]}...{normalized[-4:]}"
|
|
return "Unknown"
|
|
|
|
def _token_config_for_intent(
|
|
self, intent: PaymentIntentRecord
|
|
) -> Optional[PaymentTokenConfig]:
|
|
return self._find_token_by_address(intent.token_address, intent.chain_id)
|
|
|
|
def _confirmations_for_chain(self, chain_id: int) -> int:
|
|
chain_tokens = self._tokens_for_chain(chain_id)
|
|
token_confirmations = next(
|
|
(
|
|
int(token.confirmations)
|
|
for token in chain_tokens
|
|
if token.confirmations is not None and int(token.confirmations) > 0
|
|
),
|
|
None,
|
|
)
|
|
if token_confirmations:
|
|
return max(1, int(token_confirmations))
|
|
return int(self.confirmations)
|
|
|
|
def _service_headers(self, prefer: Optional[str] = None) -> Dict[str, str]:
|
|
headers = {
|
|
"apikey": self.supabase_service_role_key,
|
|
"Authorization": f"Bearer {self.supabase_service_role_key}",
|
|
"Accept": "application/json",
|
|
"Content-Type": "application/json",
|
|
}
|
|
if prefer:
|
|
headers["Prefer"] = prefer
|
|
return headers
|
|
|
|
def _rest(
|
|
self,
|
|
method: str,
|
|
table: str,
|
|
*,
|
|
params: Optional[Dict[str, Any]] = None,
|
|
payload: Optional[Any] = None,
|
|
prefer: Optional[str] = None,
|
|
allowed_status: Optional[List[int]] = None,
|
|
) -> Any:
|
|
url = f"{self.supabase_url}/rest/v1/{table}"
|
|
status_ok = allowed_status or [200, 201, 204]
|
|
try:
|
|
response = requests.request(
|
|
method=method.upper(),
|
|
url=url,
|
|
params=params,
|
|
json=payload,
|
|
headers=self._service_headers(prefer=prefer),
|
|
timeout=self.timeout_sec,
|
|
)
|
|
except Exception as exc:
|
|
raise PaymentCheckoutError(503, f"supabase request failed: {exc}") from exc
|
|
|
|
if response.status_code not in status_ok:
|
|
detail = response.text[:350] if response.text else response.reason
|
|
raise PaymentCheckoutError(
|
|
502,
|
|
f"supabase {method.upper()} {table} failed: {response.status_code} {detail}",
|
|
)
|
|
if not response.content:
|
|
return None
|
|
try:
|
|
return response.json()
|
|
except Exception:
|
|
return None
|
|
|
|
def _admin_auth_headers(self) -> Dict[str, str]:
|
|
return {
|
|
"apikey": self.supabase_service_role_key,
|
|
"Authorization": f"Bearer {self.supabase_service_role_key}",
|
|
"Accept": "application/json",
|
|
"Content-Type": "application/json",
|
|
}
|
|
|
|
def _auth_admin_request(
|
|
self,
|
|
method: str,
|
|
path: str,
|
|
*,
|
|
payload: Optional[Dict[str, Any]] = None,
|
|
allowed_status: Optional[List[int]] = None,
|
|
) -> Any:
|
|
url = f"{self.supabase_url}/auth/v1{path}"
|
|
status_ok = allowed_status or [200]
|
|
try:
|
|
response = requests.request(
|
|
method=method.upper(),
|
|
url=url,
|
|
json=payload,
|
|
headers=self._admin_auth_headers(),
|
|
timeout=self.timeout_sec,
|
|
)
|
|
except Exception as exc:
|
|
raise PaymentCheckoutError(
|
|
503, f"supabase auth request failed: {exc}"
|
|
) from exc
|
|
if response.status_code not in status_ok:
|
|
detail = response.text[:350] if response.text else response.reason
|
|
raise PaymentCheckoutError(
|
|
502,
|
|
(
|
|
f"supabase auth {method.upper()} {path} failed: "
|
|
f"{response.status_code} {detail}"
|
|
),
|
|
)
|
|
if not response.content:
|
|
return None
|
|
try:
|
|
return response.json()
|
|
except Exception:
|
|
return None
|
|
|
|
def _extract_user_metadata(self, user_payload: Any) -> Dict[str, Any]:
|
|
if not isinstance(user_payload, dict):
|
|
return {}
|
|
if isinstance(user_payload.get("user_metadata"), dict):
|
|
return dict(user_payload.get("user_metadata") or {})
|
|
user_obj = user_payload.get("user")
|
|
if isinstance(user_obj, dict) and isinstance(
|
|
user_obj.get("user_metadata"), dict
|
|
):
|
|
return dict(user_obj.get("user_metadata") or {})
|
|
return {}
|
|
|
|
def _extract_points_from_metadata(self, metadata: Dict[str, Any]) -> int:
|
|
if not isinstance(metadata, dict):
|
|
return 0
|
|
for key in ("points", "total_points"):
|
|
raw = metadata.get(key)
|
|
if raw is None:
|
|
continue
|
|
try:
|
|
return max(0, int(raw))
|
|
except Exception:
|
|
continue
|
|
return 0
|
|
|
|
def _resolve_points_balance(self, user_id: str) -> Dict[str, Any]:
|
|
db_user = self._db.get_user_by_supabase_user_id(user_id)
|
|
if db_user is not None:
|
|
try:
|
|
balance = max(0, int(db_user.get("points") or 0))
|
|
except Exception:
|
|
balance = 0
|
|
return {"source": "bot_db", "balance": balance}
|
|
|
|
user_obj = self._auth_admin_get_user(user_id)
|
|
metadata = self._extract_user_metadata(user_obj)
|
|
balance = self._extract_points_from_metadata(metadata)
|
|
return {"source": "supabase_metadata", "balance": balance, "metadata": metadata}
|
|
|
|
def _auth_admin_get_user(self, user_id: str) -> Dict[str, Any]:
|
|
user_id_text = str(user_id or "").strip()
|
|
if not user_id_text:
|
|
raise PaymentCheckoutError(400, "user_id required")
|
|
data = self._auth_admin_request(
|
|
"GET",
|
|
f"/admin/users/{user_id_text}",
|
|
allowed_status=[200],
|
|
)
|
|
if isinstance(data, dict):
|
|
user_obj = data.get("user")
|
|
if isinstance(user_obj, dict):
|
|
return user_obj
|
|
return data
|
|
return {}
|
|
|
|
def _auth_admin_update_user_metadata(
|
|
self,
|
|
user_id: str,
|
|
metadata: Dict[str, Any],
|
|
) -> Dict[str, Any]:
|
|
user_id_text = str(user_id or "").strip()
|
|
if not user_id_text:
|
|
raise PaymentCheckoutError(400, "user_id required")
|
|
payload = {"user_metadata": metadata or {}}
|
|
data = self._auth_admin_request(
|
|
"PUT",
|
|
f"/admin/users/{user_id_text}",
|
|
payload=payload,
|
|
allowed_status=[200],
|
|
)
|
|
if isinstance(data, dict):
|
|
user_obj = data.get("user")
|
|
if isinstance(user_obj, dict):
|
|
return user_obj
|
|
return data
|
|
return {}
|
|
|
|
def _build_points_redemption(
|
|
self,
|
|
*,
|
|
user_id: str,
|
|
plan_amount_usdc: Decimal,
|
|
use_points: bool,
|
|
requested_points_to_consume: Optional[int],
|
|
) -> Dict[str, Any]:
|
|
base = {
|
|
"enabled": bool(self.points_enabled),
|
|
"applied": False,
|
|
"points_per_usdc": int(self.points_per_usdc),
|
|
"max_discount_usdc": int(self.points_max_discount_usdc),
|
|
"points_source": "supabase_metadata",
|
|
"points_balance_snapshot": 0,
|
|
"points_to_consume": 0,
|
|
"discount_usdc": "0",
|
|
"pay_amount_usdc": plan_amount_usdc,
|
|
}
|
|
if not self.points_enabled:
|
|
return base
|
|
if not use_points:
|
|
return base
|
|
if plan_amount_usdc <= 0:
|
|
return base
|
|
points_ctx = self._resolve_points_balance(user_id)
|
|
balance = int(points_ctx.get("balance") or 0)
|
|
base["points_source"] = str(points_ctx.get("source") or "supabase_metadata")
|
|
base["points_balance_snapshot"] = balance
|
|
if balance <= 0:
|
|
return base
|
|
|
|
max_discount_usdc = min(
|
|
Decimal(int(self.points_max_discount_usdc)),
|
|
plan_amount_usdc,
|
|
)
|
|
max_points_by_plan = int(
|
|
(max_discount_usdc * Decimal(int(self.points_per_usdc))).to_integral_value(
|
|
rounding=ROUND_FLOOR
|
|
)
|
|
)
|
|
if max_points_by_plan <= 0:
|
|
return base
|
|
|
|
desired_points = max_points_by_plan
|
|
if requested_points_to_consume is not None:
|
|
try:
|
|
desired_points = max(0, int(requested_points_to_consume))
|
|
except Exception:
|
|
desired_points = 0
|
|
candidate_points = min(balance, max_points_by_plan, desired_points)
|
|
if candidate_points <= 0:
|
|
return base
|
|
|
|
normalized_points = (candidate_points // int(self.points_per_usdc)) * int(
|
|
self.points_per_usdc
|
|
)
|
|
if normalized_points <= 0:
|
|
return base
|
|
discount_units = normalized_points // int(self.points_per_usdc)
|
|
discount_usdc = Decimal(discount_units)
|
|
pay_amount = plan_amount_usdc - discount_usdc
|
|
if pay_amount <= 0:
|
|
return base
|
|
|
|
base["applied"] = True
|
|
base["points_to_consume"] = int(normalized_points)
|
|
base["discount_usdc"] = _format_decimal(discount_usdc)
|
|
base["pay_amount_usdc"] = pay_amount
|
|
return base
|
|
|
|
def _consume_points_for_intent(
|
|
self,
|
|
user_id: str,
|
|
intent: PaymentIntentRecord,
|
|
) -> Dict[str, Any]:
|
|
result = {
|
|
"enabled": bool(self.points_enabled),
|
|
"applied": False,
|
|
"points_per_usdc": int(self.points_per_usdc),
|
|
"points_redeemed": 0,
|
|
"points_before": 0,
|
|
"points_after": 0,
|
|
"discount_usdc": "0",
|
|
}
|
|
if not self.points_enabled:
|
|
return result
|
|
|
|
metadata = dict(intent.metadata or {})
|
|
redemption = metadata.get("points_redemption")
|
|
if not isinstance(redemption, dict):
|
|
return result
|
|
if not bool(redemption.get("applied")):
|
|
return result
|
|
if bool(redemption.get("consumed")):
|
|
result["applied"] = True
|
|
result["points_redeemed"] = int(redemption.get("consumed_points") or 0)
|
|
result["points_after"] = int(redemption.get("points_after") or 0)
|
|
result["discount_usdc"] = str(redemption.get("discount_usdc") or "0")
|
|
return result
|
|
|
|
planned_points = int(redemption.get("points_to_consume") or 0)
|
|
points_source = str(redemption.get("points_source") or "").strip().lower()
|
|
if planned_points <= 0:
|
|
return result
|
|
|
|
if points_source == "bot_db":
|
|
points_before = self._db.get_points_by_supabase_user_id(user_id)
|
|
if points_before <= 0:
|
|
return result
|
|
redeemable = min(points_before, planned_points)
|
|
redeemable = (redeemable // int(self.points_per_usdc)) * int(
|
|
self.points_per_usdc
|
|
)
|
|
if redeemable <= 0:
|
|
return result
|
|
spend_result = self._db.spend_points_by_supabase_user_id(
|
|
user_id, redeemable
|
|
)
|
|
if not bool(spend_result.get("ok")):
|
|
return result
|
|
points_after = int(spend_result.get("balance") or 0)
|
|
discount_usdc = Decimal(redeemable // int(self.points_per_usdc))
|
|
result["applied"] = True
|
|
result["points_redeemed"] = int(redeemable)
|
|
result["points_before"] = int(points_before)
|
|
result["points_after"] = int(points_after)
|
|
result["discount_usdc"] = _format_decimal(discount_usdc)
|
|
return result
|
|
|
|
user_obj = self._auth_admin_get_user(user_id)
|
|
user_metadata = self._extract_user_metadata(user_obj)
|
|
points_before = self._extract_points_from_metadata(user_metadata)
|
|
if points_before <= 0:
|
|
return result
|
|
|
|
redeemable = min(points_before, planned_points)
|
|
redeemable = (redeemable // int(self.points_per_usdc)) * int(
|
|
self.points_per_usdc
|
|
)
|
|
if redeemable <= 0:
|
|
return result
|
|
|
|
points_after = points_before - redeemable
|
|
updated_metadata = dict(user_metadata or {})
|
|
if "points" in updated_metadata:
|
|
updated_metadata["points"] = points_after
|
|
if "total_points" in updated_metadata:
|
|
updated_metadata["total_points"] = points_after
|
|
if "points" not in updated_metadata and "total_points" not in updated_metadata:
|
|
updated_metadata["points"] = points_after
|
|
updated_metadata["total_points"] = points_after
|
|
self._auth_admin_update_user_metadata(user_id, updated_metadata)
|
|
|
|
discount_usdc = Decimal(redeemable // int(self.points_per_usdc))
|
|
result["applied"] = True
|
|
result["points_redeemed"] = int(redeemable)
|
|
result["points_before"] = int(points_before)
|
|
result["points_after"] = int(points_after)
|
|
result["discount_usdc"] = _format_decimal(discount_usdc)
|
|
return result
|
|
|
|
def _build_web3(self, rpc_url: str) -> Web3:
|
|
return Web3(
|
|
Web3.HTTPProvider(rpc_url, request_kwargs={"timeout": self.timeout_sec})
|
|
)
|
|
|
|
def _try_connect_rpc(self, rpc_url: str, chain_id: int) -> Optional[Web3]:
|
|
try:
|
|
w3 = self._build_web3(rpc_url)
|
|
if not w3.is_connected():
|
|
return None
|
|
if int(w3.eth.chain_id) != int(chain_id):
|
|
return None
|
|
return w3
|
|
except Exception:
|
|
return None
|
|
|
|
def _rotate_rpc(self, chain_id: Optional[int] = None) -> Optional[Web3]:
|
|
target_chain_id = int(chain_id or self.default_chain_id or self.chain_id)
|
|
for rpc_url in self.rpc_urls_by_chain.get(target_chain_id, []):
|
|
w3 = self._try_connect_rpc(rpc_url, target_chain_id)
|
|
if w3 is not None:
|
|
self._w3_by_chain[target_chain_id] = w3
|
|
self._w3_url_by_chain[target_chain_id] = rpc_url
|
|
if target_chain_id == int(self.default_chain_id or self.chain_id):
|
|
self._w3 = w3
|
|
self._w3_url = rpc_url
|
|
return w3
|
|
self._w3_by_chain.pop(target_chain_id, None)
|
|
self._w3_url_by_chain.pop(target_chain_id, None)
|
|
if target_chain_id == int(self.default_chain_id or self.chain_id):
|
|
self._w3 = None
|
|
self._w3_url = ""
|
|
return None
|
|
|
|
def _get_web3(
|
|
self,
|
|
chain_id: Optional[int] = None,
|
|
force_refresh: bool = False,
|
|
) -> Web3:
|
|
target_chain_id = int(chain_id or self.default_chain_id or self.chain_id)
|
|
with self._w3_lock:
|
|
if self._w3_by_chain.get(target_chain_id) is None or force_refresh:
|
|
self._rotate_rpc(target_chain_id)
|
|
w3 = self._w3_by_chain.get(target_chain_id)
|
|
assert w3 is not None
|
|
return w3
|
|
|
|
def get_rpc_runtime_status(self) -> Dict[str, Any]:
|
|
default_chain_id = int(self.default_chain_id or self.chain_id)
|
|
candidates = list(self.rpc_urls_by_chain.get(default_chain_id, []))
|
|
chains = {
|
|
str(chain_id): {
|
|
"chain_id": chain_id,
|
|
"chain_code": self._chain_code_for(chain_id),
|
|
"chain_name": self._chain_name_for(chain_id),
|
|
"configured_rpc_count": len(urls),
|
|
"active_rpc_url": self._w3_url_by_chain.get(chain_id)
|
|
or (urls[0] if urls else ""),
|
|
"all_rpc_urls": list(urls),
|
|
}
|
|
for chain_id, urls in sorted(self.rpc_urls_by_chain.items())
|
|
}
|
|
return {
|
|
"configured_rpc_count": len(candidates),
|
|
"active_rpc_url": self._w3_url_by_chain.get(default_chain_id)
|
|
or self._w3_url
|
|
or (candidates[0] if candidates else ""),
|
|
"all_rpc_urls": candidates,
|
|
"chains": chains,
|
|
}
|
|
|
|
def _get_contract(
|
|
self,
|
|
receiver_address: Optional[str] = None,
|
|
chain_id: Optional[int] = None,
|
|
):
|
|
w3 = self._get_web3(chain_id=chain_id)
|
|
contract_address = _normalize_address(
|
|
receiver_address or self.receiver_contract
|
|
)
|
|
if not contract_address:
|
|
contract_address = self.receiver_contract
|
|
return w3.eth.contract(
|
|
address=Web3.to_checksum_address(contract_address),
|
|
abi=PAYMENT_CONTRACT_ABI,
|
|
)
|
|
|
|
def get_config_payload(self) -> Dict[str, Any]:
|
|
default_chain_id = int(self.default_chain_id or self.chain_id)
|
|
chains_payload = [
|
|
{
|
|
"chain_id": chain_id,
|
|
"code": self._chain_code_for(chain_id),
|
|
"name": self._chain_name_for(chain_id),
|
|
"native_currency_symbol": self._native_currency_for(chain_id),
|
|
"block_explorer_url": self._explorer_base_for(chain_id),
|
|
"explorer_tx_url": self._explorer_tx_url_for(chain_id),
|
|
"is_default": chain_id == default_chain_id,
|
|
}
|
|
for chain_id in self._chain_ids()
|
|
]
|
|
tokens_payload = [
|
|
{
|
|
"code": token.code,
|
|
"symbol": token.symbol,
|
|
"name": token.name,
|
|
"address": token.address,
|
|
"decimals": int(token.decimals),
|
|
"chain_id": int(token.chain_id),
|
|
"chain_code": token.chain_code,
|
|
"chain_name": token.chain_name,
|
|
"receiver_contract": token.receiver_contract,
|
|
"direct_receiver_address": token.direct_receiver_address,
|
|
"explorer_tx_url": token.explorer_tx_url,
|
|
"supports_contract_checkout": bool(token.supports_contract_checkout),
|
|
"supports_direct_transfer": bool(token.supports_direct_transfer),
|
|
"is_default": bool(
|
|
token.is_default
|
|
or self._token_key(token.chain_id, token.address)
|
|
== self.default_token_key
|
|
),
|
|
}
|
|
for token in sorted(
|
|
self.supported_tokens.values(),
|
|
key=lambda row: (int(row.chain_id), row.code),
|
|
)
|
|
]
|
|
return {
|
|
"enabled": self.enabled,
|
|
"configured": self.configured,
|
|
"chain_id": default_chain_id,
|
|
"default_chain_id": default_chain_id,
|
|
"token_address": self.token_address,
|
|
"token_decimals": self.token_decimals,
|
|
"receiver_contract": self.receiver_contract,
|
|
"direct_receiver_address": self.direct_receiver_address,
|
|
"default_token_address": self.default_token_address or self.token_address,
|
|
"chains": chains_payload,
|
|
"tokens": tokens_payload,
|
|
"confirmations": self.confirmations,
|
|
"intent_ttl_sec": self.intent_ttl_sec,
|
|
"event_name": "OrderPaid",
|
|
"event_topic0": self._event_topic,
|
|
"points_redemption": {
|
|
"enabled": bool(self.points_enabled),
|
|
"points_per_usdc": int(self.points_per_usdc),
|
|
"max_discount_usdc": int(self.points_max_discount_usdc),
|
|
},
|
|
"plans": [
|
|
{
|
|
"plan_code": plan_code,
|
|
"plan_id": int(row.get("plan_id") or 0),
|
|
"amount_usdc": str(row.get("amount_usdc")),
|
|
"duration_days": int(row.get("duration_days") or 0),
|
|
}
|
|
for plan_code, row in sorted(self.plan_catalog.items())
|
|
],
|
|
}
|
|
|
|
def _serialize_intent(self, row: Dict[str, Any]) -> PaymentIntentRecord:
|
|
chain_id = int(row.get("chain_id") or self.chain_id)
|
|
token_address = _normalize_address(
|
|
row.get("token_address") or self.token_address
|
|
)
|
|
token_decimals = self._token_decimals_for(token_address, chain_id)
|
|
amount_units = int(_parse_decimal(row.get("amount_units"), Decimal("0")))
|
|
amount_display = _units_to_decimal(amount_units, token_decimals)
|
|
return PaymentIntentRecord(
|
|
intent_id=str(row.get("id")),
|
|
order_id_hex=str(row.get("order_id_hex")),
|
|
plan_code=str(row.get("plan_code")),
|
|
plan_id=int(row.get("plan_id") or 0),
|
|
chain_id=chain_id,
|
|
amount_units=amount_units,
|
|
amount_usdc=_format_decimal(amount_display),
|
|
token_address=token_address,
|
|
token_decimals=token_decimals,
|
|
token_symbol=self._token_symbol_for(token_address, chain_id),
|
|
receiver_address=_normalize_address(
|
|
row.get("receiver_address") or self.receiver_contract
|
|
),
|
|
status=str(row.get("status") or "created"),
|
|
payment_mode=str(row.get("payment_mode") or "strict"),
|
|
allowed_wallet=_normalize_address(row.get("allowed_wallet") or "") or None,
|
|
expires_at=str(row.get("expires_at")),
|
|
tx_hash=str(row.get("tx_hash") or "") or None,
|
|
metadata=dict(row.get("metadata") or {})
|
|
if isinstance(row.get("metadata"), dict)
|
|
else {},
|
|
)
|
|
|
|
def list_wallets(self, user_id: str) -> List[WalletBindingRecord]:
|
|
self._ensure_enabled()
|
|
rows = self._rest(
|
|
"GET",
|
|
"user_wallets",
|
|
params={
|
|
"select": "chain_id,address,is_primary,verified_at",
|
|
"user_id": f"eq.{user_id}",
|
|
"chain_id": f"eq.{self.chain_id}",
|
|
"status": "eq.active",
|
|
"order": "is_primary.desc,verified_at.desc",
|
|
},
|
|
allowed_status=[200],
|
|
)
|
|
if not isinstance(rows, list):
|
|
return []
|
|
out: List[WalletBindingRecord] = []
|
|
for row in rows:
|
|
out.append(
|
|
WalletBindingRecord(
|
|
chain_id=int(row.get("chain_id") or self.chain_id),
|
|
address=_normalize_address(row.get("address") or ""),
|
|
status="active",
|
|
is_primary=bool(row.get("is_primary")),
|
|
verified_at=row.get("verified_at"),
|
|
)
|
|
)
|
|
return out
|
|
|
|
def _require_user_wallet(self, user_id: str, address: str) -> Dict[str, Any]:
|
|
normalized = _normalize_address(address)
|
|
if not normalized:
|
|
raise PaymentCheckoutError(400, "invalid wallet address")
|
|
rows = self._rest(
|
|
"GET",
|
|
"user_wallets",
|
|
params={
|
|
"select": "status",
|
|
"user_id": f"eq.{user_id}",
|
|
"chain_id": f"eq.{self.chain_id}",
|
|
"address": f"eq.{normalized}",
|
|
"limit": "1",
|
|
},
|
|
allowed_status=[200],
|
|
)
|
|
if not isinstance(rows, list) or not rows:
|
|
raise PaymentCheckoutError(403, "wallet not bound to current user")
|
|
row = rows[0]
|
|
if str(row.get("status") or "active") != "active":
|
|
raise PaymentCheckoutError(403, "wallet is not active")
|
|
return row
|
|
|
|
def create_wallet_challenge(self, user_id: str, address: str) -> Dict[str, Any]:
|
|
self._ensure_enabled()
|
|
normalized = _normalize_address(address)
|
|
if not normalized:
|
|
raise PaymentCheckoutError(400, "invalid wallet address")
|
|
now = _now_utc()
|
|
expires = now + timedelta(seconds=self.challenge_ttl_sec)
|
|
nonce = secrets.token_urlsafe(24)
|
|
message = (
|
|
"PolyWeather Wallet Binding\n"
|
|
f"User: {user_id}\n"
|
|
f"Address: {normalized}\n"
|
|
f"ChainId: {self.chain_id}\n"
|
|
f"Nonce: {nonce}\n"
|
|
f"IssuedAt: {_to_iso(now)}\n"
|
|
f"ExpiresAt: {_to_iso(expires)}"
|
|
)
|
|
self._rest(
|
|
"POST",
|
|
"wallet_link_challenges",
|
|
payload={
|
|
"user_id": user_id,
|
|
"chain_id": self.chain_id,
|
|
"address": normalized,
|
|
"nonce": nonce,
|
|
"message": message,
|
|
"expires_at": _to_iso(expires),
|
|
},
|
|
prefer="return=minimal",
|
|
allowed_status=[201],
|
|
)
|
|
return {
|
|
"address": normalized,
|
|
"chain_id": self.chain_id,
|
|
"nonce": nonce,
|
|
"message": message,
|
|
"expires_at": _to_iso(expires),
|
|
}
|
|
|
|
def verify_wallet_binding(
|
|
self,
|
|
user_id: str,
|
|
address: str,
|
|
nonce: str,
|
|
signature: str,
|
|
) -> WalletBindingRecord:
|
|
self._ensure_enabled()
|
|
normalized = _normalize_address(address)
|
|
nonce_text = str(nonce or "").strip()
|
|
signature_text = str(signature or "").strip()
|
|
if not normalized:
|
|
raise PaymentCheckoutError(400, "invalid wallet address")
|
|
if not nonce_text:
|
|
raise PaymentCheckoutError(400, "nonce required")
|
|
if not signature_text:
|
|
raise PaymentCheckoutError(400, "signature required")
|
|
|
|
challenge_rows = self._rest(
|
|
"GET",
|
|
"wallet_link_challenges",
|
|
params={
|
|
"select": "id,message,expires_at",
|
|
"user_id": f"eq.{user_id}",
|
|
"chain_id": f"eq.{self.chain_id}",
|
|
"address": f"eq.{normalized}",
|
|
"nonce": f"eq.{nonce_text}",
|
|
"consumed_at": "is.null",
|
|
"limit": "1",
|
|
},
|
|
allowed_status=[200],
|
|
)
|
|
if not isinstance(challenge_rows, list) or not challenge_rows:
|
|
raise PaymentCheckoutError(
|
|
400, "wallet challenge not found or already used"
|
|
)
|
|
|
|
challenge = challenge_rows[0]
|
|
try:
|
|
expires_at = datetime.fromisoformat(str(challenge.get("expires_at")))
|
|
except Exception:
|
|
expires_at = _now_utc() - timedelta(seconds=1)
|
|
if expires_at <= _now_utc():
|
|
raise PaymentCheckoutError(400, "wallet challenge expired")
|
|
|
|
message = str(challenge.get("message") or "")
|
|
if not message:
|
|
raise PaymentCheckoutError(400, "wallet challenge message invalid")
|
|
|
|
try:
|
|
recovered = Account.recover_message(
|
|
encode_defunct(text=message), signature=signature_text
|
|
)
|
|
except Exception:
|
|
raise PaymentCheckoutError(400, "invalid wallet signature")
|
|
if _normalize_address(recovered) != normalized:
|
|
raise PaymentCheckoutError(400, "signature does not match target wallet")
|
|
|
|
existing = self._rest(
|
|
"GET",
|
|
"user_wallets",
|
|
params={
|
|
"select": "user_id,status",
|
|
"chain_id": f"eq.{self.chain_id}",
|
|
"address": f"eq.{normalized}",
|
|
"limit": "1",
|
|
},
|
|
allowed_status=[200],
|
|
)
|
|
if isinstance(existing, list) and existing:
|
|
owner_id = str(existing[0].get("user_id") or "")
|
|
if (
|
|
owner_id
|
|
and owner_id != user_id
|
|
and str(existing[0].get("status")) == "active"
|
|
):
|
|
raise PaymentCheckoutError(
|
|
409, "wallet already bound by another account"
|
|
)
|
|
|
|
has_primary = self._rest(
|
|
"GET",
|
|
"user_wallets",
|
|
params={
|
|
"select": "id",
|
|
"user_id": f"eq.{user_id}",
|
|
"chain_id": f"eq.{self.chain_id}",
|
|
"status": "eq.active",
|
|
"is_primary": "eq.true",
|
|
"limit": "1",
|
|
},
|
|
allowed_status=[200],
|
|
)
|
|
should_primary = not (isinstance(has_primary, list) and len(has_primary) > 0)
|
|
now_iso = _to_iso(_now_utc())
|
|
self._rest(
|
|
"POST",
|
|
"user_wallets",
|
|
params={"on_conflict": "chain_id,address"},
|
|
payload={
|
|
"user_id": user_id,
|
|
"chain_id": self.chain_id,
|
|
"address": normalized,
|
|
"status": "active",
|
|
"is_primary": should_primary,
|
|
"verified_at": now_iso,
|
|
"updated_at": now_iso,
|
|
},
|
|
prefer="resolution=merge-duplicates,return=minimal",
|
|
allowed_status=[200, 201],
|
|
)
|
|
self._rest(
|
|
"PATCH",
|
|
"wallet_link_challenges",
|
|
params={"id": f"eq.{challenge.get('id')}"},
|
|
payload={"consumed_at": now_iso},
|
|
prefer="return=minimal",
|
|
allowed_status=[200],
|
|
)
|
|
return WalletBindingRecord(
|
|
chain_id=self.chain_id,
|
|
address=normalized,
|
|
status="active",
|
|
is_primary=should_primary,
|
|
verified_at=now_iso,
|
|
)
|
|
|
|
def unbind_wallet(self, user_id: str, address: str) -> Dict[str, Any]:
|
|
self._ensure_enabled()
|
|
normalized = _normalize_address(address)
|
|
if not normalized:
|
|
raise PaymentCheckoutError(400, "invalid wallet address")
|
|
|
|
# Must be an active wallet owned by current user.
|
|
self._require_user_wallet(user_id, normalized)
|
|
|
|
now_iso = _to_iso(_now_utc())
|
|
self._rest(
|
|
"PATCH",
|
|
"user_wallets",
|
|
params={
|
|
"user_id": f"eq.{user_id}",
|
|
"chain_id": f"eq.{self.chain_id}",
|
|
"address": f"eq.{normalized}",
|
|
},
|
|
payload={
|
|
"status": "revoked",
|
|
"is_primary": False,
|
|
"updated_at": now_iso,
|
|
},
|
|
prefer="return=minimal",
|
|
allowed_status=[200],
|
|
)
|
|
|
|
# Ensure there is still an active primary wallet after unbind.
|
|
active_primary_rows = self._rest(
|
|
"GET",
|
|
"user_wallets",
|
|
params={
|
|
"select": "id,address",
|
|
"user_id": f"eq.{user_id}",
|
|
"chain_id": f"eq.{self.chain_id}",
|
|
"status": "eq.active",
|
|
"is_primary": "eq.true",
|
|
"limit": "1",
|
|
},
|
|
allowed_status=[200],
|
|
)
|
|
|
|
new_primary = ""
|
|
if isinstance(active_primary_rows, list) and active_primary_rows:
|
|
new_primary = _normalize_address(
|
|
active_primary_rows[0].get("address") or ""
|
|
)
|
|
else:
|
|
active_wallet_rows = self._rest(
|
|
"GET",
|
|
"user_wallets",
|
|
params={
|
|
"select": "id,address",
|
|
"user_id": f"eq.{user_id}",
|
|
"chain_id": f"eq.{self.chain_id}",
|
|
"status": "eq.active",
|
|
"order": "verified_at.desc,updated_at.desc",
|
|
"limit": "1",
|
|
},
|
|
allowed_status=[200],
|
|
)
|
|
if isinstance(active_wallet_rows, list) and active_wallet_rows:
|
|
candidate = active_wallet_rows[0]
|
|
candidate_id = candidate.get("id")
|
|
candidate_addr = _normalize_address(candidate.get("address") or "")
|
|
if candidate_id and candidate_addr:
|
|
self._rest(
|
|
"PATCH",
|
|
"user_wallets",
|
|
params={"id": f"eq.{candidate_id}"},
|
|
payload={"is_primary": True, "updated_at": now_iso},
|
|
prefer="return=minimal",
|
|
allowed_status=[200],
|
|
)
|
|
new_primary = candidate_addr
|
|
|
|
return {
|
|
"address": normalized,
|
|
"unbound": True,
|
|
"new_primary": new_primary or None,
|
|
}
|
|
|
|
def _select_plan(self, plan_code: str) -> Dict[str, Any]:
|
|
code = str(plan_code or "").strip().lower() or "pro_monthly"
|
|
row = self.plan_catalog.get(code)
|
|
if not row:
|
|
available = ", ".join(sorted(self.plan_catalog.keys()))
|
|
raise PaymentCheckoutError(
|
|
400, f"unknown plan_code={code}; available={available}"
|
|
)
|
|
amount_dec = _parse_decimal(row.get("amount_usdc"), Decimal("0"))
|
|
if amount_dec <= 0:
|
|
raise PaymentCheckoutError(500, f"invalid plan amount for {code}")
|
|
return {
|
|
"plan_code": code,
|
|
"plan_id": int(row.get("plan_id") or 0),
|
|
"duration_days": int(row.get("duration_days") or 0),
|
|
"amount_usdc": _format_decimal(amount_dec),
|
|
"amount_usdc_decimal": amount_dec,
|
|
}
|
|
|
|
def _apply_telegram_group_pricing(
|
|
self,
|
|
user_id: str,
|
|
plan: Dict[str, Any],
|
|
) -> Dict[str, Any]:
|
|
out = dict(plan)
|
|
if str(out.get("plan_code") or "").strip().lower() != "pro_monthly":
|
|
return out
|
|
pricing = TelegramGroupPricing()
|
|
if not pricing.configured:
|
|
return out
|
|
telegram_id = None
|
|
try:
|
|
user = self._db.get_user_by_supabase_user_id(user_id)
|
|
if isinstance(user, dict):
|
|
telegram_id = int(user.get("telegram_id") or 0) or None
|
|
except Exception:
|
|
telegram_id = None
|
|
price_payload = pricing.resolve_price_for_telegram_id(telegram_id)
|
|
amount_dec = _parse_decimal(
|
|
price_payload.get("amount_usdc"), out["amount_usdc_decimal"]
|
|
)
|
|
if amount_dec <= 0:
|
|
return out
|
|
out["amount_usdc"] = _format_decimal(amount_dec)
|
|
out["amount_usdc_decimal"] = amount_dec
|
|
out["telegram_pricing"] = price_payload
|
|
return out
|
|
|
|
def _build_tx_payload(self, intent: PaymentIntentRecord) -> Dict[str, Any]:
|
|
contract = self._get_contract(intent.receiver_address, intent.chain_id)
|
|
tx_data = contract.encode_abi(
|
|
"pay",
|
|
args=[
|
|
intent.order_id_hex,
|
|
int(intent.plan_id),
|
|
int(intent.amount_units),
|
|
Web3.to_checksum_address(intent.token_address),
|
|
],
|
|
)
|
|
return {
|
|
"chain_id": int(intent.chain_id),
|
|
"to": Web3.to_checksum_address(intent.receiver_address),
|
|
"data": tx_data,
|
|
"value": "0x0",
|
|
"order_id_hex": intent.order_id_hex,
|
|
"amount_units": str(intent.amount_units),
|
|
"amount_usdc": intent.amount_usdc,
|
|
"token_address": Web3.to_checksum_address(intent.token_address),
|
|
"token_symbol": intent.token_symbol,
|
|
"token_decimals": int(intent.token_decimals),
|
|
}
|
|
|
|
def create_intent(
|
|
self,
|
|
user_id: str,
|
|
plan_code: str,
|
|
payment_mode: str = "strict",
|
|
allowed_wallet: Optional[str] = None,
|
|
token_address: Optional[str] = None,
|
|
chain_id: Optional[int] = None,
|
|
metadata: Optional[Dict[str, Any]] = None,
|
|
use_points: bool = False,
|
|
points_to_consume: Optional[int] = None,
|
|
) -> Dict[str, Any]:
|
|
self._ensure_enabled()
|
|
plan = self._apply_telegram_group_pricing(
|
|
user_id,
|
|
self._select_plan(plan_code),
|
|
)
|
|
selected_token = self._resolve_supported_token(token_address, chain_id)
|
|
selected_chain_id = int(selected_token.chain_id)
|
|
mode = str(payment_mode or "strict").strip().lower()
|
|
if mode == "manual":
|
|
mode = "direct"
|
|
if mode not in {"strict", "flex", "direct"}:
|
|
raise PaymentCheckoutError(
|
|
400, "payment_mode must be strict, flex, or direct"
|
|
)
|
|
if mode == "direct" and not selected_token.supports_direct_transfer:
|
|
raise PaymentCheckoutError(
|
|
400,
|
|
f"{selected_token.chain_name} {selected_token.symbol} does not support direct transfer",
|
|
)
|
|
if mode != "direct" and not selected_token.supports_contract_checkout:
|
|
raise PaymentCheckoutError(
|
|
400,
|
|
f"{selected_token.chain_name} {selected_token.symbol} supports manual transfer only",
|
|
)
|
|
bound_wallets = [] if mode == "direct" else self.list_wallets(user_id)
|
|
if mode != "direct" and not bound_wallets:
|
|
raise PaymentCheckoutError(403, "bind wallet first")
|
|
target_wallet = _normalize_address(allowed_wallet or "")
|
|
if mode == "direct":
|
|
target_wallet = ""
|
|
elif mode == "strict":
|
|
if target_wallet:
|
|
self._require_user_wallet(user_id, target_wallet)
|
|
else:
|
|
primary = next(
|
|
(w for w in bound_wallets if w.is_primary and w.status == "active"),
|
|
None,
|
|
)
|
|
target_wallet = primary.address if primary else bound_wallets[0].address
|
|
elif target_wallet:
|
|
self._require_user_wallet(user_id, target_wallet)
|
|
plan_amount_usdc = plan["amount_usdc_decimal"]
|
|
redemption = self._build_points_redemption(
|
|
user_id=user_id,
|
|
plan_amount_usdc=plan_amount_usdc,
|
|
use_points=bool(use_points),
|
|
requested_points_to_consume=points_to_consume,
|
|
)
|
|
final_amount_usdc = redemption["pay_amount_usdc"]
|
|
amount_units = _decimal_to_units(
|
|
final_amount_usdc, int(selected_token.decimals)
|
|
)
|
|
if amount_units <= 0:
|
|
raise PaymentCheckoutError(400, "invalid final payment amount")
|
|
combined_metadata = dict(metadata or {})
|
|
combined_metadata["token_code"] = str(selected_token.code)
|
|
combined_metadata["token_symbol"] = str(selected_token.symbol)
|
|
combined_metadata["chain_id"] = selected_chain_id
|
|
combined_metadata["chain_code"] = selected_token.chain_code
|
|
combined_metadata["chain_name"] = selected_token.chain_name
|
|
if isinstance(plan.get("telegram_pricing"), dict):
|
|
combined_metadata["telegram_pricing"] = plan["telegram_pricing"]
|
|
receiver_address = (
|
|
selected_token.direct_receiver_address
|
|
if mode == "direct"
|
|
else selected_token.receiver_contract
|
|
)
|
|
combined_metadata["amount_before_discount_usdc"] = _format_decimal(
|
|
plan_amount_usdc
|
|
)
|
|
combined_metadata["amount_after_discount_usdc"] = _format_decimal(
|
|
final_amount_usdc
|
|
)
|
|
combined_metadata["points_redemption"] = {
|
|
"enabled": bool(redemption.get("enabled")),
|
|
"applied": bool(redemption.get("applied")),
|
|
"points_per_usdc": int(
|
|
redemption.get("points_per_usdc") or self.points_per_usdc
|
|
),
|
|
"max_discount_usdc": int(
|
|
redemption.get("max_discount_usdc") or self.points_max_discount_usdc
|
|
),
|
|
"points_source": str(
|
|
redemption.get("points_source") or "supabase_metadata"
|
|
),
|
|
"points_balance_snapshot": int(
|
|
redemption.get("points_balance_snapshot") or 0
|
|
),
|
|
"points_to_consume": int(redemption.get("points_to_consume") or 0),
|
|
"discount_usdc": str(redemption.get("discount_usdc") or "0"),
|
|
}
|
|
order_id_hex = "0x" + secrets.token_hex(32)
|
|
now = _now_utc()
|
|
expires_at = now + timedelta(seconds=self.intent_ttl_sec)
|
|
intent_payload = {
|
|
"id": str(uuid.uuid4()),
|
|
"user_id": user_id,
|
|
"plan_code": plan["plan_code"],
|
|
"plan_id": plan["plan_id"],
|
|
"chain_id": selected_chain_id,
|
|
"token_address": selected_token.address,
|
|
"receiver_address": receiver_address,
|
|
"amount_units": str(amount_units),
|
|
"payment_mode": mode,
|
|
"allowed_wallet": target_wallet or None,
|
|
"order_id_hex": order_id_hex,
|
|
"status": "created",
|
|
"expires_at": _to_iso(expires_at),
|
|
"metadata": combined_metadata,
|
|
"created_at": _to_iso(now),
|
|
"updated_at": _to_iso(now),
|
|
}
|
|
self._rest(
|
|
"POST",
|
|
"payment_intents",
|
|
payload=intent_payload,
|
|
prefer="return=minimal",
|
|
allowed_status=[201],
|
|
)
|
|
intent = self._serialize_intent(intent_payload)
|
|
response = {
|
|
"intent": intent.__dict__,
|
|
"tx_payload": None if mode == "direct" else self._build_tx_payload(intent),
|
|
"plan": {
|
|
"plan_code": plan["plan_code"],
|
|
"plan_id": plan["plan_id"],
|
|
"duration_days": plan["duration_days"],
|
|
"amount_before_discount_usdc": _format_decimal(plan_amount_usdc),
|
|
"amount_after_discount_usdc": _format_decimal(final_amount_usdc),
|
|
},
|
|
"token": {
|
|
"code": selected_token.code,
|
|
"symbol": selected_token.symbol,
|
|
"name": selected_token.name,
|
|
"address": selected_token.address,
|
|
"decimals": int(selected_token.decimals),
|
|
},
|
|
"points_redemption": {
|
|
"applied": bool(redemption.get("applied")),
|
|
"points_source": str(
|
|
redemption.get("points_source") or "supabase_metadata"
|
|
),
|
|
"points_to_consume": int(redemption.get("points_to_consume") or 0),
|
|
"discount_usdc": str(redemption.get("discount_usdc") or "0"),
|
|
"points_balance_snapshot": int(
|
|
redemption.get("points_balance_snapshot") or 0
|
|
),
|
|
},
|
|
}
|
|
if mode == "direct":
|
|
response["direct_payment"] = {
|
|
"chain_id": selected_chain_id,
|
|
"chain": selected_token.chain_code,
|
|
"chain_name": selected_token.chain_name,
|
|
"token_symbol": intent.token_symbol,
|
|
"token_address": intent.token_address,
|
|
"token_decimals": int(intent.token_decimals),
|
|
"receiver_address": intent.receiver_address,
|
|
"amount_units": str(intent.amount_units),
|
|
"amount_usdc": intent.amount_usdc,
|
|
"intent_id": intent.intent_id,
|
|
"expires_at": intent.expires_at,
|
|
"explorer_tx_url": selected_token.explorer_tx_url
|
|
or self._explorer_tx_url_for(selected_chain_id),
|
|
}
|
|
return response
|
|
|
|
def get_intent(self, user_id: str, intent_id: str) -> PaymentIntentRecord:
|
|
self._ensure_enabled()
|
|
rows = self._rest(
|
|
"GET",
|
|
"payment_intents",
|
|
params={
|
|
"select": (
|
|
"id,user_id,plan_code,plan_id,chain_id,token_address,receiver_address,"
|
|
"amount_units,payment_mode,allowed_wallet,order_id_hex,status,expires_at,tx_hash,metadata"
|
|
),
|
|
"id": f"eq.{intent_id}",
|
|
"user_id": f"eq.{user_id}",
|
|
"limit": "1",
|
|
},
|
|
allowed_status=[200],
|
|
)
|
|
if not isinstance(rows, list) or not rows:
|
|
raise PaymentCheckoutError(404, "payment intent not found")
|
|
intent = self._serialize_intent(rows[0])
|
|
setattr(intent, "user_id", user_id)
|
|
return intent
|
|
|
|
def list_pending_confirm_intents(self, limit: int = 20) -> List[Dict[str, Any]]:
|
|
"""
|
|
List submitted intents that already have tx_hash and need background confirm.
|
|
"""
|
|
self._ensure_enabled()
|
|
safe_limit = max(1, min(int(limit or 20), 200))
|
|
rows = self._rest(
|
|
"GET",
|
|
"payment_intents",
|
|
params={
|
|
"select": "id,user_id,tx_hash,chain_id",
|
|
"status": "eq.submitted",
|
|
"tx_hash": "not.is.null",
|
|
"order": "updated_at.asc",
|
|
"limit": str(safe_limit),
|
|
},
|
|
allowed_status=[200],
|
|
)
|
|
if not isinstance(rows, list):
|
|
return []
|
|
|
|
out: List[Dict[str, Any]] = []
|
|
for row in rows:
|
|
if not isinstance(row, dict):
|
|
continue
|
|
intent_id = str(row.get("id") or "").strip()
|
|
user_id = str(row.get("user_id") or "").strip()
|
|
tx_hash = str(row.get("tx_hash") or "").strip().lower()
|
|
if not intent_id or not user_id or not tx_hash:
|
|
continue
|
|
out.append(
|
|
{
|
|
"intent_id": intent_id,
|
|
"user_id": user_id,
|
|
"tx_hash": tx_hash,
|
|
"chain_id": int(row.get("chain_id") or self.chain_id),
|
|
}
|
|
)
|
|
return out
|
|
|
|
def list_open_intents_by_order_id(
|
|
self,
|
|
order_id_hex: str,
|
|
limit: int = 10,
|
|
) -> List[Dict[str, Any]]:
|
|
"""
|
|
Find intents by on-chain order id for event-driven reconciliation.
|
|
Includes created/submitted intents; confirmed intents are returned too for idempotent skip.
|
|
"""
|
|
self._ensure_enabled()
|
|
normalized_order = _normalize_order_id_hex(order_id_hex)
|
|
if not normalized_order:
|
|
return []
|
|
safe_limit = max(1, min(int(limit or 10), 50))
|
|
rows = self._rest(
|
|
"GET",
|
|
"payment_intents",
|
|
params={
|
|
"select": (
|
|
"id,user_id,status,tx_hash,plan_id,token_address,amount_units"
|
|
),
|
|
"order_id_hex": f"eq.{normalized_order}",
|
|
"status": "in.(created,submitted,confirmed)",
|
|
"order": "created_at.desc",
|
|
"limit": str(safe_limit),
|
|
},
|
|
allowed_status=[200],
|
|
)
|
|
if not isinstance(rows, list):
|
|
return []
|
|
|
|
out: List[Dict[str, Any]] = []
|
|
for row in rows:
|
|
if not isinstance(row, dict):
|
|
continue
|
|
intent_id = str(row.get("id") or "").strip()
|
|
user_id = str(row.get("user_id") or "").strip()
|
|
status = str(row.get("status") or "").strip().lower()
|
|
if not intent_id or not user_id or not status:
|
|
continue
|
|
out.append(
|
|
{
|
|
"intent_id": intent_id,
|
|
"user_id": user_id,
|
|
"status": status,
|
|
"tx_hash": str(row.get("tx_hash") or "").strip().lower(),
|
|
"plan_id": int(row.get("plan_id") or 0),
|
|
"token_address": _normalize_address(row.get("token_address")),
|
|
"amount_units": int(row.get("amount_units") or 0),
|
|
}
|
|
)
|
|
return out
|
|
|
|
def _ensure_tx_hash_unused(self, tx_hash: str, intent_id: str) -> None:
|
|
tx_hash_text = str(tx_hash or "").strip().lower()
|
|
if not tx_hash_text:
|
|
return
|
|
rows = self._rest(
|
|
"GET",
|
|
"payment_transactions",
|
|
params={
|
|
"select": "intent_id",
|
|
"tx_hash": f"eq.{tx_hash_text}",
|
|
"limit": "5",
|
|
},
|
|
allowed_status=[200],
|
|
)
|
|
if not isinstance(rows, list):
|
|
return
|
|
for row in rows:
|
|
if not isinstance(row, dict):
|
|
continue
|
|
existing_intent = str(row.get("intent_id") or "").strip()
|
|
if existing_intent and existing_intent != str(intent_id):
|
|
raise PaymentCheckoutError(
|
|
409, "tx_hash already used by another payment intent"
|
|
)
|
|
|
|
def _record_duplicate_transaction(
|
|
self,
|
|
*,
|
|
intent: PaymentIntentRecord,
|
|
tx_hash: str,
|
|
from_address: Optional[str] = None,
|
|
to_address: Optional[str] = None,
|
|
status: str = "duplicate",
|
|
detail: str = "payment intent already confirmed",
|
|
) -> Dict[str, Any]:
|
|
tx_hash_text = str(tx_hash or "").strip().lower()
|
|
if not tx_hash_text:
|
|
return {}
|
|
now_iso = _to_iso(_now_utc())
|
|
try:
|
|
self._rest(
|
|
"POST",
|
|
"payment_transactions",
|
|
params={"on_conflict": "tx_hash"},
|
|
payload={
|
|
"intent_id": intent.intent_id,
|
|
"chain_id": int(intent.chain_id),
|
|
"tx_hash": tx_hash_text,
|
|
"from_address": _normalize_address(from_address) or None,
|
|
"to_address": _normalize_address(to_address)
|
|
or intent.receiver_address,
|
|
"payment_method": "direct"
|
|
if intent.payment_mode == "direct"
|
|
else "wallet",
|
|
"status": status,
|
|
"raw_receipt": {},
|
|
"raw_tx": {
|
|
"duplicate_of_intent_id": intent.intent_id,
|
|
"duplicate_reason": detail,
|
|
},
|
|
"updated_at": now_iso,
|
|
},
|
|
prefer="resolution=merge-duplicates,return=minimal",
|
|
allowed_status=[200, 201],
|
|
)
|
|
return {}
|
|
except Exception:
|
|
return {}
|
|
|
|
def validate_intent_tx(
|
|
self,
|
|
user_id: str,
|
|
intent_id: str,
|
|
tx_hash: str,
|
|
) -> Dict[str, Any]:
|
|
"""Pre-check a tx hash against an intent before submission.
|
|
|
|
Returns a validation report with ``valid`` and per-field checks.
|
|
Does NOT mutate any database state.
|
|
"""
|
|
self._ensure_enabled()
|
|
intent = self.get_intent(user_id, intent_id)
|
|
return self._validate_loaded_intent_tx(intent, tx_hash)
|
|
|
|
def _validate_loaded_intent_tx(
|
|
self,
|
|
intent: PaymentIntentRecord,
|
|
tx_hash: str,
|
|
) -> Dict[str, Any]:
|
|
tx_hash_text = str(tx_hash or "").strip().lower()
|
|
if not (tx_hash_text.startswith("0x") and len(tx_hash_text) == 66):
|
|
return {
|
|
"valid": False,
|
|
"reason": "invalid_tx_hash_format",
|
|
"checks": {"tx_hash_format": False},
|
|
}
|
|
if intent.status not in {"created", "submitted"}:
|
|
return {
|
|
"valid": False,
|
|
"reason": f"intent status is {intent.status}, cannot validate",
|
|
"checks": {"intent_status": intent.status},
|
|
}
|
|
now = _now_utc()
|
|
try:
|
|
expires_at = datetime.fromisoformat(intent.expires_at)
|
|
except Exception:
|
|
expires_at = now - timedelta(seconds=1)
|
|
if expires_at <= now:
|
|
return {
|
|
"valid": False,
|
|
"reason": "payment intent expired",
|
|
"checks": {"intent_expired": True},
|
|
}
|
|
|
|
w3 = self._get_web3(chain_id=intent.chain_id)
|
|
try:
|
|
receipt = w3.eth.get_transaction_receipt(tx_hash_text)
|
|
except Exception:
|
|
try:
|
|
w3 = self._get_web3(chain_id=intent.chain_id, force_refresh=True)
|
|
receipt = w3.eth.get_transaction_receipt(tx_hash_text)
|
|
except Exception:
|
|
receipt = None
|
|
|
|
if receipt is None:
|
|
return {
|
|
"valid": False,
|
|
"reason": "tx_not_mined",
|
|
"checks": {"tx_mined": False},
|
|
}
|
|
if int(receipt.get("status") or 0) != 1:
|
|
return {
|
|
"valid": False,
|
|
"reason": "tx_reverted",
|
|
"checks": {"tx_mined": True, "tx_status": "reverted"},
|
|
}
|
|
|
|
tx_to = _normalize_address(receipt.get("to") or "")
|
|
is_direct = intent.payment_mode == "direct"
|
|
|
|
checks: Dict[str, Any] = {
|
|
"tx_mined": True,
|
|
"tx_status": "success",
|
|
"tx_to": tx_to,
|
|
"block_number": int(receipt.get("blockNumber") or 0),
|
|
}
|
|
|
|
if is_direct:
|
|
event_match = self._extract_direct_transfer_event(receipt, intent)
|
|
if not event_match:
|
|
return {
|
|
"valid": False,
|
|
"reason": "direct_transfer_not_found",
|
|
"detail": "ERC20 Transfer event not found on token contract. "
|
|
"Ensure you transferred the correct token to the receiver address.",
|
|
"checks": checks,
|
|
}
|
|
event_from = _normalize_address(event_match.get("from"))
|
|
event_to = _normalize_address(event_match.get("to"))
|
|
event_amount = int(event_match.get("amount_units") or 0)
|
|
expected_receiver = intent.receiver_address
|
|
expected_amount = int(intent.amount_units)
|
|
|
|
receiver_match = event_to == expected_receiver
|
|
amount_match = event_amount >= expected_amount
|
|
|
|
checks["event"] = "Transfer"
|
|
checks["event_from"] = event_from
|
|
checks["event_to"] = event_to
|
|
checks["event_amount"] = str(event_amount)
|
|
checks["expected_receiver"] = expected_receiver
|
|
checks["expected_amount"] = str(expected_amount)
|
|
checks["receiver_match"] = receiver_match
|
|
checks["amount_match"] = amount_match
|
|
|
|
if not receiver_match:
|
|
return {
|
|
"valid": False,
|
|
"reason": "receiver_mismatch",
|
|
"detail": f"Transfer went to {event_to}, expected {expected_receiver}",
|
|
"checks": checks,
|
|
}
|
|
if not amount_match:
|
|
return {
|
|
"valid": False,
|
|
"reason": "amount_insufficient",
|
|
"detail": f"Transfer amount {event_amount} is less than expected {expected_amount}",
|
|
"checks": checks,
|
|
}
|
|
else:
|
|
event_match = self._extract_matching_event(receipt, intent)
|
|
if not event_match:
|
|
return {
|
|
"valid": False,
|
|
"reason": "order_paid_event_not_found",
|
|
"detail": "OrderPaid event not found. "
|
|
"Ensure the tx was sent to the correct receiver contract.",
|
|
"checks": checks,
|
|
}
|
|
event_payer = _normalize_address(event_match.get("payer"))
|
|
event_order_id = str(event_match.get("order_id_hex") or "")
|
|
event_plan_id = int(event_match.get("plan_id") or 0)
|
|
event_amount = int(event_match.get("amount_units") or 0)
|
|
event_token = _normalize_address(event_match.get("token_address") or "")
|
|
|
|
order_match = event_order_id == intent.order_id_hex.lower()
|
|
plan_match = event_plan_id == int(intent.plan_id)
|
|
token_match = event_token == intent.token_address
|
|
amount_match = event_amount == int(intent.amount_units)
|
|
|
|
checks["event"] = "OrderPaid"
|
|
checks["event_payer"] = event_payer
|
|
checks["order_id_match"] = order_match
|
|
checks["plan_id_match"] = plan_match
|
|
checks["token_match"] = token_match
|
|
checks["amount_match"] = amount_match
|
|
checks["event_amount"] = str(event_amount)
|
|
checks["expected_amount"] = str(intent.amount_units)
|
|
|
|
if not all([order_match, plan_match, token_match, amount_match]):
|
|
failures = []
|
|
if not order_match:
|
|
failures.append(
|
|
f"order_id mismatch: got {event_order_id}, expected {intent.order_id_hex.lower()}"
|
|
)
|
|
if not plan_match:
|
|
failures.append(
|
|
f"plan_id mismatch: got {event_plan_id}, expected {intent.plan_id}"
|
|
)
|
|
if not token_match:
|
|
failures.append(
|
|
f"token mismatch: got {event_token}, expected {intent.token_address}"
|
|
)
|
|
if not amount_match:
|
|
failures.append(
|
|
f"amount mismatch: got {event_amount}, expected {intent.amount_units}"
|
|
)
|
|
return {
|
|
"valid": False,
|
|
"reason": "event_mismatch",
|
|
"detail": "; ".join(failures),
|
|
"checks": checks,
|
|
}
|
|
|
|
return {"valid": True, "checks": checks}
|
|
|
|
def submit_intent_tx(
|
|
self,
|
|
user_id: str,
|
|
intent_id: str,
|
|
tx_hash: str,
|
|
from_address: Optional[str],
|
|
) -> Dict[str, Any]:
|
|
self._ensure_enabled()
|
|
intent = self.get_intent(user_id, intent_id)
|
|
tx_hash_text = str(tx_hash or "").strip().lower()
|
|
if intent.status == "confirmed":
|
|
if (
|
|
tx_hash_text
|
|
and tx_hash_text != str(intent.tx_hash or "").strip().lower()
|
|
):
|
|
self._record_duplicate_transaction(
|
|
intent=intent,
|
|
tx_hash=tx_hash_text,
|
|
from_address=from_address,
|
|
status="refund_required",
|
|
detail="submitted tx after order already paid",
|
|
)
|
|
raise PaymentCheckoutError(
|
|
409,
|
|
"该订单已支付,请勿重复付款;如已重复转账请联系客服处理退款",
|
|
)
|
|
if intent.status not in {"created", "submitted"}:
|
|
raise PaymentCheckoutError(
|
|
409, f"intent status is {intent.status}, cannot submit"
|
|
)
|
|
|
|
from_addr = _normalize_address(from_address)
|
|
if not (tx_hash_text.startswith("0x") and len(tx_hash_text) == 66):
|
|
raise PaymentCheckoutError(400, "invalid tx_hash")
|
|
if not from_addr and intent.payment_mode != "direct":
|
|
raise PaymentCheckoutError(400, "invalid from_address")
|
|
self._ensure_tx_hash_unused(tx_hash_text, intent.intent_id)
|
|
|
|
now = _now_utc()
|
|
try:
|
|
expires_at = datetime.fromisoformat(intent.expires_at)
|
|
except Exception:
|
|
expires_at = now - timedelta(seconds=1)
|
|
if expires_at <= now:
|
|
self._rest(
|
|
"PATCH",
|
|
"payment_intents",
|
|
params={"id": f"eq.{intent.intent_id}", "user_id": f"eq.{user_id}"},
|
|
payload={"status": "expired", "updated_at": _to_iso(now)},
|
|
prefer="return=minimal",
|
|
allowed_status=[200],
|
|
)
|
|
raise PaymentCheckoutError(409, "payment intent expired")
|
|
|
|
if intent.payment_mode == "direct":
|
|
from_addr = None
|
|
elif intent.payment_mode == "strict" and intent.allowed_wallet:
|
|
if from_addr != intent.allowed_wallet:
|
|
raise PaymentCheckoutError(
|
|
400,
|
|
f"strict mode requires allowed wallet {intent.allowed_wallet}",
|
|
)
|
|
else:
|
|
self._require_user_wallet(user_id, from_addr)
|
|
|
|
try:
|
|
validation = self._validate_loaded_intent_tx(intent, tx_hash_text)
|
|
except Exception as exc:
|
|
raise PaymentCheckoutError(
|
|
400,
|
|
f"payment_tx_validation_failed: {exc}",
|
|
) from exc
|
|
if not bool(validation.get("valid")):
|
|
reason = str(validation.get("reason") or "payment_tx_invalid").strip()
|
|
detail = str(validation.get("detail") or reason).strip()
|
|
message = reason if detail == reason else f"{reason}: {detail}"
|
|
raise PaymentCheckoutError(400, message)
|
|
|
|
now_iso = _to_iso(now)
|
|
self._rest(
|
|
"PATCH",
|
|
"payment_intents",
|
|
params={"id": f"eq.{intent.intent_id}", "user_id": f"eq.{user_id}"},
|
|
payload={
|
|
"status": "submitted",
|
|
"tx_hash": tx_hash_text,
|
|
"updated_at": now_iso,
|
|
},
|
|
prefer="return=minimal",
|
|
allowed_status=[200],
|
|
)
|
|
tx_payload = {
|
|
"intent_id": intent.intent_id,
|
|
"chain_id": int(intent.chain_id),
|
|
"tx_hash": tx_hash_text,
|
|
"from_address": from_addr,
|
|
"to_address": intent.receiver_address,
|
|
"payment_method": "direct" if intent.payment_mode == "direct" else "wallet",
|
|
"status": "submitted",
|
|
"updated_at": now_iso,
|
|
}
|
|
self._rest(
|
|
"POST",
|
|
"payment_transactions",
|
|
params={"on_conflict": "tx_hash"},
|
|
payload=tx_payload,
|
|
prefer="resolution=merge-duplicates,return=minimal",
|
|
allowed_status=[200, 201],
|
|
)
|
|
return {
|
|
"intent_id": intent.intent_id,
|
|
"status": "submitted",
|
|
"tx_hash": tx_hash_text,
|
|
"from_address": from_addr,
|
|
"transaction": tx_payload,
|
|
}
|
|
|
|
def _wait_receipt(self, tx_hash: str, chain_id: Optional[int] = None) -> Any:
|
|
import time as _time
|
|
|
|
start = _now_utc()
|
|
while (_now_utc() - start).total_seconds() < self.max_wait_sec:
|
|
try:
|
|
w3 = self._get_web3(chain_id=chain_id)
|
|
receipt = w3.eth.get_transaction_receipt(tx_hash)
|
|
except Exception:
|
|
try:
|
|
w3 = self._get_web3(chain_id=chain_id, force_refresh=True)
|
|
receipt = w3.eth.get_transaction_receipt(tx_hash)
|
|
except Exception:
|
|
receipt = None
|
|
if receipt and receipt.get("blockNumber"):
|
|
return receipt
|
|
try:
|
|
latest_w3 = self._get_web3(chain_id=chain_id)
|
|
if not latest_w3.is_connected():
|
|
self._get_web3(chain_id=chain_id, force_refresh=True)
|
|
except Exception:
|
|
receipt = None
|
|
_time.sleep(self.poll_interval_sec)
|
|
raise PaymentCheckoutError(408, "tx receipt timeout")
|
|
|
|
def _extract_matching_event(
|
|
self, receipt: Any, intent: PaymentIntentRecord
|
|
) -> Optional[Dict[str, Any]]:
|
|
contract = self._get_contract(intent.receiver_address, intent.chain_id)
|
|
try:
|
|
events = contract.events.OrderPaid().process_receipt(receipt)
|
|
except Exception:
|
|
events = []
|
|
if not events:
|
|
return None
|
|
|
|
for ev in events:
|
|
args = ev.get("args") if isinstance(ev, dict) else getattr(ev, "args", None)
|
|
if not args:
|
|
continue
|
|
order_id_hex = str(Web3.to_hex(args.get("orderId"))).lower()
|
|
payer = _normalize_address(args.get("payer"))
|
|
plan_id = int(args.get("planId") or 0)
|
|
token = _normalize_address(args.get("token"))
|
|
amount = int(args.get("amount") or 0)
|
|
if (
|
|
order_id_hex == intent.order_id_hex.lower()
|
|
and plan_id == int(intent.plan_id)
|
|
and token == intent.token_address
|
|
and amount == int(intent.amount_units)
|
|
):
|
|
if intent.payment_mode == "strict" and intent.allowed_wallet:
|
|
if payer != intent.allowed_wallet:
|
|
continue
|
|
return {
|
|
"order_id_hex": order_id_hex,
|
|
"payer": payer,
|
|
"plan_id": plan_id,
|
|
"token_address": token,
|
|
"amount_units": amount,
|
|
}
|
|
return None
|
|
|
|
def _extract_direct_transfer_event(
|
|
self, receipt: Any, intent: PaymentIntentRecord
|
|
) -> Optional[Dict[str, Any]]:
|
|
expected_to = intent.receiver_address
|
|
expected_amount = int(intent.amount_units)
|
|
|
|
# Collect all token contracts to check: intent's token first,
|
|
# then all other supported tokens (in case user transferred a
|
|
# different token than selected in the UI).
|
|
token_addresses: List[str] = []
|
|
if intent.token_address:
|
|
token_addresses.append(_normalize_address(intent.token_address))
|
|
for token in self._tokens_for_chain(intent.chain_id):
|
|
normalized = _normalize_address(token.address)
|
|
if normalized and normalized not in token_addresses:
|
|
token_addresses.append(normalized)
|
|
|
|
for token_addr in token_addresses:
|
|
try:
|
|
token_contract = self._get_web3(chain_id=intent.chain_id).eth.contract(
|
|
address=Web3.to_checksum_address(token_addr),
|
|
abi=[ERC20_TRANSFER_EVENT_ABI],
|
|
)
|
|
events = token_contract.events.Transfer().process_receipt(receipt)
|
|
except Exception:
|
|
continue
|
|
|
|
for ev in events:
|
|
args = (
|
|
ev.get("args")
|
|
if isinstance(ev, dict)
|
|
else getattr(ev, "args", None)
|
|
)
|
|
if not args:
|
|
continue
|
|
payer = _normalize_address(args.get("from"))
|
|
receiver = _normalize_address(args.get("to"))
|
|
amount = int(args.get("value") or 0)
|
|
if receiver == expected_to and amount >= expected_amount:
|
|
token_meta = self._token_symbol_for(token_addr, intent.chain_id)
|
|
return {
|
|
"from": payer,
|
|
"to": receiver,
|
|
"token_address": token_addr,
|
|
"amount_units": amount,
|
|
"token_mismatch": (
|
|
token_addr != _normalize_address(intent.token_address)
|
|
),
|
|
"token_symbol": token_meta,
|
|
}
|
|
|
|
return None
|
|
|
|
def _insert_payment_record(
|
|
self,
|
|
user_id: str,
|
|
tx_hash: str,
|
|
amount_units: int,
|
|
token_address: str,
|
|
payload: Dict[str, Any],
|
|
chain_id: Optional[int] = None,
|
|
) -> Dict[str, Any]:
|
|
payment_chain_id = int(chain_id or self.default_chain_id or self.chain_id)
|
|
token_decimals = self._token_decimals_for(token_address, payment_chain_id)
|
|
amount_dec = _units_to_decimal(amount_units, token_decimals)
|
|
currency = self._token_symbol_for(token_address, payment_chain_id)
|
|
payment_payload = {
|
|
"user_id": user_id,
|
|
"amount": str(amount_dec),
|
|
"currency": currency,
|
|
"chain": self._chain_label_for(payment_chain_id),
|
|
"tx_hash": tx_hash,
|
|
"status": "confirmed",
|
|
"raw_payload": payload,
|
|
"updated_at": _to_iso(_now_utc()),
|
|
}
|
|
self._rest(
|
|
"POST",
|
|
"payments",
|
|
params={"on_conflict": "tx_hash"},
|
|
payload=payment_payload,
|
|
prefer="resolution=merge-duplicates,return=minimal",
|
|
allowed_status=[200, 201],
|
|
)
|
|
return payment_payload
|
|
|
|
def _grant_subscription(
|
|
self,
|
|
user_id: str,
|
|
plan_code: str,
|
|
duration_days: int,
|
|
tx_hash: str,
|
|
payload: Dict[str, Any],
|
|
) -> Dict[str, Any]:
|
|
now = _now_utc()
|
|
latest_rows = self._rest(
|
|
"GET",
|
|
"subscriptions",
|
|
params={
|
|
"select": "starts_at,expires_at",
|
|
"user_id": f"eq.{user_id}",
|
|
"status": "eq.active",
|
|
"order": "expires_at.desc",
|
|
"limit": "20",
|
|
},
|
|
allowed_status=[200],
|
|
)
|
|
starts = now
|
|
current_subscription = None
|
|
if isinstance(latest_rows, list):
|
|
for row in latest_rows:
|
|
if not isinstance(row, dict):
|
|
continue
|
|
try:
|
|
starts_at = datetime.fromisoformat(
|
|
str(row.get("starts_at") or "").replace("Z", "+00:00")
|
|
)
|
|
if starts_at.tzinfo is None:
|
|
starts_at = starts_at.replace(tzinfo=timezone.utc)
|
|
starts_at = starts_at.astimezone(timezone.utc)
|
|
except Exception:
|
|
starts_at = None
|
|
if starts_at is None or starts_at <= now:
|
|
current_subscription = row
|
|
break
|
|
if isinstance(current_subscription, dict):
|
|
try:
|
|
latest_exp = datetime.fromisoformat(
|
|
str(current_subscription.get("expires_at") or "").replace(
|
|
"Z", "+00:00"
|
|
)
|
|
)
|
|
if latest_exp.tzinfo is None:
|
|
latest_exp = latest_exp.replace(tzinfo=timezone.utc)
|
|
latest_exp = latest_exp.astimezone(timezone.utc)
|
|
if latest_exp > starts:
|
|
starts = latest_exp
|
|
except Exception:
|
|
pass
|
|
expires = starts + timedelta(days=max(1, duration_days))
|
|
subscription_payload = {
|
|
"user_id": user_id,
|
|
"plan_code": plan_code,
|
|
"status": "active",
|
|
"starts_at": _to_iso(starts),
|
|
"expires_at": _to_iso(expires),
|
|
"source": "payment_contract",
|
|
"created_at": _to_iso(now),
|
|
"updated_at": _to_iso(now),
|
|
}
|
|
self._rest(
|
|
"POST",
|
|
"subscriptions",
|
|
payload=subscription_payload,
|
|
prefer="return=minimal",
|
|
allowed_status=[201],
|
|
)
|
|
self._rest(
|
|
"POST",
|
|
"entitlement_events",
|
|
payload={
|
|
"user_id": user_id,
|
|
"action": "subscription_granted",
|
|
"reason": "payment_confirmed",
|
|
"actor": "payment_contract_checkout",
|
|
"payload": {"tx_hash": tx_hash, **payload},
|
|
"created_at": _to_iso(now),
|
|
},
|
|
prefer="return=minimal",
|
|
allowed_status=[201],
|
|
)
|
|
SUPABASE_ENTITLEMENT.invalidate_subscription_cache(user_id)
|
|
return subscription_payload
|
|
|
|
def _ensure_confirmed_subscription(
|
|
self,
|
|
user_id: str,
|
|
intent: PaymentIntentRecord,
|
|
tx_hash: str,
|
|
) -> Optional[Dict[str, Any]]:
|
|
latest_subscription = SUPABASE_ENTITLEMENT.get_latest_active_subscription(
|
|
user_id,
|
|
respect_requirement=False,
|
|
)
|
|
if isinstance(
|
|
latest_subscription, dict
|
|
) and not self._subscription_row_is_trial(latest_subscription):
|
|
return latest_subscription
|
|
|
|
plan = self._select_plan(intent.plan_code)
|
|
return self._grant_subscription(
|
|
user_id=user_id,
|
|
plan_code=intent.plan_code,
|
|
duration_days=plan["duration_days"],
|
|
tx_hash=tx_hash,
|
|
payload={
|
|
"intent_id": intent.intent_id,
|
|
"order_id_hex": intent.order_id_hex,
|
|
"repaired_from_confirmed_intent": True,
|
|
},
|
|
)
|
|
|
|
@staticmethod
|
|
def _subscription_row_is_trial(row: Dict[str, Any]) -> bool:
|
|
plan_code = str(row.get("plan_code") or "").strip().lower()
|
|
source = str(row.get("source") or "").strip().lower()
|
|
return "trial" in plan_code or "trial" in source
|
|
|
|
def _ensure_confirm_side_effects(
|
|
self,
|
|
user_id: str,
|
|
intent: PaymentIntentRecord,
|
|
tx_hash: str,
|
|
) -> Dict[str, Any]:
|
|
payment_row = {}
|
|
if tx_hash:
|
|
payment_row = self._insert_payment_record(
|
|
user_id=user_id,
|
|
tx_hash=tx_hash,
|
|
amount_units=int(intent.amount_units),
|
|
token_address=intent.token_address,
|
|
chain_id=intent.chain_id,
|
|
payload={
|
|
"tx_hash": tx_hash,
|
|
"intent_id": intent.intent_id,
|
|
"order_id_hex": intent.order_id_hex,
|
|
"reconciled": True,
|
|
},
|
|
)
|
|
subscription_row = self._ensure_confirmed_subscription(user_id, intent, tx_hash)
|
|
return {
|
|
"payment": payment_row,
|
|
"subscription": subscription_row,
|
|
}
|
|
|
|
def _attempt_confirm_repair(
|
|
self,
|
|
*,
|
|
user_id: str,
|
|
intent: PaymentIntentRecord,
|
|
tx_hash: str,
|
|
reason: str,
|
|
detail: str,
|
|
) -> Dict[str, Any]:
|
|
self._db.append_payment_audit_event(
|
|
"payment_confirm_repair_needed",
|
|
{
|
|
"user_id": user_id,
|
|
"intent_id": intent.intent_id,
|
|
"plan_code": intent.plan_code,
|
|
"reason": str(reason or "").strip().lower(),
|
|
"detail": str(detail or "").strip(),
|
|
"tx_hash": str(tx_hash or "").strip().lower(),
|
|
},
|
|
)
|
|
repaired = self._ensure_confirm_side_effects(user_id, intent, tx_hash)
|
|
if repaired.get("payment") or repaired.get("subscription"):
|
|
self._db.append_payment_audit_event(
|
|
"payment_confirm_repaired",
|
|
{
|
|
"user_id": user_id,
|
|
"intent_id": intent.intent_id,
|
|
"plan_code": intent.plan_code,
|
|
"tx_hash": str(tx_hash or "").strip().lower(),
|
|
"reason": str(reason or "").strip().lower(),
|
|
},
|
|
)
|
|
return repaired
|
|
|
|
def _mark_intent_failed(
|
|
self,
|
|
*,
|
|
user_id: str,
|
|
intent: PaymentIntentRecord,
|
|
tx_hash: str,
|
|
reason: str,
|
|
detail: str,
|
|
extra: Optional[Dict[str, Any]] = None,
|
|
) -> None:
|
|
now_iso = _to_iso(_now_utc())
|
|
metadata = dict(intent.metadata or {})
|
|
metadata["confirm_failure"] = {
|
|
"reason": str(reason or "").strip().lower(),
|
|
"detail": str(detail or "").strip(),
|
|
"tx_hash": str(tx_hash or "").strip().lower(),
|
|
"at": now_iso,
|
|
**(extra or {}),
|
|
}
|
|
self._rest(
|
|
"PATCH",
|
|
"payment_intents",
|
|
params={"id": f"eq.{intent.intent_id}", "user_id": f"eq.{user_id}"},
|
|
payload={
|
|
"status": "failed",
|
|
"metadata": metadata,
|
|
"updated_at": now_iso,
|
|
},
|
|
prefer="return=minimal",
|
|
allowed_status=[200],
|
|
)
|
|
if tx_hash:
|
|
self._rest(
|
|
"POST",
|
|
"payment_transactions",
|
|
params={"on_conflict": "tx_hash"},
|
|
payload={
|
|
"intent_id": intent.intent_id,
|
|
"chain_id": int(intent.chain_id),
|
|
"tx_hash": str(tx_hash).strip().lower(),
|
|
"from_address": None,
|
|
"to_address": intent.receiver_address,
|
|
"status": "failed",
|
|
"updated_at": now_iso,
|
|
},
|
|
prefer="resolution=merge-duplicates,return=minimal",
|
|
allowed_status=[200, 201],
|
|
)
|
|
self._db.append_payment_audit_event(
|
|
"payment_intent_failed",
|
|
{
|
|
"intent_id": intent.intent_id,
|
|
"user_id": user_id,
|
|
"plan_code": intent.plan_code,
|
|
"reason": str(reason or "").strip().lower(),
|
|
"detail": str(detail or "").strip(),
|
|
"tx_hash": str(tx_hash or "").strip().lower(),
|
|
"receiver_expected": intent.receiver_address,
|
|
**(extra or {}),
|
|
},
|
|
)
|
|
|
|
def _notify_telegram(
|
|
self, user_id: str, plan_code: str, amount_usdc: str, tx_hash: str
|
|
) -> None:
|
|
if not self.notify_telegram:
|
|
return
|
|
token = str(os.getenv("TELEGRAM_BOT_TOKEN") or "").strip()
|
|
if not token:
|
|
return
|
|
user = self._db.get_user_by_supabase_user_id(user_id)
|
|
if not isinstance(user, dict):
|
|
return
|
|
telegram_id = int(user.get("telegram_id") or 0)
|
|
if telegram_id <= 0:
|
|
return
|
|
short_hash = (
|
|
tx_hash[:10] + "..." + tx_hash[-8:] if len(tx_hash) > 20 else tx_hash
|
|
)
|
|
text = (
|
|
"✅ PolyWeather 支付确认\n"
|
|
f"用户: {user_id}\n"
|
|
f"套餐: {plan_code}\n"
|
|
f"金额: {amount_usdc} USDC\n"
|
|
f"Tx: {short_hash}"
|
|
)
|
|
try:
|
|
requests.post(
|
|
f"https://api.telegram.org/bot{token}/sendMessage",
|
|
json={
|
|
"chat_id": str(telegram_id),
|
|
"text": text,
|
|
"disable_web_page_preview": True,
|
|
},
|
|
timeout=8,
|
|
)
|
|
except Exception:
|
|
return
|
|
|
|
def confirm_intent_tx(
|
|
self,
|
|
user_id: str,
|
|
intent_id: str,
|
|
tx_hash: Optional[str] = None,
|
|
) -> Dict[str, Any]:
|
|
self._ensure_enabled()
|
|
intent = self.get_intent(user_id, intent_id)
|
|
if intent.status == "confirmed":
|
|
tx_hash_text = str(tx_hash or intent.tx_hash or "").strip().lower()
|
|
repaired = self._ensure_confirm_side_effects(user_id, intent, tx_hash_text)
|
|
refreshed = self.get_intent(user_id, intent_id)
|
|
return {
|
|
"intent": refreshed.__dict__,
|
|
"already_confirmed": True,
|
|
"payment": repaired.get("payment"),
|
|
"subscription": repaired.get("subscription"),
|
|
}
|
|
if intent.status in {"cancelled", "expired"}:
|
|
raise PaymentCheckoutError(409, f"intent status is {intent.status}")
|
|
tx_hash_text = str(tx_hash or intent.tx_hash or "").strip().lower()
|
|
if intent.status == "failed" and not tx_hash_text:
|
|
raise PaymentCheckoutError(
|
|
409, "intent status is failed and tx_hash is missing"
|
|
)
|
|
if not tx_hash_text:
|
|
raise PaymentCheckoutError(400, "tx_hash required")
|
|
if not (tx_hash_text.startswith("0x") and len(tx_hash_text) == 66):
|
|
raise PaymentCheckoutError(400, "invalid tx_hash")
|
|
w3 = self._get_web3(chain_id=intent.chain_id)
|
|
if not w3.is_connected():
|
|
raise PaymentCheckoutError(503, "cannot connect payment rpc")
|
|
if int(w3.eth.chain_id) != int(intent.chain_id):
|
|
raise PaymentCheckoutError(503, "payment rpc chain mismatch")
|
|
# Wait for receipt first to avoid transient RPC lag on eth_getTransaction.
|
|
receipt = self._wait_receipt(tx_hash_text, chain_id=intent.chain_id)
|
|
if int(receipt.get("status") or 0) != 1:
|
|
self._mark_intent_failed(
|
|
user_id=user_id,
|
|
intent=intent,
|
|
tx_hash=tx_hash_text,
|
|
reason="tx_reverted",
|
|
detail="tx reverted",
|
|
)
|
|
raise PaymentCheckoutError(400, "tx reverted")
|
|
|
|
try:
|
|
tx = w3.eth.get_transaction(tx_hash_text)
|
|
except Exception:
|
|
tx = None
|
|
|
|
tx_get = getattr(tx, "get", None)
|
|
tx_to_raw = tx_get("to") if callable(tx_get) else None
|
|
tx_from_raw = tx_get("from") if callable(tx_get) else None
|
|
tx_to = _normalize_address(tx_to_raw or receipt.get("to"))
|
|
tx_from = _normalize_address(tx_from_raw or receipt.get("from"))
|
|
if not tx_to or not tx_from:
|
|
raise PaymentCheckoutError(409, "tx indexed partially; retry confirm")
|
|
block_number = int(receipt.get("blockNumber") or 0)
|
|
latest_block = int(w3.eth.block_number)
|
|
confirmations = max(0, latest_block - block_number + 1) if block_number else 0
|
|
required_confirmations = self._confirmations_for_chain(intent.chain_id)
|
|
if confirmations < required_confirmations:
|
|
raise PaymentCheckoutError(
|
|
409,
|
|
f"confirmations not enough: {confirmations}/{required_confirmations}",
|
|
)
|
|
is_direct = intent.payment_mode == "direct"
|
|
if is_direct:
|
|
event_match = self._extract_direct_transfer_event(receipt, intent)
|
|
event_payer = (
|
|
_normalize_address(event_match.get("from")) if event_match else None
|
|
)
|
|
effective_payer = event_payer or tx_from
|
|
routed_via_delegate = False
|
|
else:
|
|
event_match = self._extract_matching_event(receipt, intent)
|
|
event_payer = (
|
|
_normalize_address(event_match.get("payer")) if event_match else None
|
|
)
|
|
effective_payer = event_payer or tx_from
|
|
routed_via_delegate = bool(
|
|
event_match and tx_to and tx_to != intent.receiver_address
|
|
)
|
|
if tx_to != intent.receiver_address and not event_match:
|
|
self._mark_intent_failed(
|
|
user_id=user_id,
|
|
intent=intent,
|
|
tx_hash=tx_hash_text,
|
|
reason="receiver_mismatch",
|
|
detail=f"tx to mismatch: got={tx_to} expected={intent.receiver_address}",
|
|
extra={
|
|
"receiver_actual": tx_to,
|
|
"from_address": tx_from,
|
|
},
|
|
)
|
|
raise PaymentCheckoutError(
|
|
400,
|
|
f"tx to mismatch: got={tx_to} expected={intent.receiver_address}",
|
|
)
|
|
if is_direct:
|
|
pass
|
|
elif intent.payment_mode == "strict" and intent.allowed_wallet:
|
|
if effective_payer != intent.allowed_wallet:
|
|
self._mark_intent_failed(
|
|
user_id=user_id,
|
|
intent=intent,
|
|
tx_hash=tx_hash_text,
|
|
reason="sender_mismatch",
|
|
detail=f"tx sender mismatch: got={effective_payer or tx_from} expected={intent.allowed_wallet}",
|
|
extra={
|
|
"from_address": tx_from,
|
|
"event_payer": event_payer,
|
|
},
|
|
)
|
|
raise PaymentCheckoutError(
|
|
400,
|
|
f"tx sender mismatch: got={effective_payer or tx_from} expected={intent.allowed_wallet}",
|
|
)
|
|
else:
|
|
self._require_user_wallet(user_id, effective_payer)
|
|
if not event_match:
|
|
self._mark_intent_failed(
|
|
user_id=user_id,
|
|
intent=intent,
|
|
tx_hash=tx_hash_text,
|
|
reason="direct_transfer_mismatch" if is_direct else "event_mismatch",
|
|
detail=(
|
|
"ERC20 Transfer mismatch; ensure token transfer sends enough funds to receiver"
|
|
if is_direct
|
|
else "OrderPaid event mismatch; ensure contract emits OrderPaid(orderId,payer,planId,token,amount)"
|
|
),
|
|
extra={"from_address": tx_from, "receiver_actual": tx_to},
|
|
)
|
|
raise PaymentCheckoutError(
|
|
400,
|
|
"ERC20 Transfer mismatch; ensure token transfer sends enough funds to receiver"
|
|
if is_direct
|
|
else "OrderPaid event mismatch; ensure contract emits OrderPaid(orderId,payer,planId,token,amount)",
|
|
)
|
|
points_result = self._consume_points_for_intent(user_id, intent)
|
|
now_iso = _to_iso(_now_utc())
|
|
confirmed_metadata = dict(intent.metadata or {})
|
|
redemption_meta = confirmed_metadata.get("points_redemption")
|
|
if isinstance(redemption_meta, dict):
|
|
redemption_meta["consumed"] = bool(points_result.get("points_redeemed"))
|
|
redemption_meta["consumed_points"] = int(
|
|
points_result.get("points_redeemed") or 0
|
|
)
|
|
redemption_meta["points_after"] = points_result.get("points_after")
|
|
redemption_meta["consumed_at"] = now_iso
|
|
confirmed_metadata["points_redemption"] = redemption_meta
|
|
if routed_via_delegate:
|
|
confirmed_metadata["tx_envelope"] = {
|
|
"outer_to": tx_to,
|
|
"outer_from": tx_from,
|
|
"event_payer": event_payer,
|
|
"receiver_expected": intent.receiver_address,
|
|
"matched_via_event": True,
|
|
}
|
|
confirm_rows = self._rest(
|
|
"PATCH",
|
|
"payment_intents",
|
|
params={
|
|
"select": "id",
|
|
"id": f"eq.{intent.intent_id}",
|
|
"user_id": f"eq.{user_id}",
|
|
"status": "in.(created,submitted,failed)",
|
|
},
|
|
payload={
|
|
"status": "confirmed",
|
|
"tx_hash": tx_hash_text,
|
|
"confirmed_at": now_iso,
|
|
"metadata": confirmed_metadata,
|
|
"updated_at": now_iso,
|
|
},
|
|
prefer="return=representation",
|
|
allowed_status=[200],
|
|
)
|
|
if not isinstance(confirm_rows, list) or not confirm_rows:
|
|
refreshed = self.get_intent(user_id, intent.intent_id)
|
|
if refreshed.status == "confirmed":
|
|
if tx_hash_text != str(refreshed.tx_hash or "").strip().lower():
|
|
self._record_duplicate_transaction(
|
|
intent=refreshed,
|
|
tx_hash=tx_hash_text,
|
|
from_address=tx_from,
|
|
to_address=tx_to,
|
|
status="refund_required",
|
|
detail="order was already confirmed by another transaction",
|
|
)
|
|
repaired = self._ensure_confirm_side_effects(
|
|
user_id,
|
|
refreshed,
|
|
str(refreshed.tx_hash or tx_hash_text).strip().lower(),
|
|
)
|
|
return {
|
|
"intent": refreshed.__dict__,
|
|
"already_confirmed": True,
|
|
"duplicate_tx_hash": tx_hash_text,
|
|
"payment": repaired.get("payment"),
|
|
"subscription": repaired.get("subscription"),
|
|
}
|
|
raise PaymentCheckoutError(
|
|
409, f"intent status is {refreshed.status}, cannot confirm"
|
|
)
|
|
tx_payload = {
|
|
"intent_id": intent.intent_id,
|
|
"tx_hash": tx_hash_text,
|
|
"chain_id": int(intent.chain_id),
|
|
"from_address": tx_from,
|
|
"to_address": tx_to,
|
|
"block_number": block_number,
|
|
"payment_method": "direct" if is_direct else "wallet",
|
|
"status": "confirmed",
|
|
"raw_receipt": json.loads(Web3.to_json(receipt)),
|
|
"raw_tx": json.loads(Web3.to_json(tx)) if tx is not None else None,
|
|
"updated_at": now_iso,
|
|
}
|
|
self._rest(
|
|
"POST",
|
|
"payment_transactions",
|
|
params={"on_conflict": "tx_hash"},
|
|
payload=tx_payload,
|
|
prefer="resolution=merge-duplicates,return=minimal",
|
|
allowed_status=[200, 201],
|
|
)
|
|
payload = {
|
|
"tx_hash": tx_hash_text,
|
|
"block_number": block_number,
|
|
"confirmations": confirmations,
|
|
"event": event_match,
|
|
"points_redemption": points_result,
|
|
}
|
|
plan = self._select_plan(intent.plan_code)
|
|
payment_row = {}
|
|
subscription_row = {}
|
|
try:
|
|
payment_row = self._insert_payment_record(
|
|
user_id=user_id,
|
|
tx_hash=tx_hash_text,
|
|
amount_units=intent.amount_units,
|
|
token_address=intent.token_address,
|
|
chain_id=intent.chain_id,
|
|
payload=payload,
|
|
)
|
|
subscription_row = self._grant_subscription(
|
|
user_id=user_id,
|
|
plan_code=intent.plan_code,
|
|
duration_days=plan["duration_days"],
|
|
tx_hash=tx_hash_text,
|
|
payload=payload,
|
|
)
|
|
except PaymentCheckoutError as exc:
|
|
repaired = self._attempt_confirm_repair(
|
|
user_id=user_id,
|
|
intent=intent,
|
|
tx_hash=tx_hash_text,
|
|
reason="side_effect_failure",
|
|
detail=exc.detail,
|
|
)
|
|
payment_row = repaired.get("payment") or payment_row
|
|
subscription_row = repaired.get("subscription") or subscription_row
|
|
if not subscription_row:
|
|
raise
|
|
self._notify_telegram(
|
|
user_id=user_id,
|
|
plan_code=intent.plan_code,
|
|
amount_usdc=intent.amount_usdc,
|
|
tx_hash=tx_hash_text,
|
|
)
|
|
refreshed = PaymentIntentRecord(
|
|
**{
|
|
**intent.__dict__,
|
|
"status": "confirmed",
|
|
"tx_hash": tx_hash_text,
|
|
"metadata": confirmed_metadata,
|
|
}
|
|
)
|
|
return {
|
|
"intent": refreshed.__dict__,
|
|
"transaction": tx_payload,
|
|
"payment": payment_row,
|
|
"subscription": subscription_row,
|
|
"points_redemption": points_result,
|
|
"tx": payload,
|
|
}
|
|
|
|
def reconcile_latest_intent(self, user_id: str) -> Dict[str, Any]:
|
|
self._ensure_enabled()
|
|
rows = self._rest(
|
|
"GET",
|
|
"payment_intents",
|
|
params={
|
|
"select": (
|
|
"id,user_id,plan_code,plan_id,chain_id,token_address,receiver_address,"
|
|
"amount_units,payment_mode,allowed_wallet,order_id_hex,status,expires_at,tx_hash,metadata"
|
|
),
|
|
"user_id": f"eq.{user_id}",
|
|
"status": "in.(created,submitted,confirmed,failed)",
|
|
"order": "updated_at.desc",
|
|
"limit": "5",
|
|
},
|
|
allowed_status=[200],
|
|
)
|
|
if not isinstance(rows, list) or not rows:
|
|
return {"ok": False, "reason": "intent_not_found"}
|
|
|
|
attempts: List[Dict[str, Any]] = []
|
|
for row in rows:
|
|
if not isinstance(row, dict):
|
|
continue
|
|
intent = self._serialize_intent(row)
|
|
status = str(intent.status or "").strip().lower()
|
|
tx_hash_text = str(intent.tx_hash or "").strip().lower()
|
|
try:
|
|
if status in {"submitted", "failed"} and tx_hash_text:
|
|
result = self.confirm_intent_tx(
|
|
user_id, intent.intent_id, tx_hash_text
|
|
)
|
|
return {
|
|
"ok": True,
|
|
"action": "confirmed_submitted_intent"
|
|
if status == "submitted"
|
|
else "recovered_failed_intent",
|
|
**result,
|
|
}
|
|
if status == "confirmed":
|
|
repaired = self._ensure_confirm_side_effects(
|
|
user_id, intent, tx_hash_text
|
|
)
|
|
return {
|
|
"ok": True,
|
|
"action": "reconciled_confirmed_intent",
|
|
"intent": intent.__dict__,
|
|
"payment": repaired.get("payment"),
|
|
"subscription": repaired.get("subscription"),
|
|
}
|
|
except PaymentCheckoutError as exc:
|
|
attempts.append(
|
|
{
|
|
"intent_id": intent.intent_id,
|
|
"status": status,
|
|
"status_code": exc.status_code,
|
|
"error": exc.detail,
|
|
}
|
|
)
|
|
|
|
latest_subscription = SUPABASE_ENTITLEMENT.get_latest_active_subscription(
|
|
user_id,
|
|
respect_requirement=False,
|
|
)
|
|
return {
|
|
"ok": bool(latest_subscription),
|
|
"action": "checked_without_repair",
|
|
"subscription": latest_subscription,
|
|
"attempts": attempts,
|
|
}
|
|
|
|
def reconcile_recent_intents(self, limit: int = 50) -> Dict[str, Any]:
|
|
self._ensure_enabled()
|
|
safe_limit = max(1, min(int(limit or 50), 200))
|
|
rows = self._rest(
|
|
"GET",
|
|
"payment_intents",
|
|
params={
|
|
"select": "user_id",
|
|
"status": "in.(submitted,confirmed)",
|
|
"order": "updated_at.desc",
|
|
"limit": str(safe_limit),
|
|
},
|
|
allowed_status=[200],
|
|
)
|
|
if not isinstance(rows, list) or not rows:
|
|
return {"ok": True, "processed_users": 0, "repaired_users": 0}
|
|
|
|
seen_users: set[str] = set()
|
|
repaired_users = 0
|
|
for row in rows:
|
|
if not isinstance(row, dict):
|
|
continue
|
|
user_id = str(row.get("user_id") or "").strip()
|
|
if not user_id or user_id in seen_users:
|
|
continue
|
|
seen_users.add(user_id)
|
|
try:
|
|
result = self.reconcile_latest_intent(user_id)
|
|
if bool(result.get("ok")) and result.get("subscription"):
|
|
repaired_users += 1
|
|
except PaymentCheckoutError:
|
|
continue
|
|
except Exception:
|
|
continue
|
|
|
|
return {
|
|
"ok": True,
|
|
"processed_users": len(seen_users),
|
|
"repaired_users": repaired_users,
|
|
}
|
|
|
|
|
|
PAYMENT_CHECKOUT = PaymentContractCheckoutService()
|