Fix Pro payment recovery and wallet provider selection
This commit is contained in:
@@ -80,3 +80,12 @@
|
||||
{"city": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
|
||||
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(`${API_BASE}/api/payments/reconcile-latest`, {
|
||||
method: "POST",
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"content-type":
|
||||
res.headers.get("content-type") || "application/json; charset=utf-8",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to reconcile latest payment", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -171,6 +171,10 @@ type ProviderSelection = {
|
||||
mode: ProviderMode;
|
||||
};
|
||||
|
||||
type InjectedProviderOption = ProviderSelection & {
|
||||
key: string;
|
||||
};
|
||||
|
||||
type ConnectBindOptions = {
|
||||
openOverlayAfterBind?: boolean;
|
||||
};
|
||||
@@ -292,6 +296,43 @@ function getEvmProvider(): EvmProvider | null {
|
||||
return typeof root.request === "function" ? root : null;
|
||||
}
|
||||
|
||||
function listInjectedProviders(): InjectedProviderOption[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
const root = window.ethereum;
|
||||
if (!root) return [];
|
||||
const candidates = Array.isArray(root.providers)
|
||||
? root.providers.filter(
|
||||
(p): p is EvmProvider => !!p && typeof p.request === "function",
|
||||
)
|
||||
: typeof root.request === "function"
|
||||
? [root]
|
||||
: [];
|
||||
const seen = new Set<string>();
|
||||
const out: InjectedProviderOption[] = [];
|
||||
candidates.forEach((provider, index) => {
|
||||
const label = getEvmWalletLabel(provider);
|
||||
const key = [
|
||||
provider.isMetaMask ? "metamask" : "",
|
||||
provider.isRabby ? "rabby" : "",
|
||||
provider.isOkxWallet ? "okx" : "",
|
||||
provider.isBitKeep ? "bitget" : "",
|
||||
label.toLowerCase().replace(/\s+/g, "-"),
|
||||
String(index),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(":");
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
out.push({
|
||||
key,
|
||||
provider,
|
||||
label,
|
||||
mode: "auto",
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function getEvmWalletLabel(provider: EvmProvider | null): string {
|
||||
if (!provider) return "EVM 钱包";
|
||||
if (provider.isMetaMask) return "MetaMask";
|
||||
@@ -549,6 +590,21 @@ export function AccountCenter() {
|
||||
walletConnectMissing: isEn
|
||||
? "WalletConnect disabled: please configure"
|
||||
: "未启用 WalletConnect:请配置",
|
||||
walletExtensionDetected: isEn
|
||||
? "Detected browser wallets"
|
||||
: "检测到的浏览器钱包",
|
||||
walletExtensionChoose: isEn
|
||||
? "Choose extension wallet"
|
||||
: "选择浏览器钱包",
|
||||
walletRecoveryBusy: isEn
|
||||
? "Recovering Pro entitlement after on-chain payment..."
|
||||
: "正在根据链上支付恢复 Pro 权限...",
|
||||
walletRecoveryDone: isEn
|
||||
? "Pro entitlement recovered."
|
||||
: "Pro 权限已恢复。",
|
||||
walletRecoveryFailed: isEn
|
||||
? "Paid detected but entitlement is still pending. Please refresh in a minute or contact support."
|
||||
: "已检测到支付,但订阅状态仍在同步中。请稍后刷新,或联系管理员处理。",
|
||||
unbind: isEn ? "Unbind" : "解绑",
|
||||
unbindConfirm: isEn
|
||||
? "Unbind wallet {address}? You can bind it again later."
|
||||
@@ -607,15 +663,26 @@ export function AccountCenter() {
|
||||
const [selectedTokenAddress, setSelectedTokenAddress] = useState("");
|
||||
const [selectedWallet, setSelectedWallet] = useState("");
|
||||
const [providerMode, setProviderMode] = useState<ProviderMode>("auto");
|
||||
const [injectedProviderOptions, setInjectedProviderOptions] = useState<
|
||||
InjectedProviderOption[]
|
||||
>([]);
|
||||
const [selectedInjectedProviderKey, setSelectedInjectedProviderKey] =
|
||||
useState("");
|
||||
const [paymentBusy, setPaymentBusy] = useState(false);
|
||||
const [paymentInfo, setPaymentInfo] = useState("");
|
||||
const [paymentError, setPaymentError] = useState("");
|
||||
const [lastIntentId, setLastIntentId] = useState("");
|
||||
const [lastTxHash, setLastTxHash] = useState("");
|
||||
const [showSecondarySections, setShowSecondarySections] = useState(false);
|
||||
const [reconcileBusy, setReconcileBusy] = useState(false);
|
||||
|
||||
const supabaseReady = hasSupabasePublicEnv();
|
||||
const walletConnectEnabled = Boolean(WALLETCONNECT_PROJECT_ID);
|
||||
const authUserId = backend?.user_id || user?.id || "";
|
||||
const authIsAuthenticated = Boolean(authUserId);
|
||||
const paymentReadyForRecovery = Boolean(
|
||||
paymentConfig?.enabled && paymentConfig?.configured,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
@@ -648,6 +715,30 @@ export function AccountCenter() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const syncProviders = () => {
|
||||
const nextOptions = listInjectedProviders();
|
||||
setInjectedProviderOptions(nextOptions);
|
||||
setSelectedInjectedProviderKey((current) => {
|
||||
if (current && nextOptions.some((row) => row.key === current)) {
|
||||
return current;
|
||||
}
|
||||
return nextOptions[0]?.key || "";
|
||||
});
|
||||
};
|
||||
syncProviders();
|
||||
if (typeof window === "undefined") return;
|
||||
window.addEventListener("ethereum#initialized", syncProviders as EventListener, {
|
||||
once: false,
|
||||
});
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
"ethereum#initialized",
|
||||
syncProviders as EventListener,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Returns a valid access token, refreshing the session if the stored one
|
||||
* is missing or close to expiry. Throws if the user is not authenticated.
|
||||
@@ -736,14 +827,23 @@ export function AccountCenter() {
|
||||
);
|
||||
|
||||
const resolvePaymentProvider = useCallback(
|
||||
async (mode: ProviderMode = "auto"): Promise<ProviderSelection> => {
|
||||
async (
|
||||
mode: ProviderMode = "auto",
|
||||
preferredInjectedKey = "",
|
||||
): Promise<ProviderSelection> => {
|
||||
const targetChainId = Number(paymentConfig?.chain_id || 137);
|
||||
if (mode !== "walletconnect") {
|
||||
const injected = getEvmProvider();
|
||||
const injectedOptions = listInjectedProviders();
|
||||
const injected =
|
||||
injectedOptions.find((row) => row.key === preferredInjectedKey)
|
||||
?.provider || getEvmProvider();
|
||||
const label =
|
||||
injectedOptions.find((row) => row.key === preferredInjectedKey)?.label ||
|
||||
getEvmWalletLabel(injected);
|
||||
if (injected) {
|
||||
return {
|
||||
provider: injected,
|
||||
label: getEvmWalletLabel(injected),
|
||||
label,
|
||||
mode: "auto",
|
||||
};
|
||||
}
|
||||
@@ -936,6 +1036,22 @@ export function AccountCenter() {
|
||||
void loadPaymentSnapshot();
|
||||
}, [loadPaymentSnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (!lastIntentId) return;
|
||||
window.sessionStorage.setItem("polyweather:lastPaymentIntentId", lastIntentId);
|
||||
}, [lastIntentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const storedIntentId = window.sessionStorage.getItem(
|
||||
"polyweather:lastPaymentIntentId",
|
||||
);
|
||||
if (storedIntentId && !lastIntentId) {
|
||||
setLastIntentId(storedIntentId);
|
||||
}
|
||||
}, [lastIntentId]);
|
||||
|
||||
const onRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await loadSnapshot();
|
||||
@@ -943,6 +1059,74 @@ export function AccountCenter() {
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
const reconcileLatestPayment = useCallback(async () => {
|
||||
if (!authIsAuthenticated || reconcileBusy) return false;
|
||||
setReconcileBusy(true);
|
||||
try {
|
||||
const headers = await buildAuthedHeaders(true, true);
|
||||
const res = await fetch("/api/payments/reconcile-latest", {
|
||||
method: "POST",
|
||||
headers,
|
||||
});
|
||||
if (!res.ok) {
|
||||
return false;
|
||||
}
|
||||
const json = (await res.json()) as {
|
||||
ok?: boolean;
|
||||
action?: string;
|
||||
subscription?: { plan_code?: string | null } | null;
|
||||
};
|
||||
if (json.ok) {
|
||||
setPaymentInfo(copy.walletRecoveryDone);
|
||||
setPaymentError("");
|
||||
await loadSnapshot();
|
||||
await loadPaymentSnapshot();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
setReconcileBusy(false);
|
||||
}
|
||||
}, [
|
||||
authIsAuthenticated,
|
||||
buildAuthedHeaders,
|
||||
copy.walletRecoveryDone,
|
||||
loadPaymentSnapshot,
|
||||
loadSnapshot,
|
||||
reconcileBusy,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authIsAuthenticated) return;
|
||||
if (backend?.subscription_active) return;
|
||||
if (!paymentReadyForRecovery) return;
|
||||
if (!lastIntentId) return;
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
setPaymentInfo(copy.walletRecoveryBusy);
|
||||
const repaired = await reconcileLatestPayment();
|
||||
if (cancelled) return;
|
||||
if (!repaired && !backend?.subscription_active) {
|
||||
setPaymentInfo("");
|
||||
setPaymentError(copy.walletRecoveryFailed);
|
||||
}
|
||||
};
|
||||
void run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
backend?.subscription_active,
|
||||
authIsAuthenticated,
|
||||
copy.walletRecoveryBusy,
|
||||
copy.walletRecoveryFailed,
|
||||
lastIntentId,
|
||||
paymentReadyForRecovery,
|
||||
reconcileLatestPayment,
|
||||
]);
|
||||
|
||||
const onSignOut = async () => {
|
||||
if (walletConnectProviderCache?.disconnect) {
|
||||
try {
|
||||
@@ -1285,7 +1469,10 @@ export function AccountCenter() {
|
||||
|
||||
setPaymentBusy(true);
|
||||
try {
|
||||
const providerSelection = await resolvePaymentProvider(mode);
|
||||
const providerSelection = await resolvePaymentProvider(
|
||||
mode,
|
||||
selectedInjectedProviderKey,
|
||||
);
|
||||
const eth = providerSelection.provider;
|
||||
const walletLabel = providerSelection.label;
|
||||
|
||||
@@ -1480,7 +1667,10 @@ export function AccountCenter() {
|
||||
setPaymentBusy(true);
|
||||
let approvedInThisRun = false;
|
||||
try {
|
||||
const providerSelection = await resolvePaymentProvider(providerMode);
|
||||
const providerSelection = await resolvePaymentProvider(
|
||||
providerMode,
|
||||
selectedInjectedProviderKey,
|
||||
);
|
||||
const eth = providerSelection.provider;
|
||||
const activeAccounts = (await eth.request({
|
||||
method: "eth_requestAccounts",
|
||||
@@ -2153,6 +2343,31 @@ export function AccountCenter() {
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-2">
|
||||
{injectedProviderOptions.length > 1 && (
|
||||
<label className="mb-2 block">
|
||||
<span className="mb-2 block text-[11px] uppercase tracking-widest text-slate-500">
|
||||
{copy.walletExtensionDetected}
|
||||
</span>
|
||||
<select
|
||||
value={selectedInjectedProviderKey}
|
||||
onChange={(event) =>
|
||||
setSelectedInjectedProviderKey(event.target.value)
|
||||
}
|
||||
disabled={paymentBusy}
|
||||
className="w-full rounded-xl border border-white/10 bg-white/5 px-3 py-3 text-xs text-slate-200 outline-none transition-all hover:bg-white/10 disabled:opacity-60"
|
||||
>
|
||||
{injectedProviderOptions.map((option) => (
|
||||
<option
|
||||
key={option.key}
|
||||
value={option.key}
|
||||
className="bg-slate-900 text-slate-200"
|
||||
>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
setProviderMode("auto");
|
||||
|
||||
@@ -70,6 +70,13 @@ class SupabaseEntitlementService:
|
||||
self._sub_cache: Dict[str, Dict[str, object]] = {}
|
||||
self._sub_cache_lock = threading.Lock()
|
||||
|
||||
def invalidate_subscription_cache(self, user_id: str) -> None:
|
||||
key = str(user_id or "").strip()
|
||||
if not key:
|
||||
return
|
||||
with self._sub_cache_lock:
|
||||
self._sub_cache.pop(key, None)
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(self.supabase_url and self.anon_key)
|
||||
|
||||
@@ -14,6 +14,7 @@ from eth_account import Account
|
||||
from eth_account.messages import encode_defunct
|
||||
from web3 import Web3
|
||||
|
||||
from src.auth.supabase_entitlement import SUPABASE_ENTITLEMENT
|
||||
from src.database.db_manager import DBManager
|
||||
|
||||
DEFAULT_POLYGON_CHAIN_ID = 137
|
||||
@@ -1417,7 +1418,9 @@ class PaymentContractCheckoutService:
|
||||
)
|
||||
if not isinstance(rows, list) or not rows:
|
||||
raise PaymentCheckoutError(404, "payment intent not found")
|
||||
return self._serialize_intent(rows[0])
|
||||
intent = self._serialize_intent(rows[0])
|
||||
setattr(intent, "user_id", user_id)
|
||||
return intent
|
||||
|
||||
def list_pending_confirm_intents(self, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
@@ -1756,8 +1759,62 @@ class PaymentContractCheckoutService:
|
||||
prefer="return=representation",
|
||||
allowed_status=[201],
|
||||
)
|
||||
SUPABASE_ENTITLEMENT.invalidate_subscription_cache(user_id)
|
||||
return sub_rows[0] if isinstance(sub_rows, list) and sub_rows else {}
|
||||
|
||||
def _ensure_confirmed_subscription(
|
||||
self,
|
||||
user_id: str,
|
||||
intent: PaymentIntentRecord,
|
||||
tx_hash: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
SUPABASE_ENTITLEMENT.invalidate_subscription_cache(user_id)
|
||||
latest_subscription = SUPABASE_ENTITLEMENT.get_latest_active_subscription(
|
||||
user_id,
|
||||
respect_requirement=False,
|
||||
)
|
||||
if isinstance(latest_subscription, dict):
|
||||
return latest_subscription
|
||||
|
||||
plan = self._select_plan(intent.plan_code)
|
||||
return self._grant_subscription(
|
||||
user_id=user_id,
|
||||
plan_code=intent.plan_code,
|
||||
duration_days=plan["duration_days"],
|
||||
tx_hash=tx_hash,
|
||||
payload={
|
||||
"intent_id": intent.intent_id,
|
||||
"order_id_hex": intent.order_id_hex,
|
||||
"repaired_from_confirmed_intent": True,
|
||||
},
|
||||
)
|
||||
|
||||
def _ensure_confirm_side_effects(
|
||||
self,
|
||||
user_id: str,
|
||||
intent: PaymentIntentRecord,
|
||||
tx_hash: str,
|
||||
) -> Dict[str, Any]:
|
||||
payment_row = {}
|
||||
if tx_hash:
|
||||
payment_row = self._insert_payment_record(
|
||||
user_id=user_id,
|
||||
tx_hash=tx_hash,
|
||||
amount_units=int(intent.amount_units),
|
||||
token_address=intent.token_address,
|
||||
payload={
|
||||
"tx_hash": tx_hash,
|
||||
"intent_id": intent.intent_id,
|
||||
"order_id_hex": intent.order_id_hex,
|
||||
"reconciled": True,
|
||||
},
|
||||
)
|
||||
subscription_row = self._ensure_confirmed_subscription(user_id, intent, tx_hash)
|
||||
return {
|
||||
"payment": payment_row,
|
||||
"subscription": subscription_row,
|
||||
}
|
||||
|
||||
def _notify_telegram(self, user_id: str, plan_code: str, amount_usdc: str, tx_hash: str) -> None:
|
||||
if not self.notify_telegram:
|
||||
return
|
||||
@@ -1800,7 +1857,15 @@ class PaymentContractCheckoutService:
|
||||
self._ensure_enabled()
|
||||
intent = self.get_intent(user_id, intent_id)
|
||||
if intent.status == "confirmed":
|
||||
return {"intent": intent.__dict__, "already_confirmed": True}
|
||||
tx_hash_text = str(tx_hash or intent.tx_hash or "").strip().lower()
|
||||
repaired = self._ensure_confirm_side_effects(user_id, intent, tx_hash_text)
|
||||
refreshed = self.get_intent(user_id, intent_id)
|
||||
return {
|
||||
"intent": refreshed.__dict__,
|
||||
"already_confirmed": True,
|
||||
"payment": repaired.get("payment"),
|
||||
"subscription": repaired.get("subscription"),
|
||||
}
|
||||
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()
|
||||
@@ -1940,5 +2005,68 @@ class PaymentContractCheckoutService:
|
||||
"points_redemption": points_result,
|
||||
"tx": payload,
|
||||
}
|
||||
|
||||
def reconcile_latest_intent(self, user_id: str) -> Dict[str, Any]:
|
||||
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,metadata"
|
||||
),
|
||||
"user_id": f"eq.{user_id}",
|
||||
"status": "in.(created,submitted,confirmed)",
|
||||
"order": "updated_at.desc",
|
||||
"limit": "5",
|
||||
},
|
||||
allowed_status=[200],
|
||||
)
|
||||
if not isinstance(rows, list) or not rows:
|
||||
return {"ok": False, "reason": "intent_not_found"}
|
||||
|
||||
attempts: List[Dict[str, Any]] = []
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
intent = self._serialize_intent(row)
|
||||
status = str(intent.status or "").strip().lower()
|
||||
tx_hash_text = str(intent.tx_hash or "").strip().lower()
|
||||
try:
|
||||
if status == "submitted" and tx_hash_text:
|
||||
result = self.confirm_intent_tx(user_id, intent.intent_id, tx_hash_text)
|
||||
return {"ok": True, "action": "confirmed_submitted_intent", **result}
|
||||
if status == "confirmed":
|
||||
repaired = self._ensure_confirm_side_effects(user_id, intent, tx_hash_text)
|
||||
refreshed = self.get_intent(user_id, intent.intent_id)
|
||||
return {
|
||||
"ok": True,
|
||||
"action": "reconciled_confirmed_intent",
|
||||
"intent": refreshed.__dict__,
|
||||
"payment": repaired.get("payment"),
|
||||
"subscription": repaired.get("subscription"),
|
||||
}
|
||||
except PaymentCheckoutError as exc:
|
||||
attempts.append(
|
||||
{
|
||||
"intent_id": intent.intent_id,
|
||||
"status": status,
|
||||
"status_code": exc.status_code,
|
||||
"error": exc.detail,
|
||||
}
|
||||
)
|
||||
|
||||
SUPABASE_ENTITLEMENT.invalidate_subscription_cache(user_id)
|
||||
latest_subscription = SUPABASE_ENTITLEMENT.get_latest_active_subscription(
|
||||
user_id,
|
||||
respect_requirement=False,
|
||||
)
|
||||
return {
|
||||
"ok": bool(latest_subscription),
|
||||
"action": "checked_without_repair",
|
||||
"subscription": latest_subscription,
|
||||
"attempts": attempts,
|
||||
}
|
||||
PAYMENT_CHECKOUT = PaymentContractCheckoutService()
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
from src.database.db_manager import DBManager
|
||||
from src.payments.contract_checkout import PaymentContractCheckoutService
|
||||
from src.payments.contract_checkout import (
|
||||
PaymentContractCheckoutService,
|
||||
PaymentIntentRecord,
|
||||
)
|
||||
|
||||
|
||||
def test_payment_runtime_state_and_audit_event_roundtrip(tmp_path):
|
||||
@@ -38,3 +41,101 @@ def test_payment_checkout_parses_multiple_rpc_urls(monkeypatch, tmp_path):
|
||||
assert service.rpc_urls == ["https://rpc-1.example", "https://rpc-2.example"]
|
||||
assert status["configured_rpc_count"] == 2
|
||||
assert status["all_rpc_urls"][0] == "https://rpc-1.example"
|
||||
|
||||
|
||||
def test_confirm_intent_tx_repairs_confirmed_intent(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("POLYWEATHER_PAYMENT_ENABLED", "true")
|
||||
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
|
||||
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
|
||||
monkeypatch.setenv("POLYWEATHER_PAYMENT_RPC_URL", "https://rpc-1.example")
|
||||
monkeypatch.setenv(
|
||||
"POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON",
|
||||
'[{"code":"usdc_e","address":"0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174","decimals":6,"receiver_contract":"0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32","is_default":true}]',
|
||||
)
|
||||
monkeypatch.setenv("POLYWEATHER_DB_PATH", str(tmp_path / "payments.db"))
|
||||
|
||||
service = PaymentContractCheckoutService()
|
||||
intent = PaymentIntentRecord(
|
||||
intent_id="intent-1",
|
||||
order_id_hex="0x" + "1" * 64,
|
||||
plan_code="pro_monthly",
|
||||
plan_id=101,
|
||||
chain_id=137,
|
||||
amount_units=5000000,
|
||||
amount_usdc="5",
|
||||
token_address="0x2791bca1f2de4661ed88a30c99a7a9449aa84174",
|
||||
token_decimals=6,
|
||||
token_symbol="USDC.e",
|
||||
receiver_address="0xed2f13aa5ff033c58fb436e178451cd07f693f32",
|
||||
status="confirmed",
|
||||
payment_mode="strict",
|
||||
allowed_wallet="0x1111111111111111111111111111111111111111",
|
||||
expires_at="2099-01-01T00:00:00+00:00",
|
||||
tx_hash="0x" + "2" * 64,
|
||||
metadata={},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(service, "get_intent", lambda user_id, intent_id: intent)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_ensure_confirm_side_effects",
|
||||
lambda user_id, local_intent, tx_hash: {
|
||||
"payment": {"tx_hash": tx_hash},
|
||||
"subscription": {"plan_code": local_intent.plan_code},
|
||||
},
|
||||
)
|
||||
|
||||
result = service.confirm_intent_tx("user-1", "intent-1")
|
||||
|
||||
assert result["already_confirmed"] is True
|
||||
assert result["payment"]["tx_hash"] == intent.tx_hash
|
||||
assert result["subscription"]["plan_code"] == "pro_monthly"
|
||||
|
||||
|
||||
def test_reconcile_latest_intent_confirms_submitted_first(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("POLYWEATHER_PAYMENT_ENABLED", "true")
|
||||
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
|
||||
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
|
||||
monkeypatch.setenv("POLYWEATHER_PAYMENT_RPC_URL", "https://rpc-1.example")
|
||||
monkeypatch.setenv(
|
||||
"POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON",
|
||||
'[{"code":"usdc_e","address":"0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174","decimals":6,"receiver_contract":"0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32","is_default":true}]',
|
||||
)
|
||||
monkeypatch.setenv("POLYWEATHER_DB_PATH", str(tmp_path / "payments.db"))
|
||||
|
||||
service = PaymentContractCheckoutService()
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_rest",
|
||||
lambda method, table, **kwargs: [
|
||||
{
|
||||
"id": "intent-1",
|
||||
"plan_code": "pro_monthly",
|
||||
"plan_id": 101,
|
||||
"chain_id": 137,
|
||||
"token_address": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",
|
||||
"receiver_address": "0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32",
|
||||
"amount_units": "5000000",
|
||||
"payment_mode": "strict",
|
||||
"allowed_wallet": "0x1111111111111111111111111111111111111111",
|
||||
"order_id_hex": "0x" + "1" * 64,
|
||||
"status": "submitted",
|
||||
"expires_at": "2099-01-01T00:00:00+00:00",
|
||||
"tx_hash": "0x" + "2" * 64,
|
||||
"metadata": {},
|
||||
}
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"confirm_intent_tx",
|
||||
lambda user_id, intent_id, tx_hash=None: {
|
||||
"intent": {"intent_id": intent_id},
|
||||
"already_confirmed": False,
|
||||
},
|
||||
)
|
||||
|
||||
result = service.reconcile_latest_intent("user-1")
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["action"] == "confirmed_submitted_intent"
|
||||
|
||||
@@ -416,6 +416,16 @@ async def payment_confirm_tx(
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||
|
||||
|
||||
@router.post("/api/payments/reconcile-latest")
|
||||
async def payment_reconcile_latest(request: Request):
|
||||
_assert_entitlement(request)
|
||||
identity = _require_supabase_identity(request)
|
||||
try:
|
||||
return PAYMENT_CHECKOUT.reconcile_latest_intent(identity["user_id"])
|
||||
except PaymentCheckoutError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||
|
||||
|
||||
@router.get("/api/city/{name}/summary")
|
||||
async def city_summary(request: Request, name: str, force_refresh: bool = False):
|
||||
_assert_entitlement(request)
|
||||
|
||||
Reference in New Issue
Block a user