From eab6ec7cffe701503937bf4997462f6f437b7dfc Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Tue, 7 Apr 2026 09:54:21 +0800 Subject: [PATCH] Handle future subscriptions and trial overlap correctly --- scripts/grant_subscription_by_email.py | 104 +++++++++++++++++-------- src/auth/supabase_entitlement.py | 46 +++++++++-- src/payments/contract_checkout.py | 44 ++++++++--- tests/test_payments_runtime.py | 54 +++++++++++++ tests/test_supabase_entitlement.py | 36 +++++++++ 5 files changed, 236 insertions(+), 48 deletions(-) diff --git a/scripts/grant_subscription_by_email.py b/scripts/grant_subscription_by_email.py index b86f8bcc..4467cd4d 100644 --- a/scripts/grant_subscription_by_email.py +++ b/scripts/grant_subscription_by_email.py @@ -6,7 +6,6 @@ import json import os import sys from datetime import datetime, timedelta, timezone -from typing import Any, Dict from dotenv import load_dotenv @@ -93,49 +92,89 @@ def main() -> int: "GET", "subscriptions", 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}", "status": "eq.active", "order": "expires_at.desc", - "limit": "1", + "limit": "20", }, allowed_status=[200], ) - before = latest_rows[0] if isinstance(latest_rows, list) and latest_rows else None 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 if isinstance(before, dict): - expires_raw = str(before.get("expires_at") or "").strip() - if expires_raw: - try: - latest_exp = datetime.fromisoformat(expires_raw.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_at: - starts_at = latest_exp - except Exception: - pass + before_plan_code = str(before.get("plan_code") or "").strip().lower() + before_source = str(before.get("source") or "").strip().lower() + before_is_trial = "trial" in before_plan_code or "trial" in before_source + if not before_is_trial: + expires_raw = str(before.get("expires_at") or "").strip() + if expires_raw: + try: + latest_exp = datetime.fromisoformat(expires_raw.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_at: + starts_at = latest_exp + except Exception: + pass expires_at = starts_at + timedelta(days=days) - created = PAYMENT_CHECKOUT._rest( # noqa: SLF001 - "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 {} + if isinstance(upcoming, dict) and str(upcoming.get("id") or "").strip(): + updated = PAYMENT_CHECKOUT._rest( # noqa: SLF001 + "PATCH", + "subscriptions", + params={"id": f"eq.{upcoming['id']}"}, + payload={ + "starts_at": starts_at.isoformat(), + "expires_at": expires_at.isoformat(), + "updated_at": now.isoformat(), + }, + prefer="return=representation", + allowed_status=[200], + ) + subscription = updated[0] if isinstance(updated, list) and updated else {} + else: + created = PAYMENT_CHECKOUT._rest( # noqa: SLF001 + "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 "POST", @@ -151,6 +190,7 @@ def main() -> int: "days": days, "starts_at": starts_at.isoformat(), "expires_at": expires_at.isoformat(), + "mode": "updated_upcoming" if isinstance(upcoming, dict) else "created_new", }, "created_at": now.isoformat(), }, diff --git a/src/auth/supabase_entitlement.py b/src/auth/supabase_entitlement.py index 9bd127a4..e7db2be5 100644 --- a/src/auth/supabase_entitlement.py +++ b/src/auth/supabase_entitlement.py @@ -195,14 +195,15 @@ class SupabaseEntitlementService: return None try: - now_iso = datetime.now(timezone.utc).isoformat() + now = datetime.now(timezone.utc) + now_iso = now.isoformat() params = { "select": "id,user_id,status,plan_code,starts_at,expires_at", "user_id": f"eq.{user_id}", "status": "eq.active", "expires_at": f"gt.{now_iso}", "order": "expires_at.desc", - "limit": "1", + "limit": "20", } response = requests.get( self._subscription_endpoint(), @@ -219,9 +220,7 @@ class SupabaseEntitlementService: row = None else: data = response.json() if response.content else [] - row = data[0] if isinstance(data, list) and data else None - if not isinstance(row, dict): - row = None + row = self._pick_latest_current_subscription(data, now=now) with self._sub_cache_lock: self._sub_cache[user_id] = { @@ -280,6 +279,34 @@ class SupabaseEntitlementService: parsed = parsed.replace(tzinfo=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: key = str(user_id or "").strip() with self._trial_locks_guard: @@ -440,8 +467,9 @@ class SupabaseEntitlementService: logger.warning("SUPABASE_SERVICE_ROLE_KEY is missing") return [] try: + now = datetime.now(timezone.utc) safe_limit = max(1, min(int(limit or 200), 1000)) - now_iso = datetime.now(timezone.utc).isoformat() + now_iso = now.isoformat() params = { "select": "id,user_id,status,plan_code,starts_at,expires_at", "status": "eq.active", @@ -464,7 +492,11 @@ class SupabaseEntitlementService: data = response.json() if response.content else [] if not isinstance(data, list): 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: logger.warning(f"supabase active subscriptions query error: {exc}") return [] diff --git a/src/payments/contract_checkout.py b/src/payments/contract_checkout.py index 627edbc3..dc2401c1 100644 --- a/src/payments/contract_checkout.py +++ b/src/payments/contract_checkout.py @@ -1712,22 +1712,48 @@ class PaymentContractCheckoutService: "GET", "subscriptions", params={ - "select": "id,expires_at,status", + "select": "id,expires_at,status,plan_code,source,starts_at", "user_id": f"eq.{user_id}", "status": "eq.active", "order": "expires_at.desc", - "limit": "1", + "limit": "20", }, allowed_status=[200], ) starts = now - if isinstance(latest_rows, list) and latest_rows: - try: - latest_exp = datetime.fromisoformat(str(latest_rows[0].get("expires_at"))) - if latest_exp > starts: - starts = latest_exp - except Exception: - pass + current_subscription = None + if isinstance(latest_rows, list): + for row in latest_rows: + if not isinstance(row, dict): + continue + try: + starts_at = datetime.fromisoformat( + str(row.get("starts_at") or "").replace("Z", "+00:00") + ) + if starts_at.tzinfo is None: + starts_at = starts_at.replace(tzinfo=timezone.utc) + starts_at = starts_at.astimezone(timezone.utc) + except Exception: + starts_at = None + if starts_at is None or starts_at <= now: + current_subscription = row + break + if isinstance(current_subscription, dict): + 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)) sub_rows = self._rest( "POST", diff --git a/tests/test_payments_runtime.py b/tests/test_payments_runtime.py index 93ce7ccc..a1c6f125 100644 --- a/tests/test_payments_runtime.py +++ b/tests/test_payments_runtime.py @@ -1,3 +1,5 @@ +from datetime import datetime, timedelta, timezone + from src.database.db_manager import DBManager from src.payments.contract_checkout import ( PaymentCheckoutError, @@ -280,3 +282,55 @@ def test_reconcile_recent_intents_dedupes_users(monkeypatch, tmp_path): assert result["processed_users"] == 2 assert result["repaired_users"] == 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 diff --git a/tests/test_supabase_entitlement.py b/tests/test_supabase_entitlement.py index 259db46b..145e16d3 100644 --- a/tests/test_supabase_entitlement.py +++ b/tests/test_supabase_entitlement.py @@ -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"]["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"