Fix Pro payment recovery and wallet provider selection

This commit is contained in:
2569718930@qq.com
2026-03-21 12:19:53 +08:00
parent a19925fe7a
commit 4413b31396
7 changed files with 517 additions and 8 deletions
@@ -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 },
);
}
}
+220 -5
View File
@@ -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");
+7
View File
@@ -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)
+130 -2
View File
@@ -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()
+102 -1
View File
@@ -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"
+10
View File
@@ -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)