Simplify account payment flow and harden direct transfers
This commit is contained in:
@@ -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: "当前订阅价格是多少?",
|
||||
|
||||
@@ -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 ────────────────────────────────────────
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,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}`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user