Add event fallback for referral program
This commit is contained in:
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
|
import hashlib
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -240,6 +241,108 @@ class SupabaseEntitlementService:
|
|||||||
out.append(address)
|
out.append(address)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _event_payload(row: Dict[str, object]) -> Dict[str, object]:
|
||||||
|
payload = row.get("payload") if isinstance(row, dict) else None
|
||||||
|
return dict(payload) if isinstance(payload, dict) else {}
|
||||||
|
|
||||||
|
def _fetch_entitlement_events(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
action: Optional[str] = None,
|
||||||
|
since: Optional[datetime] = None,
|
||||||
|
limit: int = 1000,
|
||||||
|
) -> List[Dict[str, object]]:
|
||||||
|
params: Dict[str, Any] = {
|
||||||
|
"select": "id,user_id,action,payload,created_at",
|
||||||
|
"order": "created_at.desc",
|
||||||
|
"limit": str(max(1, min(int(limit or 1000), 5000))),
|
||||||
|
}
|
||||||
|
if user_id:
|
||||||
|
params["user_id"] = f"eq.{user_id}"
|
||||||
|
if action:
|
||||||
|
params["action"] = action
|
||||||
|
if since is not None:
|
||||||
|
params["created_at"] = f"gte.{self._to_iso(since)}"
|
||||||
|
rows = self._rest(
|
||||||
|
"GET",
|
||||||
|
"entitlement_events",
|
||||||
|
params=params,
|
||||||
|
allowed_status=[200],
|
||||||
|
)
|
||||||
|
return [row for row in rows if isinstance(row, dict)] if isinstance(rows, list) else []
|
||||||
|
|
||||||
|
def _trial_claim_exists_in_events(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
email: str,
|
||||||
|
telegram_user_id: Optional[int],
|
||||||
|
wallet_addresses: List[str],
|
||||||
|
) -> bool:
|
||||||
|
try:
|
||||||
|
rows = self._fetch_entitlement_events(
|
||||||
|
action="eq.signup_trial_claimed",
|
||||||
|
limit=2000,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
wallet_set = {
|
||||||
|
str(address or "").strip().lower()
|
||||||
|
for address in wallet_addresses
|
||||||
|
if str(address or "").strip()
|
||||||
|
}
|
||||||
|
for row in rows:
|
||||||
|
payload = self._event_payload(row)
|
||||||
|
if str(row.get("user_id") or payload.get("user_id") or "").strip() == user_id:
|
||||||
|
return True
|
||||||
|
if email and str(payload.get("email") or "").strip().lower() == email:
|
||||||
|
return True
|
||||||
|
if telegram_user_id and str(payload.get("telegram_user_id") or "") == str(telegram_user_id):
|
||||||
|
return True
|
||||||
|
event_wallets = payload.get("wallet_addresses")
|
||||||
|
if isinstance(event_wallets, list):
|
||||||
|
event_wallet_set = {
|
||||||
|
str(address or "").strip().lower()
|
||||||
|
for address in event_wallets
|
||||||
|
if str(address or "").strip()
|
||||||
|
}
|
||||||
|
if wallet_set and wallet_set.intersection(event_wallet_set):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _record_signup_trial_claim_event(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
email: str,
|
||||||
|
telegram_user_id: Optional[int],
|
||||||
|
wallet_addresses: List[str],
|
||||||
|
claimed_at: datetime,
|
||||||
|
) -> None:
|
||||||
|
self._rest(
|
||||||
|
"POST",
|
||||||
|
"entitlement_events",
|
||||||
|
payload={
|
||||||
|
"user_id": user_id,
|
||||||
|
"action": "signup_trial_claimed",
|
||||||
|
"reason": "trial_dedupe",
|
||||||
|
"actor": "supabase_auth",
|
||||||
|
"payload": {
|
||||||
|
"user_id": user_id,
|
||||||
|
"email": email,
|
||||||
|
"telegram_user_id": telegram_user_id,
|
||||||
|
"wallet_addresses": wallet_addresses,
|
||||||
|
"claimed_at": self._to_iso(claimed_at),
|
||||||
|
"storage": "entitlement_events",
|
||||||
|
},
|
||||||
|
"created_at": self._to_iso(claimed_at),
|
||||||
|
},
|
||||||
|
prefer="return=minimal",
|
||||||
|
allowed_status=[201],
|
||||||
|
)
|
||||||
|
|
||||||
def _trial_claim_exists(
|
def _trial_claim_exists(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -267,7 +370,12 @@ class SupabaseEntitlementService:
|
|||||||
if isinstance(rows, list) and rows:
|
if isinstance(rows, list) and rows:
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
raise
|
return self._trial_claim_exists_in_events(
|
||||||
|
user_id=user_id,
|
||||||
|
email=email,
|
||||||
|
telegram_user_id=telegram_user_id,
|
||||||
|
wallet_addresses=wallet_addresses,
|
||||||
|
)
|
||||||
|
|
||||||
if not wallet_addresses:
|
if not wallet_addresses:
|
||||||
return False
|
return False
|
||||||
@@ -317,31 +425,49 @@ class SupabaseEntitlementService:
|
|||||||
"claimed_at": self._to_iso(now),
|
"claimed_at": self._to_iso(now),
|
||||||
"metadata": {"wallet_addresses": wallet_addresses},
|
"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
|
claim_id = None
|
||||||
if isinstance(claim_rows, list) and claim_rows and isinstance(claim_rows[0], dict):
|
try:
|
||||||
claim_id = claim_rows[0].get("id")
|
claim_rows = self._rest(
|
||||||
if wallet_addresses and claim_id is not None:
|
|
||||||
self._rest(
|
|
||||||
"POST",
|
"POST",
|
||||||
"trial_claim_wallets",
|
"trial_claims",
|
||||||
payload=[
|
payload=claim_payload,
|
||||||
{
|
prefer="return=representation",
|
||||||
"trial_claim_id": claim_id,
|
allowed_status=[200, 201],
|
||||||
"wallet_address": address,
|
|
||||||
"created_at": self._to_iso(now),
|
|
||||||
}
|
|
||||||
for address in wallet_addresses
|
|
||||||
],
|
|
||||||
prefer="return=minimal",
|
|
||||||
allowed_status=[201],
|
|
||||||
)
|
)
|
||||||
|
if isinstance(claim_rows, list) and claim_rows and isinstance(claim_rows[0], dict):
|
||||||
|
claim_id = claim_rows[0].get("id")
|
||||||
|
except Exception:
|
||||||
|
self._record_signup_trial_claim_event(
|
||||||
|
user_id=user_key,
|
||||||
|
email=normalized_email,
|
||||||
|
telegram_user_id=telegram_user_id,
|
||||||
|
wallet_addresses=wallet_addresses,
|
||||||
|
claimed_at=now,
|
||||||
|
)
|
||||||
|
if wallet_addresses and claim_id is not None:
|
||||||
|
try:
|
||||||
|
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],
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
self._record_signup_trial_claim_event(
|
||||||
|
user_id=user_key,
|
||||||
|
email=normalized_email,
|
||||||
|
telegram_user_id=telegram_user_id,
|
||||||
|
wallet_addresses=wallet_addresses,
|
||||||
|
claimed_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
subscription_payload = {
|
subscription_payload = {
|
||||||
"user_id": user_key,
|
"user_id": user_key,
|
||||||
@@ -412,6 +538,60 @@ class SupabaseEntitlementService:
|
|||||||
def _normalize_referral_code(value: Optional[str]) -> str:
|
def _normalize_referral_code(value: Optional[str]) -> str:
|
||||||
return "".join(str(value or "").strip().upper().split())
|
return "".join(str(value or "").strip().upper().split())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fallback_referral_code_for(user_id: str) -> str:
|
||||||
|
digest = hashlib.sha256(
|
||||||
|
f"polyweather-referral-v1:{user_id}".encode("utf-8")
|
||||||
|
).hexdigest()
|
||||||
|
return f"PW{digest[:8].upper()}"
|
||||||
|
|
||||||
|
def _find_referrer_by_fallback_code(self, code: str) -> Optional[str]:
|
||||||
|
normalized_code = self._normalize_referral_code(code)
|
||||||
|
try:
|
||||||
|
rows = self._rest(
|
||||||
|
"GET",
|
||||||
|
"profiles",
|
||||||
|
params={
|
||||||
|
"select": "id",
|
||||||
|
"order": "created_at.asc",
|
||||||
|
"limit": "5000",
|
||||||
|
},
|
||||||
|
allowed_status=[200],
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
return None
|
||||||
|
for row in rows:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
candidate = str(row.get("id") or "").strip()
|
||||||
|
if candidate and self._fallback_referral_code_for(candidate) == normalized_code:
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _find_referrer_for_referral_code(self, code: str) -> Optional[str]:
|
||||||
|
normalized_code = self._normalize_referral_code(code)
|
||||||
|
try:
|
||||||
|
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 isinstance(rows, list) and rows and isinstance(rows[0], dict):
|
||||||
|
referrer_user_id = str(rows[0].get("user_id") or "").strip()
|
||||||
|
if referrer_user_id:
|
||||||
|
return referrer_user_id
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return self._find_referrer_by_fallback_code(normalized_code)
|
||||||
|
|
||||||
def ensure_referral_code(self, user_id: str) -> Optional[Dict[str, object]]:
|
def ensure_referral_code(self, user_id: str) -> Optional[Dict[str, object]]:
|
||||||
user_key = str(user_id or "").strip()
|
user_key = str(user_id or "").strip()
|
||||||
if not user_key or not self.service_role_key:
|
if not user_key or not self.service_role_key:
|
||||||
@@ -454,8 +634,76 @@ class SupabaseEntitlementService:
|
|||||||
continue
|
continue
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("referral code ensure failed user_id={}: {}", user_key, exc)
|
logger.warning("referral code ensure failed user_id={}: {}", user_key, exc)
|
||||||
|
return {
|
||||||
|
"code": self._fallback_referral_code_for(user_key),
|
||||||
|
"status": "active",
|
||||||
|
"storage": "derived",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _get_event_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._fetch_entitlement_events(
|
||||||
|
user_id=user_key,
|
||||||
|
action="in.(referral_attribution_created,referral_attribution_converted,referral_attribution_capped)",
|
||||||
|
limit=50,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
for row in rows:
|
||||||
|
action = str(row.get("action") or "").strip()
|
||||||
|
payload = self._event_payload(row)
|
||||||
|
if str(payload.get("referred_user_id") or row.get("user_id") or "").strip() != user_key:
|
||||||
|
continue
|
||||||
|
if action in {"referral_attribution_converted", "referral_attribution_capped"}:
|
||||||
|
return None
|
||||||
|
if action == "referral_attribution_created":
|
||||||
|
return {
|
||||||
|
"id": payload.get("id") or row.get("id"),
|
||||||
|
"code": str(payload.get("code") or "").strip().upper(),
|
||||||
|
"referrer_user_id": str(payload.get("referrer_user_id") or "").strip(),
|
||||||
|
"referred_user_id": user_key,
|
||||||
|
"status": "pending",
|
||||||
|
"created_at": row.get("created_at"),
|
||||||
|
"_storage": "entitlement_events",
|
||||||
|
}
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _record_referral_attribution_event(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
referrer_user_id: str,
|
||||||
|
referred_user_id: str,
|
||||||
|
code: str,
|
||||||
|
created_at: datetime,
|
||||||
|
) -> Dict[str, object]:
|
||||||
|
attribution = {
|
||||||
|
"id": f"event:{referred_user_id}:{self._normalize_referral_code(code)}",
|
||||||
|
"code": self._normalize_referral_code(code),
|
||||||
|
"referrer_user_id": referrer_user_id,
|
||||||
|
"referred_user_id": referred_user_id,
|
||||||
|
"status": "pending",
|
||||||
|
"created_at": self._to_iso(created_at),
|
||||||
|
"_storage": "entitlement_events",
|
||||||
|
}
|
||||||
|
self._rest(
|
||||||
|
"POST",
|
||||||
|
"entitlement_events",
|
||||||
|
payload={
|
||||||
|
"user_id": referred_user_id,
|
||||||
|
"action": "referral_attribution_created",
|
||||||
|
"reason": "invite_code",
|
||||||
|
"actor": "account_center",
|
||||||
|
"payload": attribution,
|
||||||
|
"created_at": self._to_iso(created_at),
|
||||||
|
},
|
||||||
|
prefer="return=minimal",
|
||||||
|
allowed_status=[201],
|
||||||
|
)
|
||||||
|
return attribution
|
||||||
|
|
||||||
def get_pending_referral_attribution(self, user_id: str) -> Optional[Dict[str, object]]:
|
def get_pending_referral_attribution(self, user_id: str) -> Optional[Dict[str, object]]:
|
||||||
user_key = str(user_id or "").strip()
|
user_key = str(user_id or "").strip()
|
||||||
if not user_key:
|
if not user_key:
|
||||||
@@ -474,10 +722,10 @@ class SupabaseEntitlementService:
|
|||||||
allowed_status=[200],
|
allowed_status=[200],
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return self._get_event_referral_attribution(user_key)
|
||||||
if isinstance(rows, list) and rows and isinstance(rows[0], dict):
|
if isinstance(rows, list) and rows and isinstance(rows[0], dict):
|
||||||
return rows[0]
|
return rows[0]
|
||||||
return None
|
return self._get_event_referral_attribution(user_key)
|
||||||
|
|
||||||
def _current_month_reward_rows(self, referrer_user_id: str) -> List[Dict[str, object]]:
|
def _current_month_reward_rows(self, referrer_user_id: str) -> List[Dict[str, object]]:
|
||||||
month_start = datetime.now(timezone.utc).replace(
|
month_start = datetime.now(timezone.utc).replace(
|
||||||
@@ -499,9 +747,39 @@ class SupabaseEntitlementService:
|
|||||||
},
|
},
|
||||||
allowed_status=[200],
|
allowed_status=[200],
|
||||||
)
|
)
|
||||||
|
except Exception:
|
||||||
|
return self._current_month_reward_event_rows(referrer_user_id, month_start)
|
||||||
|
table_rows = [row for row in rows if isinstance(row, dict)] if isinstance(rows, list) else []
|
||||||
|
if table_rows:
|
||||||
|
return table_rows
|
||||||
|
return self._current_month_reward_event_rows(referrer_user_id, month_start)
|
||||||
|
|
||||||
|
def _current_month_reward_event_rows(
|
||||||
|
self,
|
||||||
|
referrer_user_id: str,
|
||||||
|
month_start: datetime,
|
||||||
|
) -> List[Dict[str, object]]:
|
||||||
|
try:
|
||||||
|
rows = self._fetch_entitlement_events(
|
||||||
|
user_id=referrer_user_id,
|
||||||
|
action="eq.referral_reward_granted",
|
||||||
|
since=month_start,
|
||||||
|
limit=100,
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
return [row for row in rows if isinstance(row, dict)] if isinstance(rows, list) else []
|
out: List[Dict[str, object]] = []
|
||||||
|
for row in rows:
|
||||||
|
payload = self._event_payload(row)
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"id": row.get("id"),
|
||||||
|
"reward_days": int(payload.get("reward_days") or REFERRAL_REWARD_DAYS),
|
||||||
|
"created_at": row.get("created_at"),
|
||||||
|
"_storage": "entitlement_events",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
def get_referral_summary(self, user_id: str) -> Optional[Dict[str, object]]:
|
def get_referral_summary(self, user_id: str) -> Optional[Dict[str, object]]:
|
||||||
user_key = str(user_id or "").strip()
|
user_key = str(user_id or "").strip()
|
||||||
@@ -539,20 +817,9 @@ class SupabaseEntitlementService:
|
|||||||
if self.has_paid_subscription(user_key):
|
if self.has_paid_subscription(user_key):
|
||||||
raise ValueError("referral code can only be used before first paid subscription")
|
raise ValueError("referral code can only be used before first paid subscription")
|
||||||
|
|
||||||
rows = self._rest(
|
referrer_user_id = self._find_referrer_for_referral_code(normalized_code)
|
||||||
"GET",
|
if not referrer_user_id:
|
||||||
"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")
|
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:
|
if not referrer_user_id or referrer_user_id == user_key:
|
||||||
raise ValueError("cannot use your own referral code")
|
raise ValueError("cannot use your own referral code")
|
||||||
|
|
||||||
@@ -565,20 +832,28 @@ class SupabaseEntitlementService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
self._rest(
|
try:
|
||||||
"POST",
|
self._rest(
|
||||||
"referral_attributions",
|
"POST",
|
||||||
payload={
|
"referral_attributions",
|
||||||
"referrer_user_id": referrer_user_id,
|
payload={
|
||||||
"referred_user_id": user_key,
|
"referrer_user_id": referrer_user_id,
|
||||||
"code": normalized_code,
|
"referred_user_id": user_key,
|
||||||
"status": "pending",
|
"code": normalized_code,
|
||||||
"created_at": self._to_iso(now),
|
"status": "pending",
|
||||||
"updated_at": self._to_iso(now),
|
"created_at": self._to_iso(now),
|
||||||
},
|
"updated_at": self._to_iso(now),
|
||||||
prefer="return=minimal",
|
},
|
||||||
allowed_status=[201],
|
prefer="return=minimal",
|
||||||
)
|
allowed_status=[201],
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
self._record_referral_attribution_event(
|
||||||
|
referrer_user_id=referrer_user_id,
|
||||||
|
referred_user_id=user_key,
|
||||||
|
code=normalized_code,
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"already_applied": False,
|
"already_applied": False,
|
||||||
@@ -615,6 +890,113 @@ class SupabaseEntitlementService:
|
|||||||
break
|
break
|
||||||
return starts
|
return starts
|
||||||
|
|
||||||
|
def _record_referral_resolution_event(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
action: str,
|
||||||
|
referrer_user_id: str,
|
||||||
|
referred_user_id: str,
|
||||||
|
attribution: Dict[str, object],
|
||||||
|
payment_intent_id: str,
|
||||||
|
tx_hash: str,
|
||||||
|
created_at: datetime,
|
||||||
|
reward_days: int = 0,
|
||||||
|
) -> None:
|
||||||
|
self._rest(
|
||||||
|
"POST",
|
||||||
|
"entitlement_events",
|
||||||
|
payload={
|
||||||
|
"user_id": referrer_user_id if action == "referral_reward_granted" else referred_user_id,
|
||||||
|
"action": action,
|
||||||
|
"reason": "referred_user_paid",
|
||||||
|
"actor": "payment_contract_checkout",
|
||||||
|
"payload": {
|
||||||
|
"attribution_id": attribution.get("id"),
|
||||||
|
"code": attribution.get("code"),
|
||||||
|
"referrer_user_id": referrer_user_id,
|
||||||
|
"referred_user_id": referred_user_id,
|
||||||
|
"payment_intent_id": payment_intent_id,
|
||||||
|
"tx_hash": tx_hash,
|
||||||
|
"reward_days": reward_days,
|
||||||
|
"storage": "entitlement_events",
|
||||||
|
},
|
||||||
|
"created_at": self._to_iso(created_at),
|
||||||
|
},
|
||||||
|
prefer="return=minimal",
|
||||||
|
allowed_status=[201],
|
||||||
|
)
|
||||||
|
|
||||||
|
def _settle_referral_reward_with_events(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
attribution: Dict[str, object],
|
||||||
|
referrer_user_id: str,
|
||||||
|
referred_user_id: str,
|
||||||
|
payment_intent_id: str,
|
||||||
|
tx_hash: str,
|
||||||
|
now: datetime,
|
||||||
|
monthly_rewards: List[Dict[str, object]],
|
||||||
|
) -> Dict[str, object]:
|
||||||
|
if len(monthly_rewards) >= REFERRAL_MONTHLY_REWARD_LIMIT:
|
||||||
|
self._record_referral_resolution_event(
|
||||||
|
action="referral_attribution_capped",
|
||||||
|
referrer_user_id=referrer_user_id,
|
||||||
|
referred_user_id=referred_user_id,
|
||||||
|
attribution=attribution,
|
||||||
|
payment_intent_id=payment_intent_id,
|
||||||
|
tx_hash=tx_hash,
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
return {"awarded": False, "reason": "monthly_cap_reached"}
|
||||||
|
|
||||||
|
starts = self._subscription_extension_start(referrer_user_id)
|
||||||
|
expires = starts + timedelta(days=REFERRAL_REWARD_DAYS)
|
||||||
|
subscription_payload = {
|
||||||
|
"user_id": referrer_user_id,
|
||||||
|
"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._record_referral_resolution_event(
|
||||||
|
action="referral_reward_granted",
|
||||||
|
referrer_user_id=referrer_user_id,
|
||||||
|
referred_user_id=referred_user_id,
|
||||||
|
attribution=attribution,
|
||||||
|
payment_intent_id=payment_intent_id,
|
||||||
|
tx_hash=tx_hash,
|
||||||
|
created_at=now,
|
||||||
|
reward_days=REFERRAL_REWARD_DAYS,
|
||||||
|
)
|
||||||
|
self._record_referral_resolution_event(
|
||||||
|
action="referral_attribution_converted",
|
||||||
|
referrer_user_id=referrer_user_id,
|
||||||
|
referred_user_id=referred_user_id,
|
||||||
|
attribution=attribution,
|
||||||
|
payment_intent_id=payment_intent_id,
|
||||||
|
tx_hash=tx_hash,
|
||||||
|
created_at=now,
|
||||||
|
reward_days=REFERRAL_REWARD_DAYS,
|
||||||
|
)
|
||||||
|
self.invalidate_subscription_cache(referrer_user_id)
|
||||||
|
return {
|
||||||
|
"awarded": True,
|
||||||
|
"reward_days": REFERRAL_REWARD_DAYS,
|
||||||
|
"referrer_user_id": referrer_user_id,
|
||||||
|
"subscription": subscription_payload,
|
||||||
|
"storage": "entitlement_events",
|
||||||
|
}
|
||||||
|
|
||||||
def settle_referral_reward(
|
def settle_referral_reward(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -632,6 +1014,16 @@ class SupabaseEntitlementService:
|
|||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
monthly_rewards = self._current_month_reward_rows(referrer_key)
|
monthly_rewards = self._current_month_reward_rows(referrer_key)
|
||||||
|
if str(attribution.get("_storage") or "") == "entitlement_events":
|
||||||
|
return self._settle_referral_reward_with_events(
|
||||||
|
attribution=attribution,
|
||||||
|
referrer_user_id=referrer_key,
|
||||||
|
referred_user_id=referred_key,
|
||||||
|
payment_intent_id=payment_intent_id,
|
||||||
|
tx_hash=tx_hash,
|
||||||
|
now=now,
|
||||||
|
monthly_rewards=monthly_rewards,
|
||||||
|
)
|
||||||
if len(monthly_rewards) >= REFERRAL_MONTHLY_REWARD_LIMIT:
|
if len(monthly_rewards) >= REFERRAL_MONTHLY_REWARD_LIMIT:
|
||||||
self._rest(
|
self._rest(
|
||||||
"PATCH",
|
"PATCH",
|
||||||
|
|||||||
@@ -177,3 +177,133 @@ def test_referral_reward_respects_monthly_ten_invite_cap(monkeypatch):
|
|||||||
assert result["awarded"] is False
|
assert result["awarded"] is False
|
||||||
assert result["reason"] == "monthly_cap_reached"
|
assert result["reason"] == "monthly_cap_reached"
|
||||||
assert not any(call["table"] == "subscriptions" for call in writes)
|
assert not any(call["table"] == "subscriptions" for call in writes)
|
||||||
|
|
||||||
|
|
||||||
|
def test_signup_trial_falls_back_to_entitlement_events_without_trial_tables(monkeypatch):
|
||||||
|
monkeypatch.setenv("POLYWEATHER_AUTH_ENABLED", "true")
|
||||||
|
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
|
||||||
|
monkeypatch.setenv("SUPABASE_ANON_KEY", "anon")
|
||||||
|
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
|
||||||
|
service = SupabaseEntitlementService()
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_rest(method, table, **kwargs):
|
||||||
|
calls.append({"method": method, "table": table, **kwargs})
|
||||||
|
if method == "GET" and table == "user_wallets":
|
||||||
|
return []
|
||||||
|
if table in {"trial_claims", "trial_claim_wallets"}:
|
||||||
|
raise RuntimeError("table missing")
|
||||||
|
if method == "GET" and table == "entitlement_events":
|
||||||
|
return []
|
||||||
|
if method == "POST" and table in {"subscriptions", "entitlement_events"}:
|
||||||
|
return [kwargs.get("payload") or {}]
|
||||||
|
raise AssertionError((method, table, kwargs))
|
||||||
|
|
||||||
|
monkeypatch.setattr(service, "_rest", fake_rest)
|
||||||
|
|
||||||
|
result = service.ensure_signup_trial("user-1", "user@example.com")
|
||||||
|
|
||||||
|
assert result["created"] is True
|
||||||
|
event_actions = [
|
||||||
|
call["payload"]["action"]
|
||||||
|
for call in calls
|
||||||
|
if call["method"] == "POST" and call["table"] == "entitlement_events"
|
||||||
|
]
|
||||||
|
assert "signup_trial_claimed" in event_actions
|
||||||
|
assert "signup_trial_granted" in event_actions
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_referral_code_falls_back_to_profiles_and_events(monkeypatch):
|
||||||
|
monkeypatch.setenv("POLYWEATHER_AUTH_ENABLED", "true")
|
||||||
|
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
|
||||||
|
monkeypatch.setenv("SUPABASE_ANON_KEY", "anon")
|
||||||
|
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
|
||||||
|
service = SupabaseEntitlementService()
|
||||||
|
referrer_id = "00000000-0000-0000-0000-0000000000aa"
|
||||||
|
referred_id = "00000000-0000-0000-0000-0000000000bb"
|
||||||
|
events = []
|
||||||
|
|
||||||
|
def fake_rest(method, table, **kwargs):
|
||||||
|
if method == "GET" and table == "subscriptions":
|
||||||
|
return []
|
||||||
|
if table in {"referral_codes", "referral_attributions", "referral_rewards"}:
|
||||||
|
raise RuntimeError("table missing")
|
||||||
|
if method == "GET" and table == "profiles":
|
||||||
|
return [{"id": referrer_id, "email": "referrer@example.com"}]
|
||||||
|
if method == "GET" and table == "entitlement_events":
|
||||||
|
user_filter = str((kwargs.get("params") or {}).get("user_id") or "")
|
||||||
|
user_id = user_filter[3:] if user_filter.startswith("eq.") else user_filter
|
||||||
|
return [event for event in events if event.get("user_id") == user_id]
|
||||||
|
if method == "POST" and table == "entitlement_events":
|
||||||
|
payload = kwargs.get("payload") or {}
|
||||||
|
events.append(payload)
|
||||||
|
return [payload]
|
||||||
|
raise AssertionError((method, table, kwargs))
|
||||||
|
|
||||||
|
monkeypatch.setattr(service, "_rest", fake_rest)
|
||||||
|
referral_code = service.ensure_referral_code(referrer_id)["code"]
|
||||||
|
|
||||||
|
result = service.apply_referral_code(referred_id, referral_code)
|
||||||
|
|
||||||
|
assert result["ok"] is True
|
||||||
|
assert result["referral"]["applied_code"] == referral_code
|
||||||
|
assert any(event["action"] == "referral_attribution_created" for event in events)
|
||||||
|
|
||||||
|
|
||||||
|
def test_referral_reward_falls_back_to_entitlement_events_without_referral_tables(monkeypatch):
|
||||||
|
monkeypatch.setenv("POLYWEATHER_AUTH_ENABLED", "true")
|
||||||
|
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
|
||||||
|
monkeypatch.setenv("SUPABASE_ANON_KEY", "anon")
|
||||||
|
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
|
||||||
|
service = SupabaseEntitlementService()
|
||||||
|
referrer_id = "00000000-0000-0000-0000-0000000000aa"
|
||||||
|
referred_id = "00000000-0000-0000-0000-0000000000bb"
|
||||||
|
events = [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"user_id": referred_id,
|
||||||
|
"action": "referral_attribution_created",
|
||||||
|
"payload": {
|
||||||
|
"id": "event-1",
|
||||||
|
"code": "PWTEST",
|
||||||
|
"referrer_user_id": referrer_id,
|
||||||
|
"referred_user_id": referred_id,
|
||||||
|
"status": "pending",
|
||||||
|
},
|
||||||
|
"created_at": "2026-05-29T00:00:00+00:00",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
writes = []
|
||||||
|
|
||||||
|
def fake_rest(method, table, **kwargs):
|
||||||
|
if table in {"referral_attributions", "referral_rewards"}:
|
||||||
|
raise RuntimeError("table missing")
|
||||||
|
if method == "GET" and table == "entitlement_events":
|
||||||
|
user_filter = str((kwargs.get("params") or {}).get("user_id") or "")
|
||||||
|
user_id = user_filter[3:] if user_filter.startswith("eq.") else user_filter
|
||||||
|
return [event for event in events if event.get("user_id") == user_id]
|
||||||
|
if method == "GET" and table == "subscriptions":
|
||||||
|
return []
|
||||||
|
if method == "POST" and table in {"subscriptions", "entitlement_events"}:
|
||||||
|
payload = kwargs.get("payload") or {}
|
||||||
|
writes.append({"table": table, "payload": payload})
|
||||||
|
if table == "entitlement_events":
|
||||||
|
events.append(payload)
|
||||||
|
return [payload]
|
||||||
|
raise AssertionError((method, table, kwargs))
|
||||||
|
|
||||||
|
monkeypatch.setattr(service, "_rest", fake_rest)
|
||||||
|
|
||||||
|
result = service.settle_referral_reward(
|
||||||
|
referred_user_id=referred_id,
|
||||||
|
payment_intent_id="intent-1",
|
||||||
|
tx_hash="0x" + "3" * 64,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["awarded"] is True
|
||||||
|
assert any(call["table"] == "subscriptions" for call in writes)
|
||||||
|
assert any(
|
||||||
|
call["table"] == "entitlement_events"
|
||||||
|
and call["payload"]["action"] == "referral_reward_granted"
|
||||||
|
for call in writes
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user