feat: add telegram group pricing and direct payments

This commit is contained in:
2569718930@qq.com
2026-05-18 16:18:26 +08:00
parent d99a3c25e9
commit 6041d25f23
18 changed files with 1479 additions and 35 deletions
+4
View File
@@ -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=
+4
View File
@@ -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=
@@ -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
+1
View File
@@ -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
@@ -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",
});
}
}
+340 -2
View File
@@ -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<CreatedIntent["direct_payment"] | null>(null);
const [manualTxHash, setManualTxHash] = useState("");
const [telegramLoginBusy, setTelegramLoginBusy] = useState(false);
const telegramLoginWidgetRef = useRef<HTMLDivElement | null>(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<string, unknown>)[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() {
<p className="text-slate-400 text-sm mb-6">
{copy.telegramHint}
</p>
<div className="mb-5 rounded-2xl border border-emerald-400/25 bg-emerald-500/8 px-4 py-3">
<p className="text-xs font-bold text-emerald-200">
Telegram
</p>
<p className="mt-1 text-[11px] leading-5 text-emerald-100/75">
Telegram 5U 10U
</p>
<div className="mt-3 flex flex-wrap items-center gap-3">
<div ref={telegramLoginWidgetRef} />
{telegramLoginBusy ? (
<span className="text-[11px] text-cyan-200">
Telegram...
</span>
) : null}
{backend?.telegram_pricing?.amount_usdc ? (
<span className="rounded-full border border-white/10 bg-black/25 px-3 py-1.5 text-[11px] font-bold text-white">
: {backend.telegram_pricing.amount_usdc}U
{backend.telegram_pricing.is_group_member ? " · 群成员" : " · 普通价"}
</span>
) : null}
</div>
</div>
{/* Channel Upgrade / Migration Notice — Pro users only */}
{isSubscribed ? (
@@ -2934,6 +3201,77 @@ export function AccountCenter() {
</div>
</div>
)}
<div className="mb-5 rounded-2xl border border-emerald-400/25 bg-emerald-500/8 p-4">
<div className="mb-2 flex items-center justify-between gap-3">
<div>
<p className="text-xs font-bold text-emerald-200">
</p>
<p className="mt-1 text-[11px] leading-5 text-emerald-100/75">
tx hash 使
</p>
</div>
<button
type="button"
onClick={() => void createManualPaymentIntent()}
disabled={paymentBusy || !isAuthenticated}
className="shrink-0 rounded-xl border border-emerald-400/35 bg-emerald-500/15 px-3 py-2 text-[11px] font-bold text-emerald-100 transition-all hover:bg-emerald-500/25 disabled:opacity-50"
>
</button>
</div>
{manualPayment ? (
<div className="mt-3 space-y-3 rounded-xl border border-white/10 bg-black/25 p-3">
<div>
<p className="text-[10px] uppercase tracking-widest text-slate-500">
Amount
</p>
<p className="font-mono text-sm font-bold text-white">
{manualPayment.amount_usdc}{" "}
{manualPayment.token_symbol || selectedTokenLabel}
</p>
</div>
<div>
<p className="text-[10px] uppercase tracking-widest text-slate-500">
Receiver
</p>
<div className="mt-1 flex gap-2">
<code className="min-w-0 flex-1 truncate rounded-lg bg-black/40 px-2 py-2 font-mono text-[11px] text-blue-200">
{manualPayment.receiver_address}
</code>
<button
type="button"
onClick={() =>
handleCopy(manualPayment.receiver_address)
}
className="rounded-lg bg-blue-600 px-2 text-xs font-bold text-white"
>
</button>
</div>
</div>
<div>
<p className="text-[10px] uppercase tracking-widest text-slate-500">
Tx Hash
</p>
<input
value={manualTxHash}
onChange={(event) => 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"
/>
</div>
<button
type="button"
onClick={() => void submitManualPaymentTx()}
disabled={paymentBusy}
className="w-full rounded-xl bg-emerald-600 px-3 py-2 text-xs font-bold text-white transition-all hover:bg-emerald-500 disabled:opacity-50"
>
tx hash
</button>
</div>
) : null}
</div>
{boundWallets.length ? (
<div className="space-y-3">
{boundWallets.map((w) => (
@@ -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({
</>
)}
</button>
{onManualPay ? (
<button
onClick={onManualPay}
disabled={payBusy}
className={s.ctaBtn}
style={{
marginTop: 10,
background: "rgba(15, 23, 42, 0.78)",
border: "1px solid rgba(148, 163, 184, 0.28)",
}}
>
{payBusy ? (
<Loader2 size={18} className="animate-spin" />
) : (
<>
<Wallet size={17} />
{manualPayLabel || (isEn ? "Manual transfer" : "手动转账")}
<ArrowRight size={17} />
</>
)}
</button>
) : null}
{onManualPay ? (
<p
style={{
marginTop: 10,
color: "rgba(203, 213, 225, 0.72)",
fontSize: 12,
lineHeight: 1.5,
textAlign: "center",
}}
>
{isEn
? "Choose one payment method only. Do not pay again after the order is completed."
: "请只选择一种支付方式;订单完成后请勿重复付款。"}
</p>
) : null}
</div>
</div>
@@ -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 $$;
+4 -3
View File
@@ -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(),
+147
View File
@@ -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",
}
+17 -2
View File
@@ -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:
+256 -25
View File
@@ -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,
+21
View File
@@ -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]
+275
View File
@@ -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"
+165
View File
@@ -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"
+11 -1
View File
@@ -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)
+7 -1
View File
@@ -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)
+52 -1
View File
@@ -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,
}