From 6041d25f237da427d7c26018b6e7dc613b8af770 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Mon, 18 May 2026 16:18:26 +0800 Subject: [PATCH] feat: add telegram group pricing and direct payments --- .env.example | 4 + .env.secrets.example | 4 + .../2026-05-18-telegram-group-pricing.md | 61 ++++ frontend/.env.example | 1 + frontend/app/api/auth/telegram/login/route.ts | 44 +++ frontend/components/account/AccountCenter.tsx | 342 +++++++++++++++++- .../subscription/UnlockProOverlay.tsx | 41 +++ scripts/supabase/direct_payment_migration.sql | 29 ++ scripts/supabase/schema.sql | 7 +- src/auth/telegram_group_pricing.py | 147 ++++++++ src/database/db_manager.py | 19 +- src/payments/contract_checkout.py | 281 ++++++++++++-- tests/test_db_manager_init_once.py | 21 ++ tests/test_direct_payment.py | 275 ++++++++++++++ tests/test_telegram_group_pricing.py | 165 +++++++++ web/core.py | 12 +- web/routers/auth.py | 8 +- web/services/auth_api.py | 53 ++- 18 files changed, 1479 insertions(+), 35 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-18-telegram-group-pricing.md create mode 100644 frontend/app/api/auth/telegram/login/route.ts create mode 100644 scripts/supabase/direct_payment_migration.sql create mode 100644 src/auth/telegram_group_pricing.py create mode 100644 tests/test_db_manager_init_once.py create mode 100644 tests/test_direct_payment.py create mode 100644 tests/test_telegram_group_pricing.py diff --git a/.env.example b/.env.example index 8258eadf..b4e8c806 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,9 @@ WEB_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,https://polyweather TELEGRAM_BOT_TOKEN= TELEGRAM_CHAT_ID= TELEGRAM_CHAT_IDS= +POLYWEATHER_TELEGRAM_GROUP_ID= +POLYWEATHER_GROUP_MEMBER_PRICE_USDC=5 +POLYWEATHER_PUBLIC_PRICE_USDC=10 TELEGRAM_QUERY_TOPIC_CHAT_ID= TELEGRAM_QUERY_TOPIC_ID= TELEGRAM_QUERY_TOPIC_MAP= @@ -168,6 +171,7 @@ POLYWEATHER_PAYMENT_CHAIN_ID=137 POLYWEATHER_PAYMENT_RPC_URL=https://polygon-rpc.com POLYWEATHER_PAYMENT_RPC_URLS=https://polygon-rpc.com POLYWEATHER_PAYMENT_RECEIVER_CONTRACT= +POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS= POLYWEATHER_PAYMENT_TOKEN_ADDRESS=0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174 POLYWEATHER_PAYMENT_TOKEN_DECIMALS=6 POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON= diff --git a/.env.secrets.example b/.env.secrets.example index 6c93ffd3..0983f4e4 100644 --- a/.env.secrets.example +++ b/.env.secrets.example @@ -6,6 +6,9 @@ # Telegram ######################################## TELEGRAM_BOT_TOKEN= +POLYWEATHER_TELEGRAM_GROUP_ID= +POLYWEATHER_GROUP_MEMBER_PRICE_USDC=5 +POLYWEATHER_PUBLIC_PRICE_USDC=10 ######################################## # Supabase @@ -32,6 +35,7 @@ METEOBLUE_API_KEY= ######################################## NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID= POLYWEATHER_PAYMENT_RECEIVER_CONTRACT= +POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS= POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON= POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON= diff --git a/docs/superpowers/plans/2026-05-18-telegram-group-pricing.md b/docs/superpowers/plans/2026-05-18-telegram-group-pricing.md new file mode 100644 index 00000000..dfd9945f --- /dev/null +++ b/docs/superpowers/plans/2026-05-18-telegram-group-pricing.md @@ -0,0 +1,61 @@ +# Telegram Group Pricing Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking. + +**Goal:** Add Telegram Login verification so backend decides Pro price: group member 5U, non-member 10U. + +**Architecture:** Keep Supabase as the website account session, add Telegram Login as an identity link/price verification step. Backend verifies Telegram Login hash, checks getChatMember, stores the Telegram link in existing supabase_bindings, and payment intent creation recalculates price server-side. + +**Tech Stack:** FastAPI, existing DBManager bindings, Telegram Bot HTTP API, Next.js proxy routes, React account center, pytest. + +--- + +### Task 1: Telegram auth service + +**Files:** +- Create: src/auth/telegram_group_pricing.py +- Test: ests/test_telegram_group_pricing.py + +- [ ] Verify Telegram Login payload HMAC using bot token. +- [ ] Call Telegram getChatMember and treat member, dministrator, creator as group members. +- [ ] Return 5U/10U pricing payload. + +### Task 2: Backend auth route + +**Files:** +- Modify: web/core.py +- Modify: web/services/auth_api.py +- Modify: web/routers/auth.py + +- [ ] Add TelegramLoginRequest model. +- [ ] Add POST /api/auth/telegram/login requiring Supabase identity. +- [ ] Link Telegram id to current Supabase user via DBManager.bind_supabase_identity. +- [ ] Return Telegram member status and effective price. + +### Task 3: Payment dynamic price + +**Files:** +- Modify: src/payments/contract_checkout.py +- Modify: web/services/payment_api.py + +- [ ] At payment intent creation, check linked Telegram id and group membership. +- [ ] Override pro_monthly amount to 5U for members, 10U otherwise. +- [ ] Store pricing source in metadata. + +### Task 4: Frontend Telegram Login entry + +**Files:** +- Modify: rontend/components/account/AccountCenter.tsx +- Create: rontend/app/api/auth/telegram/login/route.ts + +- [ ] Load Telegram Login widget with configured bot username. +- [ ] Send payload to backend proxy. +- [ ] Show group/member price status before checkout. + +### Task 5: Verification + +**Commands:** +- python -m pytest tests\test_telegram_group_pricing.py tests\test_direct_payment.py tests\test_payments_runtime.py -q +- python -m py_compile src\auth\telegram_group_pricing.py src\payments\contract_checkout.py web\core.py web\services\auth_api.py +- python -m ruff check src\auth\telegram_group_pricing.py src\payments\contract_checkout.py web\core.py web\services\auth_api.py tests\test_telegram_group_pricing.py +- cd frontend; npm run typecheck diff --git a/frontend/.env.example b/frontend/.env.example index bcbd1cb8..2e7926cc 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -40,3 +40,4 @@ NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather-pro.vercel.app POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/your_group NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/WeatherQuant_bot +NEXT_PUBLIC_TELEGRAM_LOGIN_BOT_USERNAME=WeatherQuant_bot diff --git a/frontend/app/api/auth/telegram/login/route.ts b/frontend/app/api/auth/telegram/login/route.ts new file mode 100644 index 00000000..9b1da20c --- /dev/null +++ b/frontend/app/api/auth/telegram/login/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + applyAuthResponseCookies, + buildBackendRequestHeaders, +} from "@/lib/backend-auth"; +import { + buildProxyExceptionResponse, + buildUpstreamErrorResponse, +} from "@/lib/api-proxy"; + +const API_BASE = process.env.POLYWEATHER_API_BASE_URL; + +export async function POST(req: NextRequest) { + if (!API_BASE) { + return NextResponse.json( + { error: "POLYWEATHER_API_BASE_URL is not configured" }, + { status: 500 }, + ); + } + try { + const body = await req.text(); + const auth = await buildBackendRequestHeaders(req); + const headers = new Headers(auth.headers); + headers.set("Content-Type", "application/json"); + const res = await fetch(`${API_BASE}/api/auth/telegram/login`, { + method: "POST", + headers, + body, + cache: "no-store", + }); + if (!res.ok) { + const raw = await res.text(); + const response = buildUpstreamErrorResponse(res.status, raw); + return applyAuthResponseCookies(response, auth.response); + } + const data = await res.json(); + const response = NextResponse.json(data); + return applyAuthResponseCookies(response, auth.response); + } catch (error) { + return buildProxyExceptionResponse(error, { + publicMessage: "Failed to verify Telegram login", + }); + } +} diff --git a/frontend/components/account/AccountCenter.tsx b/frontend/components/account/AccountCenter.tsx index 6a44d544..c0d0f6c6 100644 --- a/frontend/components/account/AccountCenter.tsx +++ b/frontend/components/account/AccountCenter.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import type { User } from "@supabase/supabase-js"; @@ -68,6 +68,26 @@ type AuthMeResponse = { subscription_total_expires_at?: string | null; subscription_queued_days?: number | null; subscription_queued_count?: number | null; + telegram_pricing?: TelegramPricing | null; +}; + +type TelegramPricing = { + configured?: boolean; + telegram_id?: number | null; + telegram_status?: string | null; + is_group_member?: boolean; + amount_usdc?: string; + pricing_source?: string; +}; + +type TelegramAuthPayload = { + id: number; + first_name?: string; + last_name?: string; + username?: string; + photo_url?: string; + auth_date: number; + hash: string; }; type PaymentPlan = { @@ -133,6 +153,18 @@ type CreatedIntent = { token_symbol?: string; token_decimals?: number; }; + direct_payment?: { + chain_id: number; + chain?: string; + token_symbol?: string; + token_address: string; + token_decimals?: number; + receiver_address: string; + amount_units: string; + amount_usdc: string; + intent_id: string; + expires_at: string; + }; }; type IntentStatusResponse = { @@ -218,6 +250,11 @@ const TELEGRAM_GROUP_URL = String( const TELEGRAM_BOT_URL = String( process.env.NEXT_PUBLIC_TELEGRAM_BOT_URL || "https://t.me/WeatherQuant_bot", ).trim(); +const TELEGRAM_LOGIN_BOT_USERNAME = String( + process.env.NEXT_PUBLIC_TELEGRAM_LOGIN_BOT_USERNAME || "WeatherQuant_bot", +) + .replace(/^@/, "") + .trim(); const TELEGRAM_MARKET_CHANNEL_URL = "https://t.me/+hGAk7JsjtdhiOTUx"; const TELEGRAM_TOPICS_GROUP_URL = "https://t.me/+8vel7rwjZagxODUx"; const SUBSCRIPTION_HELP_HREF = "/subscription-help"; @@ -859,6 +896,10 @@ export function AccountCenter() { const [paymentError, setPaymentError] = useState(""); const [lastIntentId, setLastIntentId] = useState(""); const [lastTxHash, setLastTxHash] = useState(""); + const [manualPayment, setManualPayment] = useState(null); + const [manualTxHash, setManualTxHash] = useState(""); + const [telegramLoginBusy, setTelegramLoginBusy] = useState(false); + const telegramLoginWidgetRef = useRef(null); const [lastPaymentStartedAt, setLastPaymentStartedAt] = useState(0); const [showSecondarySections, setShowSecondarySections] = useState(false); const [reconcileBusy, setReconcileBusy] = useState(false); @@ -1360,6 +1401,8 @@ export function AccountCenter() { if (!backend?.subscription_active) return; setLastIntentId(""); setLastTxHash(""); + setManualPayment(null); + setManualTxHash(""); setLastPaymentStartedAt(0); clearStoredPaymentRecovery(); }, [backend?.subscription_active]); @@ -1467,6 +1510,64 @@ export function AccountCenter() { const userId = backend?.user_id || user?.id || ""; const isAuthenticated = Boolean(userId); const email = backend?.email || user?.email || ""; + useEffect(() => { + const container = telegramLoginWidgetRef.current; + if (!container || !TELEGRAM_LOGIN_BOT_USERNAME || !isAuthenticated) return; + container.innerHTML = ""; + const callbackName = "onPolyWeatherTelegramAuth"; + (window as unknown as Record)[callbackName] = async ( + payload: TelegramAuthPayload, + ) => { + setTelegramLoginBusy(true); + setPaymentError(""); + setPaymentInfo(""); + try { + const authHeaders = await buildAuthedHeaders(true, false); + const res = await fetch("/api/auth/telegram/login", { + method: "POST", + headers: authHeaders, + body: JSON.stringify(payload), + }); + if (!res.ok) { + const raw = (await res.text()).slice(0, 350); + throw new Error(`telegram login failed: ${raw}`); + } + const data = (await res.json()) as { + telegram_pricing?: TelegramPricing | null; + }; + const amount = data.telegram_pricing?.amount_usdc || "10"; + setPaymentInfo( + data.telegram_pricing?.is_group_member + ? `Telegram 群成员验证成功,当前会员价 ${amount}U。` + : `Telegram 已登录,但未检测到群成员身份,当前价格 ${amount}U。`, + ); + await loadSnapshot(); + await loadPaymentSnapshot(); + } catch (error) { + setPaymentError(normalizePaymentError(error).message); + } finally { + setTelegramLoginBusy(false); + } + }; + const script = document.createElement("script"); + script.src = "https://telegram.org/js/telegram-widget.js?22"; + script.async = true; + script.setAttribute("data-telegram-login", TELEGRAM_LOGIN_BOT_USERNAME); + script.setAttribute("data-size", "large"); + script.setAttribute("data-radius", "12"); + script.setAttribute("data-userpic", "false"); + script.setAttribute("data-request-access", "write"); + script.setAttribute("data-onauth", `${callbackName}(user)`); + container.appendChild(script); + return () => { + container.innerHTML = ""; + }; + }, [ + buildAuthedHeaders, + isAuthenticated, + loadPaymentSnapshot, + loadSnapshot, + ]); const displayName = String(user?.user_metadata?.full_name || "").trim() || (email ? String(email).split("@")[0] : "") || @@ -1673,7 +1774,9 @@ export function AccountCenter() { ); const billing = useMemo(() => { - const parsedPlanAmount = Number(selectedPlan?.amount_usdc ?? 5); + const parsedPlanAmount = Number( + backend?.telegram_pricing?.amount_usdc ?? selectedPlan?.amount_usdc ?? 5, + ); const planAmount = Number.isFinite(parsedPlanAmount) && parsedPlanAmount > 0 ? parsedPlanAmount @@ -1718,6 +1821,7 @@ export function AccountCenter() { }; }, [ paymentConfig?.points_redemption, + backend?.telegram_pricing?.amount_usdc, selectedPlan?.amount_usdc, totalPoints, usePoints, @@ -2366,6 +2470,145 @@ export function AccountCenter() { } }; + const createManualPaymentIntent = async () => { + setPaymentError(""); + setPaymentInfo(""); + setManualPayment(null); + setManualTxHash(""); + setLastIntentId(""); + setLastTxHash(""); + if (!paymentHostAllowed) { + setPaymentError( + copy.paymentHostBlocked.replace( + "{host}", + allowedPaymentHosts[0] || "polyweather-pro.vercel.app", + ), + ); + return; + } + if (!isAuthenticated) { + setPaymentError(copy.loginBeforePay); + return; + } + if (!paymentConfig?.configured) { + setPaymentError(copy.payNotReady); + return; + } + + setPaymentBusy(true); + try { + const authHeaders = await buildAuthedHeaders(true, false); + const createRes = await fetch("/api/payments/intents", { + method: "POST", + headers: authHeaders, + body: JSON.stringify({ + plan_code: selectedPlan?.plan_code || "pro_monthly", + payment_mode: "direct", + token_address: resolvedSelectedTokenAddress || undefined, + use_points: billing.canRedeem && usePoints, + points_to_consume: + billing.canRedeem && usePoints ? billing.pointsUsed : 0, + metadata: { + source: "account_center_manual_transfer", + frontend_host: currentPaymentHost || null, + account_email: email || null, + }, + }), + }); + if (!createRes.ok) { + const raw = (await createRes.text()).slice(0, 350); + throw new Error(`create manual intent failed: ${raw}`); + } + const created = (await createRes.json()) as CreatedIntent; + const direct = created.direct_payment; + const intentId = String(created.intent?.intent_id || direct?.intent_id || ""); + if (!intentId || !direct?.receiver_address || !direct?.amount_usdc) { + throw new Error("manual payment payload invalid"); + } + setLastIntentId(intentId); + setManualPayment(direct); + setShowOverlay(false); + setPaymentInfo( + `手动转账订单已创建:请在 Polygon 网络转 ${direct.amount_usdc} ${direct.token_symbol || selectedTokenLabel} 到 ${shortAddress(direct.receiver_address)},完成后提交 tx hash。`, + ); + trackAppEvent("checkout_started", { + entry: "account_center_manual_transfer", + plan_code: selectedPlan?.plan_code || "pro_monthly", + intent_id: intentId, + payment_mode: "direct", + use_points: billing.canRedeem && usePoints, + pay_amount_usd: billing.payAmount, + }); + } catch (error) { + setPaymentError(normalizePaymentError(error).message); + } finally { + setPaymentBusy(false); + } + }; + + const submitManualPaymentTx = async () => { + const txHashNorm = String(manualTxHash || "").trim().toLowerCase(); + const intentId = String(lastIntentId || manualPayment?.intent_id || "").trim(); + if (!intentId || !manualPayment) { + setPaymentError("请先创建手动转账订单。"); + return; + } + if (!txHashNorm.startsWith("0x") || txHashNorm.length !== 66) { + setPaymentError("请输入有效的 tx hash。"); + return; + } + setPaymentBusy(true); + setPaymentError(""); + try { + const authHeaders = await buildAuthedHeaders(true, false); + const submitRes = await fetch(`/api/payments/intents/${intentId}/submit`, { + method: "POST", + headers: authHeaders, + body: JSON.stringify({ tx_hash: txHashNorm }), + }); + if (!submitRes.ok) { + const raw = (await submitRes.text()).slice(0, 350); + throw new Error(`submit tx failed: ${raw}`); + } + const confirmRes = await fetch(`/api/payments/intents/${intentId}/confirm`, { + method: "POST", + headers: authHeaders, + body: JSON.stringify({ tx_hash: txHashNorm }), + }); + if (!confirmRes.ok) { + const raw = (await confirmRes.text()).slice(0, 350); + const lowerRaw = raw.toLowerCase(); + const maybePending = + confirmRes.status === 408 || + (confirmRes.status === 409 && + (lowerRaw.includes("confirmations not enough") || + lowerRaw.includes("tx indexed partially"))); + if (maybePending) { + setPaymentInfo(`交易已提交: ${shortAddress(txHashNorm)},等待链上确认中...`); + await pollIntentUntilConfirmed(intentId, authHeaders, txHashNorm); + return; + } + throw new Error(`confirm failed: ${raw}`); + } + setLastTxHash(txHashNorm); + setPaymentInfo(`支付确认成功,交易: ${shortAddress(txHashNorm)}`); + setManualPayment(null); + setManualTxHash(""); + trackAppEvent("checkout_succeeded", { + entry: "account_center_manual_transfer", + plan_code: selectedPlan?.plan_code || "pro_monthly", + intent_id: intentId, + tx_hash: txHashNorm, + }); + await loadSnapshot(); + await loadPaymentSnapshot(); + } catch (error) { + setPaymentError(normalizePaymentError(error).message); + } finally { + setPaymentBusy(false); + } + }; + const handleOverlayCheckout = async () => { if (!paymentHostAllowed) { setPaymentError( @@ -2736,9 +2979,11 @@ export function AccountCenter() { pointsPerUsd: billing.pointsPerUsdc, }} onPay={() => void handleOverlayCheckout()} + onManualPay={() => void createManualPaymentIntent()} onClose={() => setShowOverlay(false)} payBusy={paymentBusy} payLabel={hasPayingWallet ? copy.payNow : copy.connectAndPay} + manualPayLabel="手动转账" errorText={paymentError || undefined} infoText={paymentInfo || undefined} txHash={lastTxHash || undefined} @@ -2766,6 +3011,28 @@ export function AccountCenter() {

{copy.telegramHint}

+
+

+ Telegram 群内价格 +

+

+ 登录 Telegram 后由后端检查群成员身份:群成员 5U,非群成员 10U。 +

+
+
+ {telegramLoginBusy ? ( + + 正在验证 Telegram... + + ) : null} + {backend?.telegram_pricing?.amount_usdc ? ( + + 当前价格: {backend.telegram_pricing.amount_usdc}U + {backend.telegram_pricing.is_group_member ? " · 群成员" : " · 普通价"} + + ) : null} +
+
{/* Channel Upgrade / Migration Notice — Pro users only */} {isSubscribed ? ( @@ -2934,6 +3201,77 @@ export function AccountCenter() {
)} +
+
+
+

+ 手动转账(无需绑定钱包) +

+

+ 先创建订单,向唯一收款地址转账,完成后提交 tx hash 自动开通。请不要和钱包支付同时使用。 +

+
+ +
+ {manualPayment ? ( +
+
+

+ Amount +

+

+ {manualPayment.amount_usdc}{" "} + {manualPayment.token_symbol || selectedTokenLabel} +

+
+
+

+ Receiver +

+
+ + {manualPayment.receiver_address} + + +
+
+
+

+ Tx Hash +

+ setManualTxHash(event.target.value)} + placeholder="0x..." + className="mt-1 w-full rounded-xl border border-white/10 bg-black/30 px-3 py-2 font-mono text-xs text-slate-100 outline-none focus:border-emerald-400/50" + /> +
+ +
+ ) : null} +
{boundWallets.length ? (
{boundWallets.map((w) => ( diff --git a/frontend/components/subscription/UnlockProOverlay.tsx b/frontend/components/subscription/UnlockProOverlay.tsx index 9ee47eaa..e86a6c73 100644 --- a/frontend/components/subscription/UnlockProOverlay.tsx +++ b/frontend/components/subscription/UnlockProOverlay.tsx @@ -41,9 +41,11 @@ type UnlockProOverlayProps = { billing: UnlockProBilling; onToggleUsePoints: () => void; onPay: () => void; + onManualPay?: () => void; onClose?: () => void; payBusy?: boolean; payLabel?: string; + manualPayLabel?: string; locale?: "zh-CN" | "en-US"; errorText?: string; infoText?: string; @@ -76,9 +78,11 @@ export function UnlockProOverlay({ billing, onToggleUsePoints, onPay, + onManualPay, onClose, payBusy = false, payLabel, + manualPayLabel, locale = "zh-CN", errorText, infoText, @@ -368,6 +372,43 @@ export function UnlockProOverlay({ )} + {onManualPay ? ( + + ) : null} + {onManualPay ? ( +

+ {isEn + ? "Choose one payment method only. Do not pay again after the order is completed." + : "请只选择一种支付方式;订单完成后请勿重复付款。"} +

+ ) : null}
diff --git a/scripts/supabase/direct_payment_migration.sql b/scripts/supabase/direct_payment_migration.sql new file mode 100644 index 00000000..d04d1eaf --- /dev/null +++ b/scripts/supabase/direct_payment_migration.sql @@ -0,0 +1,29 @@ +-- Direct/manual transfer checkout support. +-- Run once in Supabase SQL editor before enabling payment_mode=direct in production. + +alter table public.payment_transactions + alter column from_address drop not null; + +alter table public.payment_transactions + add column if not exists payment_method text not null default 'wallet'; + +do $$ +begin + alter table public.payment_intents + drop constraint if exists payment_intents_payment_mode_check; + alter table public.payment_intents + add constraint payment_intents_payment_mode_check + check (payment_mode in ('strict', 'flex', 'direct')); + + alter table public.payment_transactions + drop constraint if exists payment_transactions_status_check; + alter table public.payment_transactions + add constraint payment_transactions_status_check + check (status in ('submitted', 'confirmed', 'failed', 'duplicate', 'refund_required')); + + alter table public.payment_transactions + drop constraint if exists payment_transactions_payment_method_check; + alter table public.payment_transactions + add constraint payment_transactions_payment_method_check + check (payment_method in ('wallet', 'manual', 'direct')); +end $$; diff --git a/scripts/supabase/schema.sql b/scripts/supabase/schema.sql index 69c74cd4..190bda23 100644 --- a/scripts/supabase/schema.sql +++ b/scripts/supabase/schema.sql @@ -91,7 +91,7 @@ create table if not exists public.payment_intents ( token_address text not null, receiver_address text not null, amount_units numeric(78,0) not null, - payment_mode text not null default 'strict' check (payment_mode in ('strict', 'flex')), + payment_mode text not null default 'strict' check (payment_mode in ('strict', 'flex', 'direct')), allowed_wallet text, order_id_hex text not null unique, status text not null default 'created' check (status in ('created', 'submitted', 'confirmed', 'expired', 'failed', 'cancelled')), @@ -114,10 +114,11 @@ create table if not exists public.payment_transactions ( intent_id uuid not null references public.payment_intents(id) on delete cascade, tx_hash text not null unique, chain_id integer not null default 137, - from_address text not null, + from_address text, to_address text not null, block_number bigint, - status text not null default 'submitted' check (status in ('submitted', 'confirmed', 'failed')), + payment_method text not null default 'wallet' check (payment_method in ('wallet', 'manual', 'direct')), + status text not null default 'submitted' check (status in ('submitted', 'confirmed', 'failed', 'duplicate', 'refund_required')), raw_receipt jsonb not null default '{}'::jsonb, raw_tx jsonb not null default '{}'::jsonb, created_at timestamptz not null default now(), diff --git a/src/auth/telegram_group_pricing.py b/src/auth/telegram_group_pricing.py new file mode 100644 index 00000000..16b12a8d --- /dev/null +++ b/src/auth/telegram_group_pricing.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +import hashlib +import hmac +import os +import time +from decimal import Decimal +from typing import Any, Dict, Optional + +import requests + +from src.utils.telegram_chat_ids import parse_telegram_chat_ids + +TELEGRAM_MEMBER_STATUSES = {"creator", "administrator", "member"} + + +def _format_decimal(value: Decimal) -> str: + text = format(value.normalize(), "f") + if "." in text: + text = text.rstrip("0").rstrip(".") + return text or "0" + + +def _decimal_env(name: str, default: str) -> Decimal: + raw = str(os.getenv(name) or default).strip() + try: + return Decimal(raw) + except Exception: + return Decimal(default) + + +def _telegram_data_check_string(payload: Dict[str, Any]) -> str: + items = [] + for key in sorted(payload.keys()): + if key == "hash": + continue + value = payload.get(key) + if value is None: + continue + items.append(f"{key}={value}") + return "\n".join(items) + + +def verify_telegram_login_payload( + payload: Dict[str, Any], + bot_token: str, + *, + max_age_sec: int = 86400, +) -> Dict[str, Any]: + token = str(bot_token or "").strip() + if not token: + raise ValueError("telegram bot token missing") + expected_hash = str(payload.get("hash") or "").strip().lower() + if not expected_hash: + raise ValueError("telegram login hash missing") + data_check = _telegram_data_check_string(payload) + secret = hashlib.sha256(token.encode("utf-8")).digest() + actual_hash = hmac.new( + secret, + data_check.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(actual_hash, expected_hash): + raise ValueError("invalid telegram login hash") + try: + auth_date = int(payload.get("auth_date") or 0) + except Exception as exc: + raise ValueError("invalid telegram auth_date") from exc + if auth_date <= 0: + raise ValueError("invalid telegram auth_date") + if max_age_sec > 0 and int(time.time()) - auth_date > max_age_sec: + raise ValueError("telegram login payload expired") + try: + telegram_id = int(payload.get("id") or 0) + except Exception as exc: + raise ValueError("invalid telegram id") from exc + if telegram_id <= 0: + raise ValueError("invalid telegram id") + return { + "telegram_id": telegram_id, + "username": str(payload.get("username") or "").strip(), + "first_name": str(payload.get("first_name") or "").strip(), + "last_name": str(payload.get("last_name") or "").strip(), + "photo_url": str(payload.get("photo_url") or "").strip(), + "auth_date": auth_date, + } + + +class TelegramGroupPricing: + def __init__(self) -> None: + self.bot_token = str(os.getenv("TELEGRAM_BOT_TOKEN") or "").strip() + self.group_chat_ids = parse_telegram_chat_ids( + os.getenv("POLYWEATHER_TELEGRAM_GROUP_ID"), + os.getenv("POLYWEATHER_TELEGRAM_GROUP_IDS"), + os.getenv("TELEGRAM_CHAT_IDS"), + os.getenv("TELEGRAM_CHAT_ID"), + ) + self.member_price = _decimal_env("POLYWEATHER_GROUP_MEMBER_PRICE_USDC", "5") + self.public_price = _decimal_env("POLYWEATHER_PUBLIC_PRICE_USDC", "10") + self.timeout_sec = max( + 2, + int(str(os.getenv("POLYWEATHER_TELEGRAM_HTTP_TIMEOUT_SEC") or "8").strip() or "8"), + ) + + @property + def configured(self) -> bool: + return bool(self.bot_token and self.group_chat_ids) + + def verify_login_payload(self, payload: Dict[str, Any]) -> Dict[str, Any]: + max_age = int(str(os.getenv("POLYWEATHER_TELEGRAM_LOGIN_MAX_AGE_SEC") or "86400")) + return verify_telegram_login_payload(payload, self.bot_token, max_age_sec=max_age) + + def get_member_status(self, telegram_id: int) -> Optional[str]: + if not self.configured: + return None + for chat_id in self.group_chat_ids: + try: + response = requests.get( + f"https://api.telegram.org/bot{self.bot_token}/getChatMember", + params={"chat_id": chat_id, "user_id": int(telegram_id)}, + timeout=self.timeout_sec, + ) + data = response.json() + except Exception: + continue + if not isinstance(data, dict) or not data.get("ok"): + continue + result = data.get("result") + if not isinstance(result, dict): + continue + status = str(result.get("status") or "").strip().lower() + if status: + return status + return None + + def resolve_price_for_telegram_id(self, telegram_id: Optional[int]) -> Dict[str, Any]: + status = self.get_member_status(int(telegram_id or 0)) if telegram_id else None + is_member = bool(status in TELEGRAM_MEMBER_STATUSES) + amount = self.member_price if is_member else self.public_price + return { + "configured": self.configured, + "telegram_id": int(telegram_id or 0) or None, + "telegram_status": status, + "is_group_member": is_member, + "amount_usdc": _format_decimal(amount), + "pricing_source": "telegram_group_member" if is_member else "telegram_public", + } diff --git a/src/database/db_manager.py b/src/database/db_manager.py index cfc86079..e3f29cf7 100644 --- a/src/database/db_manager.py +++ b/src/database/db_manager.py @@ -2,17 +2,21 @@ import sqlite3 import os import hashlib import json +import threading from datetime import datetime, timedelta -from typing import Optional, Dict, Any, List +from typing import Optional, Dict, Any, List, Set import requests from loguru import logger class DBManager: + _init_lock = threading.Lock() + _initialized_paths: Set[str] = set() + def __init__(self, db_path: Optional[str] = None): self.db_path = self._resolve_db_path(db_path) - self._init_db() + self._ensure_initialized() def _resolve_db_path(self, db_path: Optional[str]) -> str: raw = (db_path or os.getenv("POLYWEATHER_DB_PATH") or "").strip() @@ -24,6 +28,17 @@ class DBManager: def _get_connection(self): return sqlite3.connect(self.db_path) + def _init_cache_key(self) -> str: + return os.path.abspath(self.db_path) + + def _ensure_initialized(self) -> None: + cache_key = self._init_cache_key() + with self._init_lock: + if cache_key in self._initialized_paths: + return + self._init_db() + self._initialized_paths.add(cache_key) + def _supabase_profiles_endpoint(self) -> str: supabase_url = str(os.getenv("SUPABASE_URL") or "").strip().rstrip("/") if not supabase_url: diff --git a/src/payments/contract_checkout.py b/src/payments/contract_checkout.py index 33e61b60..0c553c17 100644 --- a/src/payments/contract_checkout.py +++ b/src/payments/contract_checkout.py @@ -15,6 +15,7 @@ from eth_account.messages import encode_defunct from web3 import Web3 from src.auth.supabase_entitlement import SUPABASE_ENTITLEMENT +from src.auth.telegram_group_pricing import TelegramGroupPricing from src.database.db_manager import DBManager DEFAULT_POLYGON_CHAIN_ID = 137 @@ -73,6 +74,17 @@ PAYMENT_CONTRACT_ABI = [ }, ] +ERC20_TRANSFER_EVENT_ABI = { + "anonymous": False, + "inputs": [ + {"indexed": True, "name": "from", "type": "address"}, + {"indexed": True, "name": "to", "type": "address"}, + {"indexed": False, "name": "value", "type": "uint256"}, + ], + "name": "Transfer", + "type": "event", +} + DEFAULT_PLAN_CATALOG: Dict[str, Dict[str, Any]] = { "pro_monthly": {"plan_id": 101, "amount_usdc": "5", "duration_days": 30}, "pro_quarterly": {"plan_id": 102, "amount_usdc": "79", "duration_days": 90}, @@ -276,6 +288,10 @@ class PaymentContractCheckoutService: default_token = self.supported_tokens.get(self.default_token_address) self.token_address = default_token.address if default_token else "" self.receiver_contract = default_token.receiver_contract if default_token else "" + self.direct_receiver_address = ( + _normalize_address(os.getenv("POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS") or "") + or self.receiver_contract + ) self.token_decimals = ( int(default_token.decimals) if default_token else int(self.token_decimals) ) @@ -906,6 +922,7 @@ class PaymentContractCheckoutService: "token_address": self.token_address, "token_decimals": self.token_decimals, "receiver_contract": self.receiver_contract, + "direct_receiver_address": self.direct_receiver_address, "default_token_address": self.default_token_address or self.token_address, "tokens": tokens_payload, "confirmations": self.confirmations, @@ -1261,9 +1278,37 @@ class PaymentContractCheckoutService: "plan_code": code, "plan_id": int(row.get("plan_id") or 0), "duration_days": int(row.get("duration_days") or 0), + "amount_usdc": _format_decimal(amount_dec), "amount_usdc_decimal": amount_dec, } + def _apply_telegram_group_pricing( + self, + user_id: str, + plan: Dict[str, Any], + ) -> Dict[str, Any]: + out = dict(plan) + if str(out.get("plan_code") or "").strip().lower() != "pro_monthly": + return out + pricing = TelegramGroupPricing() + if not pricing.configured: + return out + telegram_id = None + try: + user = self._db.get_user_by_supabase_user_id(user_id) + if isinstance(user, dict): + telegram_id = int(user.get("telegram_id") or 0) or None + except Exception: + telegram_id = None + price_payload = pricing.resolve_price_for_telegram_id(telegram_id) + amount_dec = _parse_decimal(price_payload.get("amount_usdc"), out["amount_usdc_decimal"]) + if amount_dec <= 0: + return out + out["amount_usdc"] = _format_decimal(amount_dec) + out["amount_usdc_decimal"] = amount_dec + out["telegram_pricing"] = price_payload + return out + def _build_tx_payload(self, intent: PaymentIntentRecord) -> Dict[str, Any]: contract = self._get_contract(intent.receiver_address) tx_data = contract.encode_abi( @@ -1300,16 +1345,23 @@ class PaymentContractCheckoutService: points_to_consume: Optional[int] = None, ) -> Dict[str, Any]: self._ensure_enabled() - plan = self._select_plan(plan_code) + plan = self._apply_telegram_group_pricing( + user_id, + self._select_plan(plan_code), + ) selected_token = self._resolve_supported_token(token_address) mode = str(payment_mode or "strict").strip().lower() - if mode not in {"strict", "flex"}: - raise PaymentCheckoutError(400, "payment_mode must be strict or flex") - bound_wallets = self.list_wallets(user_id) - if not bound_wallets: + if mode == "manual": + mode = "direct" + if mode not in {"strict", "flex", "direct"}: + raise PaymentCheckoutError(400, "payment_mode must be strict, flex, or direct") + bound_wallets = [] if mode == "direct" else self.list_wallets(user_id) + if mode != "direct" and not bound_wallets: raise PaymentCheckoutError(403, "bind wallet first") target_wallet = _normalize_address(allowed_wallet or "") - if mode == "strict": + if mode == "direct": + target_wallet = "" + elif mode == "strict": if target_wallet: self._require_user_wallet(user_id, target_wallet) else: @@ -1334,6 +1386,13 @@ class PaymentContractCheckoutService: combined_metadata = dict(metadata or {}) combined_metadata["token_code"] = str(selected_token.code) combined_metadata["token_symbol"] = str(selected_token.symbol) + if isinstance(plan.get("telegram_pricing"), dict): + combined_metadata["telegram_pricing"] = plan["telegram_pricing"] + receiver_address = ( + self.direct_receiver_address + if mode == "direct" + else selected_token.receiver_contract + ) combined_metadata["amount_before_discount_usdc"] = _format_decimal(plan_amount_usdc) combined_metadata["amount_after_discount_usdc"] = _format_decimal(final_amount_usdc) combined_metadata["points_redemption"] = { @@ -1358,7 +1417,7 @@ class PaymentContractCheckoutService: "plan_id": plan["plan_id"], "chain_id": self.chain_id, "token_address": selected_token.address, - "receiver_address": selected_token.receiver_contract, + "receiver_address": receiver_address, "amount_units": str(amount_units), "payment_mode": mode, "allowed_wallet": target_wallet or None, @@ -1375,9 +1434,9 @@ class PaymentContractCheckoutService: if not isinstance(rows, list) or not rows: raise PaymentCheckoutError(500, "failed to create payment intent") intent = self._serialize_intent(rows[0]) - return { + response = { "intent": intent.__dict__, - "tx_payload": self._build_tx_payload(intent), + "tx_payload": None if mode == "direct" else self._build_tx_payload(intent), "plan": { "plan_code": plan["plan_code"], "plan_id": plan["plan_id"], @@ -1400,6 +1459,20 @@ class PaymentContractCheckoutService: "points_balance_snapshot": int(redemption.get("points_balance_snapshot") or 0), }, } + if mode == "direct": + response["direct_payment"] = { + "chain_id": self.chain_id, + "chain": "polygon", + "token_symbol": intent.token_symbol, + "token_address": intent.token_address, + "token_decimals": int(intent.token_decimals), + "receiver_address": intent.receiver_address, + "amount_units": str(intent.amount_units), + "amount_usdc": intent.amount_usdc, + "intent_id": intent.intent_id, + "expires_at": intent.expires_at, + } + return response def get_intent(self, user_id: str, intent_id: str) -> PaymentIntentRecord: self._ensure_enabled() rows = self._rest( @@ -1525,24 +1598,102 @@ class PaymentContractCheckoutService: ) return out + def _ensure_tx_hash_unused(self, tx_hash: str, intent_id: str) -> None: + tx_hash_text = str(tx_hash or "").strip().lower() + if not tx_hash_text: + return + rows = self._rest( + "GET", + "payment_transactions", + params={ + "select": "intent_id,tx_hash,status", + "tx_hash": f"eq.{tx_hash_text}", + "limit": "5", + }, + allowed_status=[200], + ) + if not isinstance(rows, list): + return + for row in rows: + if not isinstance(row, dict): + continue + existing_intent = str(row.get("intent_id") or "").strip() + if existing_intent and existing_intent != str(intent_id): + raise PaymentCheckoutError(409, "tx_hash already used by another payment intent") + + def _record_duplicate_transaction( + self, + *, + intent: PaymentIntentRecord, + tx_hash: str, + from_address: Optional[str] = None, + to_address: Optional[str] = None, + status: str = "duplicate", + detail: str = "payment intent already confirmed", + ) -> Dict[str, Any]: + tx_hash_text = str(tx_hash or "").strip().lower() + if not tx_hash_text: + return {} + now_iso = _to_iso(_now_utc()) + try: + rows = self._rest( + "POST", + "payment_transactions", + params={"on_conflict": "tx_hash"}, + payload={ + "intent_id": intent.intent_id, + "chain_id": self.chain_id, + "tx_hash": tx_hash_text, + "from_address": _normalize_address(from_address) or None, + "to_address": _normalize_address(to_address) or intent.receiver_address, + "payment_method": "direct" if intent.payment_mode == "direct" else "wallet", + "status": status, + "raw_receipt": {}, + "raw_tx": { + "duplicate_of_intent_id": intent.intent_id, + "duplicate_reason": detail, + }, + "updated_at": now_iso, + }, + prefer="resolution=merge-duplicates,return=representation", + allowed_status=[200, 201], + ) + return rows[0] if isinstance(rows, list) and rows else {} + except Exception: + return {} + def submit_intent_tx( self, user_id: str, intent_id: str, tx_hash: str, - from_address: str, + from_address: Optional[str], ) -> Dict[str, Any]: self._ensure_enabled() intent = self.get_intent(user_id, intent_id) + tx_hash_text = str(tx_hash or "").strip().lower() + if intent.status == "confirmed": + if tx_hash_text and tx_hash_text != str(intent.tx_hash or "").strip().lower(): + self._record_duplicate_transaction( + intent=intent, + tx_hash=tx_hash_text, + from_address=from_address, + status="refund_required", + detail="submitted tx after order already paid", + ) + raise PaymentCheckoutError( + 409, + "该订单已支付,请勿重复付款;如已重复转账请联系客服处理退款", + ) if intent.status not in {"created", "submitted"}: raise PaymentCheckoutError(409, f"intent status is {intent.status}, cannot submit") - tx_hash_text = str(tx_hash or "").strip().lower() from_addr = _normalize_address(from_address) if not (tx_hash_text.startswith("0x") and len(tx_hash_text) == 66): raise PaymentCheckoutError(400, "invalid tx_hash") - if not from_addr: + if not from_addr and intent.payment_mode != "direct": raise PaymentCheckoutError(400, "invalid from_address") + self._ensure_tx_hash_unused(tx_hash_text, intent.intent_id) now = _now_utc() try: @@ -1560,7 +1711,9 @@ class PaymentContractCheckoutService: ) raise PaymentCheckoutError(409, "payment intent expired") - if intent.payment_mode == "strict" and intent.allowed_wallet: + if intent.payment_mode == "direct": + from_addr = None + elif intent.payment_mode == "strict" and intent.allowed_wallet: if from_addr != intent.allowed_wallet: raise PaymentCheckoutError( 400, @@ -1592,6 +1745,7 @@ class PaymentContractCheckoutService: "tx_hash": tx_hash_text, "from_address": from_addr, "to_address": intent.receiver_address, + "payment_method": "direct" if intent.payment_mode == "direct" else "wallet", "status": "submitted", "updated_at": now_iso, }, @@ -1669,6 +1823,38 @@ class PaymentContractCheckoutService: } return None + def _extract_direct_transfer_event( + self, receipt: Any, intent: PaymentIntentRecord + ) -> Optional[Dict[str, Any]]: + token_contract = self._get_web3().eth.contract( + address=Web3.to_checksum_address(intent.token_address), + abi=[ERC20_TRANSFER_EVENT_ABI], + ) + try: + events = token_contract.events.Transfer().process_receipt(receipt) + except Exception: + events = [] + if not events: + return None + + expected_to = intent.receiver_address + expected_amount = int(intent.amount_units) + for ev in events: + args = ev.get("args") if isinstance(ev, dict) else getattr(ev, "args", None) + if not args: + continue + payer = _normalize_address(args.get("from")) + receiver = _normalize_address(args.get("to")) + amount = int(args.get("value") or 0) + if receiver == expected_to and amount >= expected_amount: + return { + "from": payer, + "to": receiver, + "token_address": intent.token_address, + "amount_units": amount, + } + return None + def _insert_payment_record( self, user_id: str, @@ -2037,12 +2223,19 @@ class PaymentContractCheckoutService: raise PaymentCheckoutError( 409, f"confirmations not enough: {confirmations}/{self.confirmations}" ) - event_match = self._extract_matching_event(receipt, intent) - event_payer = _normalize_address(event_match.get("payer")) if event_match else None - effective_payer = event_payer or tx_from - routed_via_delegate = bool( - event_match and tx_to and tx_to != intent.receiver_address - ) + is_direct = intent.payment_mode == "direct" + if is_direct: + event_match = self._extract_direct_transfer_event(receipt, intent) + event_payer = _normalize_address(event_match.get("from")) if event_match else None + effective_payer = event_payer or tx_from + routed_via_delegate = False + else: + event_match = self._extract_matching_event(receipt, intent) + event_payer = _normalize_address(event_match.get("payer")) if event_match else None + effective_payer = event_payer or tx_from + routed_via_delegate = bool( + event_match and tx_to and tx_to != intent.receiver_address + ) if tx_to != intent.receiver_address and not event_match: self._mark_intent_failed( user_id=user_id, @@ -2059,7 +2252,9 @@ class PaymentContractCheckoutService: 400, f"tx to mismatch: got={tx_to} expected={intent.receiver_address}", ) - if intent.payment_mode == "strict" and intent.allowed_wallet: + if is_direct: + pass + elif intent.payment_mode == "strict" and intent.allowed_wallet: if effective_payer != intent.allowed_wallet: self._mark_intent_failed( user_id=user_id, @@ -2083,13 +2278,19 @@ class PaymentContractCheckoutService: user_id=user_id, intent=intent, tx_hash=tx_hash_text, - reason="event_mismatch", - detail="OrderPaid event mismatch; ensure contract emits OrderPaid(orderId,payer,planId,token,amount)", + reason="direct_transfer_mismatch" if is_direct else "event_mismatch", + detail=( + "ERC20 Transfer mismatch; ensure token transfer sends enough funds to receiver" + if is_direct + else "OrderPaid event mismatch; ensure contract emits OrderPaid(orderId,payer,planId,token,amount)" + ), extra={"from_address": tx_from, "receiver_actual": tx_to}, ) raise PaymentCheckoutError( 400, - "OrderPaid event mismatch; ensure contract emits OrderPaid(orderId,payer,planId,token,amount)", + "ERC20 Transfer mismatch; ensure token transfer sends enough funds to receiver" + if is_direct + else "OrderPaid event mismatch; ensure contract emits OrderPaid(orderId,payer,planId,token,amount)", ) points_result = self._consume_points_for_intent(user_id, intent) now_iso = _to_iso(_now_utc()) @@ -2109,10 +2310,14 @@ class PaymentContractCheckoutService: "receiver_expected": intent.receiver_address, "matched_via_event": True, } - self._rest( + confirm_rows = self._rest( "PATCH", "payment_intents", - params={"id": f"eq.{intent.intent_id}", "user_id": f"eq.{user_id}"}, + params={ + "id": f"eq.{intent.intent_id}", + "user_id": f"eq.{user_id}", + "status": "in.(created,submitted,failed)", + }, payload={ "status": "confirmed", "tx_hash": tx_hash_text, @@ -2123,6 +2328,31 @@ class PaymentContractCheckoutService: prefer="return=representation", allowed_status=[200], ) + if not isinstance(confirm_rows, list) or not confirm_rows: + refreshed = self.get_intent(user_id, intent.intent_id) + if refreshed.status == "confirmed": + if tx_hash_text != str(refreshed.tx_hash or "").strip().lower(): + self._record_duplicate_transaction( + intent=refreshed, + tx_hash=tx_hash_text, + from_address=tx_from, + to_address=tx_to, + status="refund_required", + detail="order was already confirmed by another transaction", + ) + repaired = self._ensure_confirm_side_effects( + user_id, + refreshed, + str(refreshed.tx_hash or tx_hash_text).strip().lower(), + ) + return { + "intent": refreshed.__dict__, + "already_confirmed": True, + "duplicate_tx_hash": tx_hash_text, + "payment": repaired.get("payment"), + "subscription": repaired.get("subscription"), + } + raise PaymentCheckoutError(409, f"intent status is {refreshed.status}, cannot confirm") tx_rows = self._rest( "POST", "payment_transactions", @@ -2134,6 +2364,7 @@ class PaymentContractCheckoutService: "from_address": tx_from, "to_address": tx_to, "block_number": block_number, + "payment_method": "direct" if is_direct else "wallet", "status": "confirmed", "raw_receipt": json.loads(Web3.to_json(receipt)), "raw_tx": json.loads(Web3.to_json(tx)) if tx is not None else None, diff --git a/tests/test_db_manager_init_once.py b/tests/test_db_manager_init_once.py new file mode 100644 index 00000000..51cd0c14 --- /dev/null +++ b/tests/test_db_manager_init_once.py @@ -0,0 +1,21 @@ +from src.database.db_manager import DBManager + + +def test_db_manager_initializes_each_db_path_once(monkeypatch, tmp_path): + calls = [] + + def fake_init_db(self): + calls.append(self.db_path) + + monkeypatch.setattr(DBManager, "_init_db", fake_init_db) + if hasattr(DBManager, "_initialized_paths"): + DBManager._initialized_paths.clear() # noqa: SLF001 + + path_a = str(tmp_path / "a.db") + path_b = str(tmp_path / "b.db") + + DBManager(path_a) + DBManager(path_a) + DBManager(path_b) + + assert calls == [path_a, path_b] diff --git a/tests/test_direct_payment.py b/tests/test_direct_payment.py new file mode 100644 index 00000000..14fda143 --- /dev/null +++ b/tests/test_direct_payment.py @@ -0,0 +1,275 @@ +from src.payments.contract_checkout import PaymentContractCheckoutService, PaymentIntentRecord + + +def _setup_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_direct_intent_does_not_require_bound_wallet(monkeypatch, tmp_path): + _setup_env(monkeypatch, tmp_path) + service = PaymentContractCheckoutService() + posts = [] + + def fake_rest(method, table, **kwargs): + if method == "GET" and table == "user_wallets": + return [] + if method == "POST" and table == "payment_intents": + payload = dict(kwargs["payload"]) + payload["id"] = "intent-direct-1" + payload["tx_hash"] = None + posts.append(payload) + return [payload] + raise AssertionError((method, table, kwargs)) + + monkeypatch.setattr(service, "_rest", fake_rest) + + result = service.create_intent("user-1", "pro_monthly", payment_mode="direct") + + assert result["intent"]["payment_mode"] == "direct" + assert result["intent"]["allowed_wallet"] is None + assert "direct_payment" in result + assert result["direct_payment"]["receiver_address"] == "0xed2f13aa5ff033c58fb436e178451cd07f693f32" + assert result["direct_payment"]["amount_usdc"] == "5" + assert posts[0]["payment_mode"] == "direct" + + +def test_direct_submit_tx_does_not_require_from_address(monkeypatch, tmp_path): + _setup_env(monkeypatch, tmp_path) + service = PaymentContractCheckoutService() + submitted = {} + + monkeypatch.setattr( + service, + "get_intent", + lambda user_id, intent_id: service._serialize_intent( + { + "id": intent_id, + "plan_code": "pro_monthly", + "plan_id": 101, + "chain_id": 137, + "token_address": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", + "receiver_address": "0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32", + "amount_units": "5000000", + "payment_mode": "direct", + "allowed_wallet": None, + "order_id_hex": "0x" + "1" * 64, + "status": "created", + "expires_at": "2099-01-01T00:00:00+00:00", + "tx_hash": None, + "metadata": {}, + } + ), + ) + + def fake_rest(method, table, **kwargs): + if method == "GET" and table == "payment_transactions": + return [] + if method == "PATCH" and table == "payment_intents": + submitted.update(kwargs["payload"]) + return [{"ok": True}] + if method == "POST" and table == "payment_transactions": + return [kwargs["payload"]] + raise AssertionError((method, table, kwargs)) + + monkeypatch.setattr(service, "_rest", fake_rest) + + result = service.submit_intent_tx("user-1", "intent-direct-1", "0x" + "2" * 64, "") + + assert result["status"] == "submitted" + assert result["from_address"] is None + assert submitted["tx_hash"] == "0x" + "2" * 64 + + + +def test_confirm_direct_transfer_uses_erc20_transfer_without_wallet_binding(monkeypatch, tmp_path): + _setup_env(monkeypatch, tmp_path) + service = PaymentContractCheckoutService() + tx_hash = "0x" + "3" * 64 + intent = PaymentIntentRecord( + intent_id="intent-direct-2", + 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="submitted", + payment_mode="direct", + allowed_wallet=None, + expires_at="2099-01-01T00:00:00+00:00", + tx_hash=tx_hash, + metadata={}, + ) + confirmed_intent = PaymentIntentRecord(**{**intent.__dict__, "status": "confirmed"}) + intents = [intent, confirmed_intent] + rest_calls = [] + + monkeypatch.setattr(service, "get_intent", lambda user_id, intent_id: intents.pop(0) if intents else confirmed_intent) + + class _Eth: + chain_id = 137 + block_number = 20 + + @staticmethod + def get_transaction(_tx_hash): + return { + "to": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", + "from": "0x9999999999999999999999999999999999999999", + } + + class _Web3: + eth = _Eth() + + @staticmethod + def is_connected(): + return True + + monkeypatch.setattr(service, "_get_web3", lambda: _Web3()) + monkeypatch.setattr( + service, + "_wait_receipt", + lambda _tx_hash: { + "status": 1, + "to": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", + "from": "0x9999999999999999999999999999999999999999", + "blockNumber": 10, + }, + ) + monkeypatch.setattr( + service, + "_extract_direct_transfer_event", + lambda receipt, local_intent: { + "from": "0x9999999999999999999999999999999999999999", + "to": local_intent.receiver_address, + "token_address": local_intent.token_address, + "amount_units": local_intent.amount_units, + }, + ) + monkeypatch.setattr(service, "_consume_points_for_intent", lambda user_id, local_intent: {"applied": False}) + monkeypatch.setattr(service, "_select_plan", lambda plan_code: {"duration_days": 30}) + monkeypatch.setattr(service, "_insert_payment_record", lambda **kwargs: {"tx_hash": kwargs["tx_hash"]}) + monkeypatch.setattr(service, "_grant_subscription", lambda **kwargs: {"status": "active", "plan_code": kwargs["plan_code"]}) + monkeypatch.setattr(service, "_notify_telegram", lambda **kwargs: None) + + def fake_rest(method, table, **kwargs): + rest_calls.append((method, table, kwargs)) + if method == "GET" and table == "payment_transactions": + return [] + if method == "PATCH" and table == "payment_intents": + return [{"id": intent.intent_id, "status": "confirmed"}] + if method == "POST" and table == "payment_transactions": + return [kwargs["payload"]] + raise AssertionError((method, table, kwargs)) + + monkeypatch.setattr(service, "_rest", fake_rest) + monkeypatch.setattr(service, "_require_user_wallet", lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("wallet not required"))) + + result = service.confirm_intent_tx("user-1", intent.intent_id, tx_hash) + + assert result["subscription"]["status"] == "active" + assert result["tx"]["event"]["amount_units"] == 5000000 + assert any(call[1] == "payment_transactions" for call in rest_calls) + +def test_submit_rejects_tx_hash_used_by_another_intent(monkeypatch, tmp_path): + _setup_env(monkeypatch, tmp_path) + service = PaymentContractCheckoutService() + tx_hash = "0x" + "4" * 64 + monkeypatch.setattr( + service, + "get_intent", + lambda user_id, intent_id: service._serialize_intent( + { + "id": intent_id, + "plan_code": "pro_monthly", + "plan_id": 101, + "chain_id": 137, + "token_address": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", + "receiver_address": "0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32", + "amount_units": "5000000", + "payment_mode": "direct", + "allowed_wallet": None, + "order_id_hex": "0x" + "1" * 64, + "status": "created", + "expires_at": "2099-01-01T00:00:00+00:00", + "tx_hash": None, + "metadata": {}, + } + ), + ) + + def fake_rest(method, table, **kwargs): + if method == "GET" and table == "payment_transactions": + return [{"intent_id": "other-intent", "tx_hash": tx_hash, "status": "confirmed"}] + raise AssertionError((method, table, kwargs)) + + monkeypatch.setattr(service, "_rest", fake_rest) + + try: + service.submit_intent_tx("user-1", "intent-direct-3", tx_hash, "") + except Exception as exc: + assert getattr(exc, "status_code", None) == 409 + assert "tx_hash already used" in getattr(exc, "detail", "") + else: + raise AssertionError("expected duplicate tx_hash rejection") + + +def test_submit_marks_late_tx_as_refund_required_after_intent_paid(monkeypatch, tmp_path): + _setup_env(monkeypatch, tmp_path) + service = PaymentContractCheckoutService() + paid_tx = "0x" + "5" * 64 + late_tx = "0x" + "6" * 64 + duplicate_rows = [] + monkeypatch.setattr( + service, + "get_intent", + lambda user_id, intent_id: service._serialize_intent( + { + "id": intent_id, + "plan_code": "pro_monthly", + "plan_id": 101, + "chain_id": 137, + "token_address": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", + "receiver_address": "0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32", + "amount_units": "5000000", + "payment_mode": "direct", + "allowed_wallet": None, + "order_id_hex": "0x" + "1" * 64, + "status": "confirmed", + "expires_at": "2099-01-01T00:00:00+00:00", + "tx_hash": paid_tx, + "metadata": {}, + } + ), + ) + + def fake_rest(method, table, **kwargs): + if method == "POST" and table == "payment_transactions": + duplicate_rows.append(kwargs["payload"]) + return [kwargs["payload"]] + raise AssertionError((method, table, kwargs)) + + monkeypatch.setattr(service, "_rest", fake_rest) + + try: + service.submit_intent_tx("user-1", "intent-direct-paid", late_tx, "") + except Exception as exc: + assert getattr(exc, "status_code", None) == 409 + assert "已支付" in getattr(exc, "detail", "") + else: + raise AssertionError("expected already-paid rejection") + + assert duplicate_rows[0]["tx_hash"] == late_tx + assert duplicate_rows[0]["status"] == "refund_required" + assert duplicate_rows[0]["payment_method"] == "direct" diff --git a/tests/test_telegram_group_pricing.py b/tests/test_telegram_group_pricing.py new file mode 100644 index 00000000..31ebe046 --- /dev/null +++ b/tests/test_telegram_group_pricing.py @@ -0,0 +1,165 @@ +import hashlib +import hmac +import time +from decimal import Decimal + +import pytest + +from src.auth.telegram_group_pricing import ( + TelegramGroupPricing, + verify_telegram_login_payload, +) +from src.payments.contract_checkout import PaymentContractCheckoutService + + +def _signed_payload(bot_token: str, **overrides): + payload = { + "id": "12345", + "first_name": "Ada", + "username": "ada", + "auth_date": str(int(time.time())), + } + payload.update({key: str(value) for key, value in overrides.items()}) + data_check = "\n".join( + f"{key}={payload[key]}" for key in sorted(payload.keys()) if key != "hash" + ) + secret = hashlib.sha256(bot_token.encode("utf-8")).digest() + payload["hash"] = hmac.new( + secret, + data_check.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return payload + + +def test_verify_telegram_login_payload_accepts_valid_hash(monkeypatch): + payload = _signed_payload("bot-token") + + result = verify_telegram_login_payload(payload, "bot-token", max_age_sec=60) + + assert result["telegram_id"] == 12345 + assert result["username"] == "ada" + + +def test_verify_telegram_login_payload_rejects_tampered_hash(): + payload = _signed_payload("bot-token") + payload["id"] = "999" + + with pytest.raises(ValueError, match="invalid telegram login hash"): + verify_telegram_login_payload(payload, "bot-token", max_age_sec=60) + + +def test_group_pricing_treats_member_status_as_group_member(monkeypatch): + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "bot-token") + monkeypatch.setenv("POLYWEATHER_TELEGRAM_GROUP_ID", "-100123") + monkeypatch.setenv("POLYWEATHER_GROUP_MEMBER_PRICE_USDC", "5") + monkeypatch.setenv("POLYWEATHER_PUBLIC_PRICE_USDC", "10") + + def fake_get(url, params, timeout): + assert url.endswith("/getChatMember") + assert params == {"chat_id": "-100123", "user_id": 12345} + + class Response: + status_code = 200 + + @staticmethod + def json(): + return {"ok": True, "result": {"status": "member"}} + + return Response() + + monkeypatch.setattr("src.auth.telegram_group_pricing.requests.get", fake_get) + + pricing = TelegramGroupPricing() + result = pricing.resolve_price_for_telegram_id(12345) + + assert result["is_group_member"] is True + assert result["amount_usdc"] == "5" + assert result["telegram_status"] == "member" + + +def test_group_pricing_treats_left_status_as_public_price(monkeypatch): + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "bot-token") + monkeypatch.setenv("POLYWEATHER_TELEGRAM_GROUP_ID", "-100123") + monkeypatch.setenv("POLYWEATHER_GROUP_MEMBER_PRICE_USDC", "5") + monkeypatch.setenv("POLYWEATHER_PUBLIC_PRICE_USDC", "10") + + def fake_get(url, params, timeout): + class Response: + status_code = 200 + + @staticmethod + def json(): + return {"ok": True, "result": {"status": "left"}} + + return Response() + + monkeypatch.setattr("src.auth.telegram_group_pricing.requests.get", fake_get) + + pricing = TelegramGroupPricing() + result = pricing.resolve_price_for_telegram_id(12345) + + assert result["is_group_member"] is False + assert result["amount_usdc"] == "10" + assert result["telegram_status"] == "left" + + +def test_payment_plan_uses_linked_telegram_group_price(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_DB_PATH", str(tmp_path / "payments.db")) + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "bot-token") + monkeypatch.setenv("POLYWEATHER_TELEGRAM_GROUP_ID", "-100123") + monkeypatch.setenv("POLYWEATHER_GROUP_MEMBER_PRICE_USDC", "5") + monkeypatch.setenv("POLYWEATHER_PUBLIC_PRICE_USDC", "10") + monkeypatch.setenv( + "POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON", + '[{"code":"usdc_e","address":"0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174","decimals":6,"receiver_contract":"0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32","is_default":true}]', + ) + service = PaymentContractCheckoutService() + service._db.bind_supabase_identity(12345, "user-1", "u@example.com") + + monkeypatch.setattr( + "src.auth.telegram_group_pricing.TelegramGroupPricing.resolve_price_for_telegram_id", + lambda self, telegram_id: { + "configured": True, + "telegram_id": telegram_id, + "is_group_member": True, + "telegram_status": "member", + "amount_usdc": "5", + "pricing_source": "telegram_group_member", + }, + ) + + plan = service._select_plan("pro_monthly") + priced = service._apply_telegram_group_pricing("user-1", plan) + + assert priced["amount_usdc"] == "5" + assert priced["amount_usdc_decimal"] == Decimal("5") + assert priced["telegram_pricing"]["pricing_source"] == "telegram_group_member" + + +def test_payment_plan_uses_public_price_without_telegram_link(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_DB_PATH", str(tmp_path / "payments.db")) + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "bot-token") + monkeypatch.setenv("POLYWEATHER_TELEGRAM_GROUP_ID", "-100123") + monkeypatch.setenv("POLYWEATHER_GROUP_MEMBER_PRICE_USDC", "5") + monkeypatch.setenv("POLYWEATHER_PUBLIC_PRICE_USDC", "10") + monkeypatch.setenv( + "POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON", + '[{"code":"usdc_e","address":"0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174","decimals":6,"receiver_contract":"0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32","is_default":true}]', + ) + service = PaymentContractCheckoutService() + + plan = service._select_plan("pro_monthly") + priced = service._apply_telegram_group_pricing("user-1", plan) + + assert priced["amount_usdc"] == "10" + assert priced["amount_usdc_decimal"] == Decimal("10") + assert priced["telegram_pricing"]["pricing_source"] == "telegram_public" diff --git a/web/core.py b/web/core.py index ae3519b9..1b5a68aa 100644 --- a/web/core.py +++ b/web/core.py @@ -394,13 +394,23 @@ class CreatePaymentIntentRequest(BaseModel): class SubmitPaymentTxRequest(BaseModel): tx_hash: str = Field(..., min_length=10) - from_address: str = Field(..., min_length=8) + from_address: Optional[str] = None class ConfirmPaymentTxRequest(BaseModel): tx_hash: Optional[str] = None +class TelegramLoginRequest(BaseModel): + id: int + first_name: Optional[str] = None + last_name: Optional[str] = None + username: Optional[str] = None + photo_url: Optional[str] = None + auth_date: int + hash: str = Field(..., min_length=10) + + class AnalyticsEventRequest(BaseModel): event_type: str = Field(..., min_length=3, max_length=64) client_id: Optional[str] = Field(default=None, max_length=128) diff --git a/web/routers/auth.py b/web/routers/auth.py index 7e6d83de..395722cb 100644 --- a/web/routers/auth.py +++ b/web/routers/auth.py @@ -2,7 +2,8 @@ from fastapi import APIRouter, Request -from web.services.auth_api import get_auth_me_payload +from web.core import TelegramLoginRequest +from web.services.auth_api import get_auth_me_payload, login_with_telegram router = APIRouter(tags=["auth"]) @@ -10,3 +11,8 @@ router = APIRouter(tags=["auth"]) @router.get("/api/auth/me") async def auth_me(request: Request): return get_auth_me_payload(request) + + +@router.post("/api/auth/telegram/login") +async def auth_telegram_login(request: Request, body: TelegramLoginRequest): + return login_with_telegram(request, body) diff --git a/web/services/auth_api.py b/web/services/auth_api.py index 9f26460f..7a443d33 100644 --- a/web/services/auth_api.py +++ b/web/services/auth_api.py @@ -4,8 +4,11 @@ from __future__ import annotations from typing import Any, Dict -from fastapi import Request +from fastapi import HTTPException, Request +from src.auth.telegram_group_pricing import TelegramGroupPricing +from src.database.db_manager import DBManager +from web.core import TelegramLoginRequest import web.routes as legacy_routes @@ -75,6 +78,22 @@ def get_auth_me_payload(request: Request) -> Dict[str, Any]: points = legacy_routes._resolve_auth_points(request) weekly_profile = legacy_routes._resolve_weekly_profile(request) + telegram_pricing = None + if user_id: + try: + pricing = TelegramGroupPricing() + if pricing.configured: + linked = DBManager().get_user_by_supabase_user_id(user_id) + telegram_id = ( + int(linked.get("telegram_id") or 0) + if isinstance(linked, dict) + else 0 + ) + telegram_pricing = pricing.resolve_price_for_telegram_id( + telegram_id or None + ) + except Exception: + telegram_pricing = None return { "authenticated": bool(user_id), @@ -105,4 +124,36 @@ def get_auth_me_payload(request: Request) -> Dict[str, Any]: "subscription_total_expires_at": subscription_total_expires_at, "subscription_queued_days": subscription_queued_days, "subscription_queued_count": subscription_queued_count, + "telegram_pricing": telegram_pricing, + } + + +def login_with_telegram(request: Request, body: TelegramLoginRequest) -> Dict[str, Any]: + legacy_routes._assert_entitlement(request) + identity = legacy_routes._require_supabase_identity(request) + pricing = TelegramGroupPricing() + if not pricing.configured: + raise HTTPException(status_code=503, detail="telegram login is not configured") + try: + payload = body.model_dump() if hasattr(body, "model_dump") else body.dict() + verified = pricing.verify_login_payload(payload) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + telegram_id = int(verified["telegram_id"]) + username = str(verified.get("username") or "").strip() + db = DBManager() + db.upsert_user(telegram_id, username) + bind_result = db.bind_supabase_identity( + telegram_id=telegram_id, + supabase_user_id=identity["user_id"], + supabase_email=identity.get("email") or "", + ) + if not bind_result.get("ok"): + raise HTTPException(status_code=409, detail=str(bind_result.get("reason") or "telegram bind failed")) + price = pricing.resolve_price_for_telegram_id(telegram_id) + return { + "ok": True, + "telegram": verified, + "binding": bind_result, + "telegram_pricing": price, }