= {
@@ -170,7 +171,11 @@ export function CitySidebar() {
key={city.name}
type="button"
className={clsx("city-item", isActive && "active")}
- onClick={() => void store.selectCity(city.name)}
+ onClick={() =>
+ startTransition(() => {
+ void store.selectCity(city.name);
+ })
+ }
>
diff --git a/frontend/components/dashboard/DetailPanel.tsx b/frontend/components/dashboard/DetailPanel.tsx
index 5544c171..c5e1c492 100644
--- a/frontend/components/dashboard/DetailPanel.tsx
+++ b/frontend/components/dashboard/DetailPanel.tsx
@@ -1,8 +1,8 @@
"use client";
-import { ChartConfiguration } from "chart.js/auto";
+import type { ChartConfiguration } from "chart.js";
import clsx from "clsx";
-import { useEffect, useRef } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import { ForecastTable } from "@/components/dashboard/PanelSections";
import { useChart } from "@/hooks/useChart";
import { useDashboardStore } from "@/hooks/useDashboardStore";
@@ -17,7 +17,10 @@ import {
function DetailMiniTemperatureChart({ detail }: { detail: CityDetail }) {
const { locale, t } = useI18n();
- const chartData = getTemperatureChartData(detail, locale);
+ const chartData = useMemo(
+ () => getTemperatureChartData(detail, locale),
+ [detail, locale],
+ );
const canvasRef = useChart(() => {
if (!chartData) {
@@ -129,6 +132,7 @@ export function DetailPanel() {
const detail = store.selectedDetail;
const isPro = store.proAccess.subscriptionActive;
const panelRef = useRef
(null);
+ const [heavyContentReady, setHeavyContentReady] = useState(false);
const isOverlayOpen =
Boolean(store.futureModalDate) ||
store.historyState.isOpen ||
@@ -139,7 +143,10 @@ export function DetailPanel() {
Boolean(detail) &&
!store.loadingState.cityDetail &&
!isOverlayOpen;
- const profileStats = detail ? getCityProfileStats(detail, locale) : [];
+ const profileStats = useMemo(
+ () => (detail ? getCityProfileStats(detail, locale) : []),
+ [detail, locale],
+ );
const scenery = getCityScenery(detail?.name);
const blurActiveElement = () => {
if (typeof document === "undefined") return;
@@ -170,6 +177,42 @@ export function DetailPanel() {
panel.removeAttribute("inert");
}, [isVisible]);
+ useEffect(() => {
+ if (!isVisible || !detail) {
+ setHeavyContentReady(false);
+ return;
+ }
+
+ let canceled = false;
+ let timeoutId: number | null = null;
+ let idleId: number | null = null;
+ const win = typeof window !== "undefined" ? (window as any) : null;
+
+ const markReady = () => {
+ if (!canceled) {
+ setHeavyContentReady(true);
+ }
+ };
+
+ if (win && typeof win.requestIdleCallback === "function") {
+ idleId = win.requestIdleCallback(markReady, { timeout: 180 });
+ } else if (typeof window !== "undefined") {
+ timeoutId = window.setTimeout(markReady, 80);
+ } else {
+ setHeavyContentReady(true);
+ }
+
+ return () => {
+ canceled = true;
+ if (win && idleId != null && typeof win.cancelIdleCallback === "function") {
+ win.cancelIdleCallback(idleId);
+ }
+ if (timeoutId != null && typeof window !== "undefined") {
+ window.clearTimeout(timeoutId);
+ }
+ };
+ }, [detail, isVisible]);
+
return (
diff --git a/frontend/components/dashboard/FutureForecastModal.tsx b/frontend/components/dashboard/FutureForecastModal.tsx
index 99563036..4b2ea834 100644
--- a/frontend/components/dashboard/FutureForecastModal.tsx
+++ b/frontend/components/dashboard/FutureForecastModal.tsx
@@ -12,9 +12,9 @@ import {
Wind,
} from "lucide-react";
-import { ChartConfiguration } from "chart.js/auto";
+import type { ChartConfiguration } from "chart.js";
import clsx from "clsx";
-import { CSSProperties } from "react";
+import { CSSProperties, useMemo } from "react";
import { useChart } from "@/hooks/useChart";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
@@ -186,8 +186,10 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) {
const detail = store.selectedDetail;
const view = detail ? getFutureModalView(detail, dateStr, locale) : null;
const isToday = detail ? dateStr === detail.local_date : false;
- const todayChartData =
- detail && isToday ? getTemperatureChartData(detail, locale) : null;
+ const todayChartData = useMemo(
+ () => (detail && isToday ? getTemperatureChartData(detail, locale) : null),
+ [detail, isToday, locale],
+ );
const canvasRef = useChart(() => {
if (!detail || !view) {
diff --git a/frontend/components/dashboard/HistoryModal.tsx b/frontend/components/dashboard/HistoryModal.tsx
index c6288730..21373c58 100644
--- a/frontend/components/dashboard/HistoryModal.tsx
+++ b/frontend/components/dashboard/HistoryModal.tsx
@@ -1,6 +1,6 @@
"use client";
-import { ChartConfiguration } from "chart.js/auto";
+import type { ChartConfiguration } from "chart.js";
import { useMemo } from "react";
import { useChart } from "@/hooks/useChart";
import { useDashboardStore, useHistoryData } from "@/hooks/useDashboardStore";
diff --git a/frontend/components/dashboard/MapCanvas.tsx b/frontend/components/dashboard/MapCanvas.tsx
index aa8400da..af074920 100644
--- a/frontend/components/dashboard/MapCanvas.tsx
+++ b/frontend/components/dashboard/MapCanvas.tsx
@@ -1,5 +1,7 @@
"use client";
+import "leaflet/dist/leaflet.css";
+
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useLeafletMap } from "@/hooks/useLeafletMap";
diff --git a/frontend/components/dashboard/PanelSections.tsx b/frontend/components/dashboard/PanelSections.tsx
index 2cbecc47..75f60d09 100644
--- a/frontend/components/dashboard/PanelSections.tsx
+++ b/frontend/components/dashboard/PanelSections.tsx
@@ -1,7 +1,8 @@
"use client";
-import { ChartConfiguration } from "chart.js/auto";
+import type { ChartConfiguration } from "chart.js";
import clsx from "clsx";
+import { startTransition, useMemo } from "react";
import { useChart } from "@/hooks/useChart";
import { useCityData, useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
@@ -230,7 +231,10 @@ export function HeroSummary() {
export function TemperatureChart() {
const { data } = useCityData();
const { locale, t } = useI18n();
- const chartData = data ? getTemperatureChartData(data, locale) : null;
+ const chartData = useMemo(
+ () => (data ? getTemperatureChartData(data, locale) : null),
+ [data, locale],
+ );
const canvasRef = useChart(() => {
if (!data || !chartData) {
@@ -403,7 +407,7 @@ export function ProbabilityDistribution({
const marketNoText = toPercent(marketNoPrice);
const isToday = !targetDate || targetDate === detail.local_date;
const marketTopBuckets = isToday ? getMarketTopBuckets(marketScan) : [];
- const sortedMarketTopBuckets = (() => {
+ const sortedMarketTopBuckets = useMemo(() => {
const sorted = [...marketTopBuckets].sort(
(a, b) => Number(b.probability || 0) - Number(a.probability || 0),
);
@@ -417,7 +421,7 @@ export function ProbabilityDistribution({
if (deduped.length >= 4) break;
}
return deduped;
- })();
+ }, [marketTopBuckets]);
const useMarketTopBuckets =
marketScan?.available && sortedMarketTopBuckets.length >= 2;
const topMarketBucketText = toPercent(sortedMarketTopBuckets[0]?.probability);
@@ -729,7 +733,9 @@ export function ForecastTable() {
isSelected && "selected",
)}
onClick={() => {
- store.openFutureModal(day.date);
+ startTransition(() => {
+ store.openFutureModal(day.date);
+ });
}}
>
diff --git a/frontend/components/observability/WebVitalsReporter.tsx b/frontend/components/observability/WebVitalsReporter.tsx
new file mode 100644
index 00000000..e1f025b8
--- /dev/null
+++ b/frontend/components/observability/WebVitalsReporter.tsx
@@ -0,0 +1,43 @@
+"use client";
+
+import { usePathname } from "next/navigation";
+import { useReportWebVitals } from "next/web-vitals";
+
+const TRACKED_METRICS = new Set(["INP", "LCP", "FCP"]);
+
+export function WebVitalsReporter() {
+ const pathname = usePathname();
+
+ useReportWebVitals((metric) => {
+ if (!TRACKED_METRICS.has(metric.name)) {
+ return;
+ }
+
+ const payload = {
+ id: metric.id,
+ metric: metric.name,
+ navigationType: metric.navigationType,
+ pathname: pathname || "/",
+ rating: metric.rating,
+ value: metric.value,
+ };
+
+ const body = JSON.stringify(payload);
+ const url = "/api/vitals";
+
+ if (typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") {
+ const blob = new Blob([body], { type: "application/json" });
+ navigator.sendBeacon(url, blob);
+ return;
+ }
+
+ void fetch(url, {
+ body,
+ headers: { "Content-Type": "application/json" },
+ keepalive: true,
+ method: "POST",
+ });
+ });
+
+ return null;
+}
diff --git a/frontend/hooks/useChart.ts b/frontend/hooks/useChart.ts
index 08b49207..07378980 100644
--- a/frontend/hooks/useChart.ts
+++ b/frontend/hooks/useChart.ts
@@ -1,27 +1,36 @@
"use client";
import { useEffect, useRef } from "react";
-import { Chart, ChartConfiguration, ChartType } from "chart.js/auto";
+import type { Chart as ChartInstance, ChartConfiguration, ChartType } from "chart.js";
export function useChart(
createConfig: () => ChartConfiguration,
dependencies: React.DependencyList,
) {
const canvasRef = useRef(null);
- const chartRef = useRef | null>(null);
+ const chartRef = useRef | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
+ let disposed = false;
- const config = createConfig();
- if (chartRef.current) {
- chartRef.current.destroy();
- chartRef.current = null;
- }
+ const setupChart = async () => {
+ const { Chart } = await import("chart.js/auto");
+ if (disposed) return;
- chartRef.current = new Chart(canvas, config);
+ const config = createConfig();
+ if (chartRef.current) {
+ chartRef.current.destroy();
+ chartRef.current = null;
+ }
+
+ chartRef.current = new Chart(canvas, config);
+ };
+
+ void setupChart();
return () => {
+ disposed = true;
chartRef.current?.destroy();
chartRef.current = null;
};
diff --git a/frontend/hooks/useLeafletMap.ts b/frontend/hooks/useLeafletMap.ts
index d50da66f..ec42fb24 100644
--- a/frontend/hooks/useLeafletMap.ts
+++ b/frontend/hooks/useLeafletMap.ts
@@ -216,59 +216,73 @@ export function useLeafletMap({
useEffect(() => {
const map = mapRef.current;
if (!map || !cities.length) return;
+ let canceled = false;
+ const frameId =
+ typeof window !== "undefined"
+ ? window.requestAnimationFrame(() => {
+ if (canceled) return;
- const currentMarkers = markersRef.current;
- const nextMarkers: typeof currentMarkers = {};
- const nextLastData: typeof lastCityDataRef.current = {};
+ const currentMarkers = markersRef.current;
+ const nextMarkers: typeof currentMarkers = {};
+ const nextLastData: typeof lastCityDataRef.current = {};
- cities.forEach((city) => {
- const detail = cityDetailsByName[city.name];
- const summary = citySummariesByName[city.name];
- const snapshot = detail || summary;
- const existing = currentMarkers[city.name];
+ cities.forEach((city) => {
+ const detail = cityDetailsByName[city.name];
+ const summary = citySummariesByName[city.name];
+ const snapshot = detail || summary;
+ const existing = currentMarkers[city.name];
- const currentTemp = snapshot?.current?.temp;
- const currentRisk = city.risk_level;
- const lastData = lastCityDataRef.current[city.name];
- const dataChanged =
- !lastData ||
- lastData.temp !== currentTemp ||
- lastData.risk !== currentRisk;
+ const currentTemp = snapshot?.current?.temp;
+ const currentRisk = city.risk_level;
+ const lastData = lastCityDataRef.current[city.name];
+ const dataChanged =
+ !lastData ||
+ lastData.temp !== currentTemp ||
+ lastData.risk !== currentRisk;
- if (existing) {
- if (dataChanged) {
- existing.marker.setIcon(createMarkerIcon(city, snapshot));
- }
- nextMarkers[city.name] = { city, marker: existing.marker };
- nextLastData[city.name] = { temp: currentTemp, risk: currentRisk };
- return;
+ if (existing) {
+ if (dataChanged) {
+ existing.marker.setIcon(createMarkerIcon(city, snapshot));
+ }
+ nextMarkers[city.name] = { city, marker: existing.marker };
+ nextLastData[city.name] = { temp: currentTemp, risk: currentRisk };
+ return;
+ }
+
+ // Create new marker
+ const marker = L.marker([city.lat, city.lon], {
+ icon: createMarkerIcon(city, snapshot),
+ }).addTo(map);
+
+ marker.on("click", () => {
+ map.stop();
+ // Reset lastMovedCity so we can re-fly if needed
+ lastMovedCityRef.current = null;
+ onSelectCityRef.current(city.name);
+ });
+
+ nextMarkers[city.name] = { city, marker };
+ nextLastData[city.name] = { temp: currentTemp, risk: currentRisk };
+ });
+
+ // Cleanup removed markers
+ Object.entries(currentMarkers).forEach(([name, entry]) => {
+ if (!nextMarkers[name]) {
+ map.removeLayer(entry.marker);
+ }
+ });
+
+ markersRef.current = nextMarkers;
+ lastCityDataRef.current = nextLastData;
+ })
+ : null;
+
+ return () => {
+ canceled = true;
+ if (frameId != null && typeof window !== "undefined") {
+ window.cancelAnimationFrame(frameId);
}
-
- // Create new marker
- const marker = L.marker([city.lat, city.lon], {
- icon: createMarkerIcon(city, snapshot),
- }).addTo(map);
-
- marker.on("click", () => {
- map.stop();
- // Reset lastMovedCity so we can re-fly if needed
- lastMovedCityRef.current = null;
- onSelectCityRef.current(city.name);
- });
-
- nextMarkers[city.name] = { city, marker };
- nextLastData[city.name] = { temp: currentTemp, risk: currentRisk };
- });
-
- // Cleanup removed markers
- Object.entries(currentMarkers).forEach(([name, entry]) => {
- if (!nextMarkers[name]) {
- map.removeLayer(entry.marker);
- }
- });
-
- markersRef.current = nextMarkers;
- lastCityDataRef.current = nextLastData;
+ };
}, [cities, cityDetailsByName, citySummariesByName]);
useEffect(() => {
diff --git a/frontend/lib/vitals-store.ts b/frontend/lib/vitals-store.ts
new file mode 100644
index 00000000..d546aaa0
--- /dev/null
+++ b/frontend/lib/vitals-store.ts
@@ -0,0 +1,88 @@
+type MetricName = "INP" | "LCP" | "FCP";
+
+export type VitalsSample = {
+ id: string;
+ metric: MetricName;
+ navigationType: string;
+ pathname: string;
+ rating: string;
+ timestamp: number;
+ value: number;
+};
+
+type AggregatedMetric = {
+ count: number;
+ p75: number;
+};
+
+export type VitalsSummary = {
+ generatedAt: string;
+ routes: Record>>;
+};
+
+const MAX_SAMPLES = 3000;
+const trackedMetrics = new Set(["INP", "LCP", "FCP"]);
+
+declare global {
+ // eslint-disable-next-line no-var
+ var __polyweatherVitalsStore: VitalsSample[] | undefined;
+}
+
+function getStore() {
+ if (!globalThis.__polyweatherVitalsStore) {
+ globalThis.__polyweatherVitalsStore = [];
+ }
+ return globalThis.__polyweatherVitalsStore;
+}
+
+function toPercentile(values: number[], percentile: number) {
+ if (!values.length) return 0;
+ const sorted = [...values].sort((a, b) => a - b);
+ const position = Math.ceil(percentile * sorted.length) - 1;
+ const index = Math.max(0, Math.min(sorted.length - 1, position));
+ return sorted[index];
+}
+
+export function normalizeMetricName(input: unknown): MetricName | null {
+ const value = String(input || "").trim().toUpperCase() as MetricName;
+ return trackedMetrics.has(value) ? value : null;
+}
+
+export function recordVitalsSample(sample: VitalsSample) {
+ const store = getStore();
+ store.push(sample);
+ if (store.length > MAX_SAMPLES) {
+ store.splice(0, store.length - MAX_SAMPLES);
+ }
+}
+
+export function getVitalsSummary(): VitalsSummary {
+ const store = getStore();
+ const grouped: Record> = {};
+
+ for (const item of store) {
+ const route = item.pathname || "/";
+ const metric = item.metric;
+ grouped[route] ||= { FCP: [], INP: [], LCP: [] };
+ grouped[route][metric].push(item.value);
+ }
+
+ const routes: VitalsSummary["routes"] = {};
+ Object.entries(grouped).forEach(([route, byMetric]) => {
+ const routeStats: Partial> = {};
+ (["INP", "LCP", "FCP"] as const).forEach((metric) => {
+ const values = byMetric[metric];
+ if (!values.length) return;
+ routeStats[metric] = {
+ count: values.length,
+ p75: Number(toPercentile(values, 0.75).toFixed(2)),
+ };
+ });
+ routes[route] = routeStats;
+ });
+
+ return {
+ generatedAt: new Date().toISOString(),
+ routes,
+ };
+}
diff --git a/src/payments/contract_checkout.py.bak b/src/payments/contract_checkout.py.bak
deleted file mode 100644
index 5f60c7aa..00000000
--- a/src/payments/contract_checkout.py.bak
+++ /dev/null
@@ -1,1118 +0,0 @@
-from __future__ import annotations
-
-import json
-import os
-import secrets
-import threading
-from dataclasses import dataclass
-from datetime import datetime, timedelta, timezone
-from decimal import Decimal, InvalidOperation
-from typing import Any, Dict, List, Optional
-
-import requests
-from eth_account import Account
-from eth_account.messages import encode_defunct
-from web3 import Web3
-
-DEFAULT_POLYGON_CHAIN_ID = 137
-DEFAULT_USDC_E_ADDRESS = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
-
-PAYMENT_CONTRACT_ABI = [
- {
- "inputs": [
- {"internalType": "bytes32", "name": "orderId", "type": "bytes32"},
- {"internalType": "uint256", "name": "planId", "type": "uint256"},
- {"internalType": "uint256", "name": "amount", "type": "uint256"},
- {"internalType": "address", "name": "token", "type": "address"},
- ],
- "name": "pay",
- "outputs": [],
- "stateMutability": "nonpayable",
- "type": "function",
- },
- {
- "anonymous": False,
- "inputs": [
- {
- "indexed": True,
- "internalType": "bytes32",
- "name": "orderId",
- "type": "bytes32",
- },
- {
- "indexed": True,
- "internalType": "address",
- "name": "payer",
- "type": "address",
- },
- {
- "indexed": True,
- "internalType": "uint256",
- "name": "planId",
- "type": "uint256",
- },
- {
- "indexed": False,
- "internalType": "address",
- "name": "token",
- "type": "address",
- },
- {
- "indexed": False,
- "internalType": "uint256",
- "name": "amount",
- "type": "uint256",
- },
- ],
- "name": "OrderPaid",
- "type": "event",
- },
-]
-
-DEFAULT_PLAN_CATALOG: Dict[str, Dict[str, Any]] = {
- "pro_monthly": {"plan_id": 101, "amount_usdc": "5", "duration_days": 30},
- "pro_quarterly": {"plan_id": 102, "amount_usdc": "79", "duration_days": 90},
- "pro_yearly": {"plan_id": 103, "amount_usdc": "279", "duration_days": 365},
-}
-
-
-def _env_bool(name: str, default: bool = False) -> bool:
- raw = os.getenv(name)
- if raw is None:
- return default
- return str(raw).strip().lower() in {"1", "true", "yes", "on"}
-
-
-def _env_int(name: str, default: int) -> int:
- raw = os.getenv(name)
- if raw is None:
- return default
- try:
- return int(raw)
- except Exception:
- return default
-
-
-def _normalize_address(address: Any) -> str:
- text = str(address or "").strip()
- if not text or not Web3.is_address(text):
- return ""
- return Web3.to_checksum_address(text).lower()
-
-
-def _now_utc() -> datetime:
- return datetime.now(timezone.utc)
-
-
-def _to_iso(dt: datetime) -> str:
- return dt.astimezone(timezone.utc).isoformat()
-
-
-def _parse_decimal(value: Any, default: Decimal = Decimal("0")) -> Decimal:
- try:
- return Decimal(str(value))
- except (InvalidOperation, ValueError, TypeError):
- return default
-
-
-def _decimal_to_units(amount: Decimal, decimals: int) -> int:
- q = Decimal(10) ** Decimal(max(0, int(decimals)))
- normalized = (amount * q).quantize(Decimal("1"))
- return int(normalized)
-
-
-def _units_to_decimal(units: int, decimals: int) -> Decimal:
- q = Decimal(10) ** Decimal(max(0, int(decimals)))
- return Decimal(int(units)) / q
-
-
-def _format_decimal(value: Decimal, places: int = 6) -> str:
- raw = f"{value:.{places}f}"
- return raw.rstrip("0").rstrip(".") or "0"
-
-
-def _parse_plan_catalog(raw: str) -> Dict[str, Dict[str, Any]]:
- if not raw:
- return dict(DEFAULT_PLAN_CATALOG)
- try:
- parsed = json.loads(raw)
- except Exception:
- return dict(DEFAULT_PLAN_CATALOG)
- if not isinstance(parsed, dict):
- return dict(DEFAULT_PLAN_CATALOG)
-
- out: Dict[str, Dict[str, Any]] = {}
- for plan_code, row in parsed.items():
- code = str(plan_code or "").strip().lower()
- if not code or not isinstance(row, dict):
- continue
- plan_id = int(row.get("plan_id") or 0)
- duration_days = int(row.get("duration_days") or 0)
- amount_usdc = _parse_decimal(row.get("amount_usdc"), Decimal("0"))
- if plan_id <= 0 or duration_days <= 0 or amount_usdc <= 0:
- continue
- out[code] = {
- "plan_id": plan_id,
- "duration_days": duration_days,
- "amount_usdc": _format_decimal(amount_usdc),
- }
- 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
- address: str
- status: str
- is_primary: bool
- verified_at: Optional[str]
-
-
-@dataclass
-class PaymentIntentRecord:
- intent_id: str
- order_id_hex: str
- plan_code: str
- plan_id: int
- chain_id: int
- amount_units: int
- amount_usdc: str
- token_address: str
- receiver_address: str
- status: str
- payment_mode: str
- allowed_wallet: Optional[str]
- expires_at: str
- tx_hash: Optional[str]
-
-
-class PaymentCheckoutError(Exception):
- def __init__(self, status_code: int, detail: str):
- self.status_code = int(status_code)
- self.detail = str(detail)
- super().__init__(self.detail)
-
-
-class PaymentContractCheckoutService:
- def __init__(self):
- self.enabled = _env_bool("POLYWEATHER_PAYMENT_ENABLED", False)
- self.supabase_url = str(os.getenv("SUPABASE_URL") or "").strip().rstrip("/")
- self.supabase_service_role_key = str(
- os.getenv("SUPABASE_SERVICE_ROLE_KEY") or ""
- ).strip()
- self.chain_id = _env_int("POLYWEATHER_PAYMENT_CHAIN_ID", DEFAULT_POLYGON_CHAIN_ID)
- self.token_decimals = _env_int("POLYWEATHER_PAYMENT_TOKEN_DECIMALS", 6)
- self.rpc_url = str(os.getenv("POLYWEATHER_PAYMENT_RPC_URL") or "").strip()
- self.receiver_contract = _normalize_address(
- os.getenv("POLYWEATHER_PAYMENT_RECEIVER_CONTRACT") or ""
- )
- self.token_address = _normalize_address(
- os.getenv("POLYWEATHER_PAYMENT_TOKEN_ADDRESS") or DEFAULT_USDC_E_ADDRESS
- )
- self.intent_ttl_sec = max(300, _env_int("POLYWEATHER_PAYMENT_INTENT_TTL_SEC", 1800))
- self.challenge_ttl_sec = max(
- 60, _env_int("POLYWEATHER_PAYMENT_WALLET_CHALLENGE_TTL_SEC", 600)
- )
- self.confirmations = max(
- 1, _env_int("POLYWEATHER_PAYMENT_CONFIRMATIONS", 2)
- )
- self.timeout_sec = max(5, _env_int("POLYWEATHER_PAYMENT_HTTP_TIMEOUT_SEC", 10))
- self.poll_interval_sec = max(
- 2, _env_int("POLYWEATHER_PAYMENT_POLL_INTERVAL_SEC", 4)
- )
- self.max_wait_sec = max(10, _env_int("POLYWEATHER_PAYMENT_MAX_WAIT_SEC", 50))
- 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
- )
- self._w3_lock = threading.Lock()
- self._w3: Optional[Web3] = None
- self._event_topic = Web3.keccak(
- text="OrderPaid(bytes32,address,uint256,address,uint256)"
- ).hex()
-
- @property
- def configured(self) -> bool:
- return bool(
- self.supabase_url
- and self.supabase_service_role_key
- and self.rpc_url
- and self.receiver_contract
- and self.token_address
- )
-
- def _ensure_enabled(self) -> None:
- if not self.enabled:
- raise PaymentCheckoutError(503, "payment feature disabled")
- if not self.configured:
- raise PaymentCheckoutError(
- 503,
- "payment feature not configured: require SUPABASE + RPC + contract + token",
- )
-
- def _service_headers(self, prefer: Optional[str] = None) -> Dict[str, str]:
- headers = {
- "apikey": self.supabase_service_role_key,
- "Authorization": f"Bearer {self.supabase_service_role_key}",
- "Accept": "application/json",
- "Content-Type": "application/json",
- }
- if prefer:
- headers["Prefer"] = prefer
- return headers
-
- def _rest(
- self,
- method: str,
- table: str,
- *,
- params: Optional[Dict[str, Any]] = None,
- payload: Optional[Any] = None,
- prefer: Optional[str] = None,
- allowed_status: Optional[List[int]] = None,
- ) -> Any:
- url = f"{self.supabase_url}/rest/v1/{table}"
- status_ok = allowed_status or [200, 201, 204]
- try:
- response = requests.request(
- method=method.upper(),
- url=url,
- params=params,
- json=payload,
- headers=self._service_headers(prefer=prefer),
- timeout=self.timeout_sec,
- )
- except Exception as exc:
- raise PaymentCheckoutError(503, f"supabase request failed: {exc}") from exc
-
- if response.status_code not in status_ok:
- detail = response.text[:350] if response.text else response.reason
- raise PaymentCheckoutError(
- 502,
- f"supabase {method.upper()} {table} failed: {response.status_code} {detail}",
- )
- if not response.content:
- return None
- try:
- return response.json()
- except Exception:
- return None
-
- def _get_web3(self) -> Web3:
- with self._w3_lock:
- if self._w3 is None:
- self._w3 = Web3(
- Web3.HTTPProvider(self.rpc_url, request_kwargs={"timeout": self.timeout_sec})
- )
- assert self._w3 is not None
- return self._w3
-
- def _get_contract(self):
- w3 = self._get_web3()
- return w3.eth.contract(
- address=Web3.to_checksum_address(self.receiver_contract),
- abi=PAYMENT_CONTRACT_ABI,
- )
-
- def get_config_payload(self) -> Dict[str, Any]:
- return {
- "enabled": self.enabled,
- "configured": self.configured,
- "chain_id": self.chain_id,
- "token_address": self.token_address,
- "token_decimals": self.token_decimals,
- "receiver_contract": self.receiver_contract,
- "confirmations": self.confirmations,
- "intent_ttl_sec": self.intent_ttl_sec,
- "event_name": "OrderPaid",
- "event_topic0": self._event_topic,
- "plans": [
- {
- "plan_code": plan_code,
- "plan_id": int(row.get("plan_id") or 0),
- "amount_usdc": str(row.get("amount_usdc")),
- "duration_days": int(row.get("duration_days") or 0),
- }
- for plan_code, row in sorted(self.plan_catalog.items())
- ],
- }
-
- def _serialize_intent(self, row: Dict[str, Any]) -> PaymentIntentRecord:
- amount_units = int(_parse_decimal(row.get("amount_units"), Decimal("0")))
- amount_display = _units_to_decimal(amount_units, self.token_decimals)
- return PaymentIntentRecord(
- intent_id=str(row.get("id")),
- order_id_hex=str(row.get("order_id_hex")),
- plan_code=str(row.get("plan_code")),
- plan_id=int(row.get("plan_id") or 0),
- chain_id=int(row.get("chain_id") or self.chain_id),
- amount_units=amount_units,
- amount_usdc=_format_decimal(amount_display),
- token_address=_normalize_address(row.get("token_address") or self.token_address),
- receiver_address=_normalize_address(
- row.get("receiver_address") or self.receiver_contract
- ),
- status=str(row.get("status") or "created"),
- payment_mode=str(row.get("payment_mode") or "strict"),
- allowed_wallet=_normalize_address(row.get("allowed_wallet") or "") or None,
- expires_at=str(row.get("expires_at")),
- tx_hash=str(row.get("tx_hash") or "") or None,
- )
-
- def list_wallets(self, user_id: str) -> List[WalletBindingRecord]:
- self._ensure_enabled()
- rows = self._rest(
- "GET",
- "user_wallets",
- params={
- "select": "chain_id,address,status,is_primary,verified_at",
- "user_id": f"eq.{user_id}",
- "chain_id": f"eq.{self.chain_id}",
- "order": "is_primary.desc,verified_at.desc",
- },
- allowed_status=[200],
- )
- if not isinstance(rows, list):
- return []
- out: List[WalletBindingRecord] = []
- for row in rows:
- out.append(
- WalletBindingRecord(
- chain_id=int(row.get("chain_id") or self.chain_id),
- address=_normalize_address(row.get("address") or ""),
- status=str(row.get("status") or "active"),
- is_primary=bool(row.get("is_primary")),
- verified_at=row.get("verified_at"),
- )
- )
- return out
-
- def _require_user_wallet(self, user_id: str, address: str) -> Dict[str, Any]:
- normalized = _normalize_address(address)
- if not normalized:
- raise PaymentCheckoutError(400, "invalid wallet address")
- rows = self._rest(
- "GET",
- "user_wallets",
- params={
- "select": "id,user_id,address,chain_id,status,is_primary",
- "user_id": f"eq.{user_id}",
- "chain_id": f"eq.{self.chain_id}",
- "address": f"eq.{normalized}",
- "limit": "1",
- },
- allowed_status=[200],
- )
- if not isinstance(rows, list) or not rows:
- raise PaymentCheckoutError(403, "wallet not bound to current user")
- row = rows[0]
- if str(row.get("status") or "active") != "active":
- raise PaymentCheckoutError(403, "wallet is not active")
- return row
-
- def create_wallet_challenge(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")
- now = _now_utc()
- expires = now + timedelta(seconds=self.challenge_ttl_sec)
- nonce = secrets.token_urlsafe(24)
- message = (
- "PolyWeather Wallet Binding\n"
- f"User: {user_id}\n"
- f"Address: {normalized}\n"
- f"ChainId: {self.chain_id}\n"
- f"Nonce: {nonce}\n"
- f"IssuedAt: {_to_iso(now)}\n"
- f"ExpiresAt: {_to_iso(expires)}"
- )
- self._rest(
- "POST",
- "wallet_link_challenges",
- payload={
- "user_id": user_id,
- "chain_id": self.chain_id,
- "address": normalized,
- "nonce": nonce,
- "message": message,
- "expires_at": _to_iso(expires),
- },
- prefer="return=representation",
- allowed_status=[201],
- )
- return {
- "address": normalized,
- "chain_id": self.chain_id,
- "nonce": nonce,
- "message": message,
- "expires_at": _to_iso(expires),
- }
-
- def verify_wallet_binding(
- self,
- user_id: str,
- address: str,
- nonce: str,
- signature: str,
- ) -> WalletBindingRecord:
- self._ensure_enabled()
- normalized = _normalize_address(address)
- nonce_text = str(nonce or "").strip()
- signature_text = str(signature or "").strip()
- if not normalized:
- raise PaymentCheckoutError(400, "invalid wallet address")
- if not nonce_text:
- raise PaymentCheckoutError(400, "nonce required")
- if not signature_text:
- raise PaymentCheckoutError(400, "signature required")
-
- challenge_rows = self._rest(
- "GET",
- "wallet_link_challenges",
- params={
- "select": "id,user_id,address,nonce,message,expires_at,consumed_at",
- "user_id": f"eq.{user_id}",
- "chain_id": f"eq.{self.chain_id}",
- "address": f"eq.{normalized}",
- "nonce": f"eq.{nonce_text}",
- "consumed_at": "is.null",
- "order": "created_at.desc",
- "limit": "1",
- },
- allowed_status=[200],
- )
- if not isinstance(challenge_rows, list) or not challenge_rows:
- raise PaymentCheckoutError(400, "wallet challenge not found or already used")
-
- challenge = challenge_rows[0]
- try:
- expires_at = datetime.fromisoformat(str(challenge.get("expires_at")))
- except Exception:
- expires_at = _now_utc() - timedelta(seconds=1)
- if expires_at <= _now_utc():
- raise PaymentCheckoutError(400, "wallet challenge expired")
-
- message = str(challenge.get("message") or "")
- if not message:
- raise PaymentCheckoutError(400, "wallet challenge message invalid")
-
- try:
- recovered = Account.recover_message(
- encode_defunct(text=message), signature=signature_text
- )
- except Exception:
- raise PaymentCheckoutError(400, "invalid wallet signature")
- if _normalize_address(recovered) != normalized:
- raise PaymentCheckoutError(400, "signature does not match target wallet")
-
- existing = self._rest(
- "GET",
- "user_wallets",
- params={
- "select": "id,user_id,address,status,is_primary",
- "chain_id": f"eq.{self.chain_id}",
- "address": f"eq.{normalized}",
- "limit": "1",
- },
- allowed_status=[200],
- )
- if isinstance(existing, list) and existing:
- owner_id = str(existing[0].get("user_id") or "")
- if owner_id and owner_id != user_id and str(existing[0].get("status")) == "active":
- raise PaymentCheckoutError(409, "wallet already bound by another account")
-
- has_primary = self._rest(
- "GET",
- "user_wallets",
- params={
- "select": "id",
- "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],
- )
- should_primary = not (isinstance(has_primary, list) and len(has_primary) > 0)
- now_iso = _to_iso(_now_utc())
- self._rest(
- "POST",
- "user_wallets",
- params={"on_conflict": "chain_id,address"},
- payload={
- "user_id": user_id,
- "chain_id": self.chain_id,
- "address": normalized,
- "status": "active",
- "is_primary": should_primary,
- "verified_at": now_iso,
- "updated_at": now_iso,
- },
- prefer="resolution=merge-duplicates,return=representation",
- allowed_status=[200, 201],
- )
- self._rest(
- "PATCH",
- "wallet_link_challenges",
- params={"id": f"eq.{challenge.get('id')}"},
- payload={"consumed_at": now_iso},
- prefer="return=representation",
- allowed_status=[200],
- )
- return WalletBindingRecord(
- chain_id=self.chain_id,
- address=normalized,
- status="active",
- is_primary=should_primary,
- verified_at=now_iso,
- )
-
- 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)
- if not row:
- available = ", ".join(sorted(self.plan_catalog.keys()))
- raise PaymentCheckoutError(
- 400, f"unknown plan_code={code}; available={available}"
- )
- amount_dec = _parse_decimal(row.get("amount_usdc"), Decimal("0"))
- if amount_dec <= 0:
- raise PaymentCheckoutError(500, f"invalid plan amount for {code}")
- return {
- "plan_code": code,
- "plan_id": int(row.get("plan_id") or 0),
- "duration_days": int(row.get("duration_days") or 0),
- "amount_usdc_decimal": amount_dec,
- }
-
- def _build_tx_payload(self, intent: PaymentIntentRecord) -> Dict[str, Any]:
- contract = self._get_contract()
- tx_data = contract.encode_abi(
- "pay",
- args=[
- intent.order_id_hex,
- int(intent.plan_id),
- int(intent.amount_units),
- Web3.to_checksum_address(intent.token_address),
- ],
- )
- return {
- "chain_id": self.chain_id,
- "to": Web3.to_checksum_address(intent.receiver_address),
- "data": tx_data,
- "value": "0x0",
- "order_id_hex": intent.order_id_hex,
- "amount_units": str(intent.amount_units),
- "amount_usdc": intent.amount_usdc,
- "token_address": Web3.to_checksum_address(intent.token_address),
- }
-
- def create_intent(
- self,
- user_id: str,
- plan_code: str,
- payment_mode: str = "strict",
- allowed_wallet: Optional[str] = None,
- metadata: Optional[Dict[str, Any]] = None,
- ) -> Dict[str, Any]:
- self._ensure_enabled()
- plan = self._select_plan(plan_code)
- mode = str(payment_mode or "strict").strip().lower()
- if mode not in {"strict", "flex"}:
- raise PaymentCheckoutError(400, "payment_mode must be strict or flex")
-
- bound_wallets = self.list_wallets(user_id)
- if not bound_wallets:
- raise PaymentCheckoutError(403, "bind wallet first")
-
- target_wallet = _normalize_address(allowed_wallet or "")
- if mode == "strict":
- if target_wallet:
- self._require_user_wallet(user_id, target_wallet)
- else:
- primary = next(
- (w for w in bound_wallets if w.is_primary and w.status == "active"),
- None,
- )
- target_wallet = primary.address if primary else bound_wallets[0].address
- elif target_wallet:
- self._require_user_wallet(user_id, target_wallet)
-
- amount_units = _decimal_to_units(plan["amount_usdc_decimal"], self.token_decimals)
- order_id_hex = "0x" + secrets.token_hex(32)
- now = _now_utc()
- expires_at = now + timedelta(seconds=self.intent_ttl_sec)
- rows = self._rest(
- "POST",
- "payment_intents",
- payload={
- "user_id": user_id,
- "plan_code": plan["plan_code"],
- "plan_id": plan["plan_id"],
- "chain_id": self.chain_id,
- "token_address": self.token_address,
- "receiver_address": self.receiver_contract,
- "amount_units": str(amount_units),
- "payment_mode": mode,
- "allowed_wallet": target_wallet or None,
- "order_id_hex": order_id_hex,
- "status": "created",
- "expires_at": _to_iso(expires_at),
- "metadata": metadata or {},
- "created_at": _to_iso(now),
- "updated_at": _to_iso(now),
- },
- prefer="return=representation",
- allowed_status=[201],
- )
- if not isinstance(rows, list) or not rows:
- raise PaymentCheckoutError(500, "failed to create payment intent")
- intent = self._serialize_intent(rows[0])
- return {
- "intent": intent.__dict__,
- "tx_payload": self._build_tx_payload(intent),
- "plan": {
- "plan_code": plan["plan_code"],
- "plan_id": plan["plan_id"],
- "duration_days": plan["duration_days"],
- },
- }
-
- def get_intent(self, user_id: str, intent_id: str) -> PaymentIntentRecord:
- self._ensure_enabled()
- rows = self._rest(
- "GET",
- "payment_intents",
- params={
- "select": (
- "id,user_id,plan_code,plan_id,chain_id,token_address,receiver_address,"
- "amount_units,payment_mode,allowed_wallet,order_id_hex,status,expires_at,tx_hash"
- ),
- "id": f"eq.{intent_id}",
- "user_id": f"eq.{user_id}",
- "limit": "1",
- },
- allowed_status=[200],
- )
- if not isinstance(rows, list) or not rows:
- raise PaymentCheckoutError(404, "payment intent not found")
- return self._serialize_intent(rows[0])
-
- def submit_intent_tx(
- self,
- user_id: str,
- intent_id: str,
- tx_hash: str,
- from_address: str,
- ) -> Dict[str, Any]:
- self._ensure_enabled()
- intent = self.get_intent(user_id, intent_id)
- if intent.status not in {"created", "submitted"}:
- raise PaymentCheckoutError(409, f"intent status is {intent.status}, cannot submit")
-
- tx_hash_text = str(tx_hash or "").strip().lower()
- from_addr = _normalize_address(from_address)
- if not (tx_hash_text.startswith("0x") and len(tx_hash_text) == 66):
- raise PaymentCheckoutError(400, "invalid tx_hash")
- if not from_addr:
- raise PaymentCheckoutError(400, "invalid from_address")
-
- now = _now_utc()
- try:
- expires_at = datetime.fromisoformat(intent.expires_at)
- except Exception:
- expires_at = now - timedelta(seconds=1)
- if expires_at <= now:
- self._rest(
- "PATCH",
- "payment_intents",
- params={"id": f"eq.{intent.intent_id}", "user_id": f"eq.{user_id}"},
- payload={"status": "expired", "updated_at": _to_iso(now)},
- prefer="return=representation",
- allowed_status=[200],
- )
- raise PaymentCheckoutError(409, "payment intent expired")
-
- if intent.payment_mode == "strict" and intent.allowed_wallet:
- if from_addr != intent.allowed_wallet:
- raise PaymentCheckoutError(
- 400,
- f"strict mode requires allowed wallet {intent.allowed_wallet}",
- )
- else:
- self._require_user_wallet(user_id, from_addr)
-
- now_iso = _to_iso(now)
- self._rest(
- "PATCH",
- "payment_intents",
- params={"id": f"eq.{intent.intent_id}", "user_id": f"eq.{user_id}"},
- payload={
- "status": "submitted",
- "tx_hash": tx_hash_text,
- "updated_at": now_iso,
- },
- prefer="return=representation",
- allowed_status=[200],
- )
- tx_rows = self._rest(
- "POST",
- "payment_transactions",
- params={"on_conflict": "tx_hash"},
- payload={
- "intent_id": intent.intent_id,
- "chain_id": self.chain_id,
- "tx_hash": tx_hash_text,
- "from_address": from_addr,
- "to_address": intent.receiver_address,
- "status": "submitted",
- "updated_at": now_iso,
- },
- prefer="resolution=merge-duplicates,return=representation",
- allowed_status=[200, 201],
- )
- return {
- "intent_id": intent.intent_id,
- "status": "submitted",
- "tx_hash": tx_hash_text,
- "from_address": from_addr,
- "transaction": tx_rows[0] if isinstance(tx_rows, list) and tx_rows else None,
- }
-
- def _wait_receipt(self, tx_hash: str) -> Any:
- import time as _time
-
- w3 = self._get_web3()
- start = _now_utc()
- while (_now_utc() - start).total_seconds() < self.max_wait_sec:
- try:
- receipt = w3.eth.get_transaction_receipt(tx_hash)
- except Exception:
- receipt = None
- if receipt and receipt.get("blockNumber"):
- return receipt
- _time.sleep(self.poll_interval_sec)
- raise PaymentCheckoutError(408, "tx receipt timeout")
-
- def _extract_matching_event(
- self, receipt: Any, intent: PaymentIntentRecord
- ) -> Optional[Dict[str, Any]]:
- contract = self._get_contract()
- try:
- events = contract.events.OrderPaid().process_receipt(receipt)
- except Exception:
- events = []
- if not events:
- return None
-
- for ev in events:
- args = ev.get("args") if isinstance(ev, dict) else getattr(ev, "args", None)
- if not args:
- continue
- order_id_hex = str(Web3.to_hex(args.get("orderId"))).lower()
- payer = _normalize_address(args.get("payer"))
- plan_id = int(args.get("planId") or 0)
- token = _normalize_address(args.get("token"))
- amount = int(args.get("amount") or 0)
- if (
- order_id_hex == intent.order_id_hex.lower()
- and plan_id == int(intent.plan_id)
- and token == intent.token_address
- and amount == int(intent.amount_units)
- ):
- if intent.payment_mode == "strict" and intent.allowed_wallet:
- if payer != intent.allowed_wallet:
- continue
- return {
- "order_id_hex": order_id_hex,
- "payer": payer,
- "plan_id": plan_id,
- "token_address": token,
- "amount_units": amount,
- }
- return None
-
- def _insert_payment_record(
- self,
- user_id: str,
- tx_hash: str,
- amount_units: int,
- payload: Dict[str, Any],
- ) -> Dict[str, Any]:
- amount_dec = _units_to_decimal(amount_units, self.token_decimals)
- rows = self._rest(
- "POST",
- "payments",
- params={"on_conflict": "tx_hash"},
- payload={
- "user_id": user_id,
- "amount": str(amount_dec),
- "currency": "USDC",
- "chain": "polygon",
- "tx_hash": tx_hash,
- "status": "confirmed",
- "raw_payload": payload,
- "updated_at": _to_iso(_now_utc()),
- },
- prefer="resolution=merge-duplicates,return=representation",
- allowed_status=[200, 201],
- )
- return rows[0] if isinstance(rows, list) and rows else {}
-
- def _grant_subscription(
- self,
- user_id: str,
- plan_code: str,
- duration_days: int,
- tx_hash: str,
- payload: Dict[str, Any],
- ) -> Dict[str, Any]:
- now = _now_utc()
- latest_rows = self._rest(
- "GET",
- "subscriptions",
- params={
- "select": "id,expires_at,status",
- "user_id": f"eq.{user_id}",
- "status": "eq.active",
- "order": "expires_at.desc",
- "limit": "1",
- },
- allowed_status=[200],
- )
- starts = now
- if isinstance(latest_rows, list) and latest_rows:
- try:
- latest_exp = datetime.fromisoformat(str(latest_rows[0].get("expires_at")))
- if latest_exp > starts:
- starts = latest_exp
- except Exception:
- pass
- expires = starts + timedelta(days=max(1, duration_days))
- sub_rows = self._rest(
- "POST",
- "subscriptions",
- payload={
- "user_id": user_id,
- "plan_code": plan_code,
- "status": "active",
- "starts_at": _to_iso(starts),
- "expires_at": _to_iso(expires),
- "source": "payment_contract",
- "created_at": _to_iso(now),
- "updated_at": _to_iso(now),
- },
- prefer="return=representation",
- allowed_status=[201],
- )
- self._rest(
- "POST",
- "entitlement_events",
- payload={
- "user_id": user_id,
- "action": "subscription_granted",
- "reason": "payment_confirmed",
- "actor": "payment_contract_checkout",
- "payload": {"tx_hash": tx_hash, **payload},
- "created_at": _to_iso(now),
- },
- prefer="return=representation",
- allowed_status=[201],
- )
- return sub_rows[0] if isinstance(sub_rows, list) and sub_rows else {}
-
- def _notify_telegram(self, user_id: str, plan_code: str, amount_usdc: str, tx_hash: str) -> None:
- if not self.notify_telegram:
- return
- token = str(os.getenv("TELEGRAM_BOT_TOKEN") or "").strip()
- chat_id = str(os.getenv("TELEGRAM_CHAT_ID") or "").strip()
- if not token or not chat_id:
- return
- short_hash = tx_hash[:10] + "..." + tx_hash[-8:] if len(tx_hash) > 20 else tx_hash
- text = (
- "✅ PolyWeather 支付确认\n"
- f"用户: {user_id}\n"
- f"套餐: {plan_code}\n"
- f"金额: {amount_usdc} USDC\n"
- f"Tx: {short_hash}"
- )
- try:
- requests.post(
- f"https://api.telegram.org/bot{token}/sendMessage",
- json={
- "chat_id": chat_id,
- "text": text,
- "disable_web_page_preview": True,
- },
- timeout=8,
- )
- except Exception:
- return
-
- def confirm_intent_tx(
- self,
- user_id: str,
- intent_id: str,
- tx_hash: Optional[str] = None,
- ) -> Dict[str, Any]:
- self._ensure_enabled()
- intent = self.get_intent(user_id, intent_id)
- if intent.status == "confirmed":
- return {"intent": intent.__dict__, "already_confirmed": True}
- if intent.status in {"failed", "cancelled", "expired"}:
- raise PaymentCheckoutError(409, f"intent status is {intent.status}")
-
- tx_hash_text = str(tx_hash or intent.tx_hash or "").strip().lower()
- if not tx_hash_text:
- raise PaymentCheckoutError(400, "tx_hash required")
- if not (tx_hash_text.startswith("0x") and len(tx_hash_text) == 66):
- raise PaymentCheckoutError(400, "invalid tx_hash")
-
- w3 = self._get_web3()
- if not w3.is_connected():
- raise PaymentCheckoutError(503, "cannot connect payment rpc")
- if int(w3.eth.chain_id) != int(self.chain_id):
- raise PaymentCheckoutError(503, "payment rpc chain mismatch")
-
- try:
- tx = w3.eth.get_transaction(tx_hash_text)
- except Exception:
- raise PaymentCheckoutError(404, "tx not found on chain")
-
- tx_to = _normalize_address(tx.get("to"))
- tx_from = _normalize_address(tx.get("from"))
- if tx_to != intent.receiver_address:
- raise PaymentCheckoutError(
- 400,
- f"tx to mismatch: got={tx_to} expected={intent.receiver_address}",
- )
- if intent.payment_mode == "strict" and intent.allowed_wallet:
- if tx_from != intent.allowed_wallet:
- raise PaymentCheckoutError(
- 400,
- f"tx sender mismatch: got={tx_from} expected={intent.allowed_wallet}",
- )
- else:
- self._require_user_wallet(user_id, tx_from)
-
- receipt = self._wait_receipt(tx_hash_text)
- if int(receipt.get("status") or 0) != 1:
- raise PaymentCheckoutError(400, "tx reverted")
-
- block_number = int(receipt.get("blockNumber") or 0)
- latest_block = int(w3.eth.block_number)
- confirmations = max(0, latest_block - block_number + 1) if block_number else 0
- if confirmations < self.confirmations:
- raise PaymentCheckoutError(
- 409, f"confirmations not enough: {confirmations}/{self.confirmations}"
- )
-
- event_match = self._extract_matching_event(receipt, intent)
- if not event_match:
- raise PaymentCheckoutError(
- 400,
- "OrderPaid event mismatch; ensure contract emits OrderPaid(orderId,payer,planId,token,amount)",
- )
-
- now_iso = _to_iso(_now_utc())
- self._rest(
- "PATCH",
- "payment_intents",
- params={"id": f"eq.{intent.intent_id}", "user_id": f"eq.{user_id}"},
- payload={
- "status": "confirmed",
- "tx_hash": tx_hash_text,
- "confirmed_at": now_iso,
- "updated_at": now_iso,
- },
- prefer="return=representation",
- allowed_status=[200],
- )
- tx_rows = self._rest(
- "POST",
- "payment_transactions",
- params={"on_conflict": "tx_hash"},
- payload={
- "intent_id": intent.intent_id,
- "tx_hash": tx_hash_text,
- "chain_id": self.chain_id,
- "from_address": tx_from,
- "to_address": tx_to,
- "block_number": block_number,
- "status": "confirmed",
- "raw_receipt": json.loads(Web3.to_json(receipt)),
- "raw_tx": json.loads(Web3.to_json(tx)),
- "updated_at": now_iso,
- },
- prefer="resolution=merge-duplicates,return=representation",
- allowed_status=[200, 201],
- )
-
- payload = {
- "tx_hash": tx_hash_text,
- "block_number": block_number,
- "confirmations": confirmations,
- "event": event_match,
- }
- plan = self._select_plan(intent.plan_code)
- payment_row = self._insert_payment_record(
- user_id=user_id,
- tx_hash=tx_hash_text,
- amount_units=intent.amount_units,
- payload=payload,
- )
- subscription_row = self._grant_subscription(
- user_id=user_id,
- plan_code=intent.plan_code,
- duration_days=plan["duration_days"],
- tx_hash=tx_hash_text,
- payload=payload,
- )
- self._notify_telegram(
- user_id=user_id,
- plan_code=intent.plan_code,
- amount_usdc=intent.amount_usdc,
- tx_hash=tx_hash_text,
- )
- refreshed = self.get_intent(user_id, intent.intent_id)
- return {
- "intent": refreshed.__dict__,
- "transaction": tx_rows[0] if isinstance(tx_rows, list) and tx_rows else None,
- "payment": payment_row,
- "subscription": subscription_row,
- "tx": payload,
- }
-
-
-PAYMENT_CHECKOUT = PaymentContractCheckoutService()
diff --git a/tmp.json b/tmp.json
deleted file mode 100644
index e69de29b..00000000
diff --git a/tmp2.json b/tmp2.json
deleted file mode 100644
index e69de29b..00000000
diff --git a/tmp_fix_legacy.js b/tmp_fix_legacy.js
deleted file mode 100644
index 19dedfb7..00000000
--- a/tmp_fix_legacy.js
+++ /dev/null
@@ -1,130 +0,0 @@
-const fs = require('fs');
-
-function replaceRegex(content, regex, replacement, label) {
- const next = content.replace(regex, replacement);
- if (next === content) {
- console.error('No match for', label);
- }
- return next;
-}
-
-const appJsPath = 'E:/web/PolyWeather/frontend/public/static/app.js';
-let app = fs.readFileSync(appJsPath, 'utf8');
-
-app = replaceRegex(app, /function updateCityListInfo\(cityData\) \{[\s\S]*?\n\}/, `function updateCityListInfo(cityData) {
- const cityName = cityData.name;
- const cityId = cityName.replace(/\\s/g, "-");
- const temp = cityData.current?.temp;
-
- const tempEl = document.getElementById(\`temp-\${cityId}\`);
- if (tempEl && temp != null) {
- tempEl.textContent = \`\${temp}\${cityData.temp_symbol}\`;
- tempEl.classList.add("loaded");
- }
-
- const timeEl = document.getElementById(\`time-\${cityId}\`);
- if (timeEl && cityData.local_time) {
- timeEl.textContent = \`🕐 \${cityData.local_time}\`;
- }
-
- const maxEl = document.getElementById(\`max-\${cityId}\`);
- if (maxEl && cityData.current?.max_temp_time) {
- maxEl.textContent = \`峰值 @\${cityData.current.max_temp_time}\`;
- }
-}`,'updateCityListInfo');
-
-app = app.replace(/alert\(`[^`]*cityName[^`]*`\);/, 'alert(`加载 ${cityName} 数据失败:${e.message}`);');
-
-app = replaceRegex(app, /function renderPanel\(data\) \{[\s\S]*?\n\}/, `function renderPanel(data) {
- const panel = document.getElementById("panel");
- if (!panel) return;
- panel.classList.remove("hidden");
- requestAnimationFrame(() => panel.classList.add("visible"));
-
- const panelCityName = document.getElementById("panelCityName");
- const panelLocalTime = document.getElementById("panelLocalTime");
- const badge = document.getElementById("panelRiskBadge");
-
- if (panelCityName) {
- panelCityName.textContent = \`\${data.risk?.emoji || "🏙️"} \${data.display_name}\`;
- }
- if (panelLocalTime) {
- panelLocalTime.textContent = \`🕐 \${data.local_time || "--:--"} 当地时间\`;
- }
- if (badge) {
- badge.textContent = {
- high: "🔴 高危",
- medium: "🟡 中危",
- low: "🟢 低危",
- }[data.risk?.level] || "未知";
- badge.className = \`risk-badge \${data.risk?.level || "low"}\`;
- }
-
- renderHero(data);
- renderChart(data);
- renderProbabilities(data);
- if (!selectedForecastDate) {
- selectedForecastDate = data.local_date;
- }
- renderModels(data);
- renderForecast(data);
- renderAI(data);
- renderRisk(data);
-}`,'renderPanel');
-
-app = replaceRegex(app, /const METAR_WX_MAP = \{[\s\S]*?\n\};/, `const METAR_WX_MAP = {
- RA: { label: "降雨", icon: "🌧️" },
- "-RA": { label: "小雨", icon: "🌦️" },
- "+RA": { label: "强降雨", icon: "⛈️" },
- SN: { label: "降雪", icon: "❄️" },
- "-SN": { label: "小雪", icon: "🌨️" },
- "+SN": { label: "大雪", icon: "🌨️" },
- DZ: { label: "毛毛雨", icon: "🌦️" },
- FG: { label: "雾", icon: "🌫️" },
- BR: { label: "薄雾", icon: "🌫️" },
- HZ: { label: "霾", icon: "🌫️" },
- TS: { label: "雷暴", icon: "⛈️" },
- VCTS: { label: "附近雷暴", icon: "⛈️" },
- SQ: { label: "飑", icon: "💨" },
- GS: { label: "冰雹", icon: "🌨️" },
-};`, 'METAR_WX_MAP');
-
-app = app.replace(/return \{ label: code, icon: .*? \};/, 'return { label: code, icon: "🌤️" };');
-
-app = replaceRegex(app, /function renderHero\(data\) \{[\s\S]*?document.getElementById\("heroWeather"\)\.innerHTML = `/, `function renderHero(data) {
- const cur = data.current || {};
- const sym = data.temp_symbol || "°C";
- const displayTemp = cur.temp;
-
- let weatherText = cur.cloud_desc || "未知";
- let weatherIcon =
- {
- 多云: "☁️",
- 阴天: "☁️",
- 少云: "🌤️",
- 散云: "☁️",
- 晴: "☀️",
- 晴朗: "☀️",
- }[cur.cloud_desc] || "🌤️";
-
- if (cur.wx_desc) {
- const metarTranslation = translateMETAR(cur.wx_desc);
- if (metarTranslation) {
- weatherText = metarTranslation.label;
- weatherIcon = metarTranslation.icon;
- }
- }
-
- document.getElementById("heroWeather").innerHTML = ``, 'renderHeroPrefix');
-
-fs.writeFileSync(appJsPath, app, 'utf8');
-
-const pagePath = 'E:/web/PolyWeather/frontend/app/page.tsx';
-let page = fs.readFileSync(pagePath, 'utf8');
-page = page.replace(/legacy-v13/g, 'legacy-v14');
-fs.writeFileSync(pagePath, page, 'utf8');
-
-const htmlPath = 'E:/web/PolyWeather/frontend/public/legacy/index.html';
-let html = fs.readFileSync(htmlPath, 'utf8');
-html = html.replace(/legacy-v13/g, 'legacy-v14');
-fs.writeFileSync(htmlPath, html, 'utf8');