diff --git a/.env.example b/.env.example index 7bcaf2c5..07fc5324 100644 --- a/.env.example +++ b/.env.example @@ -60,6 +60,9 @@ POLYWEATHER_PAYMENT_HTTP_TIMEOUT_SEC=10 POLYWEATHER_PAYMENT_POLL_INTERVAL_SEC=4 POLYWEATHER_PAYMENT_MAX_WAIT_SEC=50 POLYWEATHER_PAYMENT_TELEGRAM_NOTIFY_ENABLED=true +# Comma-separated allowed plans for checkout UI + backend validation. +# Default is monthly-only launch. +POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES=pro_monthly # JSON object # Example: {"pro_monthly":{"plan_id":101,"amount_usdc":"29","duration_days":30}} POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON= diff --git a/frontend/app/api/auth/me/route.ts b/frontend/app/api/auth/me/route.ts index 88bebcab..e63ad8c5 100644 --- a/frontend/app/api/auth/me/route.ts +++ b/frontend/app/api/auth/me/route.ts @@ -24,7 +24,7 @@ export async function GET(req: NextRequest) { const raw = await res.text(); const response = NextResponse.json( { error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) }, - { status: 502 }, + { status: res.status }, ); return applyAuthResponseCookies(response, auth.response); } @@ -38,4 +38,3 @@ export async function GET(req: NextRequest) { ); } } - diff --git a/frontend/app/api/payments/config/route.ts b/frontend/app/api/payments/config/route.ts index 6126296d..3de834a2 100644 --- a/frontend/app/api/payments/config/route.ts +++ b/frontend/app/api/payments/config/route.ts @@ -23,7 +23,7 @@ export async function GET(req: NextRequest) { const raw = await res.text(); const response = NextResponse.json( { error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) }, - { status: 502 }, + { status: res.status }, ); return applyAuthResponseCookies(response, auth.response); } diff --git a/frontend/app/api/payments/intents/[intentId]/confirm/route.ts b/frontend/app/api/payments/intents/[intentId]/confirm/route.ts index eacc3fe8..0c971561 100644 --- a/frontend/app/api/payments/intents/[intentId]/confirm/route.ts +++ b/frontend/app/api/payments/intents/[intentId]/confirm/route.ts @@ -36,7 +36,7 @@ export async function POST( const raw = await res.text(); const response = NextResponse.json( { error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) }, - { status: 502 }, + { status: res.status }, ); return applyAuthResponseCookies(response, auth.response); } @@ -50,4 +50,3 @@ export async function POST( ); } } - diff --git a/frontend/app/api/payments/intents/[intentId]/submit/route.ts b/frontend/app/api/payments/intents/[intentId]/submit/route.ts index da4ce6aa..58e10b09 100644 --- a/frontend/app/api/payments/intents/[intentId]/submit/route.ts +++ b/frontend/app/api/payments/intents/[intentId]/submit/route.ts @@ -36,7 +36,7 @@ export async function POST( const raw = await res.text(); const response = NextResponse.json( { error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) }, - { status: 502 }, + { status: res.status }, ); return applyAuthResponseCookies(response, auth.response); } @@ -50,4 +50,3 @@ export async function POST( ); } } - diff --git a/frontend/app/api/payments/intents/route.ts b/frontend/app/api/payments/intents/route.ts index 69a2c732..812b646d 100644 --- a/frontend/app/api/payments/intents/route.ts +++ b/frontend/app/api/payments/intents/route.ts @@ -29,7 +29,7 @@ export async function POST(req: NextRequest) { const raw = await res.text(); const response = NextResponse.json( { error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) }, - { status: 502 }, + { status: res.status }, ); return applyAuthResponseCookies(response, auth.response); } diff --git a/frontend/app/api/payments/wallets/challenge/route.ts b/frontend/app/api/payments/wallets/challenge/route.ts index 75575dd6..7c1fca8d 100644 --- a/frontend/app/api/payments/wallets/challenge/route.ts +++ b/frontend/app/api/payments/wallets/challenge/route.ts @@ -29,7 +29,7 @@ export async function POST(req: NextRequest) { const raw = await res.text(); const response = NextResponse.json( { error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) }, - { status: 502 }, + { status: res.status }, ); return applyAuthResponseCookies(response, auth.response); } @@ -43,4 +43,3 @@ export async function POST(req: NextRequest) { ); } } - diff --git a/frontend/app/api/payments/wallets/route.ts b/frontend/app/api/payments/wallets/route.ts index 289f81b0..0d34ce07 100644 --- a/frontend/app/api/payments/wallets/route.ts +++ b/frontend/app/api/payments/wallets/route.ts @@ -23,7 +23,7 @@ export async function GET(req: NextRequest) { const raw = await res.text(); const response = NextResponse.json( { error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) }, - { status: 502 }, + { status: res.status }, ); return applyAuthResponseCookies(response, auth.response); } diff --git a/frontend/app/api/payments/wallets/verify/route.ts b/frontend/app/api/payments/wallets/verify/route.ts index 90c59981..d7e7e6cd 100644 --- a/frontend/app/api/payments/wallets/verify/route.ts +++ b/frontend/app/api/payments/wallets/verify/route.ts @@ -29,7 +29,7 @@ export async function POST(req: NextRequest) { const raw = await res.text(); const response = NextResponse.json( { error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) }, - { status: 502 }, + { status: res.status }, ); return applyAuthResponseCookies(response, auth.response); } diff --git a/frontend/components/account/AccountCenter.tsx b/frontend/components/account/AccountCenter.tsx index f1e7d23e..b65296f8 100644 --- a/frontend/components/account/AccountCenter.tsx +++ b/frontend/components/account/AccountCenter.tsx @@ -20,10 +20,10 @@ import { LogIn, LogOut, Mail, - Wallet, RefreshCw, Shield, Sparkles, + Wallet, User as UserIcon, UserCheck, } from "lucide-react"; @@ -134,6 +134,14 @@ function shortAddress(address: string) { return `${text.slice(0, 8)}...${text.slice(-6)}`; } +function planDisplayName(planCode: string) { + const code = String(planCode || "").trim().toLowerCase(); + if (code === "pro_monthly") return "Pro 月付"; + if (code === "pro_quarterly") return "Pro 季付"; + if (code === "pro_yearly") return "Pro 年付"; + return planCode || "--"; +} + function toPaddedHex(value: bigint) { return value.toString(16).padStart(64, "0"); } @@ -198,6 +206,31 @@ export function AccountCenter() { const supabaseReady = hasSupabasePublicEnv(); + const buildAuthedHeaders = useCallback( + async (withJson = false): Promise> => { + const headers: Record = {}; + if (withJson) { + headers["Content-Type"] = "application/json"; + } + if (!supabaseReady) { + return headers; + } + try { + const { + data: { session }, + } = await getSupabaseBrowserClient().auth.getSession(); + const accessToken = String(session?.access_token || "").trim(); + if (accessToken) { + headers.Authorization = `Bearer ${accessToken}`; + } + } catch { + // no-op: backend proxy may still succeed via cookie-based session. + } + return headers; + }, + [supabaseReady], + ); + const loadPaymentSnapshot = useCallback(async () => { if (!backend?.authenticated) { setPaymentConfig(null); @@ -205,9 +238,16 @@ export function AccountCenter() { return; } try { + const authHeaders = await buildAuthedHeaders(false); const [configRes, walletsRes] = await Promise.all([ - fetch("/api/payments/config", { cache: "no-store" }), - fetch("/api/payments/wallets", { cache: "no-store" }), + fetch("/api/payments/config", { + cache: "no-store", + headers: authHeaders, + }), + fetch("/api/payments/wallets", { + cache: "no-store", + headers: authHeaders, + }), ]); if (configRes.ok) { const configJson = (await configRes.json()) as PaymentConfig; @@ -226,10 +266,13 @@ export function AccountCenter() { setSelectedWallet(wallets[0].address); } } + if (configRes.status === 401 || walletsRes.status === 401) { + setPaymentError("登录会话已过期,请重新登录后再进行钱包绑定或支付。"); + } } catch { return; } - }, [backend?.authenticated, selectedPlanCode, selectedWallet]); + }, [backend?.authenticated, buildAuthedHeaders, selectedPlanCode, selectedWallet]); const loadSnapshot = useCallback(async () => { setErrorText(""); @@ -237,7 +280,11 @@ export function AccountCenter() { const userPromise = supabaseReady ? getSupabaseBrowserClient().auth.getUser() : Promise.resolve({ data: { user: null as User | null } }); - const backendPromise = fetch("/api/auth/me", { cache: "no-store" }); + const authHeaders = await buildAuthedHeaders(false); + const backendPromise = fetch("/api/auth/me", { + cache: "no-store", + headers: authHeaders, + }); const [userResult, backendResult] = await Promise.all([ userPromise, @@ -257,7 +304,7 @@ export function AccountCenter() { } catch (error) { setErrorText(String(error)); } - }, [supabaseReady]); + }, [buildAuthedHeaders, supabaseReady]); useEffect(() => { let cancelled = false; @@ -372,7 +419,12 @@ export function AccountCenter() { }; const planList = paymentConfig?.plans || []; - const selectedPlan = planList.find((p) => p.plan_code === selectedPlanCode) || planList[0]; + const monthlyPlanList = planList.filter( + (plan) => String(plan.plan_code || "").trim().toLowerCase() === "pro_monthly", + ); + const effectivePlanList = monthlyPlanList.length ? monthlyPlanList : planList; + const selectedPlan = + effectivePlanList.find((p) => p.plan_code === selectedPlanCode) || effectivePlanList[0]; const paymentFeatureReady = Boolean(paymentConfig?.enabled && paymentConfig?.configured); const connectAndBindWallet = async () => { @@ -396,10 +448,14 @@ export function AccountCenter() { if (!address) { throw new Error("钱包账户为空"); } + const authHeaders = await buildAuthedHeaders(true); + if (!authHeaders.Authorization) { + throw new Error("登录会话失效,请重新登录后再绑定钱包。"); + } setWalletAddress(address); const challengeRes = await fetch("/api/payments/wallets/challenge", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: authHeaders, body: JSON.stringify({ address }), }); if (!challengeRes.ok) { @@ -421,7 +477,7 @@ export function AccountCenter() { })) as string; const verifyRes = await fetch("/api/payments/wallets/verify", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: authHeaders, body: JSON.stringify({ address, nonce, signature }), }); if (!verifyRes.ok) { @@ -453,7 +509,9 @@ export function AccountCenter() { setPaymentError("未检测到 MetaMask。"); return; } - const payingWallet = (selectedWallet || walletAddress || "").toLowerCase(); + const payingWallet = String( + selectedWallet || walletAddress || boundWallets[0]?.address || "", + ).toLowerCase(); if (!payingWallet) { setPaymentError("请先绑定钱包。"); return; @@ -461,6 +519,10 @@ export function AccountCenter() { setPaymentBusy(true); try { + const authHeaders = await buildAuthedHeaders(true); + if (!authHeaders.Authorization) { + throw new Error("登录会话失效,请重新登录后再支付。"); + } const currentChainIdHex = String( (await eth.request({ method: "eth_chainId" })) || "", ); @@ -475,9 +537,9 @@ export function AccountCenter() { const createRes = await fetch("/api/payments/intents", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: authHeaders, body: JSON.stringify({ - plan_code: selectedPlanCode || "pro_monthly", + plan_code: selectedPlan?.plan_code || "pro_monthly", payment_mode: "strict", allowed_wallet: payingWallet, metadata: { source: "account_center" }, @@ -548,7 +610,7 @@ export function AccountCenter() { const submitRes = await fetch(`/api/payments/intents/${intentId}/submit`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm, from_address: payingWallet, @@ -561,7 +623,7 @@ export function AccountCenter() { const confirmRes = await fetch(`/api/payments/intents/${intentId}/confirm`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm }), }); if (!confirmRes.ok) { @@ -581,31 +643,32 @@ export function AccountCenter() { return (
-
-
+
+
-
+
-
-

- 账户中心 -

-

- 管理您的身份、权限与 Bot 绑定 -

-
-
+
- 返回看板 + +
+

账户中心

+

+ 积分体系 v4.3 · 管理身份、订阅与 Bot 绑定 +

+
+
+
) : ( 登录 / 注册 @@ -790,7 +853,7 @@ export function AccountCenter() { 选择套餐并支付

- {planList.map((plan) => ( + {effectivePlanList.map((plan) => (
diff --git a/frontend/components/dashboard/FutureForecastModal.tsx b/frontend/components/dashboard/FutureForecastModal.tsx index 007903e3..1b948f29 100644 --- a/frontend/components/dashboard/FutureForecastModal.tsx +++ b/frontend/components/dashboard/FutureForecastModal.tsx @@ -18,6 +18,7 @@ import { CSSProperties } from "react"; import { useChart } from "@/hooks/useChart"; import { useDashboardStore } from "@/hooks/useDashboardStore"; import { useI18n } from "@/hooks/useI18n"; +import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall"; import { ModelForecast, ProbabilityDistribution, @@ -438,6 +439,8 @@ export function FutureForecastModal() { const detail = store.selectedDetail; const marketScan = store.selectedMarketScan; const dateStr = store.futureModalDate; + const isPro = store.proAccess.subscriptionActive; + const isProLoading = store.proAccess.loading; if (!detail || !dateStr) return null; @@ -568,6 +571,7 @@ export function FutureForecastModal() { "future-refresh-btn", store.loadingState.marketScan && "spinning", )} + disabled={!isPro || isProLoading} onClick={() => { if (isToday) { void store.openTodayModal(true); @@ -575,7 +579,15 @@ export function FutureForecastModal() { } store.openFutureModal(dateStr, true); }} - title={locale === "en-US" ? "Refresh Data" : "刷新数据"} + title={ + !isPro + ? locale === "en-US" + ? "Pro subscription required" + : "需要 Pro 订阅" + : locale === "en-US" + ? "Refresh Data" + : "刷新数据" + } >
- {isToday ? ( + {isProLoading ? ( +
+ {t("dashboard.loading")} +
+ ) : !isPro ? ( + + ) : isToday ? (
-
- {isLoading ? ( - {t("history.loading")} - ) : error ? ( - {t("history.error")} - ) : !summary.recentData.length ? ( - {t("history.empty")} - ) : ( - <> -
- {t("history.hitRate")} - - {summary.hitRate != null ? `${summary.hitRate}%` : "--"} + {isProLoading ? ( +
+ {t("dashboard.loading")} +
+ ) : !isPro ? ( + + ) : ( + <> +
+ {isLoading ? ( + + {t("history.loading")} -
-
- {t("history.mae")} - - {summary.debMae != null ? `${summary.debMae}°` : "--"} + ) : error ? ( + + {t("history.error")} -
-
- {t("history.sample")} - - {t("history.sampleDays", { count: summary.settledCount })} + ) : !summary.recentData.length ? ( + + {t("history.empty")} -
- - )} -
- {!isLoading && !error && } + ) : ( + <> +
+ {t("history.hitRate")} + + {summary.hitRate != null ? `${summary.hitRate}%` : "--"} + +
+
+ {t("history.mae")} + + {summary.debMae != null ? `${summary.debMae}°` : "--"} + +
+
+ {t("history.sample")} + + {t("history.sampleDays", { count: summary.settledCount })} + +
+ + )} +
+ {!isLoading && !error && } + + )}
diff --git a/frontend/components/dashboard/ProFeaturePaywall.tsx b/frontend/components/dashboard/ProFeaturePaywall.tsx new file mode 100644 index 00000000..86d3d5d1 --- /dev/null +++ b/frontend/components/dashboard/ProFeaturePaywall.tsx @@ -0,0 +1,105 @@ +"use client"; + +import Link from "next/link"; +import { + ArrowRight, + BarChart3, + Crown, + Lock, + ShieldCheck, + Sparkles, + Zap, +} from "lucide-react"; +import { useI18n } from "@/hooks/useI18n"; + +type ProFeaturePaywallProps = { + feature: "today" | "history" | "future"; +}; + +function getFeatureLabel( + locale: "zh-CN" | "en-US", + feature: "today" | "history" | "future", +) { + if (locale === "en-US") { + if (feature === "today") return "Intraday Analysis"; + if (feature === "history") return "History Reconciliation"; + return "Future-date Analysis"; + } + if (feature === "today") return "今日日内分析"; + if (feature === "history") return "历史对账"; + return "未来日期分析"; +} + +export function ProFeaturePaywall({ feature }: ProFeaturePaywallProps) { + const { locale } = useI18n(); + const featureLabel = getFeatureLabel(locale, feature); + const isEn = locale === "en-US"; + + return ( +
+
+ +
+ +
+

+ {isEn ? "Unlock PolyWeather Pro" : "开启 PolyWeather Pro"} +

+

+ {isEn + ? `This module (${featureLabel}) is available for subscribers only. Upgrade to unlock advanced weather intelligence.` + : `当前模块(${featureLabel})仅对订阅用户开放。升级后可解锁更完整的气象分析能力。`} +

+
+ +
+ {[ + { + icon: Zap, + text: isEn ? "Real-time radar and lightning tracking" : "实时雷达与闪电追踪", + }, + { + icon: ShieldCheck, + text: isEn ? "15-day high-precision trend analysis" : "15 天高精度趋势分析", + }, + { + icon: BarChart3, + text: isEn ? "Pro-grade station raw data" : "专业级气象站原始数据", + }, + { + icon: Sparkles, + text: isEn ? "Ad-free experience across all surfaces" : "全平台无广告体验", + }, + ].map((item) => ( +
+ + {item.text} +
+ ))} +
+ +
+ + {isEn ? "Upgrade now - $5 / month" : "立即升级 - $5 / 月"} + + +
+ +
+ + + {isEn ? "Payments are secured with AES-256 encryption" : "所有交易均经过 AES-256 加密保护"} + +
+
+ ); +} diff --git a/frontend/hooks/useDashboardStore.tsx b/frontend/hooks/useDashboardStore.tsx index 0ac5ed1b..d57781ea 100644 --- a/frontend/hooks/useDashboardStore.tsx +++ b/frontend/hooks/useDashboardStore.tsx @@ -22,6 +22,7 @@ import { HistoryState, LoadingState, MarketScan, + ProAccessState, } from "@/lib/dashboard-types"; interface DashboardStoreValue extends DashboardState { @@ -39,6 +40,7 @@ interface DashboardStoreValue extends DashboardState { openTodayModal: (forceRefresh?: boolean) => Promise; registerMapStopMotion: (stopMotion: () => void) => void; refreshAll: () => Promise; + refreshProAccess: () => Promise; refreshSelectedCity: () => Promise; selectedMarketScan: MarketScan | null; selectedDetail: CityDetail | null; @@ -68,6 +70,15 @@ function getInitialHistoryState(): HistoryState { }; } +function getInitialProAccessState(): ProAccessState { + return { + loading: true, + authenticated: false, + subscriptionActive: false, + error: null, + }; +} + const AI_EMPTY_PATTERNS = [ /暂无\s*AI\s*分析/i, /当前以结构化气象与模型数据为主/i, @@ -205,6 +216,9 @@ export function DashboardStoreProvider({ getInitialHistoryState, ); const [isGuideOpen, setIsGuideOpen] = useState(false); + const [proAccess, setProAccess] = useState( + getInitialProAccessState, + ); const mapStopMotionRef = useRef<() => void>(() => {}); const hydratedSelectionRef = useRef(false); @@ -369,10 +383,50 @@ export function DashboardStoreProvider({ } }; + const refreshProAccess = async () => { + setProAccess((current) => ({ + ...current, + loading: true, + error: null, + })); + try { + const response = await fetch("/api/auth/me", { + cache: "no-store", + headers: { + Accept: "application/json", + }, + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const payload = (await response.json()) as { + authenticated?: boolean; + subscription_active?: boolean | null; + }; + setProAccess({ + loading: false, + authenticated: Boolean(payload.authenticated), + subscriptionActive: payload.subscription_active === true, + error: null, + }); + } catch (error) { + setProAccess({ + loading: false, + authenticated: false, + subscriptionActive: false, + error: String(error), + }); + } + }; + useEffect(() => { void loadCities(); }, []); + useEffect(() => { + void refreshProAccess(); + }, []); + useEffect(() => { if (!cities.length) return; @@ -514,6 +568,15 @@ export function DashboardStoreProvider({ const openHistory = async () => { if (!selectedCity) return; + if (!proAccess.subscriptionActive) { + setHistoryState((current) => ({ + ...current, + error: null, + isOpen: true, + loading: false, + })); + return; + } setHistoryState((current) => ({ ...current, error: null, @@ -558,10 +621,11 @@ export function DashboardStoreProvider({ isGuideOpen, loadCities, loadingState, + proAccess, openFutureModal: (dateStr: string, forceRefresh = false) => { mapStopMotionRef.current(); setFutureModalDate(dateStr); - if (!selectedCity) return; + if (!selectedCity || !proAccess.subscriptionActive) return; const cacheKey = getMarketScanCacheKey(selectedCity, dateStr); setLoadingState((current) => ({ ...current, marketScan: true })); void ensureCityMarketScan( @@ -584,6 +648,13 @@ export function DashboardStoreProvider({ mapStopMotionRef.current(); const cachedDetail = cityDetailsByName[selectedCity]; + if (cachedDetail?.local_date) { + setSelectedForecastDate(cachedDetail.local_date); + setFutureModalDate(cachedDetail.local_date); + } + if (!proAccess.subscriptionActive) { + return; + } // 乐观 UI: 有缓存则立刻秒开 modal,不阻塞显示 if (cachedDetail?.local_date) { @@ -633,6 +704,7 @@ export function DashboardStoreProvider({ mapStopMotionRef.current = stopMotion; }, refreshAll, + refreshProAccess, refreshSelectedCity, selectedMarketScan, selectedCity, @@ -652,6 +724,7 @@ export function DashboardStoreProvider({ isPanelOpen, isGuideOpen, loadingState, + proAccess, marketScanByCityName, selectedMarketScan, selectedCity, diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts index 11ad42ab..f10f6a7b 100644 --- a/frontend/lib/dashboard-types.ts +++ b/frontend/lib/dashboard-types.ts @@ -314,6 +314,13 @@ export interface HistoryState { dataByCity: Record; } +export interface ProAccessState { + loading: boolean; + authenticated: boolean; + subscriptionActive: boolean; + error: string | null; +} + export interface DashboardState { cities: CityListItem[]; cityDetailsByName: Record; @@ -323,4 +330,5 @@ export interface DashboardState { selectedForecastDate: string | null; loadingState: LoadingState; historyState: HistoryState; + proAccess: ProAccessState; } diff --git a/src/auth/supabase_entitlement.py b/src/auth/supabase_entitlement.py index 87b2d01a..1f58dcd8 100644 --- a/src/auth/supabase_entitlement.py +++ b/src/auth/supabase_entitlement.py @@ -134,15 +134,11 @@ class SupabaseEntitlementService: logger.warning(f"supabase auth user check failed: {exc}") return None - def has_active_subscription(self, user_id: str) -> bool: - if not self.require_subscription: - return True + def _query_active_subscription(self, user_id: str) -> bool: if not user_id: return False if not self.service_role_key: - logger.warning( - "POLYWEATHER_AUTH_REQUIRE_SUBSCRIPTION=true but SUPABASE_SERVICE_ROLE_KEY is missing", - ) + logger.warning("SUPABASE_SERVICE_ROLE_KEY is missing") return False now_ts = time.time() @@ -188,6 +184,14 @@ class SupabaseEntitlementService: logger.warning(f"supabase subscription query error user_id={user_id}: {exc}") return False + def has_active_subscription( + self, + user_id: str, + respect_requirement: bool = True, + ) -> bool: + if respect_requirement and not self.require_subscription: + return True + return self._query_active_subscription(user_id) + SUPABASE_ENTITLEMENT = SupabaseEntitlementService() - diff --git a/src/payments/contract_checkout.py b/src/payments/contract_checkout.py index 59023b1a..c6997a42 100644 --- a/src/payments/contract_checkout.py +++ b/src/payments/contract_checkout.py @@ -159,6 +159,18 @@ def _parse_plan_catalog(raw: str) -> Dict[str, Dict[str, Any]]: return out or dict(DEFAULT_PLAN_CATALOG) +def _parse_allowed_plan_codes(raw: str) -> List[str]: + text = str(raw or "").strip() + if not text: + return ["pro_monthly"] + out: List[str] = [] + for part in text.split(","): + code = str(part or "").strip().lower() + if code and code not in out: + out.append(code) + return out or ["pro_monthly"] + + @dataclass class WalletBindingRecord: chain_id: int @@ -224,6 +236,21 @@ class PaymentContractCheckoutService: self.plan_catalog = _parse_plan_catalog( os.getenv("POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON") or "" ) + self.allowed_plan_codes = _parse_allowed_plan_codes( + os.getenv("POLYWEATHER_PAYMENT_ALLOWED_PLAN_CODES") or "" + ) + filtered_catalog = { + code: row + for code, row in self.plan_catalog.items() + if code in self.allowed_plan_codes + } + if filtered_catalog: + self.plan_catalog = filtered_catalog + elif "pro_monthly" in self.plan_catalog: + self.plan_catalog = {"pro_monthly": self.plan_catalog["pro_monthly"]} + elif self.plan_catalog: + first_code = sorted(self.plan_catalog.keys())[0] + self.plan_catalog = {first_code: self.plan_catalog[first_code]} self.notify_telegram = _env_bool( "POLYWEATHER_PAYMENT_TELEGRAM_NOTIFY_ENABLED", True ) diff --git a/web/app.py b/web/app.py index 72a1af39..ff8baf07 100644 --- a/web/app.py +++ b/web/app.py @@ -1082,8 +1082,10 @@ async def auth_me(request: Request): subscription_active = None if SUPABASE_ENTITLEMENT.enabled and user_id: try: - if SUPABASE_ENTITLEMENT.require_subscription: - subscription_active = SUPABASE_ENTITLEMENT.has_active_subscription(user_id) + subscription_active = SUPABASE_ENTITLEMENT.has_active_subscription( + user_id, + respect_requirement=False, + ) except Exception: subscription_active = None