feat: add telegram group pricing and direct payments
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 $$;
|
||||
@@ -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(),
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
@@ -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"
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user