Add trial and referral subscription program
This commit is contained in:
@@ -1,15 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Optional
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import requests
|
||||
from loguru import logger
|
||||
|
||||
from src.database.db_manager import DBManager
|
||||
|
||||
SIGNUP_TRIAL_PLAN_CODE = "signup_trial_3d"
|
||||
SIGNUP_TRIAL_SOURCE = "signup_trial"
|
||||
SIGNUP_TRIAL_DAYS = 3
|
||||
|
||||
REFERRAL_REWARD_DAYS = 3
|
||||
REFERRAL_MONTHLY_REWARD_LIMIT = 10
|
||||
REFERRAL_MONTHLY_DAY_LIMIT = 30
|
||||
REFERRAL_DISCOUNT_USDC = "3"
|
||||
REFERRAL_MONTHLY_DISCOUNTED_AMOUNT_USDC = "26.9"
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool = False) -> bool:
|
||||
raw = os.getenv(name)
|
||||
@@ -121,9 +134,595 @@ class SupabaseEntitlementService:
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
def _service_rest_headers(self, prefer: Optional[str] = None) -> Dict[str, str]:
|
||||
headers = self._request_headers_for_service_role()
|
||||
headers["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:
|
||||
if not self.supabase_url or not self.service_role_key:
|
||||
raise RuntimeError("supabase service role is not configured")
|
||||
status_ok = allowed_status or [200, 201, 204]
|
||||
response = requests.request(
|
||||
method=method.upper(),
|
||||
url=f"{self.supabase_url}/rest/v1/{table}",
|
||||
headers=self._service_rest_headers(prefer=prefer),
|
||||
params=params,
|
||||
json=payload,
|
||||
timeout=self.timeout_sec,
|
||||
)
|
||||
if response.status_code not in status_ok:
|
||||
detail = response.text[:350] if response.text else response.reason
|
||||
raise RuntimeError(
|
||||
f"supabase {method.upper()} {table} failed: "
|
||||
f"{response.status_code} {detail}"
|
||||
)
|
||||
if not response.content:
|
||||
return None
|
||||
try:
|
||||
return response.json()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _admin_user_endpoint(self, user_id: str) -> str:
|
||||
return f"{self.supabase_url}/auth/v1/admin/users/{user_id}"
|
||||
|
||||
@staticmethod
|
||||
def _to_iso(dt: datetime) -> str:
|
||||
return dt.astimezone(timezone.utc).isoformat()
|
||||
|
||||
@staticmethod
|
||||
def _normalize_email(value: Optional[str]) -> str:
|
||||
return str(value or "").strip().lower()
|
||||
|
||||
@staticmethod
|
||||
def _is_trial_subscription_row(row: Optional[Dict[str, object]]) -> bool:
|
||||
if not isinstance(row, dict):
|
||||
return False
|
||||
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
|
||||
|
||||
@staticmethod
|
||||
def _is_paid_subscription_row(row: Optional[Dict[str, object]]) -> bool:
|
||||
if not isinstance(row, dict):
|
||||
return False
|
||||
if SupabaseEntitlementService._is_trial_subscription_row(row):
|
||||
return False
|
||||
source = str(row.get("source") or "").strip().lower()
|
||||
if "referral_reward" in source:
|
||||
return False
|
||||
return "payment" in source or source in {"payment_contract", "payment_manual"}
|
||||
|
||||
def _telegram_user_id_for(self, user_id: str) -> Optional[int]:
|
||||
try:
|
||||
linked = DBManager().get_user_by_supabase_user_id(user_id)
|
||||
if not isinstance(linked, dict):
|
||||
return None
|
||||
telegram_id = int(linked.get("telegram_id") or 0)
|
||||
return telegram_id or None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _active_wallet_addresses_for(self, user_id: str) -> List[str]:
|
||||
try:
|
||||
rows = self._rest(
|
||||
"GET",
|
||||
"user_wallets",
|
||||
params={
|
||||
"select": "address",
|
||||
"user_id": f"eq.{user_id}",
|
||||
"status": "eq.active",
|
||||
"limit": "50",
|
||||
},
|
||||
allowed_status=[200],
|
||||
)
|
||||
except Exception:
|
||||
return []
|
||||
out: List[str] = []
|
||||
if isinstance(rows, list):
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
address = str(row.get("address") or "").strip().lower()
|
||||
if address and address not in out:
|
||||
out.append(address)
|
||||
return out
|
||||
|
||||
def _trial_claim_exists(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
email: str,
|
||||
telegram_user_id: Optional[int],
|
||||
wallet_addresses: List[str],
|
||||
) -> bool:
|
||||
checks = [f"user_id.eq.{user_id}"]
|
||||
if email:
|
||||
checks.append(f"email.eq.{email}")
|
||||
if telegram_user_id:
|
||||
checks.append(f"telegram_user_id.eq.{telegram_user_id}")
|
||||
try:
|
||||
rows = self._rest(
|
||||
"GET",
|
||||
"trial_claims",
|
||||
params={
|
||||
"select": "id",
|
||||
"or": f"({','.join(checks)})",
|
||||
"limit": "1",
|
||||
},
|
||||
allowed_status=[200],
|
||||
)
|
||||
if isinstance(rows, list) and rows:
|
||||
return True
|
||||
except Exception:
|
||||
raise
|
||||
|
||||
if not wallet_addresses:
|
||||
return False
|
||||
try:
|
||||
wallet_rows = self._rest(
|
||||
"GET",
|
||||
"trial_claim_wallets",
|
||||
params={
|
||||
"select": "id",
|
||||
"wallet_address": f"in.({','.join(wallet_addresses)})",
|
||||
"limit": "1",
|
||||
},
|
||||
allowed_status=[200],
|
||||
)
|
||||
return bool(isinstance(wallet_rows, list) and wallet_rows)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def ensure_signup_trial(self, user_id: str, email: Optional[str] = None) -> Dict[str, object]:
|
||||
user_key = str(user_id or "").strip()
|
||||
if not user_key:
|
||||
return {"created": False, "reason": "missing_user_id"}
|
||||
if not _env_bool("POLYWEATHER_SIGNUP_TRIAL_ENABLED", True):
|
||||
return {"created": False, "reason": "disabled"}
|
||||
if not self.supabase_url or not self.service_role_key:
|
||||
return {"created": False, "reason": "supabase_not_configured"}
|
||||
|
||||
normalized_email = self._normalize_email(email)
|
||||
try:
|
||||
telegram_user_id = self._telegram_user_id_for(user_key)
|
||||
wallet_addresses = self._active_wallet_addresses_for(user_key)
|
||||
if self._trial_claim_exists(
|
||||
user_id=user_key,
|
||||
email=normalized_email,
|
||||
telegram_user_id=telegram_user_id,
|
||||
wallet_addresses=wallet_addresses,
|
||||
):
|
||||
return {"created": False, "reason": "already_claimed"}
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
expires = now + timedelta(days=SIGNUP_TRIAL_DAYS)
|
||||
claim_payload = {
|
||||
"user_id": user_key,
|
||||
"email": normalized_email,
|
||||
"telegram_user_id": telegram_user_id,
|
||||
"primary_wallet_address": wallet_addresses[0] if wallet_addresses else None,
|
||||
"claimed_at": self._to_iso(now),
|
||||
"metadata": {"wallet_addresses": wallet_addresses},
|
||||
}
|
||||
claim_rows = self._rest(
|
||||
"POST",
|
||||
"trial_claims",
|
||||
payload=claim_payload,
|
||||
prefer="return=representation",
|
||||
allowed_status=[200, 201],
|
||||
)
|
||||
claim_id = None
|
||||
if isinstance(claim_rows, list) and claim_rows and isinstance(claim_rows[0], dict):
|
||||
claim_id = claim_rows[0].get("id")
|
||||
if wallet_addresses and claim_id is not None:
|
||||
self._rest(
|
||||
"POST",
|
||||
"trial_claim_wallets",
|
||||
payload=[
|
||||
{
|
||||
"trial_claim_id": claim_id,
|
||||
"wallet_address": address,
|
||||
"created_at": self._to_iso(now),
|
||||
}
|
||||
for address in wallet_addresses
|
||||
],
|
||||
prefer="return=minimal",
|
||||
allowed_status=[201],
|
||||
)
|
||||
|
||||
subscription_payload = {
|
||||
"user_id": user_key,
|
||||
"plan_code": SIGNUP_TRIAL_PLAN_CODE,
|
||||
"status": "active",
|
||||
"starts_at": self._to_iso(now),
|
||||
"expires_at": self._to_iso(expires),
|
||||
"source": SIGNUP_TRIAL_SOURCE,
|
||||
"created_at": self._to_iso(now),
|
||||
"updated_at": self._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_key,
|
||||
"action": "signup_trial_granted",
|
||||
"reason": "first_auth",
|
||||
"actor": "supabase_auth",
|
||||
"payload": {
|
||||
"plan_code": SIGNUP_TRIAL_PLAN_CODE,
|
||||
"expires_at": self._to_iso(expires),
|
||||
},
|
||||
"created_at": self._to_iso(now),
|
||||
},
|
||||
prefer="return=minimal",
|
||||
allowed_status=[201],
|
||||
)
|
||||
self.invalidate_subscription_cache(user_key)
|
||||
return {
|
||||
"created": True,
|
||||
"plan_code": SIGNUP_TRIAL_PLAN_CODE,
|
||||
"expires_at": self._to_iso(expires),
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.warning("signup trial grant failed user_id={}: {}", user_key, exc)
|
||||
return {"created": False, "reason": "error"}
|
||||
|
||||
def has_paid_subscription(self, user_id: str) -> bool:
|
||||
user_key = str(user_id or "").strip()
|
||||
if not user_key:
|
||||
return False
|
||||
try:
|
||||
rows = self._rest(
|
||||
"GET",
|
||||
"subscriptions",
|
||||
params={
|
||||
"select": "plan_code,source,status,starts_at,expires_at",
|
||||
"user_id": f"eq.{user_key}",
|
||||
"limit": "100",
|
||||
},
|
||||
allowed_status=[200],
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
if not isinstance(rows, list):
|
||||
return False
|
||||
return any(self._is_paid_subscription_row(row) for row in rows if isinstance(row, dict))
|
||||
|
||||
@staticmethod
|
||||
def _normalize_referral_code(value: Optional[str]) -> str:
|
||||
return "".join(str(value or "").strip().upper().split())
|
||||
|
||||
def ensure_referral_code(self, user_id: str) -> Optional[Dict[str, object]]:
|
||||
user_key = str(user_id or "").strip()
|
||||
if not user_key or not self.service_role_key:
|
||||
return None
|
||||
try:
|
||||
rows = self._rest(
|
||||
"GET",
|
||||
"referral_codes",
|
||||
params={
|
||||
"select": "code,status,created_at",
|
||||
"user_id": f"eq.{user_key}",
|
||||
"status": "eq.active",
|
||||
"limit": "1",
|
||||
},
|
||||
allowed_status=[200],
|
||||
)
|
||||
if isinstance(rows, list) and rows and isinstance(rows[0], dict):
|
||||
return rows[0]
|
||||
now = datetime.now(timezone.utc)
|
||||
for _ in range(5):
|
||||
code = f"PW{secrets.token_hex(4).upper()}"
|
||||
try:
|
||||
created = self._rest(
|
||||
"POST",
|
||||
"referral_codes",
|
||||
payload={
|
||||
"user_id": user_key,
|
||||
"code": code,
|
||||
"status": "active",
|
||||
"created_at": self._to_iso(now),
|
||||
"updated_at": self._to_iso(now),
|
||||
},
|
||||
prefer="return=representation",
|
||||
allowed_status=[200, 201],
|
||||
)
|
||||
if isinstance(created, list) and created and isinstance(created[0], dict):
|
||||
return created[0]
|
||||
return {"code": code, "status": "active"}
|
||||
except Exception:
|
||||
continue
|
||||
except Exception as exc:
|
||||
logger.warning("referral code ensure failed user_id={}: {}", user_key, exc)
|
||||
return None
|
||||
|
||||
def get_pending_referral_attribution(self, user_id: str) -> Optional[Dict[str, object]]:
|
||||
user_key = str(user_id or "").strip()
|
||||
if not user_key:
|
||||
return None
|
||||
try:
|
||||
rows = self._rest(
|
||||
"GET",
|
||||
"referral_attributions",
|
||||
params={
|
||||
"select": "id,code,referrer_user_id,referred_user_id,status,created_at",
|
||||
"referred_user_id": f"eq.{user_key}",
|
||||
"status": "eq.pending",
|
||||
"order": "created_at.desc",
|
||||
"limit": "1",
|
||||
},
|
||||
allowed_status=[200],
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
if isinstance(rows, list) and rows and isinstance(rows[0], dict):
|
||||
return rows[0]
|
||||
return None
|
||||
|
||||
def _current_month_reward_rows(self, referrer_user_id: str) -> List[Dict[str, object]]:
|
||||
month_start = datetime.now(timezone.utc).replace(
|
||||
day=1,
|
||||
hour=0,
|
||||
minute=0,
|
||||
second=0,
|
||||
microsecond=0,
|
||||
)
|
||||
try:
|
||||
rows = self._rest(
|
||||
"GET",
|
||||
"referral_rewards",
|
||||
params={
|
||||
"select": "id,reward_days,created_at",
|
||||
"referrer_user_id": f"eq.{referrer_user_id}",
|
||||
"created_at": f"gte.{self._to_iso(month_start)}",
|
||||
"limit": "100",
|
||||
},
|
||||
allowed_status=[200],
|
||||
)
|
||||
except Exception:
|
||||
return []
|
||||
return [row for row in rows if isinstance(row, dict)] if isinstance(rows, list) else []
|
||||
|
||||
def get_referral_summary(self, user_id: str) -> Optional[Dict[str, object]]:
|
||||
user_key = str(user_id or "").strip()
|
||||
if not user_key or not self.service_role_key:
|
||||
return None
|
||||
try:
|
||||
code_row = self.ensure_referral_code(user_key) or {}
|
||||
pending = self.get_pending_referral_attribution(user_key)
|
||||
rewards = self._current_month_reward_rows(user_key)
|
||||
reward_count = len(rewards)
|
||||
reward_days = sum(int(row.get("reward_days") or 0) for row in rewards)
|
||||
return {
|
||||
"code": str(code_row.get("code") or ""),
|
||||
"discount_usdc": REFERRAL_DISCOUNT_USDC,
|
||||
"discounted_monthly_amount_usdc": REFERRAL_MONTHLY_DISCOUNTED_AMOUNT_USDC,
|
||||
"reward_days": REFERRAL_REWARD_DAYS,
|
||||
"monthly_reward_limit": REFERRAL_MONTHLY_REWARD_LIMIT,
|
||||
"monthly_reward_days_limit": REFERRAL_MONTHLY_DAY_LIMIT,
|
||||
"monthly_reward_count": reward_count,
|
||||
"monthly_reward_days": min(reward_days, REFERRAL_MONTHLY_DAY_LIMIT),
|
||||
"applied_code": str(pending.get("code") or "") if isinstance(pending, dict) else "",
|
||||
"attribution_status": str(pending.get("status") or "") if isinstance(pending, dict) else "",
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.warning("referral summary failed user_id={}: {}", user_key, exc)
|
||||
return None
|
||||
|
||||
def apply_referral_code(self, user_id: str, code: str) -> Dict[str, object]:
|
||||
user_key = str(user_id or "").strip()
|
||||
normalized_code = self._normalize_referral_code(code)
|
||||
if not user_key:
|
||||
raise ValueError("user_id required")
|
||||
if len(normalized_code) < 3:
|
||||
raise ValueError("invalid referral code")
|
||||
if self.has_paid_subscription(user_key):
|
||||
raise ValueError("referral code can only be used before first paid subscription")
|
||||
|
||||
rows = self._rest(
|
||||
"GET",
|
||||
"referral_codes",
|
||||
params={
|
||||
"select": "user_id,code,status",
|
||||
"code": f"eq.{normalized_code}",
|
||||
"status": "eq.active",
|
||||
"limit": "1",
|
||||
},
|
||||
allowed_status=[200],
|
||||
)
|
||||
if not isinstance(rows, list) or not rows or not isinstance(rows[0], dict):
|
||||
raise ValueError("referral code not found")
|
||||
referrer_user_id = str(rows[0].get("user_id") or "").strip()
|
||||
if not referrer_user_id or referrer_user_id == user_key:
|
||||
raise ValueError("cannot use your own referral code")
|
||||
|
||||
existing = self.get_pending_referral_attribution(user_key)
|
||||
if isinstance(existing, dict):
|
||||
return {
|
||||
"ok": True,
|
||||
"already_applied": True,
|
||||
"referral": self.get_referral_summary(user_key),
|
||||
}
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
self._rest(
|
||||
"POST",
|
||||
"referral_attributions",
|
||||
payload={
|
||||
"referrer_user_id": referrer_user_id,
|
||||
"referred_user_id": user_key,
|
||||
"code": normalized_code,
|
||||
"status": "pending",
|
||||
"created_at": self._to_iso(now),
|
||||
"updated_at": self._to_iso(now),
|
||||
},
|
||||
prefer="return=minimal",
|
||||
allowed_status=[201],
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"already_applied": False,
|
||||
"referral": self.get_referral_summary(user_key),
|
||||
}
|
||||
|
||||
def _subscription_extension_start(self, user_id: str) -> datetime:
|
||||
now = datetime.now(timezone.utc)
|
||||
try:
|
||||
rows = self._rest(
|
||||
"GET",
|
||||
"subscriptions",
|
||||
params={
|
||||
"select": "starts_at,expires_at,plan_code,source",
|
||||
"user_id": f"eq.{user_id}",
|
||||
"status": "eq.active",
|
||||
"expires_at": f"gt.{self._to_iso(now)}",
|
||||
"order": "expires_at.desc",
|
||||
"limit": "20",
|
||||
},
|
||||
allowed_status=[200],
|
||||
)
|
||||
except Exception:
|
||||
return now
|
||||
starts = now
|
||||
if isinstance(rows, list):
|
||||
for row in rows:
|
||||
if not isinstance(row, dict) or self._is_trial_subscription_row(row):
|
||||
continue
|
||||
exp = self._parse_iso_datetime(str(row.get("expires_at") or ""))
|
||||
starts_at = self._parse_iso_datetime(str(row.get("starts_at") or ""))
|
||||
if exp and (starts_at is None or starts_at <= now) and exp > starts:
|
||||
starts = exp
|
||||
break
|
||||
return starts
|
||||
|
||||
def settle_referral_reward(
|
||||
self,
|
||||
*,
|
||||
referred_user_id: str,
|
||||
payment_intent_id: str,
|
||||
tx_hash: str,
|
||||
) -> Dict[str, object]:
|
||||
referred_key = str(referred_user_id or "").strip()
|
||||
attribution = self.get_pending_referral_attribution(referred_key)
|
||||
if not isinstance(attribution, dict):
|
||||
return {"awarded": False, "reason": "no_pending_referral"}
|
||||
referrer_key = str(attribution.get("referrer_user_id") or "").strip()
|
||||
if not referrer_key or referrer_key == referred_key:
|
||||
return {"awarded": False, "reason": "invalid_referrer"}
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
monthly_rewards = self._current_month_reward_rows(referrer_key)
|
||||
if len(monthly_rewards) >= REFERRAL_MONTHLY_REWARD_LIMIT:
|
||||
self._rest(
|
||||
"PATCH",
|
||||
"referral_attributions",
|
||||
params={"id": f"eq.{attribution.get('id')}"},
|
||||
payload={
|
||||
"status": "capped",
|
||||
"updated_at": self._to_iso(now),
|
||||
"converted_payment_intent_id": payment_intent_id,
|
||||
"converted_tx_hash": tx_hash,
|
||||
},
|
||||
prefer="return=minimal",
|
||||
allowed_status=[204],
|
||||
)
|
||||
return {"awarded": False, "reason": "monthly_cap_reached"}
|
||||
|
||||
starts = self._subscription_extension_start(referrer_key)
|
||||
expires = starts + timedelta(days=REFERRAL_REWARD_DAYS)
|
||||
subscription_payload = {
|
||||
"user_id": referrer_key,
|
||||
"plan_code": "pro_monthly",
|
||||
"status": "active",
|
||||
"starts_at": self._to_iso(starts),
|
||||
"expires_at": self._to_iso(expires),
|
||||
"source": "referral_reward",
|
||||
"created_at": self._to_iso(now),
|
||||
"updated_at": self._to_iso(now),
|
||||
}
|
||||
self._rest(
|
||||
"POST",
|
||||
"subscriptions",
|
||||
payload=subscription_payload,
|
||||
prefer="return=minimal",
|
||||
allowed_status=[201],
|
||||
)
|
||||
self._rest(
|
||||
"POST",
|
||||
"referral_rewards",
|
||||
payload={
|
||||
"referral_attribution_id": attribution.get("id"),
|
||||
"referrer_user_id": referrer_key,
|
||||
"referred_user_id": referred_key,
|
||||
"payment_intent_id": payment_intent_id,
|
||||
"tx_hash": tx_hash,
|
||||
"reward_days": REFERRAL_REWARD_DAYS,
|
||||
"created_at": self._to_iso(now),
|
||||
},
|
||||
prefer="return=minimal",
|
||||
allowed_status=[201],
|
||||
)
|
||||
self._rest(
|
||||
"PATCH",
|
||||
"referral_attributions",
|
||||
params={"id": f"eq.{attribution.get('id')}"},
|
||||
payload={
|
||||
"status": "converted",
|
||||
"converted_payment_intent_id": payment_intent_id,
|
||||
"converted_tx_hash": tx_hash,
|
||||
"converted_at": self._to_iso(now),
|
||||
"updated_at": self._to_iso(now),
|
||||
},
|
||||
prefer="return=minimal",
|
||||
allowed_status=[204],
|
||||
)
|
||||
self._rest(
|
||||
"POST",
|
||||
"entitlement_events",
|
||||
payload={
|
||||
"user_id": referrer_key,
|
||||
"action": "referral_reward_granted",
|
||||
"reason": "referred_user_paid",
|
||||
"actor": "payment_contract_checkout",
|
||||
"payload": {
|
||||
"referred_user_id": referred_key,
|
||||
"payment_intent_id": payment_intent_id,
|
||||
"tx_hash": tx_hash,
|
||||
"reward_days": REFERRAL_REWARD_DAYS,
|
||||
},
|
||||
"created_at": self._to_iso(now),
|
||||
},
|
||||
prefer="return=minimal",
|
||||
allowed_status=[201],
|
||||
)
|
||||
self.invalidate_subscription_cache(referrer_key)
|
||||
return {
|
||||
"awarded": True,
|
||||
"reward_days": REFERRAL_REWARD_DAYS,
|
||||
"referrer_user_id": referrer_key,
|
||||
"subscription": subscription_payload,
|
||||
}
|
||||
|
||||
def get_identity(self, access_token: str) -> Optional[SupabaseIdentity]:
|
||||
if not access_token:
|
||||
return None
|
||||
|
||||
@@ -13,6 +13,7 @@ from typing import Any, Dict, List, Optional
|
||||
import requests
|
||||
from eth_account import Account
|
||||
from eth_account.messages import encode_defunct
|
||||
from loguru import logger
|
||||
from web3 import Web3
|
||||
|
||||
from src.auth.supabase_entitlement import SUPABASE_ENTITLEMENT
|
||||
@@ -90,9 +91,12 @@ ERC20_TRANSFER_EVENT_ABI = {
|
||||
}
|
||||
|
||||
DEFAULT_PLAN_CATALOG: Dict[str, Dict[str, Any]] = {
|
||||
"pro_monthly": {"plan_id": 101, "amount_usdc": "10", "duration_days": 30},
|
||||
"pro_monthly": {"plan_id": 101, "amount_usdc": "29.9", "duration_days": 30},
|
||||
"pro_quarterly": {"plan_id": 102, "amount_usdc": "79.9", "duration_days": 90},
|
||||
}
|
||||
|
||||
REFERRAL_FIRST_MONTH_DISCOUNT_USDC = Decimal("3")
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool = False) -> bool:
|
||||
raw = os.getenv(name)
|
||||
@@ -195,13 +199,13 @@ def _parse_plan_catalog(raw: str) -> Dict[str, Dict[str, Any]]:
|
||||
def _parse_allowed_plan_codes(raw: str) -> List[str]:
|
||||
text = str(raw or "").strip()
|
||||
if not text:
|
||||
return ["pro_monthly"]
|
||||
return ["pro_monthly", "pro_quarterly"]
|
||||
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"]
|
||||
return out or ["pro_monthly", "pro_quarterly"]
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -363,6 +367,10 @@ class PaymentContractCheckoutService:
|
||||
self.notify_telegram = _env_bool(
|
||||
"POLYWEATHER_PAYMENT_TELEGRAM_NOTIFY_ENABLED", True
|
||||
)
|
||||
self.telegram_payment_pricing_enabled = _env_bool(
|
||||
"POLYWEATHER_PAYMENT_TELEGRAM_PRICING_ENABLED",
|
||||
False,
|
||||
)
|
||||
self.points_enabled = _env_bool("POLYWEATHER_PAYMENT_POINTS_ENABLED", True)
|
||||
self.points_per_usdc = max(
|
||||
1, _env_int("POLYWEATHER_PAYMENT_POINTS_PER_USDC", 500)
|
||||
@@ -1650,6 +1658,55 @@ class PaymentContractCheckoutService:
|
||||
out["telegram_pricing"] = price_payload
|
||||
return out
|
||||
|
||||
def _get_pending_referral_attribution(self, user_id: str) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
row = SUPABASE_ENTITLEMENT.get_pending_referral_attribution(user_id)
|
||||
return dict(row) if isinstance(row, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _has_prior_paid_subscription(self, user_id: str) -> bool:
|
||||
try:
|
||||
return bool(SUPABASE_ENTITLEMENT.has_paid_subscription(user_id))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _apply_referral_pricing(
|
||||
self,
|
||||
user_id: str,
|
||||
plan: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
out = dict(plan)
|
||||
attribution = self._get_pending_referral_attribution(user_id)
|
||||
if not attribution or self._has_prior_paid_subscription(user_id):
|
||||
return out
|
||||
|
||||
out["referral_attribution"] = {
|
||||
"id": attribution.get("id"),
|
||||
"code": str(attribution.get("code") or "").strip().upper(),
|
||||
"referrer_user_id": str(attribution.get("referrer_user_id") or "").strip(),
|
||||
"referred_user_id": user_id,
|
||||
}
|
||||
if str(out.get("plan_code") or "").strip().lower() != "pro_monthly":
|
||||
return out
|
||||
|
||||
base_amount = _parse_decimal(out.get("amount_usdc_decimal"), Decimal("0"))
|
||||
discount = min(REFERRAL_FIRST_MONTH_DISCOUNT_USDC, base_amount)
|
||||
discounted = base_amount - discount
|
||||
if discount <= 0 or discounted <= 0:
|
||||
return out
|
||||
|
||||
out["amount_before_discount_usdc_decimal"] = base_amount
|
||||
out["amount_usdc_decimal"] = discounted
|
||||
out["amount_usdc"] = _format_decimal(discounted)
|
||||
out["referral_discount"] = {
|
||||
"discount_usdc": _format_decimal(discount),
|
||||
"amount_before_discount_usdc": _format_decimal(base_amount),
|
||||
"amount_after_discount_usdc": _format_decimal(discounted),
|
||||
"reason": "first_month_referral",
|
||||
}
|
||||
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(
|
||||
@@ -1687,10 +1744,10 @@ class PaymentContractCheckoutService:
|
||||
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_plan = self._select_plan(plan_code)
|
||||
if self.telegram_payment_pricing_enabled:
|
||||
selected_plan = self._apply_telegram_group_pricing(user_id, selected_plan)
|
||||
plan = self._apply_referral_pricing(user_id, selected_plan)
|
||||
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()
|
||||
@@ -1728,6 +1785,10 @@ class PaymentContractCheckoutService:
|
||||
elif target_wallet:
|
||||
self._require_user_wallet(user_id, target_wallet)
|
||||
plan_amount_usdc = plan["amount_usdc_decimal"]
|
||||
amount_before_discount_usdc = plan.get(
|
||||
"amount_before_discount_usdc_decimal",
|
||||
plan_amount_usdc,
|
||||
)
|
||||
redemption = self._build_points_redemption(
|
||||
user_id=user_id,
|
||||
plan_amount_usdc=plan_amount_usdc,
|
||||
@@ -1748,13 +1809,17 @@ class PaymentContractCheckoutService:
|
||||
combined_metadata["chain_name"] = selected_token.chain_name
|
||||
if isinstance(plan.get("telegram_pricing"), dict):
|
||||
combined_metadata["telegram_pricing"] = plan["telegram_pricing"]
|
||||
if isinstance(plan.get("referral_attribution"), dict):
|
||||
combined_metadata["referral_attribution"] = plan["referral_attribution"]
|
||||
if isinstance(plan.get("referral_discount"), dict):
|
||||
combined_metadata["referral_discount"] = plan["referral_discount"]
|
||||
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
|
||||
amount_before_discount_usdc
|
||||
)
|
||||
combined_metadata["amount_after_discount_usdc"] = _format_decimal(
|
||||
final_amount_usdc
|
||||
@@ -1813,7 +1878,9 @@ class PaymentContractCheckoutService:
|
||||
"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_before_discount_usdc": _format_decimal(
|
||||
amount_before_discount_usdc
|
||||
),
|
||||
"amount_after_discount_usdc": _format_decimal(final_amount_usdc),
|
||||
},
|
||||
"token": {
|
||||
@@ -2487,13 +2554,14 @@ class PaymentContractCheckoutService:
|
||||
duration_days: int,
|
||||
tx_hash: str,
|
||||
payload: Dict[str, Any],
|
||||
source: str = "payment",
|
||||
) -> Dict[str, Any]:
|
||||
now = _now_utc()
|
||||
latest_rows = self._rest(
|
||||
"GET",
|
||||
"subscriptions",
|
||||
params={
|
||||
"select": "starts_at,expires_at",
|
||||
"select": "starts_at,expires_at,plan_code,source",
|
||||
"user_id": f"eq.{user_id}",
|
||||
"status": "eq.active",
|
||||
"order": "expires_at.desc",
|
||||
@@ -2507,6 +2575,8 @@ class PaymentContractCheckoutService:
|
||||
for row in latest_rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
if self._subscription_row_is_trial(row):
|
||||
continue
|
||||
try:
|
||||
starts_at = datetime.fromisoformat(
|
||||
str(row.get("starts_at") or "").replace("Z", "+00:00")
|
||||
@@ -2540,7 +2610,7 @@ class PaymentContractCheckoutService:
|
||||
"status": "active",
|
||||
"starts_at": _to_iso(starts),
|
||||
"expires_at": _to_iso(expires),
|
||||
"source": "payment_contract",
|
||||
"source": str(source or "payment").strip() or "payment",
|
||||
"created_at": _to_iso(now),
|
||||
"updated_at": _to_iso(now),
|
||||
}
|
||||
@@ -2602,6 +2672,31 @@ class PaymentContractCheckoutService:
|
||||
source = str(row.get("source") or "").strip().lower()
|
||||
return "trial" in plan_code or "trial" in source
|
||||
|
||||
def _settle_referral_reward_for_intent(
|
||||
self,
|
||||
user_id: str,
|
||||
intent: PaymentIntentRecord,
|
||||
tx_hash: str,
|
||||
) -> Dict[str, Any]:
|
||||
metadata = dict(intent.metadata or {})
|
||||
if not isinstance(metadata.get("referral_attribution"), dict):
|
||||
return {}
|
||||
try:
|
||||
result = SUPABASE_ENTITLEMENT.settle_referral_reward(
|
||||
referred_user_id=user_id,
|
||||
payment_intent_id=intent.intent_id,
|
||||
tx_hash=tx_hash,
|
||||
)
|
||||
return dict(result) if isinstance(result, dict) else {}
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"referral reward settlement failed user_id={} intent_id={}: {}",
|
||||
user_id,
|
||||
intent.intent_id,
|
||||
exc,
|
||||
)
|
||||
return {"awarded": False, "reason": "settlement_error"}
|
||||
|
||||
def _ensure_confirm_side_effects(
|
||||
self,
|
||||
user_id: str,
|
||||
@@ -2624,9 +2719,15 @@ class PaymentContractCheckoutService:
|
||||
},
|
||||
)
|
||||
subscription_row = self._ensure_confirmed_subscription(user_id, intent, tx_hash)
|
||||
referral_reward = self._settle_referral_reward_for_intent(
|
||||
user_id,
|
||||
intent,
|
||||
tx_hash,
|
||||
)
|
||||
return {
|
||||
"payment": payment_row,
|
||||
"subscription": subscription_row,
|
||||
"referral_reward": referral_reward,
|
||||
}
|
||||
|
||||
def _attempt_confirm_repair(
|
||||
@@ -2779,6 +2880,7 @@ class PaymentContractCheckoutService:
|
||||
"already_confirmed": True,
|
||||
"payment": repaired.get("payment"),
|
||||
"subscription": repaired.get("subscription"),
|
||||
"referral_reward": repaired.get("referral_reward"),
|
||||
}
|
||||
if intent.status in {"cancelled", "expired"}:
|
||||
raise PaymentCheckoutError(409, f"intent status is {intent.status}")
|
||||
@@ -2964,6 +3066,7 @@ class PaymentContractCheckoutService:
|
||||
"duplicate_tx_hash": tx_hash_text,
|
||||
"payment": repaired.get("payment"),
|
||||
"subscription": repaired.get("subscription"),
|
||||
"referral_reward": repaired.get("referral_reward"),
|
||||
}
|
||||
raise PaymentCheckoutError(
|
||||
409, f"intent status is {refreshed.status}, cannot confirm"
|
||||
@@ -2999,6 +3102,7 @@ class PaymentContractCheckoutService:
|
||||
plan = self._select_plan(intent.plan_code)
|
||||
payment_row = {}
|
||||
subscription_row = {}
|
||||
referral_reward = {}
|
||||
try:
|
||||
payment_row = self._insert_payment_record(
|
||||
user_id=user_id,
|
||||
@@ -3015,6 +3119,12 @@ class PaymentContractCheckoutService:
|
||||
tx_hash=tx_hash_text,
|
||||
payload=payload,
|
||||
)
|
||||
intent.metadata = confirmed_metadata
|
||||
referral_reward = self._settle_referral_reward_for_intent(
|
||||
user_id,
|
||||
intent,
|
||||
tx_hash_text,
|
||||
)
|
||||
except PaymentCheckoutError as exc:
|
||||
repaired = self._attempt_confirm_repair(
|
||||
user_id=user_id,
|
||||
@@ -3025,6 +3135,7 @@ class PaymentContractCheckoutService:
|
||||
)
|
||||
payment_row = repaired.get("payment") or payment_row
|
||||
subscription_row = repaired.get("subscription") or subscription_row
|
||||
referral_reward = repaired.get("referral_reward") or referral_reward
|
||||
if not subscription_row:
|
||||
raise
|
||||
self._notify_telegram(
|
||||
@@ -3046,6 +3157,7 @@ class PaymentContractCheckoutService:
|
||||
"transaction": tx_payload,
|
||||
"payment": payment_row,
|
||||
"subscription": subscription_row,
|
||||
"referral_reward": referral_reward,
|
||||
"points_redemption": points_result,
|
||||
"tx": payload,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user