Handle future subscriptions and trial overlap correctly

This commit is contained in:
2569718930@qq.com
2026-04-07 09:54:21 +08:00
parent 420aa32a39
commit eab6ec7cff
5 changed files with 236 additions and 48 deletions
+72 -32
View File
@@ -6,7 +6,6 @@ import json
import os import os
import sys import sys
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Dict
from dotenv import load_dotenv from dotenv import load_dotenv
@@ -93,49 +92,89 @@ def main() -> int:
"GET", "GET",
"subscriptions", "subscriptions",
params={ params={
"select": "id,expires_at,status,plan_code,starts_at", "select": "id,expires_at,status,plan_code,starts_at,source,created_at",
"user_id": f"eq.{user_id}", "user_id": f"eq.{user_id}",
"status": "eq.active", "status": "eq.active",
"order": "expires_at.desc", "order": "expires_at.desc",
"limit": "1", "limit": "20",
}, },
allowed_status=[200], allowed_status=[200],
) )
before = latest_rows[0] if isinstance(latest_rows, list) and latest_rows else None
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
before = None
upcoming = None
if isinstance(latest_rows, list):
for row in latest_rows:
if not isinstance(row, dict):
continue
starts_raw = str(row.get("starts_at") or "").strip()
starts_dt = None
if starts_raw:
try:
starts_dt = datetime.fromisoformat(starts_raw.replace("Z", "+00:00"))
if starts_dt.tzinfo is None:
starts_dt = starts_dt.replace(tzinfo=timezone.utc)
starts_dt = starts_dt.astimezone(timezone.utc)
except Exception:
starts_dt = None
if starts_dt is None or starts_dt <= now:
if before is None:
before = row
elif upcoming is None and str(row.get("plan_code") or "").strip().lower() == plan_code.lower():
upcoming = row
starts_at = now starts_at = now
if isinstance(before, dict): if isinstance(before, dict):
expires_raw = str(before.get("expires_at") or "").strip() before_plan_code = str(before.get("plan_code") or "").strip().lower()
if expires_raw: before_source = str(before.get("source") or "").strip().lower()
try: before_is_trial = "trial" in before_plan_code or "trial" in before_source
latest_exp = datetime.fromisoformat(expires_raw.replace("Z", "+00:00")) if not before_is_trial:
if latest_exp.tzinfo is None: expires_raw = str(before.get("expires_at") or "").strip()
latest_exp = latest_exp.replace(tzinfo=timezone.utc) if expires_raw:
latest_exp = latest_exp.astimezone(timezone.utc) try:
if latest_exp > starts_at: latest_exp = datetime.fromisoformat(expires_raw.replace("Z", "+00:00"))
starts_at = latest_exp if latest_exp.tzinfo is None:
except Exception: latest_exp = latest_exp.replace(tzinfo=timezone.utc)
pass latest_exp = latest_exp.astimezone(timezone.utc)
if latest_exp > starts_at:
starts_at = latest_exp
except Exception:
pass
expires_at = starts_at + timedelta(days=days) expires_at = starts_at + timedelta(days=days)
created = PAYMENT_CHECKOUT._rest( # noqa: SLF001 if isinstance(upcoming, dict) and str(upcoming.get("id") or "").strip():
"POST", updated = PAYMENT_CHECKOUT._rest( # noqa: SLF001
"subscriptions", "PATCH",
payload={ "subscriptions",
"user_id": user_id, params={"id": f"eq.{upcoming['id']}"},
"plan_code": plan_code, payload={
"status": "active", "starts_at": starts_at.isoformat(),
"starts_at": starts_at.isoformat(), "expires_at": expires_at.isoformat(),
"expires_at": expires_at.isoformat(), "updated_at": now.isoformat(),
"source": actor, },
"created_at": now.isoformat(), prefer="return=representation",
"updated_at": now.isoformat(), allowed_status=[200],
}, )
prefer="return=representation", subscription = updated[0] if isinstance(updated, list) and updated else {}
allowed_status=[201], else:
) created = PAYMENT_CHECKOUT._rest( # noqa: SLF001
subscription = created[0] if isinstance(created, list) and created else {} "POST",
"subscriptions",
payload={
"user_id": user_id,
"plan_code": plan_code,
"status": "active",
"starts_at": starts_at.isoformat(),
"expires_at": expires_at.isoformat(),
"source": actor,
"created_at": now.isoformat(),
"updated_at": now.isoformat(),
},
prefer="return=representation",
allowed_status=[201],
)
subscription = created[0] if isinstance(created, list) and created else {}
PAYMENT_CHECKOUT._rest( # noqa: SLF001 PAYMENT_CHECKOUT._rest( # noqa: SLF001
"POST", "POST",
@@ -151,6 +190,7 @@ def main() -> int:
"days": days, "days": days,
"starts_at": starts_at.isoformat(), "starts_at": starts_at.isoformat(),
"expires_at": expires_at.isoformat(), "expires_at": expires_at.isoformat(),
"mode": "updated_upcoming" if isinstance(upcoming, dict) else "created_new",
}, },
"created_at": now.isoformat(), "created_at": now.isoformat(),
}, },
+39 -7
View File
@@ -195,14 +195,15 @@ class SupabaseEntitlementService:
return None return None
try: try:
now_iso = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc)
now_iso = now.isoformat()
params = { params = {
"select": "id,user_id,status,plan_code,starts_at,expires_at", "select": "id,user_id,status,plan_code,starts_at,expires_at",
"user_id": f"eq.{user_id}", "user_id": f"eq.{user_id}",
"status": "eq.active", "status": "eq.active",
"expires_at": f"gt.{now_iso}", "expires_at": f"gt.{now_iso}",
"order": "expires_at.desc", "order": "expires_at.desc",
"limit": "1", "limit": "20",
} }
response = requests.get( response = requests.get(
self._subscription_endpoint(), self._subscription_endpoint(),
@@ -219,9 +220,7 @@ class SupabaseEntitlementService:
row = None row = None
else: else:
data = response.json() if response.content else [] data = response.json() if response.content else []
row = data[0] if isinstance(data, list) and data else None row = self._pick_latest_current_subscription(data, now=now)
if not isinstance(row, dict):
row = None
with self._sub_cache_lock: with self._sub_cache_lock:
self._sub_cache[user_id] = { self._sub_cache[user_id] = {
@@ -280,6 +279,34 @@ class SupabaseEntitlementService:
parsed = parsed.replace(tzinfo=timezone.utc) parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc) return parsed.astimezone(timezone.utc)
def _is_subscription_started(
self,
row: Optional[Dict[str, object]],
*,
now: Optional[datetime] = None,
) -> bool:
if not isinstance(row, dict):
return False
starts_at = self._parse_iso_datetime(str(row.get("starts_at") or ""))
if starts_at is None:
return True
current = now or datetime.now(timezone.utc)
return starts_at <= current
def _pick_latest_current_subscription(
self,
rows: object,
*,
now: Optional[datetime] = None,
) -> Optional[Dict[str, object]]:
if not isinstance(rows, list):
return None
current = now or datetime.now(timezone.utc)
for row in rows:
if isinstance(row, dict) and self._is_subscription_started(row, now=current):
return row
return None
def _get_trial_lock(self, user_id: str) -> threading.Lock: def _get_trial_lock(self, user_id: str) -> threading.Lock:
key = str(user_id or "").strip() key = str(user_id or "").strip()
with self._trial_locks_guard: with self._trial_locks_guard:
@@ -440,8 +467,9 @@ class SupabaseEntitlementService:
logger.warning("SUPABASE_SERVICE_ROLE_KEY is missing") logger.warning("SUPABASE_SERVICE_ROLE_KEY is missing")
return [] return []
try: try:
now = datetime.now(timezone.utc)
safe_limit = max(1, min(int(limit or 200), 1000)) safe_limit = max(1, min(int(limit or 200), 1000))
now_iso = datetime.now(timezone.utc).isoformat() now_iso = now.isoformat()
params = { params = {
"select": "id,user_id,status,plan_code,starts_at,expires_at", "select": "id,user_id,status,plan_code,starts_at,expires_at",
"status": "eq.active", "status": "eq.active",
@@ -464,7 +492,11 @@ class SupabaseEntitlementService:
data = response.json() if response.content else [] data = response.json() if response.content else []
if not isinstance(data, list): if not isinstance(data, list):
return [] return []
return [row for row in data if isinstance(row, dict)] return [
row
for row in data
if isinstance(row, dict) and self._is_subscription_started(row, now=now)
]
except Exception as exc: except Exception as exc:
logger.warning(f"supabase active subscriptions query error: {exc}") logger.warning(f"supabase active subscriptions query error: {exc}")
return [] return []
+35 -9
View File
@@ -1712,22 +1712,48 @@ class PaymentContractCheckoutService:
"GET", "GET",
"subscriptions", "subscriptions",
params={ params={
"select": "id,expires_at,status", "select": "id,expires_at,status,plan_code,source,starts_at",
"user_id": f"eq.{user_id}", "user_id": f"eq.{user_id}",
"status": "eq.active", "status": "eq.active",
"order": "expires_at.desc", "order": "expires_at.desc",
"limit": "1", "limit": "20",
}, },
allowed_status=[200], allowed_status=[200],
) )
starts = now starts = now
if isinstance(latest_rows, list) and latest_rows: current_subscription = None
try: if isinstance(latest_rows, list):
latest_exp = datetime.fromisoformat(str(latest_rows[0].get("expires_at"))) for row in latest_rows:
if latest_exp > starts: if not isinstance(row, dict):
starts = latest_exp continue
except Exception: try:
pass 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):
current_plan_code = str(current_subscription.get("plan_code") or "").strip().lower()
current_source = str(current_subscription.get("source") or "").strip().lower()
current_is_trial = "trial" in current_plan_code or "trial" in current_source
if not current_is_trial:
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)) expires = starts + timedelta(days=max(1, duration_days))
sub_rows = self._rest( sub_rows = self._rest(
"POST", "POST",
+54
View File
@@ -1,3 +1,5 @@
from datetime import datetime, timedelta, timezone
from src.database.db_manager import DBManager from src.database.db_manager import DBManager
from src.payments.contract_checkout import ( from src.payments.contract_checkout import (
PaymentCheckoutError, PaymentCheckoutError,
@@ -280,3 +282,55 @@ def test_reconcile_recent_intents_dedupes_users(monkeypatch, tmp_path):
assert result["processed_users"] == 2 assert result["processed_users"] == 2
assert result["repaired_users"] == 2 assert result["repaired_users"] == 2
assert seen == ["user-1", "user-2"] assert seen == ["user-1", "user-2"]
def test_grant_subscription_starts_immediately_when_only_trial_is_active(monkeypatch, tmp_path):
monkeypatch.setenv("POLYWEATHER_PAYMENT_ENABLED", "true")
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
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_e","address":"0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174","decimals":6,"receiver_contract":"0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32","is_default":true}]',
)
monkeypatch.setenv("POLYWEATHER_DB_PATH", str(tmp_path / "payments.db"))
service = PaymentContractCheckoutService()
trial_end = datetime.now(timezone.utc) + timedelta(days=2)
captured_post = {}
def _fake_rest(method, table, **kwargs):
if method == "GET" and table == "subscriptions":
return [
{
"id": 1,
"status": "active",
"plan_code": "signup_trial_3d",
"source": "signup_trial",
"starts_at": (datetime.now(timezone.utc) - timedelta(days=1)).isoformat(),
"expires_at": trial_end.isoformat(),
}
]
if method == "POST" and table == "subscriptions":
captured_post.update(kwargs.get("payload") or {})
return [captured_post]
if method == "POST" and table == "entitlement_events":
return [{"ok": True}]
return []
monkeypatch.setattr(service, "_rest", _fake_rest)
before_call = datetime.now(timezone.utc)
result = service._grant_subscription(
user_id="user-1",
plan_code="pro_monthly",
duration_days=30,
tx_hash="0x" + "1" * 64,
payload={"kind": "test"},
)
after_call = datetime.now(timezone.utc)
starts_at = datetime.fromisoformat(str(result["starts_at"]).replace("Z", "+00:00"))
assert starts_at >= before_call - timedelta(seconds=1)
assert starts_at <= after_call
+36
View File
@@ -64,3 +64,39 @@ def test_ensure_signup_trial_grants_three_day_subscription(monkeypatch):
) )
assert subscription_insert["json"]["user_id"] == "user-1" assert subscription_insert["json"]["user_id"] == "user-1"
assert subscription_insert["json"]["source"] == "signup_trial" assert subscription_insert["json"]["source"] == "signup_trial"
def test_latest_active_subscription_ignores_future_start(monkeypatch):
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key")
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
service = SupabaseEntitlementService()
now = datetime.now(timezone.utc)
current_trial = {
"id": 1,
"user_id": "user-1",
"status": "active",
"plan_code": "signup_trial_3d",
"starts_at": (now - timedelta(days=1)).isoformat(),
"expires_at": (now + timedelta(days=2)).isoformat(),
}
future_paid = {
"id": 2,
"user_id": "user-1",
"status": "active",
"plan_code": "pro_monthly",
"starts_at": (now + timedelta(days=2)).isoformat(),
"expires_at": (now + timedelta(days=32)).isoformat(),
}
def _fake_get(url, headers=None, params=None, timeout=None):
return _Response(200, [future_paid, current_trial])
monkeypatch.setattr(entitlement_module.requests, "get", _fake_get)
result = service._query_latest_active_subscription("user-1")
assert result is not None
assert result["plan_code"] == "signup_trial_3d"