From 609fc8914a5885d4a44996111185104c11f52bad Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Mon, 16 Mar 2026 20:30:46 +0800 Subject: [PATCH] feat: Implement user account center with authentication, subscription management, and cryptocurrency payment integration. --- frontend/app/api/payments/wallets/route.ts | 52 ++++ frontend/app/auth/login/page.tsx | 8 +- frontend/components/account/AccountCenter.tsx | 292 ++++++++++++++---- frontend/components/auth/LoginClient.tsx | 79 +++-- src/payments/contract_checkout.py | 80 +++++ web/app.py | 17 + 6 files changed, 448 insertions(+), 80 deletions(-) diff --git a/frontend/app/api/payments/wallets/route.ts b/frontend/app/api/payments/wallets/route.ts index 0d34ce07..05bd33e4 100644 --- a/frontend/app/api/payments/wallets/route.ts +++ b/frontend/app/api/payments/wallets/route.ts @@ -40,3 +40,55 @@ export async function GET(req: NextRequest) { } } +export async function DELETE(req: NextRequest) { + if (!API_BASE) { + return NextResponse.json( + { error: "POLYWEATHER_API_BASE_URL is not configured" }, + { status: 500 }, + ); + } + let payload: Record = {}; + try { + payload = (await req.json()) as Record; + } catch { + payload = {}; + } + try { + const auth = await buildBackendRequestHeaders(req); + const res = await fetch(`${API_BASE}/api/payments/wallets`, { + method: "DELETE", + headers: { + ...auth.headers, + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + cache: "no-store", + }); + const raw = await res.text(); + if (!res.ok) { + const response = NextResponse.json( + { error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) }, + { status: res.status }, + ); + return applyAuthResponseCookies(response, auth.response); + } + let data: unknown = { ok: true }; + if (raw) { + try { + data = JSON.parse(raw); + } catch { + data = { ok: true, raw }; + } + } + const response = NextResponse.json(data, { + headers: { "Cache-Control": "no-store" }, + }); + return applyAuthResponseCookies(response, auth.response); + } catch (error) { + return NextResponse.json( + { error: "Failed to unbind wallet", detail: String(error) }, + { status: 500 }, + ); + } +} + diff --git a/frontend/app/auth/login/page.tsx b/frontend/app/auth/login/page.tsx index 4530d5e1..c5112f36 100644 --- a/frontend/app/auth/login/page.tsx +++ b/frontend/app/auth/login/page.tsx @@ -1,4 +1,5 @@ import { LoginClient } from "@/components/auth/LoginClient"; +import { I18nProvider } from "@/hooks/useI18n"; type PageProps = { searchParams?: Promise<{ next?: string }>; @@ -16,6 +17,9 @@ function normalizeNextPath(input: string | undefined) { export default async function LoginPage({ searchParams }: PageProps) { const params = (await searchParams) || {}; const nextPath = normalizeNextPath(params.next); - return ; + return ( + + + + ); } - diff --git a/frontend/components/account/AccountCenter.tsx b/frontend/components/account/AccountCenter.tsx index bec543ce..a9cd4b0f 100644 --- a/frontend/components/account/AccountCenter.tsx +++ b/frontend/components/account/AccountCenter.tsx @@ -41,6 +41,7 @@ import { getSupabaseBrowserClient, hasSupabasePublicEnv, } from "@/lib/supabase/client"; +import { useI18n } from "@/hooks/useI18n"; const UnlockProOverlay = dynamic( () => @@ -489,6 +490,94 @@ function normalizePaymentError(error: unknown): NormalizedPaymentError { export function AccountCenter() { const router = useRouter(); + const { locale } = useI18n(); + const isEn = locale === "en-US"; + const copy = useMemo( + () => ({ + backHome: isEn ? "Back to Home" : "返回首页", + accountCenter: isEn ? "Account Center" : "账户中心", + loadingAccount: isEn ? "Loading account info..." : "加载账户信息中...", + refresh: isEn ? "Refresh" : "刷新", + signOut: isEn ? "Sign Out" : "退出", + signIn: isEn ? "Sign In" : "登录", + upgradePro: isEn ? "Upgrade Pro" : "升级 Pro", + guestUser: isEn ? "Guest User" : "游客用户", + joinedAt: isEn ? "Joined" : "加入时间", + totalPoints: isEn ? "Total Points" : "总积分 (荣誉)", + weeklyRank: isEn ? "Weekly Rank" : "周排行 (竞技)", + weeklyRewards: isEn ? "Weekly Rewards" : "周榜奖励", + membershipDetails: isEn ? "Membership Details" : "会员权限详情", + identityStatus: isEn ? "Identity Status" : "身份状态", + authMode: isEn ? "Auth Mode" : "鉴权模式", + weatherEngine: isEn ? "Weather Engine" : "气象引擎", + intradayAnalysis: isEn ? "Intraday Analysis" : "今日内分析", + historyFuture: + isEn ? "Historical + Future-date Analysis" : "历史对账 + 未来日期分析", + smartPush: + isEn ? "Cross-platform Smart Weather Push" : "全平台智能气象推送", + deepMode: + isEn ? "Deep mode (incl. high-temp window)" : "深度版(含高温时段)", + compactVisible: isEn ? "Compact visible" : "简版可见", + enabled: isEn ? "Enabled" : "已开启", + locked: isEn ? "Locked" : "锁定", + boundEmail: isEn ? "Bound Email" : "绑定邮箱", + loginMethod: isEn ? "Sign-in Method" : "登录方式", + renewalDate: isEn ? "Renewal Date" : "续费日期", + authResult: isEn ? "Auth Result" : "鉴权结果", + passed: isEn ? "Passed" : "通过", + restricted: isEn ? "Restricted" : "受限", + telegramBind: isEn ? "Telegram Bot Binding" : "Telegram Bot 绑定", + telegramHint: isEn + ? "Send the command below to the polyweather bot to sync notifications and access." + : "将下方命令发送给polyweather机器人,实现全平台气象推送与权限同步。", + copyCommand: isEn ? "Copy command" : "复制命令", + paymentMgmt: isEn ? "Payment Management" : "支付管理", + paymentToken: isEn ? "Payment Token" : "支付币种", + primary: "Primary", + polygonChain: "Polygon Chain", + noWallet: isEn ? "No payout wallet bound yet." : "未绑定任何收件钱包", + bindExt: + isEn ? "Bind Browser Wallet (EVM Extension)" : "绑定浏览器钱包(EVM扩展)", + bindQr: isEn ? "Bind via QR (WalletConnect)" : "扫码绑定(WalletConnect)", + walletConnectMissing: isEn + ? "WalletConnect disabled: please configure" + : "未启用 WalletConnect:请配置", + unbind: isEn ? "Unbind" : "解绑", + unbindConfirm: isEn + ? "Unbind wallet {address}? You can bind it again later." + : "确认解绑钱包 {address}?后续可重新绑定。", + unbindDone: isEn ? "Wallet unbound." : "钱包已解绑。", + unbindDonePrimary: isEn + ? "Wallet unbound. New primary: {address}" + : "钱包已解绑,新的主钱包:{address}", + unbindFailed: isEn ? "Failed to unbind wallet" : "解绑钱包失败", + payNow: isEn ? "Subscribe & Activate" : "立即订阅并激活服务", + connectAndPay: isEn ? "Connect Wallet & Pay" : "连接钱包并支付", + loginBeforeBind: isEn + ? "Please sign in before binding wallet." + : "请先登录后再绑定钱包。", + loginBeforePay: isEn + ? "Please sign in before payment." + : "请先登录后再支付。", + bindFirstBeforePay: isEn + ? "Please bind a wallet first." + : "请先绑定钱包。", + payNotReady: isEn + ? "Payment service is not fully configured." + : "支付服务未配置完成。", + openBindFlow: isEn + ? "Please bind a wallet first. Opening bind flow..." + : "请先完成钱包绑定,正在拉起绑定流程...", + walletBoundCreatingOrder: isEn + ? "Wallet bound. Creating order and sending payment..." + : "钱包已绑定,正在创建订单并发起支付...", + proMember: "PRO MEMBER", + freeTier: "FREE TIER", + proPendingSync: isEn ? "Activated (pending sync)" : "已开通(待同步)", + noProSubscription: isEn ? "No Pro subscription" : "暂无 Pro 订阅", + }), + [isEn], + ); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); @@ -522,7 +611,12 @@ export function AccountCenter() { * is missing or close to expiry. Throws if the user is not authenticated. */ const getValidAccessToken = useCallback(async (): Promise => { - if (!supabaseReady) throw new Error("Supabase 未配置,无法获取登录凭证。"); + if (!supabaseReady) + throw new Error( + isEn + ? "Supabase is not configured. Unable to get auth token." + : "Supabase 未配置,无法获取登录凭证。", + ); const client = getSupabaseBrowserClient(); // First try the cached session. const { @@ -539,10 +633,14 @@ export function AccountCenter() { if (refreshedToken) return refreshedToken; throw new Error( error?.message - ? `登录会话已失效 (${error.message}),请退出后重新登录。` - : "登录会话已失效,请退出后重新登录。", + ? isEn + ? `Session expired (${error.message}). Please sign out and sign in again.` + : `登录会话已失效 (${error.message}),请退出后重新登录。` + : isEn + ? "Session expired. Please sign out and sign in again." + : "登录会话已失效,请退出后重新登录。", ); - }, [supabaseReady]); + }, [isEn, supabaseReady]); const buildAuthedHeaders = useCallback( async (withJson = false): Promise> => { @@ -764,19 +862,19 @@ export function AccountCenter() { const displayName = String(user?.user_metadata?.full_name || "").trim() || (email ? String(email).split("@")[0] : "") || - "PolyWeather 用户"; + copy.guestUser; const initials = (displayName.slice(0, 2) || "PW").toUpperCase(); - const joinedAt = formatTime(user?.created_at, "zh-CN"); + const joinedAt = formatTime(user?.created_at, locale); const isSubscribed = Boolean(backend?.subscription_active); const expiryRaw = String( backend?.subscription_expires_at || user?.user_metadata?.pro_expiry || "", ).trim(); - const expiryFormatted = formatTime(expiryRaw, "zh-CN"); + const expiryFormatted = formatTime(expiryRaw, locale); const proExpiry = isSubscribed ? expiryFormatted !== "--" ? expiryFormatted - : expiryRaw || "已开通(待同步)" - : "暂无 Pro 订阅"; + : expiryRaw || copy.proPendingSync + : copy.noProSubscription; // Points Logic const backendPointsRaw = Number(backend?.points); @@ -1073,7 +1171,7 @@ export function AccountCenter() { setPaymentError(""); setPaymentInfo(""); if (!isAuthenticated) { - setPaymentError("请先登录后再绑定钱包。"); + setPaymentError(copy.loginBeforeBind); return false; } @@ -1101,7 +1199,7 @@ export function AccountCenter() { method: "eth_requestAccounts", })) as string[]; const address = String(accounts?.[0] || "").toLowerCase(); - if (!address) throw new Error("钱包账户为空"); + if (!address) throw new Error(isEn ? "Wallet account is empty." : "钱包账户为空"); const existingWallet = boundWallets.find( (w) => String(w.address || "").toLowerCase() === address, @@ -1163,16 +1261,77 @@ export function AccountCenter() { } }; + const handleUnbindWallet = async (address: string) => { + const target = String(address || "").toLowerCase(); + if (!target) return; + if (!isAuthenticated) { + setPaymentError(copy.loginBeforeBind); + return; + } + const confirmed = window.confirm( + copy.unbindConfirm.replace("{address}", shortAddress(target)), + ); + if (!confirmed) return; + + setPaymentBusy(true); + setPaymentError(""); + setPaymentInfo(""); + try { + const headers = await buildAuthedHeaders(true); + const res = await fetch("/api/payments/wallets", { + method: "DELETE", + headers, + body: JSON.stringify({ address: target }), + }); + const raw = await res.text(); + if (!res.ok) { + let detail = raw; + try { + const parsed = JSON.parse(raw); + detail = String(parsed?.detail || parsed?.error || raw); + } catch { + // ignore + } + throw new Error(detail || `HTTP ${res.status}`); + } + + let data: Record = {}; + try { + data = raw ? (JSON.parse(raw) as Record) : {}; + } catch { + data = {}; + } + const newPrimary = String(data?.new_primary || "").toLowerCase(); + if (selectedWallet === target) { + setSelectedWallet(newPrimary || ""); + } + if (walletAddress === target) { + setWalletAddress(newPrimary || ""); + } + await loadPaymentSnapshot(); + setPaymentInfo( + newPrimary + ? copy.unbindDonePrimary.replace("{address}", shortAddress(newPrimary)) + : copy.unbindDone, + ); + } catch (error) { + const message = normalizePaymentError(error).message; + setPaymentError(`${copy.unbindFailed}: ${message}`); + } finally { + setPaymentBusy(false); + } + }; + const createIntentAndPay = async () => { setPaymentError(""); setPaymentInfo(""); setLastTxHash(""); if (!isAuthenticated) { - setPaymentError("请先登录后再支付。"); + setPaymentError(copy.loginBeforePay); return; } if (!paymentConfig?.configured) { - setPaymentError("支付服务未配置完成。"); + setPaymentError(copy.payNotReady); return; } @@ -1180,7 +1339,7 @@ export function AccountCenter() { selectedWallet || walletAddress || boundWallets[0]?.address || "", ).toLowerCase(); if (!fallbackWallet) { - setPaymentError("请先绑定钱包。"); + setPaymentError(copy.bindFirstBeforePay); return; } @@ -1193,7 +1352,7 @@ export function AccountCenter() { method: "eth_requestAccounts", })) as string[]; const activeAddress = String(activeAccounts?.[0] || "").toLowerCase(); - if (!activeAddress) throw new Error("钱包账户为空"); + if (!activeAddress) throw new Error(isEn ? "Wallet account is empty." : "钱包账户为空"); const boundAddrSet = new Set( boundWallets.map((row) => String(row.address || "").toLowerCase()), @@ -1408,16 +1567,16 @@ export function AccountCenter() { const handleOverlayCheckout = async () => { if (!isAuthenticated) { - setPaymentError("请先登录后再支付。"); + setPaymentError(copy.loginBeforePay); return; } if (!hasPayingWallet) { - setPaymentInfo("请先完成钱包绑定,正在拉起绑定流程..."); + setPaymentInfo(copy.openBindFlow); const bound = await connectAndBindWallet(providerMode, { openOverlayAfterBind: true, }); if (!bound) return; - setPaymentInfo("钱包已绑定,正在创建订单并发起支付..."); + setPaymentInfo(copy.walletBoundCreatingOrder); await createIntentAndPay(); return; } @@ -1431,7 +1590,7 @@ export function AccountCenter() {
-

加载账户信息中...

+

{copy.loadingAccount}

); @@ -1449,7 +1608,8 @@ export function AccountCenter() {

- 账户中心 + {copy.accountCenter}

@@ -1468,7 +1628,7 @@ export function AccountCenter() { onClick={() => setShowOverlay(true)} className="flex items-center gap-2 px-4 py-2 bg-yellow-500/10 hover:bg-yellow-500/20 border border-yellow-500/30 text-yellow-500 rounded-xl text-sm transition-all animate-pulse" > - 升级 Pro + {copy.upgradePro} )} {isAuthenticated ? ( ) : ( - 登录 + {copy.signIn} )} @@ -1521,11 +1681,11 @@ export function AccountCenter() { - {isSubscribed ? "PRO MEMBER" : "FREE TIER"} + {isSubscribed ? copy.proMember : copy.freeTier}

- {email || "游客用户"} + {email || copy.guestUser}

@@ -1535,14 +1695,17 @@ export function AccountCenter() {
- 加入时间: {joinedAt} + {" "} + + {copy.joinedAt}: {joinedAt} +

- 总积分 (荣誉) + {copy.totalPoints}

{" "} @@ -1551,7 +1714,7 @@ export function AccountCenter() {

- 周排行 (竞技) + {copy.weeklyRank}

#{weeklyRank} @@ -1564,7 +1727,8 @@ export function AccountCenter() {

- 周榜奖励 + {" "} + {copy.weeklyRewards}

@@ -1617,49 +1781,49 @@ export function AccountCenter() { >

- 会员权限详情 + {copy.membershipDetails}

- - + +

- 身份状态 + {copy.identityStatus}

- +
@@ -1685,7 +1849,7 @@ export function AccountCenter() { onClose={() => setShowOverlay(false)} payBusy={paymentBusy} payLabel={ - hasPayingWallet ? "立即订阅并激活服务" : "连接钱包并支付" + hasPayingWallet ? copy.payNow : copy.connectAndPay } errorText={paymentError || undefined} infoText={paymentInfo || undefined} @@ -1708,10 +1872,10 @@ export function AccountCenter() { />

- Telegram Bot 绑定 + {copy.telegramBind}

- 将下方命令发送给polyweather机器人,实现全平台气象推送与权限同步。 + {copy.telegramHint}

@@ -1720,7 +1884,8 @@ export function AccountCenter() { @@ -1732,12 +1897,12 @@ export function AccountCenter() {

- 支付管理 + {copy.paymentMgmt}

{availableTokenList.length > 0 && (

- 支付币种 + {copy.paymentToken}

{availableTokenList.map((token) => { @@ -1781,17 +1946,28 @@ export function AccountCenter() { {w.is_primary && ( - Primary + {copy.primary} )}
-
Polygon Chain
+
{copy.polygonChain}
+
+ +
))}
) : (

- 未绑定任何收件钱包 + {copy.noWallet}

)}
@@ -1805,7 +1981,7 @@ export function AccountCenter() { disabled={paymentBusy || !isAuthenticated} className="w-full py-3 border border-white/10 bg-white/5 hover:bg-white/10 rounded-xl text-xs font-bold text-slate-300 transition-all flex items-center justify-center gap-2 disabled:opacity-60" > - 绑定浏览器钱包(EVM扩展) + {copy.bindExt} {!walletConnectEnabled && (

- 未启用 WalletConnect:请配置 + {copy.walletConnectMissing} NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID diff --git a/frontend/components/auth/LoginClient.tsx b/frontend/components/auth/LoginClient.tsx index 57ad0fc5..22481c94 100644 --- a/frontend/components/auth/LoginClient.tsx +++ b/frontend/components/auth/LoginClient.tsx @@ -17,6 +17,7 @@ import { getSupabaseBrowserClient, hasSupabasePublicEnv, } from "@/lib/supabase/client"; +import { useI18n } from "@/hooks/useI18n"; type Mode = "login" | "signup"; @@ -26,6 +27,7 @@ type LoginClientProps = { export function LoginClient({ nextPath }: LoginClientProps) { const router = useRouter(); + const { locale } = useI18n(); const [mode, setMode] = useState("login"); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); @@ -34,6 +36,42 @@ export function LoginClient({ nextPath }: LoginClientProps) { const [infoText, setInfoText] = useState(""); const supabaseReady = hasSupabasePublicEnv(); + const isEn = locale === "en-US"; + const copy = { + backHome: isEn ? "Back to Home" : "返回首页", + subtitle: isEn + ? "Explore weather details from every corner of the world" + : "探索世界每一个角落的气象细节", + googleOneClick: isEn + ? "Continue with Google" + : "使用 Google 账号一键登录", + orEmail: isEn ? "Or continue with email" : "或使用邮箱", + login: isEn ? "Sign In" : "登录", + signup: isEn ? "Sign Up" : "注册", + passwordLoginPlaceholder: isEn ? "Enter password" : "输入密码", + passwordSignupPlaceholder: isEn + ? "Set at least 6 characters" + : "设置至少 6 位密码", + loginSubmit: isEn ? "Start your weather journey" : "开启天气之旅", + signupSubmit: isEn ? "Create account now" : "立即创建账号", + loginHint: isEn + ? "After signing in, your homepage will be personalized." + : "登录后将为您个性化定制首页数据", + signupHint: isEn + ? "By signing up, you agree to our Terms of Service." + : "注册即代表同意我们的服务条款", + realtime: isEn ? "Realtime data" : "实时数据", + highPrecision: isEn ? "High-precision forecast" : "高精度预测", + supabaseMissing: isEn + ? "Supabase is not configured. Sign-in is unavailable." + : "Supabase 未配置,无法使用登录", + needEmailPassword: isEn + ? "Please enter email and password." + : "请输入邮箱和密码", + signupCheckEmail: isEn + ? "Sign-up successful. Please verify your email before signing in." + : "注册成功,请检查邮箱并完成验证后登录。", + } as const; useEffect(() => { if (!supabaseReady) return; @@ -53,7 +91,7 @@ export function LoginClient({ nextPath }: LoginClientProps) { setErrorText(""); setInfoText(""); if (!supabaseReady) { - setErrorText("Supabase 未配置,无法使用登录"); + setErrorText(copy.supabaseMissing); return; } @@ -82,11 +120,11 @@ export function LoginClient({ nextPath }: LoginClientProps) { setErrorText(""); setInfoText(""); if (!supabaseReady) { - setErrorText("Supabase 未配置,无法使用登录"); + setErrorText(copy.supabaseMissing); return; } if (!email.trim() || !password.trim()) { - setErrorText("请输入邮箱和密码"); + setErrorText(copy.needEmailPassword); return; } @@ -124,7 +162,7 @@ export function LoginClient({ nextPath }: LoginClientProps) { router.replace(nextPath); return; } - setInfoText("注册成功,请检查邮箱并完成验证后登录。"); + setInfoText(copy.signupCheckEmail); } finally { setLoading(false); } @@ -141,18 +179,17 @@ export function LoginClient({ nextPath }: LoginClientProps) { -

PolyWeather

-

探索世界每一个角落的气象细节

+

{copy.subtitle}

- 或使用邮箱 + {copy.orEmail}
@@ -183,7 +220,7 @@ export function LoginClient({ nextPath }: LoginClientProps) { : "text-slate-400 hover:text-slate-200" }`} > - 登录 + {copy.login}
@@ -218,7 +255,11 @@ export function LoginClient({ nextPath }: LoginClientProps) { minLength={6} value={password} onChange={(event) => setPassword(event.target.value)} - placeholder={isLogin ? "输入密码" : "设置至少 6 位密码"} + placeholder={ + isLogin + ? copy.passwordLoginPlaceholder + : copy.passwordSignupPlaceholder + } className="w-full rounded-xl border border-white/10 bg-white/5 py-3.5 pl-12 pr-4 text-white placeholder:text-slate-600 transition-all focus:border-blue-500/50 focus:outline-none focus:ring-2 focus:ring-blue-500/50" />
@@ -228,7 +269,7 @@ export function LoginClient({ nextPath }: LoginClientProps) { disabled={loading} className="group mt-8 flex w-full items-center justify-center rounded-xl bg-gradient-to-r from-blue-600 to-indigo-600 py-3.5 font-bold text-white shadow-xl shadow-blue-600/20 transition-all hover:from-blue-500 hover:to-indigo-500 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-70" > - {isLogin ? "开启天气之旅" : "立即创建账号"} + {isLogin ? copy.loginSubmit : copy.signupSubmit} @@ -238,23 +279,21 @@ export function LoginClient({ nextPath }: LoginClientProps) {

- {isLogin ? "登录后将为您个性化定制首页数据" : "注册即代表同意我们的服务条款"} + {isLogin ? copy.loginHint : copy.signupHint}

{!supabaseReady ? ( -

- Supabase 未配置,无法使用登录 -

+

{copy.supabaseMissing}

) : null}
- 实时数据 + {copy.realtime} - 高精度预测 + {copy.highPrecision}
diff --git a/src/payments/contract_checkout.py b/src/payments/contract_checkout.py index 07ee19d2..badf9396 100644 --- a/src/payments/contract_checkout.py +++ b/src/payments/contract_checkout.py @@ -1119,6 +1119,86 @@ class PaymentContractCheckoutService: verified_at=now_iso, ) + def unbind_wallet(self, user_id: str, address: str) -> Dict[str, Any]: + self._ensure_enabled() + normalized = _normalize_address(address) + if not normalized: + raise PaymentCheckoutError(400, "invalid wallet address") + + # Must be an active wallet owned by current user. + self._require_user_wallet(user_id, normalized) + + now_iso = _to_iso(_now_utc()) + self._rest( + "PATCH", + "user_wallets", + params={ + "user_id": f"eq.{user_id}", + "chain_id": f"eq.{self.chain_id}", + "address": f"eq.{normalized}", + }, + payload={ + "status": "revoked", + "is_primary": False, + "updated_at": now_iso, + }, + prefer="return=representation", + allowed_status=[200], + ) + + # Ensure there is still an active primary wallet after unbind. + active_primary_rows = self._rest( + "GET", + "user_wallets", + params={ + "select": "id,address", + "user_id": f"eq.{user_id}", + "chain_id": f"eq.{self.chain_id}", + "status": "eq.active", + "is_primary": "eq.true", + "limit": "1", + }, + allowed_status=[200], + ) + + new_primary = "" + if isinstance(active_primary_rows, list) and active_primary_rows: + new_primary = _normalize_address(active_primary_rows[0].get("address") or "") + else: + active_wallet_rows = self._rest( + "GET", + "user_wallets", + params={ + "select": "id,address", + "user_id": f"eq.{user_id}", + "chain_id": f"eq.{self.chain_id}", + "status": "eq.active", + "order": "verified_at.desc,updated_at.desc", + "limit": "1", + }, + allowed_status=[200], + ) + if isinstance(active_wallet_rows, list) and active_wallet_rows: + candidate = active_wallet_rows[0] + candidate_id = candidate.get("id") + candidate_addr = _normalize_address(candidate.get("address") or "") + if candidate_id and candidate_addr: + self._rest( + "PATCH", + "user_wallets", + params={"id": f"eq.{candidate_id}"}, + payload={"is_primary": True, "updated_at": now_iso}, + prefer="return=representation", + allowed_status=[200], + ) + new_primary = candidate_addr + + return { + "address": normalized, + "unbound": True, + "new_primary": new_primary or None, + } + def _select_plan(self, plan_code: str) -> Dict[str, Any]: code = str(plan_code or "").strip().lower() or "pro_monthly" row = self.plan_catalog.get(code) diff --git a/web/app.py b/web/app.py index c4b37f85..ef826feb 100644 --- a/web/app.py +++ b/web/app.py @@ -261,6 +261,10 @@ class WalletVerifyRequest(BaseModel): signature: str = Field(..., min_length=20) +class WalletUnbindRequest(BaseModel): + address: str = Field(..., min_length=8) + + class CreatePaymentIntentRequest(BaseModel): plan_code: str = Field(default="pro_monthly", min_length=2) payment_mode: str = Field(default="strict") @@ -1226,6 +1230,19 @@ async def payment_wallets(request: Request): raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc +@app.delete("/api/payments/wallets") +async def payment_wallet_unbind(request: Request, body: WalletUnbindRequest): + _assert_entitlement(request) + identity = _require_supabase_identity(request) + try: + return PAYMENT_CHECKOUT.unbind_wallet( + user_id=identity["user_id"], + address=body.address, + ) + except PaymentCheckoutError as exc: + raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc + + @app.post("/api/payments/wallets/challenge") async def payment_wallet_challenge(request: Request, body: WalletChallengeRequest): _assert_entitlement(request)