From 82d60cf05ffdc163e092a2620550094b8d8b400f Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Tue, 19 May 2026 18:26:45 +0800 Subject: [PATCH] feat: implement Telegram-to-Web account binding flow and add supporting database and UI components --- frontend/components/account/AccountCenter.tsx | 40 +++-- src/bot/handlers/basic.py | 127 ++++++++++++++-- src/database/db_manager.py | 31 ++++ src/payments/contract_checkout.py | 34 +++-- tests/test_bot_basic_handler.py | 138 +++++++++++++++++- tests/test_payments_runtime.py | 108 +++++++++++++- 6 files changed, 423 insertions(+), 55 deletions(-) diff --git a/frontend/components/account/AccountCenter.tsx b/frontend/components/account/AccountCenter.tsx index 67b811c9..e264ce04 100644 --- a/frontend/components/account/AccountCenter.tsx +++ b/frontend/components/account/AccountCenter.tsx @@ -727,23 +727,23 @@ export function AccountCenter() { restricted: isEn ? "Restricted" : "受限", telegramBind: isEn ? "Telegram Bot Binding" : "Telegram Bot 绑定", telegramHint: isEn - ? "Use one-click Bot binding first to sync notifications and access. Telegram group access is reviewed automatically for Pro users." - : "优先使用「一键绑定机器人」同步通知与权限。Telegram 群组会根据 Pro 状态自动审核入群申请。", + ? "Use one-click Telegram binding first to sync notifications and access. After binding, refresh this page and submit your Telegram group join request." + : "优先使用「一键绑定 Telegram Bot」同步通知与权限。绑定完成后刷新本页,再提交 Telegram 群组入群申请。", telegramFallbackHint: isEn - ? "Fallback: if one-click binding does not open Telegram correctly, copy the command below and send it to @WeatherQuant_bot." - : "备用复制方式:如果一键绑定无法正常打开 Telegram,请复制下方命令并发送给 @WeatherQuant_bot。", + ? "Fallback copy method: only use this if one-click binding does not open Telegram correctly. Copy the command below and send it to @WeatherQuant_bot. After binding, refresh this page to show the group entry." + : "兜底复制方式:仅在一键绑定无法正常打开 Telegram 时使用。请复制下方命令并发送给 @WeatherQuant_bot。绑定完成后刷新本页,即可显示入群入口。", paymentManualSupport: isEn ? "If payment succeeds but Pro is still not activated, email yhrsc30@gmail.com. This project is currently maintained by one developer, so manual recovery may be needed in edge cases." : "如果付款成功后 Pro 仍未开通,请发邮件到 yhrsc30@gmail.com。当前项目由我一人维护,极少数边缘情况可能需要人工补开。给你带来的不便,敬请谅解!", telegramBotLink: isEn ? "Open Bot (@WeatherQuant_bot)" : "打开机器人 (@WeatherQuant_bot)", - telegramBotBindLink: isEn ? "One-click Bot Binding" : "一键绑定机器人", + telegramBotBindLink: isEn ? "One-click Telegram Binding" : "一键绑定 Telegram Bot", telegramGroupLink: isEn ? "Join Telegram Group" : "加入 Telegram 群组", telegramTopicsGroupLink: isEn ? "Real-time Weather Updates" : "城市实测温度群", - copyCommand: isEn ? "Copy fallback command" : "复制备用命令", + copyCommand: isEn ? "Copy fallback command" : "复制兜底命令", paymentMgmt: isEn ? "Payment Management" : "支付管理", paymentToken: isEn ? "Payment Token" : "支付币种", paymentAccount: isEn ? "Subscription Account" : "订阅归属账号", @@ -1545,6 +1545,10 @@ export function AccountCenter() { Number(backend?.subscription_queued_days || 0), ); const hasQueuedExtension = Boolean(isSubscribed && queuedExtensionDays > 0); + const canAccessPaidTelegramGroup = Boolean( + isSubscribed && (!isTrialPlan || hasQueuedExtension), + ); + const telegramBound = Number(backend?.telegram_pricing?.telegram_id || 0) > 0; const displayExpiryRaw = isSubscribed ? totalExpiryRaw : currentExpiryRaw; const reminderExpiryRaw = isSubscribed ? totalExpiryRaw @@ -1813,7 +1817,9 @@ export function AccountCenter() { const botUrl = String(data.bot_url || "").trim(); if (!botUrl) throw new Error("telegram bind link missing"); window.open(botUrl, "_blank", "noopener,noreferrer"); - setPaymentInfo("已打开 Telegram Bot,请在 Bot 内点击 Start 完成绑定。"); + setPaymentInfo( + "已打开 Telegram Bot,请在 Bot 内点击 Start 并确认绑定;完成后刷新本页再申请入群。", + ); } catch (error) { setPaymentError(normalizePaymentError(error).message); } finally { @@ -2988,14 +2994,14 @@ export function AccountCenter() { chainId={paymentConfig?.chain_id || 137} paymentTokenLabel={selectedTokenLabel} faqHref={SUBSCRIPTION_HELP_HREF} - telegramGroupUrl={TELEGRAM_GROUP_URL} + telegramGroupUrl="" /> )} {/* Telegram Bot Section — paid users only */} - {showSecondarySections && isSubscribed ? ( + {showSecondarySections && canAccessPaidTelegramGroup ? (
{html.escape(self.io_layer.display_name(message.from_user))}\n"
+ f"网站账号: {html.escape(masked_email or supabase_user_id)}\n\n"
+ "确认后,入群申请将按此网站账号的 Pro 状态自动审核。"
+ )
+ reply_markup = self._build_confirm_bind_markup(token)
+ self.bot.reply_to(message, text, parse_mode="HTML", reply_markup=reply_markup)
+ return "confirm_prompted"
+
+ def handle_bind_confirm_callback(self, call: Any) -> str:
+ data = str(getattr(call, "data", "") or "")
+ token = data[len("confirm_bind:") :].strip() if data.startswith("confirm_bind:") else ""
+ message = getattr(call, "message", None)
+ user = getattr(call, "from_user", None)
+ if hasattr(self.bot, "answer_callback_query"):
+ try:
+ self.bot.answer_callback_query(getattr(call, "id", None))
+ except Exception:
+ pass
+ if message is None or user is None:
+ logger.warning("telegram bind confirm callback missing message/user")
+ return "invalid_callback"
+ return self._bind_from_web_token(message, user, token)
+
+ def _bind_from_web_token(self, message: Any, user: Any, token: str) -> str:
if not token:
self.bot.reply_to(message, "❌ 绑定链接无效,请回到网页重新点击一键绑定。")
return "invalid_token"
- user = message.from_user
try:
payload = self.io_layer.db.consume_web_bind_token(token)
except Exception as exc:
@@ -160,11 +212,37 @@ class BasicCommandHandler:
message,
(
"✅ 账号绑定完成。\n"
- "现在可以回到网页点击“加入 Telegram 群组”,入群申请会自动审核。"
+ "现在可以回到网页刷新本页,再点击“加入 Telegram 群组”。入群申请会自动审核。"
),
)
return "bound"
+ @staticmethod
+ def _mask_email(email: str) -> str:
+ email = str(email or "").strip()
+ if "@" not in email:
+ return email
+ name, domain = email.split("@", 1)
+ if not name:
+ return f"***@{domain}"
+ return f"{name[0]}***@{domain}"
+
+ @staticmethod
+ def _build_confirm_bind_markup(token: str) -> Any:
+ try:
+ from telebot import types # type: ignore
+
+ markup = types.InlineKeyboardMarkup()
+ markup.add(
+ types.InlineKeyboardButton(
+ "确认绑定",
+ callback_data=f"confirm_bind:{token}",
+ )
+ )
+ return markup
+ except Exception:
+ return None
+
def handle_id(self, message: Any) -> None:
trace = CommandTrace("/id", message)
try:
@@ -344,10 +422,7 @@ class BasicCommandHandler:
for supabase_user_id in supabase_user_ids:
try:
- if self.entitlement_service.has_active_subscription(
- supabase_user_id,
- respect_requirement=False,
- ):
+ if self._has_paid_subscription(supabase_user_id):
self.bot.approve_chat_join_request(int(chat_id), int(user_id))
logger.info(
"telegram join request approved chat_id={} user_id={} supabase_user_id={}",
@@ -371,6 +446,40 @@ class BasicCommandHandler:
reason="no_active_subscription",
)
+ def _has_paid_subscription(self, supabase_user_id: str) -> bool:
+ if hasattr(self.entitlement_service, "get_subscription_window"):
+ window = self.entitlement_service.get_subscription_window(
+ supabase_user_id,
+ respect_requirement=False,
+ )
+ rows = window.get("rows") if isinstance(window, dict) else None
+ if isinstance(rows, list):
+ for row in rows:
+ if self._subscription_row_is_paid(row):
+ return True
+ if hasattr(self.entitlement_service, "get_latest_active_subscription"):
+ row = self.entitlement_service.get_latest_active_subscription(
+ supabase_user_id,
+ respect_requirement=False,
+ )
+ return self._subscription_row_is_paid(row)
+ return bool(
+ self.entitlement_service.has_active_subscription(
+ supabase_user_id,
+ respect_requirement=False,
+ )
+ )
+
+ @staticmethod
+ def _subscription_row_is_paid(row: Any) -> 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()
+ if not plan_code and not source:
+ return False
+ return "trial" not in plan_code and "trial" not in source
+
def _handle_ineligible_join_request(self, chat_id: int, user_id: int, reason: str) -> str:
action = str(os.getenv("POLYWEATHER_TELEGRAM_JOIN_INELIGIBLE_ACTION") or "pending").strip().lower()
if action in {"decline", "reject", "deny"}:
diff --git a/src/database/db_manager.py b/src/database/db_manager.py
index 41c2d7db..f81ac685 100644
--- a/src/database/db_manager.py
+++ b/src/database/db_manager.py
@@ -1464,6 +1464,37 @@ class DBManager:
conn.commit()
return int(row["telegram_id"])
+ def peek_web_bind_token(self, token: str) -> Optional[Dict[str, str]]:
+ token = str(token or "").strip()
+ if not token:
+ return None
+ now = datetime.now()
+ with self._get_connection() as conn:
+ conn.row_factory = sqlite3.Row
+ row = conn.execute(
+ """
+ SELECT supabase_user_id, supabase_email, expires_at
+ FROM web_telegram_bind_tokens
+ WHERE token = ?
+ LIMIT 1
+ """,
+ (token,),
+ ).fetchone()
+ if not row:
+ return None
+ try:
+ expires_at = datetime.fromisoformat(row["expires_at"])
+ except Exception:
+ expires_at = now
+ if now > expires_at:
+ conn.execute("DELETE FROM web_telegram_bind_tokens WHERE token = ?", (token,))
+ conn.commit()
+ return None
+ return {
+ "supabase_user_id": str(row["supabase_user_id"] or "").strip().lower(),
+ "supabase_email": str(row["supabase_email"] or "").strip(),
+ }
+
def create_web_bind_token(
self,
supabase_user_id: str,
diff --git a/src/payments/contract_checkout.py b/src/payments/contract_checkout.py
index 0c553c17..ba7a89c3 100644
--- a/src/payments/contract_checkout.py
+++ b/src/payments/contract_checkout.py
@@ -1927,19 +1927,17 @@ class PaymentContractCheckoutService:
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
+ 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",
@@ -1985,7 +1983,9 @@ class PaymentContractCheckoutService:
user_id,
respect_requirement=False,
)
- if isinstance(latest_subscription, dict):
+ if isinstance(latest_subscription, dict) and not self._subscription_row_is_trial(
+ latest_subscription
+ ):
return latest_subscription
plan = self._select_plan(intent.plan_code)
@@ -2001,6 +2001,12 @@ class PaymentContractCheckoutService:
},
)
+ @staticmethod
+ def _subscription_row_is_trial(row: Dict[str, Any]) -> bool:
+ 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
+
def _ensure_confirm_side_effects(
self,
user_id: str,
diff --git a/tests/test_bot_basic_handler.py b/tests/test_bot_basic_handler.py
index 824b821a..c04841e2 100644
--- a/tests/test_bot_basic_handler.py
+++ b/tests/test_bot_basic_handler.py
@@ -10,14 +10,16 @@ class DummyBot:
self.sent_messages = []
self.approved_join_requests = []
self.declined_join_requests = []
+ self.callback_handlers = []
- def reply_to(self, message, text, parse_mode=None, disable_web_page_preview=None):
+ def reply_to(self, message, text, parse_mode=None, disable_web_page_preview=None, **kwargs):
self.replies.append(
{
"text": text,
"parse_mode": parse_mode,
"chat_id": message.chat.id,
"disable_web_page_preview": disable_web_page_preview,
+ "reply_markup": kwargs.get("reply_markup"),
}
)
@@ -50,6 +52,13 @@ class DummyBot:
return _decorator
+ def callback_query_handler(self, *args, **kwargs): # pragma: no cover - decorator stub
+ def _decorator(func):
+ self.callback_handlers.append((kwargs.get("func"), func))
+ return func
+
+ return _decorator
+
def approve_chat_join_request(self, chat_id, user_id):
self.approved_join_requests.append({"chat_id": chat_id, "user_id": user_id})
@@ -94,14 +103,12 @@ def test_basic_handler_diag_returns_html():
def test_start_bind_token_binds_telegram_to_web_account():
bot = DummyBot()
db = SimpleNamespace(
- consume_web_bind_token=lambda token: {
+ peek_web_bind_token=lambda token: {
"supabase_user_id": "user-1",
"supabase_email": "u@example.com",
}
if token == "abc123"
else None,
- upsert_user=lambda *_args, **_kwargs: None,
- bind_supabase_identity=lambda **_kwargs: {"ok": True, "reason": "bound"},
)
io_layer = SimpleNamespace(
build_welcome_text=lambda: "WELCOME",
@@ -124,7 +131,54 @@ def test_start_bind_token_binds_telegram_to_web_account():
handler.handle_start_help(_message("/start bind_abc123"))
assert len(bot.replies) == 1
- assert "账号绑定完成" in bot.replies[0]["text"]
+ assert "确认绑定" in bot.replies[0]["text"]
+ assert "u***@example.com" in bot.replies[0]["text"]
+
+
+def test_confirm_bind_callback_consumes_token_and_binds_account():
+ bot = DummyBot()
+ consumed = []
+ bound = []
+
+ def _consume(token):
+ consumed.append(token)
+ return {"supabase_user_id": "user-1", "supabase_email": "u@example.com"}
+
+ db = SimpleNamespace(
+ consume_web_bind_token=_consume,
+ upsert_user=lambda *_args, **_kwargs: None,
+ bind_supabase_identity=lambda **kwargs: bound.append(kwargs)
+ or {"ok": True, "reason": "bound"},
+ )
+ io_layer = SimpleNamespace(
+ build_welcome_text=lambda: "WELCOME",
+ build_points_rank_text=lambda _user: "TOP",
+ display_name=lambda user: user.username,
+ db=db,
+ )
+ handler = BasicCommandHandler(
+ bot=bot,
+ io_layer=io_layer,
+ runtime_status_provider=lambda: RuntimeStatus(
+ started_at="2026-03-12 00:00:00 UTC",
+ loops=[],
+ command_access_mode="group_member",
+ protected_commands=["/city", "/deb"],
+ required_group_chat_id="-1001234567890",
+ ),
+ )
+ call = SimpleNamespace(
+ data="confirm_bind:abc123",
+ from_user=SimpleNamespace(id=12345, username="ada", first_name="Ada"),
+ message=_message("callback"),
+ )
+
+ result = handler.handle_bind_confirm_callback(call)
+
+ assert result == "bound"
+ assert consumed == ["abc123"]
+ assert bound[0]["telegram_id"] == 12345
+ assert bound[0]["supabase_user_id"] == "user-1"
def test_basic_handler_markets_returns_summary():
@@ -253,6 +307,80 @@ def test_join_request_keeps_unbound_user_pending_by_default(monkeypatch):
assert bot.declined_join_requests == []
+def test_join_request_keeps_trial_user_pending(monkeypatch):
+ monkeypatch.setenv("POLYWEATHER_TELEGRAM_GROUP_ID", "-100123")
+ bot = DummyBot()
+ db = SimpleNamespace(list_supabase_user_ids_for_telegram=lambda telegram_id: ["user-1"])
+ io_layer = SimpleNamespace(
+ build_welcome_text=lambda: "WELCOME",
+ build_points_rank_text=lambda _user: "TOP",
+ db=db,
+ )
+ entitlement = SimpleNamespace(
+ get_latest_active_subscription=lambda user_id, respect_requirement=False: {
+ "plan_code": "signup_trial_3d",
+ "source": "signup_trial",
+ }
+ )
+ handler = BasicCommandHandler(
+ bot=bot,
+ io_layer=io_layer,
+ runtime_status_provider=lambda: RuntimeStatus(
+ started_at="2026-03-12 00:00:00 UTC",
+ loops=[],
+ command_access_mode="group_member",
+ protected_commands=["/city", "/deb"],
+ required_group_chat_id="-100123",
+ ),
+ entitlement_service=entitlement,
+ )
+
+ result = handler.handle_chat_join_request(_join_request())
+
+ assert result == "pending:no_active_subscription"
+ assert bot.approved_join_requests == []
+
+
+def test_join_request_approves_trial_user_with_queued_paid_subscription(monkeypatch):
+ monkeypatch.setenv("POLYWEATHER_TELEGRAM_GROUP_ID", "-100123")
+ bot = DummyBot()
+ db = SimpleNamespace(list_supabase_user_ids_for_telegram=lambda telegram_id: ["user-1"])
+ io_layer = SimpleNamespace(
+ build_welcome_text=lambda: "WELCOME",
+ build_points_rank_text=lambda _user: "TOP",
+ db=db,
+ )
+ entitlement = SimpleNamespace(
+ get_subscription_window=lambda user_id, respect_requirement=False: {
+ "rows": [
+ {"plan_code": "signup_trial_3d", "source": "signup_trial"},
+ {"plan_code": "pro_monthly", "source": "payment_contract"},
+ ]
+ },
+ get_latest_active_subscription=lambda user_id, respect_requirement=False: {
+ "plan_code": "signup_trial_3d",
+ "source": "signup_trial",
+ },
+ )
+ handler = BasicCommandHandler(
+ bot=bot,
+ io_layer=io_layer,
+ runtime_status_provider=lambda: RuntimeStatus(
+ started_at="2026-03-12 00:00:00 UTC",
+ loops=[],
+ command_access_mode="group_member",
+ protected_commands=["/city", "/deb"],
+ required_group_chat_id="-100123",
+ ),
+ entitlement_service=entitlement,
+ )
+
+ result = handler.handle_chat_join_request(_join_request())
+
+ assert result == "approved"
+ assert bot.approved_join_requests == [{"chat_id": -100123, "user_id": 12345}]
+
+
def test_join_request_can_decline_ineligible_user_when_configured(monkeypatch):
monkeypatch.setenv("POLYWEATHER_TELEGRAM_GROUP_ID", "-100123")
monkeypatch.setenv("POLYWEATHER_TELEGRAM_JOIN_INELIGIBLE_ACTION", "decline")
diff --git a/tests/test_payments_runtime.py b/tests/test_payments_runtime.py
index a1c6f125..fafb9838 100644
--- a/tests/test_payments_runtime.py
+++ b/tests/test_payments_runtime.py
@@ -8,6 +8,18 @@ from src.payments.contract_checkout import (
)
+def _payment_env(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"))
+
+
def test_payment_runtime_state_and_audit_event_roundtrip(tmp_path):
db_path = tmp_path / "payments.db"
db = DBManager(str(db_path))
@@ -24,6 +36,95 @@ def test_payment_runtime_state_and_audit_event_roundtrip(tmp_path):
assert events[0]["payload"]["events"] == 2
+def test_paid_subscription_starts_after_active_trial(monkeypatch, tmp_path):
+ _payment_env(monkeypatch, tmp_path)
+ service = PaymentContractCheckoutService()
+ now = datetime.now(timezone.utc)
+ trial_expires = now + timedelta(days=2)
+ inserted = []
+
+ def fake_rest(method, table, **kwargs):
+ if method == "GET" and table == "subscriptions":
+ return [
+ {
+ "id": "trial-1",
+ "expires_at": trial_expires.isoformat(),
+ "status": "active",
+ "plan_code": "signup_trial_3d",
+ "source": "signup_trial",
+ "starts_at": (now - timedelta(days=1)).isoformat(),
+ }
+ ]
+ if method == "POST" and table == "subscriptions":
+ inserted.append(kwargs["payload"])
+ return [kwargs["payload"]]
+ if method == "POST" and table == "entitlement_events":
+ return [kwargs["payload"]]
+ raise AssertionError((method, table, kwargs))
+
+ monkeypatch.setattr(service, "_rest", fake_rest)
+
+ row = service._grant_subscription(
+ user_id="user-1",
+ plan_code="pro_monthly",
+ duration_days=30,
+ tx_hash="0x" + "7" * 64,
+ payload={},
+ )
+
+ starts_at = datetime.fromisoformat(str(row["starts_at"]))
+ expires_at = datetime.fromisoformat(str(row["expires_at"]))
+ assert starts_at == trial_expires
+ assert expires_at == trial_expires + timedelta(days=30)
+
+
+def test_confirm_side_effect_repair_does_not_treat_trial_as_paid(monkeypatch, tmp_path):
+ _payment_env(monkeypatch, tmp_path)
+ service = PaymentContractCheckoutService()
+ intent = PaymentIntentRecord(
+ intent_id="intent-trial-repair",
+ order_id_hex="0x" + "1" * 64,
+ plan_code="pro_monthly",
+ plan_id=101,
+ chain_id=137,
+ amount_units=5000000,
+ amount_usdc="5",
+ token_address="0x2791bca1f2de4661ed88a30c99a7a9449aa84174",
+ token_decimals=6,
+ token_symbol="USDC.e",
+ receiver_address="0xed2f13aa5ff033c58fb436e178451cd07f693f32",
+ status="confirmed",
+ payment_mode="strict",
+ allowed_wallet="0x1111111111111111111111111111111111111111",
+ expires_at="2099-01-01T00:00:00+00:00",
+ tx_hash="0x" + "8" * 64,
+ metadata={},
+ )
+ trial_row = {"plan_code": "signup_trial_3d", "source": "signup_trial"}
+ granted = []
+
+ monkeypatch.setattr(
+ "src.payments.contract_checkout.SUPABASE_ENTITLEMENT.get_latest_active_subscription",
+ lambda user_id, respect_requirement=False: trial_row,
+ )
+ monkeypatch.setattr(
+ "src.payments.contract_checkout.SUPABASE_ENTITLEMENT.invalidate_subscription_cache",
+ lambda user_id: None,
+ )
+ monkeypatch.setattr(service, "_select_plan", lambda plan_code: {"duration_days": 30})
+ monkeypatch.setattr(
+ service,
+ "_grant_subscription",
+ lambda **kwargs: granted.append(kwargs)
+ or {"plan_code": kwargs["plan_code"], "status": "active"},
+ )
+
+ result = service._ensure_confirmed_subscription("user-1", intent, intent.tx_hash or "")
+
+ assert result["plan_code"] == "pro_monthly"
+ assert granted
+
+
def test_payment_checkout_parses_multiple_rpc_urls(monkeypatch, tmp_path):
monkeypatch.setenv("POLYWEATHER_PAYMENT_ENABLED", "true")
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
@@ -284,7 +385,7 @@ def test_reconcile_recent_intents_dedupes_users(monkeypatch, tmp_path):
assert seen == ["user-1", "user-2"]
-def test_grant_subscription_starts_immediately_when_only_trial_is_active(monkeypatch, tmp_path):
+def test_grant_subscription_starts_after_trial_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")
@@ -320,7 +421,6 @@ def test_grant_subscription_starts_immediately_when_only_trial_is_active(monkeyp
monkeypatch.setattr(service, "_rest", _fake_rest)
- before_call = datetime.now(timezone.utc)
result = service._grant_subscription(
user_id="user-1",
plan_code="pro_monthly",
@@ -328,9 +428,7 @@ def test_grant_subscription_starts_immediately_when_only_trial_is_active(monkeyp
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
+ assert starts_at == trial_end