feat: implement Telegram-to-Web account binding flow and add supporting database and UI components

This commit is contained in:
2569718930@qq.com
2026-05-19 18:26:45 +08:00
parent a5c508cce8
commit 82d60cf05f
6 changed files with 423 additions and 55 deletions
+18 -22
View File
@@ -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=""
/>
</div>
)}
</div>
{/* Telegram Bot Section — paid users only */}
{showSecondarySections && isSubscribed ? (
{showSecondarySections && canAccessPaidTelegramGroup ? (
<div className="lg:col-span-12 grid grid-cols-1 md:flex gap-6">
<section className="flex-1 bg-white/5 border border-white/10 rounded-[2rem] p-8 relative overflow-hidden group">
<Bot
@@ -3028,19 +3034,9 @@ export function AccountCenter() {
) : null}
<div className="mb-4 flex flex-wrap gap-2">
{TELEGRAM_BOT_URL ? (
<Link
href={TELEGRAM_BOT_URL}
target="_blank"
rel="noreferrer"
className="inline-flex min-h-9 items-center gap-1 rounded-lg border border-cyan-400/30 bg-cyan-500/10 px-3 py-2 text-xs font-semibold text-cyan-200 hover:bg-cyan-500/20"
>
{copy.telegramBotLink}
<ExternalLink size={12} />
</Link>
) : null}
{TELEGRAM_TOPICS_GROUP_URL &&
TELEGRAM_TOPICS_GROUP_URL !== TELEGRAM_GROUP_URL ? (
TELEGRAM_TOPICS_GROUP_URL !== TELEGRAM_GROUP_URL &&
telegramBound ? (
<Link
href={TELEGRAM_TOPICS_GROUP_URL}
target="_blank"
@@ -3051,7 +3047,7 @@ export function AccountCenter() {
<ExternalLink size={12} />
</Link>
) : null}
{TELEGRAM_GROUP_URL ? (
{TELEGRAM_GROUP_URL && telegramBound ? (
<Link
href={TELEGRAM_GROUP_URL}
target="_blank"
+118 -9
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import html
import os
from typing import Any
from typing import Callable
@@ -67,6 +68,13 @@ class BasicCommandHandler:
def _chat_join_request(request):
self.handle_chat_join_request(request)
if hasattr(self.bot, "callback_query_handler"):
@self.bot.callback_query_handler(
func=lambda call: str(getattr(call, "data", "") or "").startswith("confirm_bind:")
)
def _confirm_bind(call):
self.handle_bind_confirm_callback(call)
@self.bot.message_handler(
content_types=["text"],
func=lambda message: extract_command_name(
@@ -118,19 +126,63 @@ class BasicCommandHandler:
payload = str(parts[1] if len(parts) > 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: <code>{html.escape(self.io_layer.display_name(message.from_user))}</code>\n"
f"网站账号: <code>{html.escape(masked_email or supabase_user_id)}</code>\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"}:
+31
View File
@@ -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,
+20 -14
View File
@@ -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,
+133 -5
View File
@@ -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")
+103 -5
View File
@@ -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