2026-05-29 19:24:46 +08:00
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
|
|
|
|
from src.auth.supabase_entitlement import SupabaseEntitlementService
|
|
|
|
|
from src.payments.contract_checkout import PaymentContractCheckoutService
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _payment_env(monkeypatch, tmp_path):
|
|
|
|
|
monkeypatch.setenv("POLYWEATHER_PAYMENT_ENABLED", "true")
|
|
|
|
|
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
|
|
|
|
|
monkeypatch.setenv("SUPABASE_ANON_KEY", "anon")
|
|
|
|
|
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
|
|
|
|
|
monkeypatch.setenv("POLYWEATHER_PAYMENT_RPC_URL", "https://rpc-1.example")
|
|
|
|
|
monkeypatch.setenv(
|
|
|
|
|
"POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON",
|
|
|
|
|
'[{"code":"usdc","address":"0x3c499c542cef5e3811e1192ce70d8cc03d5c3359","decimals":6,"receiver_contract":"0x351a1bca5f49dd0046a7cf0bafa7e12fa6441c3a","direct_receiver_address":"0x351a1bca5f49dd0046a7cf0bafa7e12fa6441c3a","is_default":true}]',
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setenv("POLYWEATHER_DB_PATH", str(tmp_path / "payments.db"))
|
|
|
|
|
monkeypatch.delenv("POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON", raising=False)
|
|
|
|
|
monkeypatch.delenv("POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES", raising=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_default_payment_catalog_has_monthly_and_quarterly_prices(monkeypatch, tmp_path):
|
|
|
|
|
_payment_env(monkeypatch, tmp_path)
|
|
|
|
|
service = PaymentContractCheckoutService()
|
|
|
|
|
|
|
|
|
|
plans = {
|
|
|
|
|
row["plan_code"]: row for row in service.get_config_payload()["plans"]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
assert plans["pro_monthly"]["amount_usdc"] == "29.9"
|
|
|
|
|
assert plans["pro_monthly"]["duration_days"] == 30
|
|
|
|
|
assert plans["pro_quarterly"]["amount_usdc"] == "79.9"
|
|
|
|
|
assert plans["pro_quarterly"]["duration_days"] == 90
|
|
|
|
|
|
2026-05-30 18:04:36 +08:00
|
|
|
points_config = service.get_config_payload()["points_redemption"]
|
|
|
|
|
assert points_config["points_per_usdc"] == 500
|
|
|
|
|
assert points_config["max_discount_usdc_by_plan"]["pro_monthly"] == 3
|
|
|
|
|
assert points_config["max_discount_usdc_by_plan"]["pro_quarterly"] == 8
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_points_redemption_uses_plan_specific_caps(monkeypatch, tmp_path):
|
|
|
|
|
_payment_env(monkeypatch, tmp_path)
|
|
|
|
|
service = PaymentContractCheckoutService()
|
|
|
|
|
intent_posts = []
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
service,
|
|
|
|
|
"_get_pending_referral_attribution",
|
|
|
|
|
lambda user_id: None,
|
|
|
|
|
raising=False,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
service,
|
|
|
|
|
"_resolve_points_balance",
|
|
|
|
|
lambda user_id: {
|
|
|
|
|
"source": "supabase_metadata",
|
|
|
|
|
"balance": 5000,
|
|
|
|
|
"metadata": {"points": 5000},
|
|
|
|
|
},
|
|
|
|
|
raising=False,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def fake_rest(method, table, **kwargs):
|
|
|
|
|
if method == "POST" and table == "payment_intents":
|
|
|
|
|
intent_posts.append(kwargs["payload"])
|
|
|
|
|
return []
|
|
|
|
|
raise AssertionError((method, table, kwargs))
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(service, "_rest", fake_rest)
|
|
|
|
|
|
|
|
|
|
monthly = service.create_intent(
|
|
|
|
|
user_id="user-1",
|
|
|
|
|
plan_code="pro_monthly",
|
|
|
|
|
payment_mode="direct",
|
|
|
|
|
use_points=True,
|
|
|
|
|
)
|
|
|
|
|
quarterly = service.create_intent(
|
|
|
|
|
user_id="user-1",
|
|
|
|
|
plan_code="pro_quarterly",
|
|
|
|
|
payment_mode="direct",
|
|
|
|
|
use_points=True,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert monthly["plan"]["amount_after_discount_usdc"] == "26.9"
|
|
|
|
|
assert intent_posts[0]["metadata"]["points_redemption"]["points_to_consume"] == 1500
|
|
|
|
|
assert intent_posts[0]["metadata"]["points_redemption"]["max_discount_usdc"] == 3
|
|
|
|
|
|
|
|
|
|
assert quarterly["plan"]["amount_after_discount_usdc"] == "71.9"
|
|
|
|
|
assert intent_posts[1]["metadata"]["points_redemption"]["points_to_consume"] == 4000
|
|
|
|
|
assert intent_posts[1]["metadata"]["points_redemption"]["max_discount_usdc"] == 8
|
|
|
|
|
|
2026-05-29 19:24:46 +08:00
|
|
|
|
|
|
|
|
def test_paid_subscription_replaces_active_signup_trial_immediately(monkeypatch, tmp_path):
|
|
|
|
|
_payment_env(monkeypatch, tmp_path)
|
|
|
|
|
service = PaymentContractCheckoutService()
|
|
|
|
|
writes = []
|
|
|
|
|
|
|
|
|
|
def fake_rest(method, table, **kwargs):
|
|
|
|
|
if method == "GET" and table == "subscriptions":
|
|
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
"starts_at": "2026-05-29T00:00:00+00:00",
|
|
|
|
|
"expires_at": "2026-06-01T00:00:00+00:00",
|
|
|
|
|
"plan_code": "signup_trial_3d",
|
|
|
|
|
"source": "signup_trial",
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
if method == "POST" and table in {"subscriptions", "entitlement_events"}:
|
|
|
|
|
writes.append({"table": table, "payload": kwargs["payload"]})
|
|
|
|
|
return []
|
|
|
|
|
raise AssertionError((method, table, kwargs))
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(service, "_rest", fake_rest)
|
|
|
|
|
|
|
|
|
|
result = service._grant_subscription(
|
|
|
|
|
user_id="user-1",
|
|
|
|
|
plan_code="pro_monthly",
|
|
|
|
|
duration_days=30,
|
|
|
|
|
tx_hash="0x" + "1" * 64,
|
|
|
|
|
payload={"kind": "paid"},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
starts = datetime.fromisoformat(result["starts_at"])
|
|
|
|
|
expires = datetime.fromisoformat(result["expires_at"])
|
|
|
|
|
assert result["source"] == "payment"
|
|
|
|
|
assert starts.date() == datetime.now(timezone.utc).date()
|
|
|
|
|
assert 29 <= (expires - starts).days <= 30
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_signup_trial_claim_creates_three_day_trial_once(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 = []
|
|
|
|
|
|
2026-05-30 16:02:02 +08:00
|
|
|
monkeypatch.setattr(
|
|
|
|
|
service,
|
|
|
|
|
"_rpc",
|
|
|
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
|
|
|
RuntimeError("PGRST202 function not found")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
2026-05-29 19:24:46 +08:00
|
|
|
def fake_rest(method, table, **kwargs):
|
|
|
|
|
calls.append({"method": method, "table": table, **kwargs})
|
|
|
|
|
if method == "GET" and table in {"trial_claims", "user_wallets"}:
|
|
|
|
|
return []
|
|
|
|
|
if method == "POST" and table in {"trial_claims", "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
|
|
|
|
|
sub_write = next(call for call in calls if call["table"] == "subscriptions")
|
|
|
|
|
payload = sub_write["payload"]
|
|
|
|
|
assert payload["plan_code"] == "signup_trial_3d"
|
|
|
|
|
assert payload["source"] == "signup_trial"
|
|
|
|
|
assert payload["status"] == "active"
|
|
|
|
|
assert payload["user_id"] == "user-1"
|
|
|
|
|
|
|
|
|
|
|
2026-05-30 16:02:02 +08:00
|
|
|
def test_signup_trial_uses_atomic_database_claim_rpc(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()
|
|
|
|
|
rpc_calls = []
|
|
|
|
|
|
|
|
|
|
def fake_rpc(name, payload, **kwargs):
|
|
|
|
|
rpc_calls.append({"name": name, "payload": payload, **kwargs})
|
|
|
|
|
return {
|
|
|
|
|
"created": True,
|
|
|
|
|
"plan_code": "signup_trial_3d",
|
|
|
|
|
"expires_at": "2026-06-02T00:00:00+00:00",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def fake_rest(method, table, **kwargs):
|
|
|
|
|
if method == "GET" and table == "user_wallets":
|
|
|
|
|
return [{"address": "0xabc"}]
|
|
|
|
|
raise AssertionError(
|
|
|
|
|
f"signup trial must use atomic RPC instead of legacy {method} {table}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(service, "_rpc", fake_rpc, raising=False)
|
|
|
|
|
monkeypatch.setattr(service, "_rest", fake_rest)
|
|
|
|
|
|
|
|
|
|
result = service.ensure_signup_trial("user-1", "USER@example.com")
|
|
|
|
|
|
|
|
|
|
assert result["created"] is True
|
|
|
|
|
assert rpc_calls == [
|
|
|
|
|
{
|
|
|
|
|
"name": "claim_signup_trial",
|
|
|
|
|
"payload": {
|
|
|
|
|
"p_user_id": "user-1",
|
|
|
|
|
"p_email": "user@example.com",
|
|
|
|
|
"p_telegram_user_id": None,
|
|
|
|
|
"p_wallet_addresses": ["0xabc"],
|
|
|
|
|
},
|
|
|
|
|
"allowed_status": [200],
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
2026-05-29 19:24:46 +08:00
|
|
|
def test_referral_discount_applies_to_first_monthly_payment(monkeypatch, tmp_path):
|
|
|
|
|
_payment_env(monkeypatch, tmp_path)
|
|
|
|
|
service = PaymentContractCheckoutService()
|
|
|
|
|
intent_posts = []
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
service,
|
|
|
|
|
"_get_pending_referral_attribution",
|
|
|
|
|
lambda user_id: {
|
|
|
|
|
"id": 7,
|
|
|
|
|
"code": "YUAN2026",
|
|
|
|
|
"referrer_user_id": "referrer-1",
|
|
|
|
|
},
|
|
|
|
|
raising=False,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
service,
|
|
|
|
|
"_has_prior_paid_subscription",
|
|
|
|
|
lambda user_id: False,
|
|
|
|
|
raising=False,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def fake_rest(method, table, **kwargs):
|
|
|
|
|
if method == "POST" and table == "payment_intents":
|
|
|
|
|
intent_posts.append(kwargs["payload"])
|
|
|
|
|
return []
|
|
|
|
|
raise AssertionError((method, table, kwargs))
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(service, "_rest", fake_rest)
|
|
|
|
|
|
|
|
|
|
result = service.create_intent(
|
|
|
|
|
user_id="referred-1",
|
|
|
|
|
plan_code="pro_monthly",
|
|
|
|
|
payment_mode="direct",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result["plan"]["amount_before_discount_usdc"] == "29.9"
|
2026-05-30 18:04:36 +08:00
|
|
|
assert result["plan"]["amount_after_discount_usdc"] == "20"
|
|
|
|
|
assert intent_posts[0]["amount_units"] == "20000000"
|
|
|
|
|
assert intent_posts[0]["metadata"]["referral_discount"]["discount_usdc"] == "9.9"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_referral_first_month_price_does_not_stack_with_points(monkeypatch, tmp_path):
|
|
|
|
|
_payment_env(monkeypatch, tmp_path)
|
|
|
|
|
service = PaymentContractCheckoutService()
|
|
|
|
|
intent_posts = []
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
service,
|
|
|
|
|
"_get_pending_referral_attribution",
|
|
|
|
|
lambda user_id: {
|
|
|
|
|
"id": 7,
|
|
|
|
|
"code": "YUAN2026",
|
|
|
|
|
"referrer_user_id": "referrer-1",
|
|
|
|
|
},
|
|
|
|
|
raising=False,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
service,
|
|
|
|
|
"_has_prior_paid_subscription",
|
|
|
|
|
lambda user_id: False,
|
|
|
|
|
raising=False,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
service,
|
|
|
|
|
"_resolve_points_balance",
|
|
|
|
|
lambda user_id: {
|
|
|
|
|
"source": "supabase_metadata",
|
|
|
|
|
"balance": 5000,
|
|
|
|
|
"metadata": {"points": 5000},
|
|
|
|
|
},
|
|
|
|
|
raising=False,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def fake_rest(method, table, **kwargs):
|
|
|
|
|
if method == "POST" and table == "payment_intents":
|
|
|
|
|
intent_posts.append(kwargs["payload"])
|
|
|
|
|
return []
|
|
|
|
|
raise AssertionError((method, table, kwargs))
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(service, "_rest", fake_rest)
|
|
|
|
|
|
|
|
|
|
result = service.create_intent(
|
|
|
|
|
user_id="referred-1",
|
|
|
|
|
plan_code="pro_monthly",
|
|
|
|
|
payment_mode="direct",
|
|
|
|
|
use_points=True,
|
|
|
|
|
points_to_consume=1500,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result["plan"]["amount_after_discount_usdc"] == "20"
|
|
|
|
|
assert intent_posts[0]["amount_units"] == "20000000"
|
|
|
|
|
assert intent_posts[0]["metadata"]["points_redemption"]["applied"] is False
|
|
|
|
|
assert intent_posts[0]["metadata"]["points_redemption"]["points_to_consume"] == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_referral_reward_grants_points_instead_of_pro_days(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()
|
|
|
|
|
writes = []
|
|
|
|
|
point_grants = []
|
|
|
|
|
|
|
|
|
|
def fake_rest(method, table, **kwargs):
|
|
|
|
|
if method == "GET" and table == "referral_attributions":
|
|
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
"id": 77,
|
|
|
|
|
"referrer_user_id": "referrer-1",
|
|
|
|
|
"referred_user_id": "referred-1",
|
|
|
|
|
"status": "pending",
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
if method == "GET" and table == "referral_rewards":
|
|
|
|
|
return []
|
|
|
|
|
if method in {"POST", "PATCH"}:
|
|
|
|
|
writes.append({"method": method, "table": table, **kwargs})
|
|
|
|
|
return []
|
|
|
|
|
raise AssertionError((method, table, kwargs))
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(service, "_rest", fake_rest)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
service,
|
|
|
|
|
"_grant_referral_points",
|
|
|
|
|
lambda referrer_user_id, points: point_grants.append(
|
|
|
|
|
{"referrer_user_id": referrer_user_id, "points": points}
|
|
|
|
|
)
|
|
|
|
|
or {"ok": True, "points_added": points, "points_after": points},
|
|
|
|
|
raising=False,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = service.settle_referral_reward(
|
|
|
|
|
referred_user_id="referred-1",
|
|
|
|
|
payment_intent_id="intent-1",
|
|
|
|
|
tx_hash="0x" + "2" * 64,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result["awarded"] is True
|
|
|
|
|
assert result["reward_points"] == 3500
|
|
|
|
|
assert result["reward_days"] == 0
|
|
|
|
|
assert point_grants == [{"referrer_user_id": "referrer-1", "points": 3500}]
|
|
|
|
|
assert not any(call["table"] == "subscriptions" for call in writes)
|
|
|
|
|
reward_write = next(call for call in writes if call["table"] == "referral_rewards")
|
|
|
|
|
assert reward_write["payload"]["reward_points"] == 3500
|
|
|
|
|
assert reward_write["payload"]["reward_days"] == 0
|
2026-05-29 19:24:46 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_referral_reward_respects_monthly_ten_invite_cap(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()
|
|
|
|
|
writes = []
|
|
|
|
|
|
|
|
|
|
def fake_rest(method, table, **kwargs):
|
|
|
|
|
if method == "GET" and table == "referral_attributions":
|
|
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
"id": 77,
|
|
|
|
|
"referrer_user_id": "referrer-1",
|
|
|
|
|
"referred_user_id": "referred-1",
|
|
|
|
|
"status": "pending",
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
if method == "GET" and table == "referral_rewards":
|
2026-05-30 18:04:36 +08:00
|
|
|
return [{"id": i, "reward_points": 3500} for i in range(10)]
|
2026-05-29 19:24:46 +08:00
|
|
|
if method in {"POST", "PATCH"}:
|
|
|
|
|
writes.append({"method": method, "table": table, **kwargs})
|
|
|
|
|
return []
|
|
|
|
|
raise AssertionError((method, table, kwargs))
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(service, "_rest", fake_rest)
|
|
|
|
|
|
|
|
|
|
result = service.settle_referral_reward(
|
|
|
|
|
referred_user_id="referred-1",
|
|
|
|
|
payment_intent_id="intent-1",
|
|
|
|
|
tx_hash="0x" + "2" * 64,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result["awarded"] is False
|
|
|
|
|
assert result["reason"] == "monthly_cap_reached"
|
2026-05-30 18:04:36 +08:00
|
|
|
assert not any(call["table"] == "referral_rewards" for call in writes)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_referral_reward_existing_reward_row_does_not_grant_points(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()
|
|
|
|
|
point_grants = []
|
|
|
|
|
|
|
|
|
|
def fake_rest(method, table, **kwargs):
|
|
|
|
|
if method == "GET" and table == "referral_attributions":
|
|
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
"id": 77,
|
|
|
|
|
"referrer_user_id": "referrer-1",
|
|
|
|
|
"referred_user_id": "referred-1",
|
|
|
|
|
"status": "pending",
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
if method == "GET" and table == "referral_rewards":
|
|
|
|
|
params = kwargs.get("params") or {}
|
|
|
|
|
if params.get("referral_attribution_id") == "eq.77":
|
|
|
|
|
return [{"id": 88, "reward_points": 3500}]
|
|
|
|
|
return []
|
|
|
|
|
if method in {"POST", "PATCH"}:
|
|
|
|
|
return []
|
|
|
|
|
raise AssertionError((method, table, kwargs))
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(service, "_rest", fake_rest)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
service,
|
|
|
|
|
"_grant_referral_points",
|
|
|
|
|
lambda referrer_user_id, points: point_grants.append(
|
|
|
|
|
{"referrer_user_id": referrer_user_id, "points": points}
|
|
|
|
|
)
|
|
|
|
|
or {"ok": True, "points_added": points, "points_after": points},
|
|
|
|
|
raising=False,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = service.settle_referral_reward(
|
|
|
|
|
referred_user_id="referred-1",
|
|
|
|
|
payment_intent_id="intent-1",
|
|
|
|
|
tx_hash="0x" + "2" * 64,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result["awarded"] is False
|
|
|
|
|
assert result["reason"] == "already_rewarded"
|
|
|
|
|
assert point_grants == []
|
2026-05-29 19:34:56 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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 = []
|
|
|
|
|
|
2026-05-30 16:02:02 +08:00
|
|
|
monkeypatch.setattr(
|
|
|
|
|
service,
|
|
|
|
|
"_rpc",
|
|
|
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
|
|
|
RuntimeError("PGRST202 function not found")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
2026-05-29 19:34:56 +08:00
|
|
|
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 = []
|
2026-05-30 18:04:36 +08:00
|
|
|
point_grants = []
|
2026-05-29 19:34:56 +08:00
|
|
|
|
|
|
|
|
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]
|
2026-05-30 18:04:36 +08:00
|
|
|
if method == "POST" and table == "entitlement_events":
|
2026-05-29 19:34:56 +08:00
|
|
|
payload = kwargs.get("payload") or {}
|
|
|
|
|
writes.append({"table": table, "payload": payload})
|
2026-05-30 18:04:36 +08:00
|
|
|
events.append(payload)
|
2026-05-29 19:34:56 +08:00
|
|
|
return [payload]
|
|
|
|
|
raise AssertionError((method, table, kwargs))
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(service, "_rest", fake_rest)
|
2026-05-30 18:04:36 +08:00
|
|
|
monkeypatch.setattr(
|
|
|
|
|
service,
|
|
|
|
|
"_grant_referral_points",
|
|
|
|
|
lambda referrer_user_id, points: point_grants.append(
|
|
|
|
|
{"referrer_user_id": referrer_user_id, "points": points}
|
|
|
|
|
)
|
|
|
|
|
or {"ok": True, "points_added": points, "points_after": points},
|
|
|
|
|
raising=False,
|
|
|
|
|
)
|
2026-05-29 19:34:56 +08:00
|
|
|
|
|
|
|
|
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
|
2026-05-30 18:04:36 +08:00
|
|
|
assert result["reward_points"] == 3500
|
|
|
|
|
assert not any(call["table"] == "subscriptions" for call in writes)
|
|
|
|
|
assert point_grants == [{"referrer_user_id": referrer_id, "points": 3500}]
|
2026-05-29 19:34:56 +08:00
|
|
|
assert any(
|
|
|
|
|
call["table"] == "entitlement_events"
|
|
|
|
|
and call["payload"]["action"] == "referral_reward_granted"
|
2026-05-30 18:04:36 +08:00
|
|
|
and call["payload"]["payload"]["reward_points"] == 3500
|
2026-05-29 19:34:56 +08:00
|
|
|
for call in writes
|
|
|
|
|
)
|