Simplify account payment flow and harden direct transfers
This commit is contained in:
@@ -40,7 +40,7 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气
|
|||||||
- 概率分布层(基于 DEB 融合 + 高斯分桶)
|
- 概率分布层(基于 DEB 融合 + 高斯分桶)
|
||||||
- 实时终端多城市图表(跑道/官方站点/DEB/概率带)
|
- 实时终端多城市图表(跑道/官方站点/DEB/概率带)
|
||||||
- 历史对账 + 未来日期分析
|
- 历史对账 + 未来日期分析
|
||||||
- 全平台智能气象推送
|
- Telegram 缓存推送与业务频道通知
|
||||||
|
|
||||||
## 4. 收费与积分规则(默认)
|
## 4. 收费与积分规则(默认)
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ const FAQ_ITEMS = [
|
|||||||
{
|
{
|
||||||
q_zh: "Pro 包含哪些功能?",
|
q_zh: "Pro 包含哪些功能?",
|
||||||
q_en: "What features does Pro include?",
|
q_en: "What features does Pro include?",
|
||||||
a_zh: "开通后可解锁:天气决策台、多城市图表巡检、未来日期分析、全平台智能气象推送。",
|
a_zh: "开通后可解锁:结算源优先终端、多城市图表巡检、未来日期分析、Telegram 缓存推送。",
|
||||||
a_en: "Unlocks: weather decision terminal, multi-city chart monitoring, future-date analysis, and cross-platform smart weather push.",
|
a_en: "Unlocks: settlement-source-first terminal, multi-city chart monitoring, future-date analysis, and Telegram cached alerts.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
q_zh: "当前订阅价格是多少?",
|
q_zh: "当前订阅价格是多少?",
|
||||||
|
|||||||
@@ -41,11 +41,9 @@ import {
|
|||||||
} from "@/lib/supabase/client";
|
} from "@/lib/supabase/client";
|
||||||
import { markAnalyticsOnce, trackAppEvent } from "@/lib/app-analytics";
|
import { markAnalyticsOnce, trackAppEvent } from "@/lib/app-analytics";
|
||||||
import { useI18n } from "@/hooks/useI18n";
|
import { useI18n } from "@/hooks/useI18n";
|
||||||
import { UnlockProOverlay } from "@/components/subscription/UnlockProOverlay";
|
|
||||||
|
|
||||||
import type { AuthMeResponse } from "./types";
|
import type { AuthMeResponse } from "./types";
|
||||||
import {
|
import {
|
||||||
SUBSCRIPTION_HELP_HREF,
|
|
||||||
TELEGRAM_BOT_URL,
|
TELEGRAM_BOT_URL,
|
||||||
TELEGRAM_GROUP_URL,
|
TELEGRAM_GROUP_URL,
|
||||||
TELEGRAM_TOPICS_GROUP_URL,
|
TELEGRAM_TOPICS_GROUP_URL,
|
||||||
@@ -85,7 +83,6 @@ export function AccountCenter() {
|
|||||||
const [trialValueReplay, setTrialValueReplay] = useState(() => readTrialValueReplay());
|
const [trialValueReplay, setTrialValueReplay] = useState(() => readTrialValueReplay());
|
||||||
|
|
||||||
// ── Shared state (declared in component, written by hook via setters) ─
|
// ── Shared state (declared in component, written by hook via setters) ─
|
||||||
const [showOverlay, setShowOverlay] = useState(false);
|
|
||||||
const [usePoints, setUsePoints] = useState(true);
|
const [usePoints, setUsePoints] = useState(true);
|
||||||
const [errorText, setErrorText] = useState("");
|
const [errorText, setErrorText] = useState("");
|
||||||
const [updatedAt, setUpdatedAt] = useState<string>("");
|
const [updatedAt, setUpdatedAt] = useState<string>("");
|
||||||
@@ -104,7 +101,6 @@ export function AccountCenter() {
|
|||||||
paymentInfo,
|
paymentInfo,
|
||||||
paymentError,
|
paymentError,
|
||||||
lastIntentId,
|
lastIntentId,
|
||||||
lastTxHash,
|
|
||||||
lastPaymentStartedAt,
|
lastPaymentStartedAt,
|
||||||
telegramBindOpening,
|
telegramBindOpening,
|
||||||
telegramBindUrl,
|
telegramBindUrl,
|
||||||
@@ -158,7 +154,6 @@ export function AccountCenter() {
|
|||||||
allowedPaymentHosts,
|
allowedPaymentHosts,
|
||||||
currentPaymentHost,
|
currentPaymentHost,
|
||||||
paymentHostAllowed,
|
paymentHostAllowed,
|
||||||
selectedPlan,
|
|
||||||
selectedPaymentToken,
|
selectedPaymentToken,
|
||||||
selectedTokenLabel,
|
selectedTokenLabel,
|
||||||
availableTokenList,
|
availableTokenList,
|
||||||
@@ -168,7 +163,6 @@ export function AccountCenter() {
|
|||||||
resolvedSelectedTokenAddress,
|
resolvedSelectedTokenAddress,
|
||||||
paymentReceiverAddress,
|
paymentReceiverAddress,
|
||||||
paymentWalletLabel,
|
paymentWalletLabel,
|
||||||
hasPayingWallet,
|
|
||||||
totalPoints,
|
totalPoints,
|
||||||
billing,
|
billing,
|
||||||
|
|
||||||
@@ -177,11 +171,9 @@ export function AccountCenter() {
|
|||||||
loadPaymentSnapshot,
|
loadPaymentSnapshot,
|
||||||
connectAndBindWallet,
|
connectAndBindWallet,
|
||||||
handleUnbindWallet,
|
handleUnbindWallet,
|
||||||
createIntentAndPay,
|
|
||||||
createManualPaymentIntent,
|
createManualPaymentIntent,
|
||||||
submitManualPaymentTx,
|
submitManualPaymentTx,
|
||||||
validateTxHash,
|
validateTxHash,
|
||||||
handleOverlayCheckout,
|
|
||||||
createTelegramBotBindCommand,
|
createTelegramBotBindCommand,
|
||||||
openTelegramBotBindLink,
|
openTelegramBotBindLink,
|
||||||
} = useAccountPayment({
|
} = useAccountPayment({
|
||||||
@@ -195,8 +187,6 @@ export function AccountCenter() {
|
|||||||
setBackend,
|
setBackend,
|
||||||
setErrorText,
|
setErrorText,
|
||||||
setUpdatedAt,
|
setUpdatedAt,
|
||||||
showOverlay,
|
|
||||||
setShowOverlay,
|
|
||||||
usePoints,
|
usePoints,
|
||||||
setUsePoints,
|
setUsePoints,
|
||||||
});
|
});
|
||||||
@@ -408,7 +398,7 @@ export function AccountCenter() {
|
|||||||
);
|
);
|
||||||
const paymentFeatureReady = paymentReadyForRecovery;
|
const paymentFeatureReady = paymentReadyForRecovery;
|
||||||
const canTrialUpgrade = Boolean(isSubscribed && isTrialSubscription);
|
const canTrialUpgrade = Boolean(isSubscribed && isTrialSubscription);
|
||||||
const canOpenCheckoutOverlay = Boolean(
|
const canStartPayment = Boolean(
|
||||||
paymentFeatureReady &&
|
paymentFeatureReady &&
|
||||||
(canTrialUpgrade || !isSubscribed || showExpiringSoon || showExpiredReminder),
|
(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_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 },
|
{ plan_code: "pro_quarterly", plan_id: 102, amount_usdc: "79.9", duration_days: 90 },
|
||||||
];
|
];
|
||||||
const selectedPlanDurationDays = Number(selectedPlan?.duration_days || 30);
|
const manualTxHashReady = /^0x[a-fA-F0-9]{64}$/.test(manualTxHash.trim());
|
||||||
const overlayPlanLabel =
|
|
||||||
selectedPlanCode === "pro_quarterly"
|
|
||||||
? copy.quarterlyPlan
|
|
||||||
: copy.monthlyPlan;
|
|
||||||
const overlayPeriodLabel = isEn
|
|
||||||
? `/ ${selectedPlanDurationDays} days`
|
|
||||||
: `/ ${selectedPlanDurationDays} 天`;
|
|
||||||
const trialValueReplaySummary = useMemo(
|
const trialValueReplaySummary = useMemo(
|
||||||
() => buildTrialValueReplaySummary(trialValueReplay, {
|
() => buildTrialValueReplaySummary(trialValueReplay, {
|
||||||
isEn,
|
isEn,
|
||||||
@@ -466,9 +449,7 @@ export function AccountCenter() {
|
|||||||
referralCodeInput.trim(),
|
referralCodeInput.trim(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── Payment overlay tracking effect ──────────────────────
|
const focusPaymentManagement = useCallback(() => {
|
||||||
useEffect(() => {
|
|
||||||
if (!showOverlay || !canOpenCheckoutOverlay) return;
|
|
||||||
trackAppEvent("paywall_viewed", {
|
trackAppEvent("paywall_viewed", {
|
||||||
entry: "account_center",
|
entry: "account_center",
|
||||||
user_state: isAuthenticated ? "logged_in" : "guest",
|
user_state: isAuthenticated ? "logged_in" : "guest",
|
||||||
@@ -476,26 +457,25 @@ export function AccountCenter() {
|
|||||||
expiring_soon: showExpiringSoon,
|
expiring_soon: showExpiringSoon,
|
||||||
subscription_plan_code: planCode || null,
|
subscription_plan_code: planCode || null,
|
||||||
});
|
});
|
||||||
|
const paymentSection = document.getElementById("payment-management");
|
||||||
|
paymentSection?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
}, [
|
}, [
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
canOpenCheckoutOverlay,
|
|
||||||
planCode,
|
planCode,
|
||||||
showExpiredReminder,
|
showExpiredReminder,
|
||||||
showExpiringSoon,
|
showExpiringSoon,
|
||||||
showOverlay,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showOverlay || !canOpenCheckoutOverlay) return;
|
|
||||||
if (searchParams.get("checkout") === "1") {
|
if (searchParams.get("checkout") === "1") {
|
||||||
setShowOverlay(true);
|
window.setTimeout(focusPaymentManagement, 0);
|
||||||
}
|
}
|
||||||
}, [canOpenCheckoutOverlay, searchParams, showOverlay]);
|
}, [focusPaymentManagement, searchParams]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isTrialSubscription) return;
|
if (!isTrialSubscription) return;
|
||||||
setTrialValueReplay(readTrialValueReplay());
|
setTrialValueReplay(readTrialValueReplay());
|
||||||
}, [authUserId, isTrialSubscription, showOverlay]);
|
}, [authUserId, isTrialSubscription]);
|
||||||
|
|
||||||
const openTrialValueReplayCheckout = useCallback(() => {
|
const openTrialValueReplayCheckout = useCallback(() => {
|
||||||
trackAppEvent("paywall_feature_clicked", {
|
trackAppEvent("paywall_feature_clicked", {
|
||||||
@@ -507,8 +487,8 @@ export function AccountCenter() {
|
|||||||
replay_rows_available: trialValueReplay.rowsAvailableMax,
|
replay_rows_available: trialValueReplay.rowsAvailableMax,
|
||||||
subscription_plan_code: planCode || null,
|
subscription_plan_code: planCode || null,
|
||||||
});
|
});
|
||||||
setShowOverlay(true);
|
focusPaymentManagement();
|
||||||
}, [authUserId, planCode, trialValueReplay]);
|
}, [authUserId, focusPaymentManagement, planCode, trialValueReplay]);
|
||||||
|
|
||||||
// ── Referral points display ────────────────────────────
|
// ── Referral points display ────────────────────────────
|
||||||
const referralRewardPointsRaw = Number(referral?.reward_points ?? 3500);
|
const referralRewardPointsRaw = Number(referral?.reward_points ?? 3500);
|
||||||
@@ -641,9 +621,10 @@ export function AccountCenter() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{!showOverlay && canOpenCheckoutOverlay && (
|
{canStartPayment && (
|
||||||
<button
|
<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"
|
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} />{" "}
|
<Crown size={16} />{" "}
|
||||||
@@ -709,7 +690,7 @@ export function AccountCenter() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="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"
|
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} />
|
<Crown size={16} />
|
||||||
@@ -719,7 +700,7 @@ export function AccountCenter() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isTrialSubscription && canOpenCheckoutOverlay && (
|
{isTrialSubscription && canStartPayment && (
|
||||||
<section className="lg:col-span-12 overflow-hidden rounded-2xl border border-amber-200 bg-white shadow-sm">
|
<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="grid gap-0 md:grid-cols-[minmax(0,1fr)_auto]">
|
||||||
<div className="min-w-0 p-6">
|
<div className="min-w-0 p-6">
|
||||||
@@ -899,7 +880,7 @@ export function AccountCenter() {
|
|||||||
{/* Subscription Info & Paywall */}
|
{/* Subscription Info & Paywall */}
|
||||||
<div className="lg:col-span-12 relative">
|
<div className="lg:col-span-12 relative">
|
||||||
<div
|
<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">
|
<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">
|
<h3 className="mb-4 text-sm font-bold uppercase text-blue-700">
|
||||||
@@ -990,43 +971,6 @@ export function AccountCenter() {
|
|||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<AccountFeedbackPanel
|
<AccountFeedbackPanel
|
||||||
@@ -1058,8 +1002,8 @@ export function AccountCenter() {
|
|||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowOverlay(true)}
|
onClick={focusPaymentManagement}
|
||||||
disabled={!canOpenCheckoutOverlay}
|
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"
|
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} />
|
<Crown size={14} />
|
||||||
@@ -1183,6 +1127,7 @@ export function AccountCenter() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div
|
<div
|
||||||
|
id="payment-management"
|
||||||
data-testid="payment-management-grid"
|
data-testid="payment-management-grid"
|
||||||
className={`grid gap-6 lg:items-start ${
|
className={`grid gap-6 lg:items-start ${
|
||||||
hasTelegramPanel ? "" : "lg:grid-cols-[minmax(0,1fr)_minmax(320px,380px)]"
|
hasTelegramPanel ? "" : "lg:grid-cols-[minmax(0,1fr)_minmax(320px,380px)]"
|
||||||
@@ -1694,6 +1639,10 @@ export function AccountCenter() {
|
|||||||
"amount_insufficient"
|
"amount_insufficient"
|
||||||
? copy
|
? copy
|
||||||
.verifyAmountLow
|
.verifyAmountLow
|
||||||
|
: txValidation.reason ===
|
||||||
|
"direct_self_transfer"
|
||||||
|
? copy
|
||||||
|
.verifySelfTransfer
|
||||||
: txValidation.reason ===
|
: txValidation.reason ===
|
||||||
"tx_reverted"
|
"tx_reverted"
|
||||||
? copy
|
? copy
|
||||||
@@ -1711,11 +1660,7 @@ export function AccountCenter() {
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
void submitManualPaymentTx()
|
void submitManualPaymentTx()
|
||||||
}
|
}
|
||||||
disabled={
|
disabled={paymentBusy || !manualTxHashReady}
|
||||||
paymentBusy ||
|
|
||||||
!(txValidation.checked &&
|
|
||||||
txValidation.valid === true)
|
|
||||||
}
|
|
||||||
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"
|
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}
|
{copy.paymentManualSubmit}
|
||||||
|
|||||||
@@ -27,10 +27,6 @@ export function runTests() {
|
|||||||
path.join(projectRoot, "components", "account", "useBilling.ts"),
|
path.join(projectRoot, "components", "account", "useBilling.ts"),
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
const unlockProOverlay = fs.readFileSync(
|
|
||||||
path.join(projectRoot, "components", "subscription", "UnlockProOverlay.tsx"),
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
const telegramPricing = fs.readFileSync(
|
const telegramPricing = fs.readFileSync(
|
||||||
path.join(projectRoot, "components", "account", "telegram-pricing.ts"),
|
path.join(projectRoot, "components", "account", "telegram-pricing.ts"),
|
||||||
"utf8",
|
"utf8",
|
||||||
@@ -93,17 +89,16 @@ export function runTests() {
|
|||||||
!accountCenter.includes(["private", "Group", "Monthly", "Plan"].join("")) &&
|
!accountCenter.includes(["private", "Group", "Monthly", "Plan"].join("")) &&
|
||||||
!accountCopy.includes(["Private", "group", "monthly"].join(" ")) &&
|
!accountCopy.includes(["Private", "group", "monthly"].join(" ")) &&
|
||||||
!accountCopy.includes(["私", "密", "群", "月", "付"].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(
|
assert(
|
||||||
accountCenter.includes("overlayPlanLabel") &&
|
accountCenter.includes("displayPlanList.map") &&
|
||||||
accountCenter.includes("overlayPeriodLabel") &&
|
accountCenter.includes("plan.amount_usdc") &&
|
||||||
|
accountCenter.includes("USDC") &&
|
||||||
!accountCenter.includes(`copy.${["private", "Group", "Monthly", "Plan"].join("")}`) &&
|
!accountCenter.includes(`copy.${["private", "Group", "Monthly", "Plan"].join("")}`) &&
|
||||||
unlockProOverlay.includes("planLabel") &&
|
!accountCenter.includes("overlayPlanLabel") &&
|
||||||
unlockProOverlay.includes("USDC") &&
|
!accountCenter.includes("overlayPeriodLabel"),
|
||||||
!unlockProOverlay.includes("<span className={s.price}>${planPriceUsd.toFixed(2)}</span>") &&
|
"payment management must display payment amounts as USDC without relying on the removed checkout overlay",
|
||||||
!unlockProOverlay.includes('<span className={s.summaryUnit}>USD</span>'),
|
|
||||||
"checkout overlay must display payment amounts as USDC without exposing the discounted-price source label",
|
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
types.includes("ReferralSummary") &&
|
types.includes("ReferralSummary") &&
|
||||||
|
|||||||
@@ -80,10 +80,14 @@ export function runTests() {
|
|||||||
"AccountCenter must validate backend-returned manual payment receiver before displaying it",
|
"AccountCenter must validate backend-returned manual payment receiver before displaying it",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
/!\(\s*txValidation\.checked\s*&&\s*txValidation\.valid === true\s*\)/.test(
|
accountCenterSource.includes("manualTxHashReady") &&
|
||||||
accountCenterSource,
|
accountCenterSource.includes("/^0x[a-fA-F0-9]{64}$/") &&
|
||||||
),
|
!/!\(\s*txValidation\.checked\s*&&\s*txValidation\.valid === true\s*\)/.test(
|
||||||
"manual payment submit button must require checked && valid === true",
|
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(
|
assert(
|
||||||
paymentFlowSource.includes("validateTxHash") &&
|
paymentFlowSource.includes("validateTxHash") &&
|
||||||
|
|||||||
@@ -152,14 +152,10 @@ export function runTests() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
accountCenterSource.includes(
|
!accountCenterSource.includes("UnlockProOverlay") &&
|
||||||
'import { UnlockProOverlay } from "@/components/subscription/UnlockProOverlay";',
|
!accountCenterSource.includes("showOverlay") &&
|
||||||
),
|
accountCenterSource.includes("focusPaymentManagement"),
|
||||||
"checkout overlay must be in the account bundle, not lazy-loaded after the user clicks pay",
|
"account page must use the payment management section as the only checkout surface instead of a fixed Pro overlay",
|
||||||
);
|
|
||||||
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",
|
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
!/STATIC_ASSETS\s*=\s*\[[^\]]*["']\/_next\//s.test(serviceWorkerSource),
|
!/STATIC_ASSETS\s*=\s*\[[^\]]*["']\/_next\//s.test(serviceWorkerSource),
|
||||||
@@ -416,6 +412,12 @@ export function runTests() {
|
|||||||
.length >= 3,
|
.length >= 3,
|
||||||
"manual payment mutations must require a valid Supabase bearer token instead of forwarding unauthenticated requests that surface raw backend JSON",
|
"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(
|
assert(
|
||||||
paymentFlowSource.includes("verifyPaymentAuthReady") &&
|
paymentFlowSource.includes("verifyPaymentAuthReady") &&
|
||||||
paymentFlowSource.indexOf("await verifyPaymentAuthReady()") <
|
paymentFlowSource.indexOf("await verifyPaymentAuthReady()") <
|
||||||
|
|||||||
@@ -28,14 +28,14 @@ export function runTests() {
|
|||||||
accountCenter.includes("canTrialUpgrade") &&
|
accountCenter.includes("canTrialUpgrade") &&
|
||||||
accountCenter.includes("isTrialSubscription") &&
|
accountCenter.includes("isTrialSubscription") &&
|
||||||
accountCenter.includes("canTrialUpgrade || !isSubscribed"),
|
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(
|
assert(
|
||||||
accountCenter.includes("useSearchParams") &&
|
accountCenter.includes("useSearchParams") &&
|
||||||
accountCenter.includes('searchParams.get("checkout") === "1"') &&
|
accountCenter.includes('searchParams.get("checkout") === "1"') &&
|
||||||
accountCenter.includes("setShowOverlay(true)"),
|
accountCenter.includes("focusPaymentManagement"),
|
||||||
"account page must support /account?checkout=1 for direct upgrade entry",
|
"account page must support /account?checkout=1 by focusing payment management",
|
||||||
);
|
);
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
|
|||||||
@@ -262,6 +262,9 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
|
|||||||
? "Please enter a valid tx hash."
|
? "Please enter a valid tx hash."
|
||||||
: "请输入有效的 tx hash。",
|
: "请输入有效的 tx hash。",
|
||||||
verifying: isEn ? "Verifying..." : "验证中...",
|
verifying: isEn ? "Verifying..." : "验证中...",
|
||||||
|
verifyUnavailable: isEn
|
||||||
|
? "Pre-check is temporarily unavailable. You can still submit; the backend will verify on-chain before activation."
|
||||||
|
: "预验证暂时不可用。你仍可提交,后端会在开通前再次链上校验。",
|
||||||
verifyAddressMatch: isEn
|
verifyAddressMatch: isEn
|
||||||
? "Receiver address and amount match"
|
? "Receiver address and amount match"
|
||||||
: "收款地址和金额匹配",
|
: "收款地址和金额匹配",
|
||||||
@@ -277,6 +280,9 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
|
|||||||
verifyTxReverted: isEn
|
verifyTxReverted: isEn
|
||||||
? "This transaction was reverted."
|
? "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: " : "验证失败: ",
|
verifyFailed: isEn ? "Verification failed: " : "验证失败: ",
|
||||||
verifyUnknown: isEn ? "Unknown error" : "未知错误",
|
verifyUnknown: isEn ? "Unknown error" : "未知错误",
|
||||||
// ── Telegram bind messages ────────────────────────────────────────
|
// ── Telegram bind messages ────────────────────────────────────────
|
||||||
|
|||||||
@@ -208,10 +208,6 @@ export type Eip6963ProviderDetail = {
|
|||||||
provider: EvmProvider;
|
provider: EvmProvider;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ConnectBindOptions = {
|
|
||||||
openOverlayAfterBind?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PaymentRecoveryState = {
|
export type PaymentRecoveryState = {
|
||||||
intentId: string;
|
intentId: string;
|
||||||
txHash: string;
|
txHash: string;
|
||||||
|
|||||||
@@ -54,8 +54,6 @@ export interface UseAccountPaymentParams {
|
|||||||
setBackend: Dispatch<SetStateAction<AuthMeResponse | null>>;
|
setBackend: Dispatch<SetStateAction<AuthMeResponse | null>>;
|
||||||
setErrorText: (text: string) => void;
|
setErrorText: (text: string) => void;
|
||||||
setUpdatedAt: (text: string) => void;
|
setUpdatedAt: (text: string) => void;
|
||||||
showOverlay: boolean;
|
|
||||||
setShowOverlay: (v: boolean) => void;
|
|
||||||
usePoints: boolean;
|
usePoints: boolean;
|
||||||
setUsePoints: (v: boolean) => void;
|
setUsePoints: (v: boolean) => void;
|
||||||
}
|
}
|
||||||
@@ -73,8 +71,6 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
|||||||
setBackend,
|
setBackend,
|
||||||
setErrorText,
|
setErrorText,
|
||||||
setUpdatedAt,
|
setUpdatedAt,
|
||||||
showOverlay,
|
|
||||||
setShowOverlay,
|
|
||||||
usePoints,
|
usePoints,
|
||||||
setUsePoints,
|
setUsePoints,
|
||||||
} = params;
|
} = params;
|
||||||
@@ -445,7 +441,6 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
|||||||
setPaymentBusy,
|
setPaymentBusy,
|
||||||
setPaymentInfo,
|
setPaymentInfo,
|
||||||
setPaymentError,
|
setPaymentError,
|
||||||
setShowOverlay,
|
|
||||||
clearPaymentMessages,
|
clearPaymentMessages,
|
||||||
authIsAuthenticated,
|
authIsAuthenticated,
|
||||||
getValidAccessToken,
|
getValidAccessToken,
|
||||||
@@ -521,7 +516,6 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
|||||||
setLastIntentId,
|
setLastIntentId,
|
||||||
setLastTxHash,
|
setLastTxHash,
|
||||||
setLastPaymentStartedAt,
|
setLastPaymentStartedAt,
|
||||||
setShowOverlay,
|
|
||||||
setManualPayment,
|
setManualPayment,
|
||||||
setManualTxHash,
|
setManualTxHash,
|
||||||
setTxValidation,
|
setTxValidation,
|
||||||
@@ -634,7 +628,6 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
|||||||
createManualPaymentIntent: paymentFlow.createManualPaymentIntent,
|
createManualPaymentIntent: paymentFlow.createManualPaymentIntent,
|
||||||
submitManualPaymentTx: paymentFlow.submitManualPaymentTx,
|
submitManualPaymentTx: paymentFlow.submitManualPaymentTx,
|
||||||
validateTxHash: paymentFlow.validateTxHash,
|
validateTxHash: paymentFlow.validateTxHash,
|
||||||
handleOverlayCheckout: paymentFlow.handleOverlayCheckout,
|
|
||||||
createTelegramBotBindCommand: billing.createTelegramBotBindCommand,
|
createTelegramBotBindCommand: billing.createTelegramBotBindCommand,
|
||||||
openTelegramBotBindLink: billing.openTelegramBotBindLink,
|
openTelegramBotBindLink: billing.openTelegramBotBindLink,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useCallback, useMemo } from "react";
|
|||||||
import type {
|
import type {
|
||||||
AuthMeResponse,
|
AuthMeResponse,
|
||||||
BoundWallet,
|
BoundWallet,
|
||||||
ConnectBindOptions,
|
|
||||||
CreatedIntent,
|
CreatedIntent,
|
||||||
EvmProvider,
|
EvmProvider,
|
||||||
IntentStatusResponse,
|
IntentStatusResponse,
|
||||||
@@ -69,7 +68,6 @@ export interface UsePaymentFlowParams {
|
|||||||
setLastIntentId: (v: string) => void;
|
setLastIntentId: (v: string) => void;
|
||||||
setLastTxHash: (v: string) => void;
|
setLastTxHash: (v: string) => void;
|
||||||
setLastPaymentStartedAt: (v: number) => void;
|
setLastPaymentStartedAt: (v: number) => void;
|
||||||
setShowOverlay: (v: boolean) => void;
|
|
||||||
setManualPayment: (v: CreatedIntent["direct_payment"] | null) => void;
|
setManualPayment: (v: CreatedIntent["direct_payment"] | null) => void;
|
||||||
setManualTxHash: (v: string) => void;
|
setManualTxHash: (v: string) => void;
|
||||||
setTxValidation: (v: PaymentTxValidationState) => void;
|
setTxValidation: (v: PaymentTxValidationState) => void;
|
||||||
@@ -110,7 +108,7 @@ export interface UsePaymentFlowParams {
|
|||||||
ensureTargetChain: (eth: EvmProvider, targetChainId: number, chain?: PaymentChainOption) => Promise<void>;
|
ensureTargetChain: (eth: EvmProvider, targetChainId: number, chain?: PaymentChainOption) => Promise<void>;
|
||||||
|
|
||||||
// Ref-wrapped cross-hook callbacks
|
// 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>;
|
handleSubmit409Ref: React.MutableRefObject<((intentId: string, txHashNorm: string, raw: string) => Promise<void>) | null>;
|
||||||
|
|
||||||
// Wallet provider helpers
|
// Wallet provider helpers
|
||||||
@@ -143,7 +141,6 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
|||||||
setLastIntentId,
|
setLastIntentId,
|
||||||
setLastTxHash,
|
setLastTxHash,
|
||||||
setLastPaymentStartedAt,
|
setLastPaymentStartedAt,
|
||||||
setShowOverlay,
|
|
||||||
setManualPayment,
|
setManualPayment,
|
||||||
setManualTxHash,
|
setManualTxHash,
|
||||||
setTxValidation,
|
setTxValidation,
|
||||||
@@ -727,7 +724,6 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
|||||||
setLastIntentId(intentId);
|
setLastIntentId(intentId);
|
||||||
setManualPayment(direct);
|
setManualPayment(direct);
|
||||||
setPaymentMethodTab("manual");
|
setPaymentMethodTab("manual");
|
||||||
setShowOverlay(false);
|
|
||||||
const chainName = direct.chain_name || chainIdToDisplayName(direct.chain_id);
|
const chainName = direct.chain_name || chainIdToDisplayName(direct.chain_id);
|
||||||
setPaymentInfo(
|
setPaymentInfo(
|
||||||
copy.manualOrderCreated
|
copy.manualOrderCreated
|
||||||
@@ -833,45 +829,35 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setTxValidation({ loading: true, checked: false });
|
setTxValidation({ loading: true, checked: false });
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = globalThis.setTimeout(() => controller.abort(), 12000);
|
||||||
try {
|
try {
|
||||||
const headers = await buildAuthedHeaders(true, true);
|
const headers = await buildAuthedHeaders(true, true);
|
||||||
const res = await fetch(`/api/payments/intents/${intentId}/validate`, {
|
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 {
|
const json = (await res.json()) as {
|
||||||
valid?: boolean; reason?: string; detail?: string; checks?: Record<string, unknown>;
|
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 });
|
setTxValidation({ loading: false, checked: true, valid: Boolean(json.valid), reason: json.reason, detail: json.detail, checks: json.checks });
|
||||||
} catch {
|
} 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 {
|
return {
|
||||||
fetchLatestPaymentConfig,
|
fetchLatestPaymentConfig,
|
||||||
@@ -880,7 +866,6 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
|||||||
createManualPaymentIntent,
|
createManualPaymentIntent,
|
||||||
submitManualPaymentTx,
|
submitManualPaymentTx,
|
||||||
validateTxHash,
|
validateTxHash,
|
||||||
handleOverlayCheckout,
|
|
||||||
availableTokenList,
|
availableTokenList,
|
||||||
availableChainList,
|
availableChainList,
|
||||||
selectedPaymentChain,
|
selectedPaymentChain,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import type {
|
import type {
|
||||||
BoundWallet,
|
BoundWallet,
|
||||||
ConnectBindOptions,
|
|
||||||
Eip6963ProviderDetail,
|
Eip6963ProviderDetail,
|
||||||
EvmProvider,
|
EvmProvider,
|
||||||
InjectedProviderOption,
|
InjectedProviderOption,
|
||||||
@@ -56,7 +55,6 @@ export interface UseWalletBindParams {
|
|||||||
setPaymentBusy: (v: boolean) => void;
|
setPaymentBusy: (v: boolean) => void;
|
||||||
setPaymentInfo: (v: string) => void;
|
setPaymentInfo: (v: string) => void;
|
||||||
setPaymentError: (v: string) => void;
|
setPaymentError: (v: string) => void;
|
||||||
setShowOverlay: (v: boolean) => void;
|
|
||||||
clearPaymentMessages: () => void;
|
clearPaymentMessages: () => void;
|
||||||
|
|
||||||
// Derived values from master
|
// Derived values from master
|
||||||
@@ -91,7 +89,6 @@ export function useWalletBind(params: UseWalletBindParams) {
|
|||||||
setPaymentBusy,
|
setPaymentBusy,
|
||||||
setPaymentInfo,
|
setPaymentInfo,
|
||||||
setPaymentError,
|
setPaymentError,
|
||||||
setShowOverlay,
|
|
||||||
clearPaymentMessages,
|
clearPaymentMessages,
|
||||||
authIsAuthenticated,
|
authIsAuthenticated,
|
||||||
getValidAccessToken,
|
getValidAccessToken,
|
||||||
@@ -258,7 +255,7 @@ export function useWalletBind(params: UseWalletBindParams) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── connectAndBindWallet ────────────────────────────────
|
// ── connectAndBindWallet ────────────────────────────────
|
||||||
const connectAndBindWallet = async (mode: ProviderMode = "auto", options: ConnectBindOptions = {}): Promise<boolean> => {
|
const connectAndBindWallet = async (mode: ProviderMode = "auto"): Promise<boolean> => {
|
||||||
clearPaymentMessages();
|
clearPaymentMessages();
|
||||||
if (!authIsAuthenticated) {
|
if (!authIsAuthenticated) {
|
||||||
setPaymentError(copy.loginBeforeBind);
|
setPaymentError(copy.loginBeforeBind);
|
||||||
@@ -292,9 +289,8 @@ export function useWalletBind(params: UseWalletBindParams) {
|
|||||||
if (existingWallet) {
|
if (existingWallet) {
|
||||||
setWalletAddress(address);
|
setWalletAddress(address);
|
||||||
setSelectedWallet(address);
|
setSelectedWallet(address);
|
||||||
setPaymentInfo(`${walletLabel} 已绑定: ${shortAddress(address)}。${binanceBindHint || "现在可点击“立即订阅并激活服务”。"}`);
|
setPaymentInfo(`${walletLabel} 已绑定: ${shortAddress(address)}。${binanceBindHint || "可在支付管理继续支付。"}`);
|
||||||
await Promise.all([loadSnapshot(), loadPaymentSnapshot()]);
|
await Promise.all([loadSnapshot(), loadPaymentSnapshot()]);
|
||||||
if (options.openOverlayAfterBind) setShowOverlay(true);
|
|
||||||
setPaymentBusy(false);
|
setPaymentBusy(false);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -332,9 +328,8 @@ export function useWalletBind(params: UseWalletBindParams) {
|
|||||||
throw new Error(copy.verifyFailedRaw.replace("{raw}", message));
|
throw new Error(copy.verifyFailedRaw.replace("{raw}", message));
|
||||||
}
|
}
|
||||||
|
|
||||||
setPaymentInfo(`${walletLabel} 绑定成功: ${shortAddress(address)}。${binanceBindHint || "现在可点击“立即订阅并激活服务”。"}`);
|
setPaymentInfo(`${walletLabel} 绑定成功: ${shortAddress(address)}。${binanceBindHint || "可在支付管理继续支付。"}`);
|
||||||
setProviderMode(providerSelection.mode);
|
setProviderMode(providerSelection.mode);
|
||||||
if (options.openOverlayAfterBind) setShowOverlay(true);
|
|
||||||
await Promise.all([loadSnapshot(), loadPaymentSnapshot()]);
|
await Promise.all([loadSnapshot(), loadPaymentSnapshot()]);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -12,13 +12,15 @@ export function runTests() {
|
|||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const legacyPhrase of [
|
const legacyPhrases = [
|
||||||
"全球最精准",
|
["全球", "最精准"].join(""),
|
||||||
"全平台覆盖",
|
["全平台", "覆盖"].join(""),
|
||||||
"全平台智能气象推送",
|
["全平台", "智能气象", "推送"].join(""),
|
||||||
"High-precision weather intelligence, delivered everywhere.",
|
["High-precision weather intelligence", "delivered everywhere."].join(", "),
|
||||||
"Cross-platform alerts",
|
["Cross-platform", "alerts"].join(" "),
|
||||||
]) {
|
];
|
||||||
|
|
||||||
|
for (const legacyPhrase of legacyPhrases) {
|
||||||
assert(
|
assert(
|
||||||
!overlaySource.includes(legacyPhrase),
|
!overlaySource.includes(legacyPhrase),
|
||||||
`UnlockProOverlay must not show legacy marketing copy: ${legacyPhrase}`,
|
`UnlockProOverlay must not show legacy marketing copy: ${legacyPhrase}`,
|
||||||
|
|||||||
@@ -2301,6 +2301,7 @@ class PaymentContractCheckoutService:
|
|||||||
|
|
||||||
receiver_match = event_to == expected_receiver
|
receiver_match = event_to == expected_receiver
|
||||||
amount_match = event_amount >= expected_amount
|
amount_match = event_amount >= expected_amount
|
||||||
|
sender_is_receiver = event_from == expected_receiver
|
||||||
|
|
||||||
checks["event"] = "Transfer"
|
checks["event"] = "Transfer"
|
||||||
checks["event_from"] = event_from
|
checks["event_from"] = event_from
|
||||||
@@ -2310,6 +2311,7 @@ class PaymentContractCheckoutService:
|
|||||||
checks["expected_amount"] = str(expected_amount)
|
checks["expected_amount"] = str(expected_amount)
|
||||||
checks["receiver_match"] = receiver_match
|
checks["receiver_match"] = receiver_match
|
||||||
checks["amount_match"] = amount_match
|
checks["amount_match"] = amount_match
|
||||||
|
checks["sender_is_receiver"] = sender_is_receiver
|
||||||
|
|
||||||
if not receiver_match:
|
if not receiver_match:
|
||||||
return {
|
return {
|
||||||
@@ -2318,6 +2320,13 @@ class PaymentContractCheckoutService:
|
|||||||
"detail": f"Transfer went to {event_to}, expected {expected_receiver}",
|
"detail": f"Transfer went to {event_to}, expected {expected_receiver}",
|
||||||
"checks": checks,
|
"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:
|
if not amount_match:
|
||||||
return {
|
return {
|
||||||
"valid": False,
|
"valid": False,
|
||||||
@@ -3087,6 +3096,23 @@ class PaymentContractCheckoutService:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self._require_user_wallet(user_id, effective_payer)
|
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:
|
if not event_match:
|
||||||
self._mark_intent_failed(
|
self._mark_intent_failed(
|
||||||
user_id=user_id,
|
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")
|
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):
|
def test_confirm_direct_transfer_uses_erc20_transfer_without_wallet_binding(monkeypatch, tmp_path):
|
||||||
_setup_env(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"
|
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):
|
def test_submit_rejects_tx_hash_used_by_another_intent(monkeypatch, tmp_path):
|
||||||
_setup_env(monkeypatch, tmp_path)
|
_setup_env(monkeypatch, tmp_path)
|
||||||
service = PaymentContractCheckoutService()
|
service = PaymentContractCheckoutService()
|
||||||
|
|||||||
Reference in New Issue
Block a user