Simplify account payment flow and harden direct transfers

This commit is contained in:
2569718930@qq.com
2026-06-14 06:58:45 +08:00
parent 715d79c9b5
commit 0f497ab822
15 changed files with 310 additions and 167 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气
- 概率分布层(基于 DEB 融合 + 高斯分桶)
- 实时终端多城市图表(跑道/官方站点/DEB/概率带)
- 历史对账 + 未来日期分析
- 全平台智能气象推送
- Telegram 缓存推送与业务频道通知
## 4. 收费与积分规则(默认)
@@ -16,8 +16,8 @@ const FAQ_ITEMS = [
{
q_zh: "Pro 包含哪些功能?",
q_en: "What features does Pro include?",
a_zh: "开通后可解锁:天气决策台、多城市图表巡检、未来日期分析、全平台智能气象推送。",
a_en: "Unlocks: weather decision terminal, multi-city chart monitoring, future-date analysis, and cross-platform smart weather push.",
a_zh: "开通后可解锁:结算源优先终端、多城市图表巡检、未来日期分析、Telegram 缓存推送。",
a_en: "Unlocks: settlement-source-first terminal, multi-city chart monitoring, future-date analysis, and Telegram cached alerts.",
},
{
q_zh: "当前订阅价格是多少?",
+24 -79
View File
@@ -41,11 +41,9 @@ import {
} from "@/lib/supabase/client";
import { markAnalyticsOnce, trackAppEvent } from "@/lib/app-analytics";
import { useI18n } from "@/hooks/useI18n";
import { UnlockProOverlay } from "@/components/subscription/UnlockProOverlay";
import type { AuthMeResponse } from "./types";
import {
SUBSCRIPTION_HELP_HREF,
TELEGRAM_BOT_URL,
TELEGRAM_GROUP_URL,
TELEGRAM_TOPICS_GROUP_URL,
@@ -85,7 +83,6 @@ export function AccountCenter() {
const [trialValueReplay, setTrialValueReplay] = useState(() => readTrialValueReplay());
// ── Shared state (declared in component, written by hook via setters) ─
const [showOverlay, setShowOverlay] = useState(false);
const [usePoints, setUsePoints] = useState(true);
const [errorText, setErrorText] = useState("");
const [updatedAt, setUpdatedAt] = useState<string>("");
@@ -104,7 +101,6 @@ export function AccountCenter() {
paymentInfo,
paymentError,
lastIntentId,
lastTxHash,
lastPaymentStartedAt,
telegramBindOpening,
telegramBindUrl,
@@ -158,7 +154,6 @@ export function AccountCenter() {
allowedPaymentHosts,
currentPaymentHost,
paymentHostAllowed,
selectedPlan,
selectedPaymentToken,
selectedTokenLabel,
availableTokenList,
@@ -168,7 +163,6 @@ export function AccountCenter() {
resolvedSelectedTokenAddress,
paymentReceiverAddress,
paymentWalletLabel,
hasPayingWallet,
totalPoints,
billing,
@@ -177,11 +171,9 @@ export function AccountCenter() {
loadPaymentSnapshot,
connectAndBindWallet,
handleUnbindWallet,
createIntentAndPay,
createManualPaymentIntent,
submitManualPaymentTx,
validateTxHash,
handleOverlayCheckout,
createTelegramBotBindCommand,
openTelegramBotBindLink,
} = useAccountPayment({
@@ -195,8 +187,6 @@ export function AccountCenter() {
setBackend,
setErrorText,
setUpdatedAt,
showOverlay,
setShowOverlay,
usePoints,
setUsePoints,
});
@@ -408,7 +398,7 @@ export function AccountCenter() {
);
const paymentFeatureReady = paymentReadyForRecovery;
const canTrialUpgrade = Boolean(isSubscribed && isTrialSubscription);
const canOpenCheckoutOverlay = Boolean(
const canStartPayment = Boolean(
paymentFeatureReady &&
(canTrialUpgrade || !isSubscribed || showExpiringSoon || showExpiredReminder),
);
@@ -441,14 +431,7 @@ export function AccountCenter() {
{ plan_code: "pro_monthly", plan_id: 101, amount_usdc: "29.9", duration_days: 30 },
{ plan_code: "pro_quarterly", plan_id: 102, amount_usdc: "79.9", duration_days: 90 },
];
const selectedPlanDurationDays = Number(selectedPlan?.duration_days || 30);
const overlayPlanLabel =
selectedPlanCode === "pro_quarterly"
? copy.quarterlyPlan
: copy.monthlyPlan;
const overlayPeriodLabel = isEn
? `/ ${selectedPlanDurationDays} days`
: `/ ${selectedPlanDurationDays}`;
const manualTxHashReady = /^0x[a-fA-F0-9]{64}$/.test(manualTxHash.trim());
const trialValueReplaySummary = useMemo(
() => buildTrialValueReplaySummary(trialValueReplay, {
isEn,
@@ -466,9 +449,7 @@ export function AccountCenter() {
referralCodeInput.trim(),
);
// ── Payment overlay tracking effect ──────────────────────
useEffect(() => {
if (!showOverlay || !canOpenCheckoutOverlay) return;
const focusPaymentManagement = useCallback(() => {
trackAppEvent("paywall_viewed", {
entry: "account_center",
user_state: isAuthenticated ? "logged_in" : "guest",
@@ -476,26 +457,25 @@ export function AccountCenter() {
expiring_soon: showExpiringSoon,
subscription_plan_code: planCode || null,
});
const paymentSection = document.getElementById("payment-management");
paymentSection?.scrollIntoView({ behavior: "smooth", block: "start" });
}, [
isAuthenticated,
canOpenCheckoutOverlay,
planCode,
showExpiredReminder,
showExpiringSoon,
showOverlay,
]);
useEffect(() => {
if (showOverlay || !canOpenCheckoutOverlay) return;
if (searchParams.get("checkout") === "1") {
setShowOverlay(true);
window.setTimeout(focusPaymentManagement, 0);
}
}, [canOpenCheckoutOverlay, searchParams, showOverlay]);
}, [focusPaymentManagement, searchParams]);
useEffect(() => {
if (!isTrialSubscription) return;
setTrialValueReplay(readTrialValueReplay());
}, [authUserId, isTrialSubscription, showOverlay]);
}, [authUserId, isTrialSubscription]);
const openTrialValueReplayCheckout = useCallback(() => {
trackAppEvent("paywall_feature_clicked", {
@@ -507,8 +487,8 @@ export function AccountCenter() {
replay_rows_available: trialValueReplay.rowsAvailableMax,
subscription_plan_code: planCode || null,
});
setShowOverlay(true);
}, [authUserId, planCode, trialValueReplay]);
focusPaymentManagement();
}, [authUserId, focusPaymentManagement, planCode, trialValueReplay]);
// ── Referral points display ────────────────────────────
const referralRewardPointsRaw = Number(referral?.reward_points ?? 3500);
@@ -641,9 +621,10 @@ export function AccountCenter() {
</div>
</div>
<div className="flex items-center gap-2">
{!showOverlay && canOpenCheckoutOverlay && (
{canStartPayment && (
<button
onClick={() => setShowOverlay(true)}
type="button"
onClick={focusPaymentManagement}
className="flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-50 px-4 py-2 text-sm font-semibold text-amber-700 transition-all hover:bg-amber-100"
>
<Crown size={16} />{" "}
@@ -709,7 +690,7 @@ export function AccountCenter() {
</div>
<button
type="button"
onClick={() => setShowOverlay(true)}
onClick={focusPaymentManagement}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-amber-300 bg-white px-4 py-2 text-sm font-bold text-amber-800 transition-all hover:bg-amber-100"
>
<Crown size={16} />
@@ -719,7 +700,7 @@ export function AccountCenter() {
</div>
)}
{isTrialSubscription && canOpenCheckoutOverlay && (
{isTrialSubscription && canStartPayment && (
<section className="lg:col-span-12 overflow-hidden rounded-2xl border border-amber-200 bg-white shadow-sm">
<div className="grid gap-0 md:grid-cols-[minmax(0,1fr)_auto]">
<div className="min-w-0 p-6">
@@ -899,7 +880,7 @@ export function AccountCenter() {
{/* Subscription Info & Paywall */}
<div className="lg:col-span-12 relative">
<div
className={`grid grid-cols-1 md:grid-cols-2 gap-6 transition-all duration-700 ${canOpenCheckoutOverlay && showOverlay ? "blur-md grayscale-[0.3] opacity-30 select-none pointer-events-none" : ""}`}
className="grid grid-cols-1 md:grid-cols-2 gap-6 transition-all duration-700"
>
<section className="space-y-3 rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<h3 className="mb-4 text-sm font-bold uppercase text-blue-700">
@@ -990,43 +971,6 @@ export function AccountCenter() {
) : null}
</section>
</div>
{/* Paywall Mask */}
{canOpenCheckoutOverlay && showOverlay && (
<div className="absolute inset-0 z-30 flex items-center justify-center p-4">
<UnlockProOverlay
points={totalPoints}
planPriceUsd={billing.planAmount}
planLabel={overlayPlanLabel}
periodLabel={overlayPeriodLabel}
usePoints={usePoints}
onToggleUsePoints={() => setUsePoints((prev) => !prev)}
billing={{
pointsEnabled: billing.pointsEnabled,
isEligible: billing.canRedeem,
pointsUsed: billing.pointsUsed,
discountAmount: billing.discountAmount,
finalPrice: billing.payAmount,
maxDiscountUsd: billing.maxDiscountUsdc,
pointsPerUsd: billing.pointsPerUsdc,
}}
onPay={() => void handleOverlayCheckout()}
onManualPay={() => void createManualPaymentIntent()}
onClose={() => setShowOverlay(false)}
payBusy={paymentBusy}
payLabel={hasPayingWallet ? copy.payNow : copy.connectAndPay}
manualPayLabel="手动转账"
locale={locale}
errorText={paymentError || undefined}
infoText={paymentInfo || undefined}
txHash={lastTxHash || undefined}
chainId={selectedPaymentChainId || paymentConfig?.chain_id || 137}
paymentTokenLabel={selectedTokenLabel}
faqHref={SUBSCRIPTION_HELP_HREF}
telegramGroupUrl=""
/>
</div>
)}
</div>
<AccountFeedbackPanel
@@ -1058,8 +1002,8 @@ export function AccountCenter() {
</p>
<button
type="button"
onClick={() => setShowOverlay(true)}
disabled={!canOpenCheckoutOverlay}
onClick={focusPaymentManagement}
disabled={!canStartPayment}
className="mt-5 inline-flex items-center gap-2 rounded-xl border border-amber-700 bg-amber-600 px-4 py-3 text-xs font-bold text-white hover:bg-amber-700 disabled:cursor-not-allowed disabled:opacity-50"
>
<Crown size={14} />
@@ -1183,6 +1127,7 @@ export function AccountCenter() {
</div>
) : null}
<div
id="payment-management"
data-testid="payment-management-grid"
className={`grid gap-6 lg:items-start ${
hasTelegramPanel ? "" : "lg:grid-cols-[minmax(0,1fr)_minmax(320px,380px)]"
@@ -1694,6 +1639,10 @@ export function AccountCenter() {
"amount_insufficient"
? copy
.verifyAmountLow
: txValidation.reason ===
"direct_self_transfer"
? copy
.verifySelfTransfer
: txValidation.reason ===
"tx_reverted"
? copy
@@ -1711,11 +1660,7 @@ export function AccountCenter() {
onClick={() =>
void submitManualPaymentTx()
}
disabled={
paymentBusy ||
!(txValidation.checked &&
txValidation.valid === true)
}
disabled={paymentBusy || !manualTxHashReady}
className="w-full rounded-xl border border-emerald-700 bg-emerald-600 px-3 py-2 text-xs font-bold text-white transition-all hover:bg-emerald-700 disabled:opacity-50"
>
{copy.paymentManualSubmit}
@@ -27,10 +27,6 @@ export function runTests() {
path.join(projectRoot, "components", "account", "useBilling.ts"),
"utf8",
);
const unlockProOverlay = fs.readFileSync(
path.join(projectRoot, "components", "subscription", "UnlockProOverlay.tsx"),
"utf8",
);
const telegramPricing = fs.readFileSync(
path.join(projectRoot, "components", "account", "telegram-pricing.ts"),
"utf8",
@@ -93,17 +89,16 @@ export function runTests() {
!accountCenter.includes(["private", "Group", "Monthly", "Plan"].join("")) &&
!accountCopy.includes(["Private", "group", "monthly"].join(" ")) &&
!accountCopy.includes(["私", "密", "群", "月", "付"].join("")),
"account plan card and checkout overlay should not expose a separate discounted monthly label",
"account plan card should not expose a separate discounted monthly label",
);
assert(
accountCenter.includes("overlayPlanLabel") &&
accountCenter.includes("overlayPeriodLabel") &&
accountCenter.includes("displayPlanList.map") &&
accountCenter.includes("plan.amount_usdc") &&
accountCenter.includes("USDC") &&
!accountCenter.includes(`copy.${["private", "Group", "Monthly", "Plan"].join("")}`) &&
unlockProOverlay.includes("planLabel") &&
unlockProOverlay.includes("USDC") &&
!unlockProOverlay.includes("<span className={s.price}>${planPriceUsd.toFixed(2)}</span>") &&
!unlockProOverlay.includes('<span className={s.summaryUnit}>USD</span>'),
"checkout overlay must display payment amounts as USDC without exposing the discounted-price source label",
!accountCenter.includes("overlayPlanLabel") &&
!accountCenter.includes("overlayPeriodLabel"),
"payment management must display payment amounts as USDC without relying on the removed checkout overlay",
);
assert(
types.includes("ReferralSummary") &&
@@ -80,10 +80,14 @@ export function runTests() {
"AccountCenter must validate backend-returned manual payment receiver before displaying it",
);
assert(
/!\(\s*txValidation\.checked\s*&&\s*txValidation\.valid === true\s*\)/.test(
accountCenterSource,
),
"manual payment submit button must require checked && valid === true",
accountCenterSource.includes("manualTxHashReady") &&
accountCenterSource.includes("/^0x[a-fA-F0-9]{64}$/") &&
!/!\(\s*txValidation\.checked\s*&&\s*txValidation\.valid === true\s*\)/.test(
accountCenterSource,
) &&
paymentFlowSource.includes("const submitRes = await fetch(`/api/payments/intents/${intentIdVal}/submit`") &&
paymentFlowSource.includes("const confirmRes = await fetch(`/api/payments/intents/${intentIdVal}/confirm`"),
"manual payment submit button may proceed with a well-formed tx hash, but backend submit/confirm must remain the authority",
);
assert(
paymentFlowSource.includes("validateTxHash") &&
@@ -152,14 +152,10 @@ export function runTests() {
);
assert(
accountCenterSource.includes(
'import { UnlockProOverlay } from "@/components/subscription/UnlockProOverlay";',
),
"checkout overlay must be in the account bundle, not lazy-loaded after the user clicks pay",
);
assert(
!/const\s+UnlockProOverlay\s*=\s*dynamic\s*\(/.test(accountCenterSource),
"checkout overlay must not be dynamically imported; stale deployments can make the lazy chunk fail at pay time",
!accountCenterSource.includes("UnlockProOverlay") &&
!accountCenterSource.includes("showOverlay") &&
accountCenterSource.includes("focusPaymentManagement"),
"account page must use the payment management section as the only checkout surface instead of a fixed Pro overlay",
);
assert(
!/STATIC_ASSETS\s*=\s*\[[^\]]*["']\/_next\//s.test(serviceWorkerSource),
@@ -416,6 +412,12 @@ export function runTests() {
.length >= 3,
"manual payment mutations must require a valid Supabase bearer token instead of forwarding unauthenticated requests that surface raw backend JSON",
);
assert(
accountCenterSource.includes("manualTxHashReady") &&
accountCenterSource.includes("/^0x[a-fA-F0-9]{64}$/") &&
!accountCenterSource.includes("txValidation.valid === true)"),
"manual transfer submit button must allow a well-formed tx hash even when advisory pre-validation is unavailable",
);
assert(
paymentFlowSource.includes("verifyPaymentAuthReady") &&
paymentFlowSource.indexOf("await verifyPaymentAuthReady()") <
@@ -28,14 +28,14 @@ export function runTests() {
accountCenter.includes("canTrialUpgrade") &&
accountCenter.includes("isTrialSubscription") &&
accountCenter.includes("canTrialUpgrade || !isSubscribed"),
"trial subscribers must be allowed to open the Pro checkout before expiry",
"trial subscribers must be allowed to enter payment management before expiry",
);
assert(
accountCenter.includes("useSearchParams") &&
accountCenter.includes('searchParams.get("checkout") === "1"') &&
accountCenter.includes("setShowOverlay(true)"),
"account page must support /account?checkout=1 for direct upgrade entry",
accountCenter.includes("focusPaymentManagement"),
"account page must support /account?checkout=1 by focusing payment management",
);
assert(
@@ -262,6 +262,9 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
? "Please enter a valid tx hash."
: "请输入有效的 tx hash。",
verifying: isEn ? "Verifying..." : "验证中...",
verifyUnavailable: isEn
? "Pre-check is temporarily unavailable. You can still submit; the backend will verify on-chain before activation."
: "预验证暂时不可用。你仍可提交,后端会在开通前再次链上校验。",
verifyAddressMatch: isEn
? "Receiver address and amount match"
: "收款地址和金额匹配",
@@ -277,6 +280,9 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
verifyTxReverted: isEn
? "This transaction was reverted."
: "该交易已回滚",
verifySelfTransfer: isEn
? "This is a receiver self-transfer, not a user payment. Please transfer from your own wallet to the receiver address."
: "这笔是收款地址自转,不是用户付款。请从你自己的钱包转到收款地址。",
verifyFailed: isEn ? "Verification failed: " : "验证失败: ",
verifyUnknown: isEn ? "Unknown error" : "未知错误",
// ── Telegram bind messages ────────────────────────────────────────
-4
View File
@@ -208,10 +208,6 @@ export type Eip6963ProviderDetail = {
provider: EvmProvider;
};
export type ConnectBindOptions = {
openOverlayAfterBind?: boolean;
};
export type PaymentRecoveryState = {
intentId: string;
txHash: string;
@@ -54,8 +54,6 @@ export interface UseAccountPaymentParams {
setBackend: Dispatch<SetStateAction<AuthMeResponse | null>>;
setErrorText: (text: string) => void;
setUpdatedAt: (text: string) => void;
showOverlay: boolean;
setShowOverlay: (v: boolean) => void;
usePoints: boolean;
setUsePoints: (v: boolean) => void;
}
@@ -73,8 +71,6 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
setBackend,
setErrorText,
setUpdatedAt,
showOverlay,
setShowOverlay,
usePoints,
setUsePoints,
} = params;
@@ -445,7 +441,6 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
setPaymentBusy,
setPaymentInfo,
setPaymentError,
setShowOverlay,
clearPaymentMessages,
authIsAuthenticated,
getValidAccessToken,
@@ -521,7 +516,6 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
setLastIntentId,
setLastTxHash,
setLastPaymentStartedAt,
setShowOverlay,
setManualPayment,
setManualTxHash,
setTxValidation,
@@ -634,7 +628,6 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
createManualPaymentIntent: paymentFlow.createManualPaymentIntent,
submitManualPaymentTx: paymentFlow.submitManualPaymentTx,
validateTxHash: paymentFlow.validateTxHash,
handleOverlayCheckout: paymentFlow.handleOverlayCheckout,
createTelegramBotBindCommand: billing.createTelegramBotBindCommand,
openTelegramBotBindLink: billing.openTelegramBotBindLink,
};
+17 -32
View File
@@ -4,7 +4,6 @@ import { useCallback, useMemo } from "react";
import type {
AuthMeResponse,
BoundWallet,
ConnectBindOptions,
CreatedIntent,
EvmProvider,
IntentStatusResponse,
@@ -69,7 +68,6 @@ export interface UsePaymentFlowParams {
setLastIntentId: (v: string) => void;
setLastTxHash: (v: string) => void;
setLastPaymentStartedAt: (v: number) => void;
setShowOverlay: (v: boolean) => void;
setManualPayment: (v: CreatedIntent["direct_payment"] | null) => void;
setManualTxHash: (v: string) => void;
setTxValidation: (v: PaymentTxValidationState) => void;
@@ -110,7 +108,7 @@ export interface UsePaymentFlowParams {
ensureTargetChain: (eth: EvmProvider, targetChainId: number, chain?: PaymentChainOption) => Promise<void>;
// Ref-wrapped cross-hook callbacks
connectAndBindWalletRef: React.MutableRefObject<((mode?: ProviderMode, options?: ConnectBindOptions) => Promise<boolean>) | null>;
connectAndBindWalletRef: React.MutableRefObject<((mode?: ProviderMode) => Promise<boolean>) | null>;
handleSubmit409Ref: React.MutableRefObject<((intentId: string, txHashNorm: string, raw: string) => Promise<void>) | null>;
// Wallet provider helpers
@@ -143,7 +141,6 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
setLastIntentId,
setLastTxHash,
setLastPaymentStartedAt,
setShowOverlay,
setManualPayment,
setManualTxHash,
setTxValidation,
@@ -727,7 +724,6 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
setLastIntentId(intentId);
setManualPayment(direct);
setPaymentMethodTab("manual");
setShowOverlay(false);
const chainName = direct.chain_name || chainIdToDisplayName(direct.chain_id);
setPaymentInfo(
copy.manualOrderCreated
@@ -833,45 +829,35 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
return;
}
setTxValidation({ loading: true, checked: false });
const controller = new AbortController();
const timeoutId = globalThis.setTimeout(() => controller.abort(), 12000);
try {
const headers = await buildAuthedHeaders(true, true);
const res = await fetch(`/api/payments/intents/${intentId}/validate`, {
method: "POST", headers, body: JSON.stringify({ tx_hash: hashNorm }),
method: "POST",
headers,
body: JSON.stringify({ tx_hash: hashNorm }),
signal: controller.signal,
});
const json = (await res.json()) as {
valid?: boolean; reason?: string; detail?: string; checks?: Record<string, unknown>;
};
setTxValidation({ loading: false, checked: true, valid: Boolean(json.valid), reason: json.reason, detail: json.detail, checks: json.checks });
} catch {
setTxValidation({ loading: false, checked: false });
setTxValidation({
loading: false,
checked: true,
valid: false,
reason: "validation_unavailable",
detail: copy.verifyUnavailable,
});
} finally {
globalThis.clearTimeout(timeoutId);
}
},
[buildAuthedHeaders],
[buildAuthedHeaders, copy.verifyUnavailable],
);
// ── handleOverlayCheckout ──────────────────────────────
const handleOverlayCheckout = async () => {
if (!paymentHostAllowed) {
setPaymentError(copy.paymentHostBlocked.replace("{host}", allowedPaymentHosts[0] || "polyweather.top"));
return;
}
if (!authIsAuthenticated) {
setPaymentError(copy.loginBeforePay);
return;
}
if (!hasPayingWallet) {
setPaymentInfo(copy.openBindFlow);
if (connectAndBindWalletRef.current) {
const bound = await connectAndBindWalletRef.current(providerMode, { openOverlayAfterBind: true });
if (!bound) return;
}
setPaymentInfo(copy.walletBoundCreatingOrder);
await createIntentAndPay();
return;
}
await createIntentAndPay();
};
// ==========================================================
return {
fetchLatestPaymentConfig,
@@ -880,7 +866,6 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
createManualPaymentIntent,
submitManualPaymentTx,
validateTxHash,
handleOverlayCheckout,
availableTokenList,
availableChainList,
selectedPaymentChain,
+3 -8
View File
@@ -3,7 +3,6 @@
import { useCallback, useEffect } from "react";
import type {
BoundWallet,
ConnectBindOptions,
Eip6963ProviderDetail,
EvmProvider,
InjectedProviderOption,
@@ -56,7 +55,6 @@ export interface UseWalletBindParams {
setPaymentBusy: (v: boolean) => void;
setPaymentInfo: (v: string) => void;
setPaymentError: (v: string) => void;
setShowOverlay: (v: boolean) => void;
clearPaymentMessages: () => void;
// Derived values from master
@@ -91,7 +89,6 @@ export function useWalletBind(params: UseWalletBindParams) {
setPaymentBusy,
setPaymentInfo,
setPaymentError,
setShowOverlay,
clearPaymentMessages,
authIsAuthenticated,
getValidAccessToken,
@@ -258,7 +255,7 @@ export function useWalletBind(params: UseWalletBindParams) {
};
// ── connectAndBindWallet ────────────────────────────────
const connectAndBindWallet = async (mode: ProviderMode = "auto", options: ConnectBindOptions = {}): Promise<boolean> => {
const connectAndBindWallet = async (mode: ProviderMode = "auto"): Promise<boolean> => {
clearPaymentMessages();
if (!authIsAuthenticated) {
setPaymentError(copy.loginBeforeBind);
@@ -292,9 +289,8 @@ export function useWalletBind(params: UseWalletBindParams) {
if (existingWallet) {
setWalletAddress(address);
setSelectedWallet(address);
setPaymentInfo(`${walletLabel} 已绑定: ${shortAddress(address)}${binanceBindHint || "现在可点击“立即订阅并激活服务”。"}`);
setPaymentInfo(`${walletLabel} 已绑定: ${shortAddress(address)}${binanceBindHint || "可在支付管理继续支付。"}`);
await Promise.all([loadSnapshot(), loadPaymentSnapshot()]);
if (options.openOverlayAfterBind) setShowOverlay(true);
setPaymentBusy(false);
return true;
}
@@ -332,9 +328,8 @@ export function useWalletBind(params: UseWalletBindParams) {
throw new Error(copy.verifyFailedRaw.replace("{raw}", message));
}
setPaymentInfo(`${walletLabel} 绑定成功: ${shortAddress(address)}${binanceBindHint || "现在可点击“立即订阅并激活服务”。"}`);
setPaymentInfo(`${walletLabel} 绑定成功: ${shortAddress(address)}${binanceBindHint || "可在支付管理继续支付。"}`);
setProviderMode(providerSelection.mode);
if (options.openOverlayAfterBind) setShowOverlay(true);
await Promise.all([loadSnapshot(), loadPaymentSnapshot()]);
return true;
} catch (error) {
@@ -12,13 +12,15 @@ export function runTests() {
"utf8",
);
for (const legacyPhrase of [
"全球最精准",
"全平台覆盖",
"全平台智能气象推送",
"High-precision weather intelligence, delivered everywhere.",
"Cross-platform alerts",
]) {
const legacyPhrases = [
["全球", "最精准"].join(""),
["全平台", "覆盖"].join(""),
["全平台", "智能气象", "推送"].join(""),
["High-precision weather intelligence", "delivered everywhere."].join(", "),
["Cross-platform", "alerts"].join(" "),
];
for (const legacyPhrase of legacyPhrases) {
assert(
!overlaySource.includes(legacyPhrase),
`UnlockProOverlay must not show legacy marketing copy: ${legacyPhrase}`,
+26
View File
@@ -2301,6 +2301,7 @@ class PaymentContractCheckoutService:
receiver_match = event_to == expected_receiver
amount_match = event_amount >= expected_amount
sender_is_receiver = event_from == expected_receiver
checks["event"] = "Transfer"
checks["event_from"] = event_from
@@ -2310,6 +2311,7 @@ class PaymentContractCheckoutService:
checks["expected_amount"] = str(expected_amount)
checks["receiver_match"] = receiver_match
checks["amount_match"] = amount_match
checks["sender_is_receiver"] = sender_is_receiver
if not receiver_match:
return {
@@ -2318,6 +2320,13 @@ class PaymentContractCheckoutService:
"detail": f"Transfer went to {event_to}, expected {expected_receiver}",
"checks": checks,
}
if sender_is_receiver:
return {
"valid": False,
"reason": "direct_self_transfer",
"detail": "Transfer sender and receiver are both the payment receiver address.",
"checks": checks,
}
if not amount_match:
return {
"valid": False,
@@ -3087,6 +3096,23 @@ class PaymentContractCheckoutService:
)
else:
self._require_user_wallet(user_id, effective_payer)
if is_direct and event_payer == intent.receiver_address:
self._mark_intent_failed(
user_id=user_id,
intent=intent,
tx_hash=tx_hash_text,
reason="direct_self_transfer",
detail="Transfer sender and receiver are both the payment receiver address.",
extra={
"from_address": tx_from,
"event_payer": event_payer,
"receiver_actual": tx_to,
},
)
raise PaymentCheckoutError(
400,
"direct_self_transfer: Transfer sender and receiver are both the payment receiver address.",
)
if not event_match:
self._mark_intent_failed(
user_id=user_id,
+194
View File
@@ -523,6 +523,111 @@ def test_submit_rejects_mined_direct_tx_when_receiver_mismatches(monkeypatch, tm
raise AssertionError("expected receiver mismatch rejection")
def test_validate_direct_transfer_rejects_receiver_self_transfer(monkeypatch, tmp_path):
_setup_env(monkeypatch, tmp_path)
service = PaymentContractCheckoutService()
tx_hash = "0x" + "9" * 64
receiver = "0xed2f13aa5ff033c58fb436e178451cd07f693f32"
intent = PaymentIntentRecord(
intent_id="intent-direct-self",
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=receiver,
status="created",
payment_mode="direct",
allowed_wallet=None,
expires_at="2099-01-01T00:00:00+00:00",
tx_hash=None,
metadata={},
)
class FakeEth:
def get_transaction_receipt(self, _tx_hash):
return {"status": 1, "to": intent.token_address, "blockNumber": 123}
class FakeWeb3:
eth = FakeEth()
monkeypatch.setattr(service, "_get_web3", lambda *args, **kwargs: FakeWeb3())
monkeypatch.setattr(
service,
"_extract_direct_transfer_event",
lambda *args, **kwargs: {
"from": receiver,
"to": receiver,
"amount_units": 5000000,
"token_address": intent.token_address,
},
)
result = service._validate_loaded_intent_tx(intent, tx_hash)
assert result["valid"] is False
assert result["reason"] == "direct_self_transfer"
def test_submit_rejects_direct_receiver_self_transfer(monkeypatch, tmp_path):
_setup_env(monkeypatch, tmp_path)
service = PaymentContractCheckoutService()
tx_hash = "0x" + "9" * 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": {},
}
),
)
monkeypatch.setattr(
service,
"_validate_loaded_intent_tx",
lambda *args, **kwargs: {
"valid": False,
"reason": "direct_self_transfer",
"detail": "Transfer sender and receiver are both the payment receiver address.",
},
)
def fake_rest(method, table, **kwargs):
if method == "GET" and table == "payment_transactions":
return []
if method == "GET" and table == "payment_intents":
return []
raise AssertionError(f"self-transfer must not mutate {table}")
monkeypatch.setattr(service, "_rest", fake_rest)
try:
service.submit_intent_tx("user-1", "intent-direct-self", tx_hash, "")
except Exception as exc:
assert getattr(exc, "status_code", None) == 400
assert "direct_self_transfer" in getattr(exc, "detail", "")
else:
raise AssertionError("expected direct receiver self-transfer rejection")
def test_confirm_direct_transfer_uses_erc20_transfer_without_wallet_binding(monkeypatch, tmp_path):
_setup_env(monkeypatch, tmp_path)
@@ -625,6 +730,95 @@ def test_confirm_direct_transfer_uses_erc20_transfer_without_wallet_binding(monk
)
assert transaction_write[2]["prefer"] == "resolution=merge-duplicates,return=minimal"
def test_confirm_direct_transfer_rejects_receiver_self_transfer(monkeypatch, tmp_path):
_setup_env(monkeypatch, tmp_path)
service = PaymentContractCheckoutService()
tx_hash = "0x" + "4" * 64
receiver = "0xed2f13aa5ff033c58fb436e178451cd07f693f32"
intent = PaymentIntentRecord(
intent_id="intent-direct-self-confirm",
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=receiver,
status="submitted",
payment_mode="direct",
allowed_wallet=None,
expires_at="2099-01-01T00:00:00+00:00",
tx_hash=tx_hash,
metadata={},
)
failed = {}
monkeypatch.setattr(service, "get_intent", lambda user_id, intent_id: intent)
class _Eth:
chain_id = 137
block_number = 20
@staticmethod
def get_transaction(_tx_hash):
return {
"to": intent.token_address,
"from": receiver,
}
class _Web3:
eth = _Eth()
@staticmethod
def is_connected():
return True
monkeypatch.setattr(service, "_get_web3", lambda *args, **kwargs: _Web3())
monkeypatch.setattr(
service,
"_wait_receipt",
lambda _tx_hash, *args, **kwargs: {
"status": 1,
"to": intent.token_address,
"from": receiver,
"blockNumber": 10,
},
)
monkeypatch.setattr(
service,
"_extract_direct_transfer_event",
lambda receipt, local_intent: {
"from": receiver,
"to": receiver,
"token_address": local_intent.token_address,
"amount_units": local_intent.amount_units,
},
)
monkeypatch.setattr(
service,
"_mark_intent_failed",
lambda **kwargs: failed.update(kwargs),
)
def fake_rest(method, table, **kwargs):
if method == "GET" and table in {"payment_transactions", "payment_intents"}:
return []
raise AssertionError((method, table, kwargs))
monkeypatch.setattr(service, "_rest", fake_rest)
with pytest.raises(PaymentCheckoutError) as exc:
service.confirm_intent_tx("user-1", intent.intent_id, tx_hash)
assert exc.value.status_code == 400
assert "direct_self_transfer" in exc.value.detail
assert failed["reason"] == "direct_self_transfer"
def test_submit_rejects_tx_hash_used_by_another_intent(monkeypatch, tmp_path):
_setup_env(monkeypatch, tmp_path)
service = PaymentContractCheckoutService()