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 ? (
- {TELEGRAM_BOT_URL ? ( - - {copy.telegramBotLink} - - - ) : null} {TELEGRAM_TOPICS_GROUP_URL && - TELEGRAM_TOPICS_GROUP_URL !== TELEGRAM_GROUP_URL ? ( + TELEGRAM_TOPICS_GROUP_URL !== TELEGRAM_GROUP_URL && + telegramBound ? ( ) : null} - {TELEGRAM_GROUP_URL ? ( + {TELEGRAM_GROUP_URL && telegramBound ? ( 1 else "").strip() if payload.startswith("bind_"): token = payload[len("bind_") :].strip() - result = self._bind_from_web_token(message, token) - trace.set_status("ok" if result == "bound" else "error", result) + result = self._prompt_bind_from_web_token(message, token) + trace.set_status("ok" if result == "confirm_prompted" else "error", result) return self.bot.reply_to(message, self.io_layer.build_welcome_text(), parse_mode="HTML") trace.set_status("ok") finally: trace.emit() - def _bind_from_web_token(self, message: Any, token: str) -> str: + def _prompt_bind_from_web_token(self, message: Any, token: str) -> str: + if not token: + self.bot.reply_to(message, "❌ 绑定链接无效,请回到网页重新点击一键绑定。") + return "invalid_token" + try: + payload = self.io_layer.db.peek_web_bind_token(token) + except Exception as exc: + logger.warning("web bind token peek failed token_prefix={}: {}", token[:6], exc) + payload = None + if not isinstance(payload, dict): + self.bot.reply_to(message, "❌ 绑定链接已过期或无效,请回到网页重新点击一键绑定。") + return "invalid_or_expired_token" + + supabase_user_id = str(payload.get("supabase_user_id") or "").strip() + supabase_email = str(payload.get("supabase_email") or "").strip() + if not supabase_user_id: + self.bot.reply_to(message, "❌ 绑定链接缺少网页账号信息,请重新登录后再试。") + return "missing_supabase_user_id" + + masked_email = self._mask_email(supabase_email) + text = ( + "请确认绑定:\n" + f"Telegram: {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