feat: Introduce web application with payment processing and Supabase authentication.
This commit is contained in:
@@ -45,6 +45,24 @@ POLYWEATHER_BOT_USE_SUPABASE_ENTITLEMENT=false
|
||||
POLYWEATHER_REQUIRE_ENTITLEMENT=false
|
||||
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
|
||||
|
||||
# P1 Contract Checkout (MetaMask + Polygon USDC)
|
||||
POLYWEATHER_PAYMENT_ENABLED=false
|
||||
POLYWEATHER_PAYMENT_CHAIN_ID=137
|
||||
POLYWEATHER_PAYMENT_RPC_URL=https://polygon-rpc.com
|
||||
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=
|
||||
POLYWEATHER_PAYMENT_TOKEN_ADDRESS=0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174
|
||||
POLYWEATHER_PAYMENT_TOKEN_DECIMALS=6
|
||||
POLYWEATHER_PAYMENT_CONFIRMATIONS=2
|
||||
POLYWEATHER_PAYMENT_INTENT_TTL_SEC=1800
|
||||
POLYWEATHER_PAYMENT_WALLET_CHALLENGE_TTL_SEC=600
|
||||
POLYWEATHER_PAYMENT_HTTP_TIMEOUT_SEC=10
|
||||
POLYWEATHER_PAYMENT_POLL_INTERVAL_SEC=4
|
||||
POLYWEATHER_PAYMENT_MAX_WAIT_SEC=50
|
||||
POLYWEATHER_PAYMENT_TELEGRAM_NOTIFY_ENABLED=true
|
||||
# JSON object
|
||||
# Example: {"pro_monthly":{"plan_id":101,"amount_usdc":"29","duration_days":30}}
|
||||
POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON=
|
||||
|
||||
# Polymarket P0 Read-Only Market Layer
|
||||
POLYMARKET_MARKET_SCAN_ENABLED=true
|
||||
POLYMARKET_GAMMA_URL=https://gamma-api.polymarket.com
|
||||
|
||||
@@ -94,6 +94,12 @@ flowchart TD
|
||||
5. Observability:
|
||||
- Vercel Speed Insights integrated.
|
||||
- Telegram alert/watcher startup diagnostics exposed through `/diag`.
|
||||
6. P1 contract checkout (new):
|
||||
- New payment APIs: `/api/payments/config|wallets|intents/*`.
|
||||
- MetaMask wallet binding via nonce challenge + `personal_sign`.
|
||||
- Frontend receives contract `tx_payload` and calls `eth_sendTransaction`.
|
||||
- Backend validates `OrderPaid(orderId,payer,planId,token,amount)` onchain event and auto-grants entitlement.
|
||||
- Confirmation writes `payments/subscriptions/entitlement_events` and can notify Telegram.
|
||||
|
||||
## Repository Layout
|
||||
|
||||
|
||||
@@ -94,6 +94,12 @@ flowchart TD
|
||||
5. 可观测性:
|
||||
- 前端集成 Vercel Speed Insights。
|
||||
- Bot 启动和后台循环状态可通过 `/diag` 查看。
|
||||
6. P1 合约支付链路(新增):
|
||||
- 新增支付接口:`/api/payments/config|wallets|intents/*`。
|
||||
- 支持 MetaMask 钱包绑定(nonce + `personal_sign` 验签)。
|
||||
- 支持合约订单支付:前端拿 `tx_payload` 调 `eth_sendTransaction`。
|
||||
- 后端按 `OrderPaid(orderId,payer,planId,token,amount)` 事件验单并自动开通订阅。
|
||||
- 交易确认后自动写入 `payments/subscriptions/entitlement_events` 并可推送 Telegram。
|
||||
|
||||
## 目录说明
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Supabase + Google 登录接入说明(P0)
|
||||
# Supabase + Google 登录 + 合约支付接入说明(P1)
|
||||
|
||||
## 1. 目标
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
- `subscriptions`
|
||||
- `payments`
|
||||
- `entitlement_events`
|
||||
- `user_wallets`
|
||||
- `wallet_link_challenges`
|
||||
- `payment_intents`
|
||||
- `payment_transactions`
|
||||
|
||||
并建立 `auth.users -> profiles` 同步触发器。
|
||||
|
||||
@@ -56,6 +60,24 @@ SUPABASE_SERVICE_ROLE_KEY=
|
||||
SUPABASE_HTTP_TIMEOUT_SEC=8
|
||||
SUPABASE_AUTH_CACHE_TTL_SEC=30
|
||||
SUPABASE_SUB_CACHE_TTL_SEC=60
|
||||
|
||||
# P1 合约支付(MetaMask + Polygon USDC)
|
||||
POLYWEATHER_PAYMENT_ENABLED=true
|
||||
POLYWEATHER_PAYMENT_CHAIN_ID=137
|
||||
POLYWEATHER_PAYMENT_RPC_URL=https://polygon-rpc.com
|
||||
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=0x<your_payment_contract>
|
||||
POLYWEATHER_PAYMENT_TOKEN_ADDRESS=0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174
|
||||
POLYWEATHER_PAYMENT_TOKEN_DECIMALS=6
|
||||
POLYWEATHER_PAYMENT_CONFIRMATIONS=2
|
||||
POLYWEATHER_PAYMENT_INTENT_TTL_SEC=1800
|
||||
POLYWEATHER_PAYMENT_WALLET_CHALLENGE_TTL_SEC=600
|
||||
POLYWEATHER_PAYMENT_HTTP_TIMEOUT_SEC=10
|
||||
POLYWEATHER_PAYMENT_POLL_INTERVAL_SEC=4
|
||||
POLYWEATHER_PAYMENT_MAX_WAIT_SEC=50
|
||||
POLYWEATHER_PAYMENT_TELEGRAM_NOTIFY_ENABLED=true
|
||||
# JSON 示例:
|
||||
# {"pro_monthly":{"plan_id":101,"amount_usdc":"29","duration_days":30}}
|
||||
POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON=
|
||||
```
|
||||
|
||||
可选(Bot 也走 Supabase 订阅):
|
||||
@@ -78,3 +100,23 @@ POLYWEATHER_BOT_USE_SUPABASE_ENTITLEMENT=true
|
||||
2. `POLYWEATHER_AUTH_REQUIRED=false` 时,未登录访问首页与 `/api/cities` 应返回 200。
|
||||
3. 登录后访问 `/api/auth/me`,应返回 `authenticated=true` 与 `user_id`。
|
||||
4. `POLYWEATHER_AUTH_REQUIRED=true` 时,未登录访问受保护接口应返回 401 或跳转登录页。
|
||||
|
||||
## 7. P1 支付链路验证
|
||||
|
||||
1. 登录后访问 `GET /api/payments/config`,应看到 `enabled=true`、`configured=true`。
|
||||
2. 账户页点击“连接并绑定 MetaMask”,完成签名后 `GET /api/payments/wallets` 可看到地址。
|
||||
3. 点击“创建订单并支付”:
|
||||
- `POST /api/payments/intents`
|
||||
- MetaMask 发交易到 `POLYWEATHER_PAYMENT_RECEIVER_CONTRACT`
|
||||
- `POST /api/payments/intents/{intent_id}/submit`
|
||||
- `POST /api/payments/intents/{intent_id}/confirm`
|
||||
4. 确认后应自动写入:
|
||||
- `payments`(`status=confirmed`)
|
||||
- `subscriptions`(新增 `active` 记录)
|
||||
- `entitlement_events`(`subscription_granted`)
|
||||
|
||||
合约事件要求:
|
||||
|
||||
- 合约需在 `pay(orderId, planId, amount, token)` 成功后发出
|
||||
`OrderPaid(bytes32 orderId, address payer, uint256 planId, address token, uint256 amount)`
|
||||
(字段顺序和类型需一致)。
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
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 GET(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/config`, {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
|
||||
{ status: 502 },
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data, {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch payment config", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
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,
|
||||
context: { params: Promise<{ intentId: string }> },
|
||||
) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
const { intentId } = await context.params;
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/payments/intents/${encodeURIComponent(intentId)}/confirm`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...auth.headers,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body ?? {}),
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
|
||||
{ status: 502 },
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to confirm payment tx", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
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,
|
||||
context: { params: Promise<{ intentId: string }> },
|
||||
) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
const { intentId } = await context.params;
|
||||
try {
|
||||
const body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/payments/intents/${encodeURIComponent(intentId)}/submit`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...auth.headers,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body ?? {}),
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
|
||||
{ status: 502 },
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to submit payment tx", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
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 body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(`${API_BASE}/api/payments/intents`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...auth.headers,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body ?? {}),
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
|
||||
{ status: 502 },
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create payment intent", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
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 body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(`${API_BASE}/api/payments/wallets/challenge`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...auth.headers,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body ?? {}),
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
|
||||
{ status: 502 },
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create wallet challenge", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
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 GET(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/wallets`, {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
|
||||
{ status: 502 },
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data, {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch wallets", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
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 body = await req.json();
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(`${API_BASE}/api/payments/wallets/verify`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...auth.headers,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body ?? {}),
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
|
||||
{ status: 502 },
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to verify wallet binding", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,20 +6,24 @@ import { useRouter } from "next/navigation";
|
||||
import type { User } from "@supabase/supabase-js";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
BadgeCheck,
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
Clock,
|
||||
Copy,
|
||||
Crown,
|
||||
CreditCard,
|
||||
Fingerprint,
|
||||
Hash,
|
||||
Loader2,
|
||||
LogIn,
|
||||
LogOut,
|
||||
Mail,
|
||||
Wallet,
|
||||
RefreshCw,
|
||||
Shield,
|
||||
Sparkles,
|
||||
User as UserIcon,
|
||||
UserCheck,
|
||||
} from "lucide-react";
|
||||
@@ -38,6 +42,56 @@ type AuthMeResponse = {
|
||||
subscription_active?: boolean | null;
|
||||
};
|
||||
|
||||
type PaymentPlan = {
|
||||
plan_code: string;
|
||||
plan_id: number;
|
||||
amount_usdc: string;
|
||||
duration_days: number;
|
||||
};
|
||||
|
||||
type PaymentConfig = {
|
||||
enabled?: boolean;
|
||||
configured?: boolean;
|
||||
chain_id?: number;
|
||||
token_address?: string;
|
||||
token_decimals?: number;
|
||||
receiver_contract?: string;
|
||||
confirmations?: number;
|
||||
plans?: PaymentPlan[];
|
||||
};
|
||||
|
||||
type BoundWallet = {
|
||||
chain_id: number;
|
||||
address: string;
|
||||
status: string;
|
||||
is_primary: boolean;
|
||||
verified_at?: string | null;
|
||||
};
|
||||
|
||||
type CreatedIntent = {
|
||||
intent?: {
|
||||
intent_id: string;
|
||||
order_id_hex: string;
|
||||
plan_code: string;
|
||||
amount_usdc: string;
|
||||
allowed_wallet?: string | null;
|
||||
};
|
||||
tx_payload?: {
|
||||
chain_id: number;
|
||||
to: string;
|
||||
data: string;
|
||||
value: string;
|
||||
};
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
ethereum?: {
|
||||
request: (args: { method: string; params?: any[] | object }) => Promise<any>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type InfoItemProps = {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
@@ -72,6 +126,12 @@ function normalizeProvider(user: User | null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
function shortAddress(address: string) {
|
||||
const text = String(address || "");
|
||||
if (!text.startsWith("0x") || text.length < 12) return text || "--";
|
||||
return `${text.slice(0, 8)}...${text.slice(-6)}`;
|
||||
}
|
||||
|
||||
function InfoItem({ icon: Icon, label, value, status = "default" }: InfoItemProps) {
|
||||
return (
|
||||
<div className="group flex items-center justify-between rounded-2xl border border-white/5 bg-white/5 p-4 transition-all hover:bg-white/10">
|
||||
@@ -102,9 +162,52 @@ export function AccountCenter() {
|
||||
const [updatedAt, setUpdatedAt] = useState<string>("");
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [backend, setBackend] = useState<AuthMeResponse | null>(null);
|
||||
const [paymentConfig, setPaymentConfig] = useState<PaymentConfig | null>(null);
|
||||
const [boundWallets, setBoundWallets] = useState<BoundWallet[]>([]);
|
||||
const [walletAddress, setWalletAddress] = useState("");
|
||||
const [selectedPlanCode, setSelectedPlanCode] = useState("pro_monthly");
|
||||
const [selectedWallet, setSelectedWallet] = useState("");
|
||||
const [paymentBusy, setPaymentBusy] = useState(false);
|
||||
const [paymentInfo, setPaymentInfo] = useState("");
|
||||
const [paymentError, setPaymentError] = useState("");
|
||||
const [lastIntentId, setLastIntentId] = useState("");
|
||||
const [lastTxHash, setLastTxHash] = useState("");
|
||||
|
||||
const supabaseReady = hasSupabasePublicEnv();
|
||||
|
||||
const loadPaymentSnapshot = useCallback(async () => {
|
||||
if (!backend?.authenticated) {
|
||||
setPaymentConfig(null);
|
||||
setBoundWallets([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [configRes, walletsRes] = await Promise.all([
|
||||
fetch("/api/payments/config", { cache: "no-store" }),
|
||||
fetch("/api/payments/wallets", { cache: "no-store" }),
|
||||
]);
|
||||
if (configRes.ok) {
|
||||
const configJson = (await configRes.json()) as PaymentConfig;
|
||||
setPaymentConfig(configJson);
|
||||
if (!selectedPlanCode && Array.isArray(configJson.plans) && configJson.plans.length) {
|
||||
setSelectedPlanCode(configJson.plans[0].plan_code);
|
||||
}
|
||||
}
|
||||
if (walletsRes.ok) {
|
||||
const walletsJson = (await walletsRes.json()) as {
|
||||
wallets?: BoundWallet[];
|
||||
};
|
||||
const wallets = Array.isArray(walletsJson.wallets) ? walletsJson.wallets : [];
|
||||
setBoundWallets(wallets);
|
||||
if (wallets.length && !selectedWallet) {
|
||||
setSelectedWallet(wallets[0].address);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}, [backend?.authenticated, selectedPlanCode, selectedWallet]);
|
||||
|
||||
const loadSnapshot = useCallback(async () => {
|
||||
setErrorText("");
|
||||
try {
|
||||
@@ -146,9 +249,14 @@ export function AccountCenter() {
|
||||
};
|
||||
}, [loadSnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPaymentSnapshot();
|
||||
}, [loadPaymentSnapshot]);
|
||||
|
||||
const onRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await loadSnapshot();
|
||||
await loadPaymentSnapshot();
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
@@ -216,6 +324,177 @@ export function AccountCenter() {
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const planList = paymentConfig?.plans || [];
|
||||
const selectedPlan = planList.find((p) => p.plan_code === selectedPlanCode) || planList[0];
|
||||
const paymentFeatureReady = Boolean(paymentConfig?.enabled && paymentConfig?.configured);
|
||||
|
||||
const connectAndBindWallet = async () => {
|
||||
setPaymentError("");
|
||||
setPaymentInfo("");
|
||||
if (!isAuthenticated) {
|
||||
setPaymentError("请先登录后再绑定钱包。");
|
||||
return;
|
||||
}
|
||||
const eth = window.ethereum;
|
||||
if (!eth) {
|
||||
setPaymentError("未检测到 MetaMask,请先安装扩展。");
|
||||
return;
|
||||
}
|
||||
setPaymentBusy(true);
|
||||
try {
|
||||
const accounts = (await eth.request({
|
||||
method: "eth_requestAccounts",
|
||||
})) as string[];
|
||||
const address = String(accounts?.[0] || "").toLowerCase();
|
||||
if (!address) {
|
||||
throw new Error("钱包账户为空");
|
||||
}
|
||||
setWalletAddress(address);
|
||||
const challengeRes = await fetch("/api/payments/wallets/challenge", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ address }),
|
||||
});
|
||||
if (!challengeRes.ok) {
|
||||
const raw = (await challengeRes.text()).slice(0, 300);
|
||||
throw new Error(`challenge failed: ${raw}`);
|
||||
}
|
||||
const challengeJson = (await challengeRes.json()) as {
|
||||
nonce?: string;
|
||||
message?: string;
|
||||
};
|
||||
const message = String(challengeJson.message || "");
|
||||
const nonce = String(challengeJson.nonce || "");
|
||||
if (!message || !nonce) {
|
||||
throw new Error("challenge payload invalid");
|
||||
}
|
||||
const signature = (await eth.request({
|
||||
method: "personal_sign",
|
||||
params: [message, address],
|
||||
})) as string;
|
||||
const verifyRes = await fetch("/api/payments/wallets/verify", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ address, nonce, signature }),
|
||||
});
|
||||
if (!verifyRes.ok) {
|
||||
const raw = (await verifyRes.text()).slice(0, 300);
|
||||
throw new Error(`verify failed: ${raw}`);
|
||||
}
|
||||
setPaymentInfo(`钱包绑定成功: ${shortAddress(address)}`);
|
||||
await loadPaymentSnapshot();
|
||||
} catch (error) {
|
||||
setPaymentError(String(error));
|
||||
} finally {
|
||||
setPaymentBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createIntentAndPay = async () => {
|
||||
setPaymentError("");
|
||||
setPaymentInfo("");
|
||||
if (!isAuthenticated) {
|
||||
setPaymentError("请先登录后再支付。");
|
||||
return;
|
||||
}
|
||||
if (!paymentConfig?.configured) {
|
||||
setPaymentError("支付服务未配置完成。");
|
||||
return;
|
||||
}
|
||||
const eth = window.ethereum;
|
||||
if (!eth) {
|
||||
setPaymentError("未检测到 MetaMask。");
|
||||
return;
|
||||
}
|
||||
const payingWallet = (selectedWallet || walletAddress || "").toLowerCase();
|
||||
if (!payingWallet) {
|
||||
setPaymentError("请先绑定钱包。");
|
||||
return;
|
||||
}
|
||||
|
||||
setPaymentBusy(true);
|
||||
try {
|
||||
const currentChainIdHex = String(
|
||||
(await eth.request({ method: "eth_chainId" })) || "",
|
||||
);
|
||||
const targetChainId = Number(paymentConfig.chain_id || 137);
|
||||
const targetChainHex = `0x${targetChainId.toString(16)}`;
|
||||
if (currentChainIdHex.toLowerCase() !== targetChainHex.toLowerCase()) {
|
||||
await eth.request({
|
||||
method: "wallet_switchEthereumChain",
|
||||
params: [{ chainId: targetChainHex }],
|
||||
});
|
||||
}
|
||||
|
||||
const createRes = await fetch("/api/payments/intents", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
plan_code: selectedPlanCode || "pro_monthly",
|
||||
payment_mode: "strict",
|
||||
allowed_wallet: payingWallet,
|
||||
metadata: { source: "account_center" },
|
||||
}),
|
||||
});
|
||||
if (!createRes.ok) {
|
||||
const raw = (await createRes.text()).slice(0, 350);
|
||||
throw new Error(`create intent failed: ${raw}`);
|
||||
}
|
||||
const created = (await createRes.json()) as CreatedIntent;
|
||||
const intentId = String(created.intent?.intent_id || "");
|
||||
const txPayload = created.tx_payload;
|
||||
if (!intentId || !txPayload?.to || !txPayload?.data) {
|
||||
throw new Error("intent payload invalid");
|
||||
}
|
||||
setLastIntentId(intentId);
|
||||
|
||||
const txHash = (await eth.request({
|
||||
method: "eth_sendTransaction",
|
||||
params: [
|
||||
{
|
||||
from: payingWallet,
|
||||
to: txPayload.to,
|
||||
data: txPayload.data,
|
||||
value: txPayload.value || "0x0",
|
||||
},
|
||||
],
|
||||
})) as string;
|
||||
const txHashNorm = String(txHash || "").toLowerCase();
|
||||
setLastTxHash(txHashNorm);
|
||||
|
||||
const submitRes = await fetch(`/api/payments/intents/${intentId}/submit`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
tx_hash: txHashNorm,
|
||||
from_address: payingWallet,
|
||||
}),
|
||||
});
|
||||
if (!submitRes.ok) {
|
||||
const raw = (await submitRes.text()).slice(0, 350);
|
||||
throw new Error(`submit tx failed: ${raw}`);
|
||||
}
|
||||
|
||||
const confirmRes = await fetch(`/api/payments/intents/${intentId}/confirm`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ tx_hash: txHashNorm }),
|
||||
});
|
||||
if (!confirmRes.ok) {
|
||||
const raw = (await confirmRes.text()).slice(0, 350);
|
||||
setPaymentInfo(`交易已提交: ${shortAddress(txHashNorm)},等待确认中。`);
|
||||
throw new Error(`confirm pending: ${raw}`);
|
||||
}
|
||||
setPaymentInfo(`支付确认成功,交易: ${shortAddress(txHashNorm)}`);
|
||||
await loadSnapshot();
|
||||
await loadPaymentSnapshot();
|
||||
} catch (error) {
|
||||
setPaymentError(String(error));
|
||||
} finally {
|
||||
setPaymentBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen w-full overflow-hidden bg-[#0b0f1a] p-4 font-sans text-slate-200 md:p-8">
|
||||
<div className="pointer-events-none absolute right-0 top-0 h-[500px] w-[500px] rounded-full bg-blue-600/10 blur-[120px]" />
|
||||
@@ -350,6 +629,138 @@ export function AccountCenter() {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-12">
|
||||
<section className="rounded-3xl border border-emerald-500/20 bg-gradient-to-r from-emerald-600/10 to-cyan-600/10 p-8 backdrop-blur-sm">
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 className="flex items-center gap-2 text-lg font-bold">
|
||||
<Wallet size={20} className="text-emerald-300" /> 钱包支付(P1 合约收款)
|
||||
</h3>
|
||||
<span className="rounded-full border border-white/10 bg-white/10 px-3 py-1 text-xs text-slate-300">
|
||||
Chain #{paymentConfig?.chain_id || 137}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!isAuthenticated ? (
|
||||
<p className="rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-300">
|
||||
请先登录,再绑定钱包并完成支付。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{isAuthenticated && !paymentFeatureReady ? (
|
||||
<p className="rounded-xl border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-300">
|
||||
支付服务未配置完成(需要后端开启 `POLYWEATHER_PAYMENT_*` 配置)。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{isAuthenticated && paymentFeatureReady ? (
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div className="rounded-2xl border border-white/10 bg-black/30 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<p className="flex items-center gap-2 text-sm font-semibold text-slate-200">
|
||||
<BadgeCheck size={16} className="text-emerald-300" />
|
||||
已绑定钱包
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void connectAndBindWallet()}
|
||||
disabled={paymentBusy}
|
||||
className="rounded-lg border border-emerald-400/30 bg-emerald-500/20 px-3 py-1 text-xs text-emerald-200 transition hover:bg-emerald-500/30 disabled:opacity-60"
|
||||
>
|
||||
连接并绑定 MetaMask
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{boundWallets.length ? (
|
||||
boundWallets.map((wallet) => (
|
||||
<label
|
||||
key={wallet.address}
|
||||
className="flex cursor-pointer items-center justify-between rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-slate-200"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="payWallet"
|
||||
className="h-4 w-4"
|
||||
checked={selectedWallet === wallet.address}
|
||||
onChange={() => setSelectedWallet(wallet.address)}
|
||||
/>
|
||||
{shortAddress(wallet.address)}
|
||||
{wallet.is_primary ? (
|
||||
<span className="rounded bg-blue-500/30 px-1.5 py-0.5 text-[10px] text-blue-100">
|
||||
主
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">{wallet.status}</span>
|
||||
</label>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-slate-400">暂无已绑定钱包。</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/10 bg-black/30 p-4">
|
||||
<p className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-200">
|
||||
<CreditCard size={16} className="text-cyan-300" />
|
||||
选择套餐并支付
|
||||
</p>
|
||||
<div className="mb-3 space-y-2">
|
||||
{planList.map((plan) => (
|
||||
<label
|
||||
key={plan.plan_code}
|
||||
className="flex cursor-pointer items-center justify-between rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-slate-200">
|
||||
<input
|
||||
type="radio"
|
||||
name="payPlan"
|
||||
className="h-4 w-4"
|
||||
checked={selectedPlanCode === plan.plan_code}
|
||||
onChange={() => setSelectedPlanCode(plan.plan_code)}
|
||||
/>
|
||||
{plan.plan_code}
|
||||
</span>
|
||||
<span className="text-cyan-200">
|
||||
{plan.amount_usdc} USDC / {plan.duration_days} 天
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void createIntentAndPay()}
|
||||
disabled={paymentBusy || !selectedPlan || !selectedWallet}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-emerald-600 to-cyan-600 px-4 py-3 text-sm font-semibold text-white transition hover:from-emerald-500 hover:to-cyan-500 disabled:opacity-60"
|
||||
>
|
||||
{paymentBusy ? <Loader2 size={16} className="animate-spin" /> : <Sparkles size={16} />}
|
||||
创建订单并支付
|
||||
</button>
|
||||
{lastIntentId ? (
|
||||
<p className="mt-2 text-xs text-slate-400">
|
||||
Intent: {lastIntentId}
|
||||
</p>
|
||||
) : null}
|
||||
{lastTxHash ? (
|
||||
<p className="mt-1 text-xs text-slate-400">Tx: {shortAddress(lastTxHash)}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{paymentInfo ? (
|
||||
<p className="mt-4 rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-200">
|
||||
{paymentInfo}
|
||||
</p>
|
||||
) : null}
|
||||
{paymentError ? (
|
||||
<p className="mt-3 rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-300">
|
||||
{paymentError}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-12">
|
||||
<section className="group relative overflow-hidden rounded-3xl border border-blue-500/20 bg-gradient-to-r from-blue-600/10 to-indigo-600/10 p-8 backdrop-blur-sm">
|
||||
<div className="absolute right-0 top-0 translate-x-1/4 -translate-y-1/4">
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
-- PolyWeather minimal commerce/auth schema (P0)
|
||||
-- Run in Supabase SQL editor.
|
||||
|
||||
create extension if not exists pgcrypto;
|
||||
|
||||
create table if not exists public.profiles (
|
||||
id uuid primary key references auth.users(id) on delete cascade,
|
||||
email text not null default '',
|
||||
@@ -48,6 +50,83 @@ create table if not exists public.entitlement_events (
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists public.user_wallets (
|
||||
id bigserial primary key,
|
||||
user_id uuid not null references auth.users(id) on delete cascade,
|
||||
chain_id integer not null default 137,
|
||||
address text not null,
|
||||
status text not null default 'active' check (status in ('active', 'revoked')),
|
||||
is_primary boolean not null default false,
|
||||
verified_at timestamptz,
|
||||
last_used_at timestamptz,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique(chain_id, address)
|
||||
);
|
||||
|
||||
create index if not exists idx_user_wallets_user_chain
|
||||
on public.user_wallets(user_id, chain_id, status, is_primary desc, verified_at desc);
|
||||
|
||||
create table if not exists public.wallet_link_challenges (
|
||||
id bigserial primary key,
|
||||
user_id uuid not null references auth.users(id) on delete cascade,
|
||||
chain_id integer not null default 137,
|
||||
address text not null,
|
||||
nonce text not null unique,
|
||||
message text not null,
|
||||
expires_at timestamptz not null,
|
||||
consumed_at timestamptz,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_wallet_link_challenges_lookup
|
||||
on public.wallet_link_challenges(user_id, chain_id, address, nonce, created_at desc);
|
||||
|
||||
create table if not exists public.payment_intents (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid not null references auth.users(id) on delete cascade,
|
||||
plan_code text not null,
|
||||
plan_id bigint not null,
|
||||
chain_id integer not null default 137,
|
||||
token_address text not null,
|
||||
receiver_address text not null,
|
||||
amount_units numeric(78,0) not null,
|
||||
payment_mode text not null default 'strict' check (payment_mode in ('strict', 'flex')),
|
||||
allowed_wallet text,
|
||||
order_id_hex text not null unique,
|
||||
status text not null default 'created' check (status in ('created', 'submitted', 'confirmed', 'expired', 'failed', 'cancelled')),
|
||||
tx_hash text,
|
||||
expires_at timestamptz not null,
|
||||
confirmed_at timestamptz,
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_payment_intents_user_status
|
||||
on public.payment_intents(user_id, status, created_at desc);
|
||||
|
||||
create index if not exists idx_payment_intents_tx_hash
|
||||
on public.payment_intents(tx_hash);
|
||||
|
||||
create table if not exists public.payment_transactions (
|
||||
id bigserial primary key,
|
||||
intent_id uuid not null references public.payment_intents(id) on delete cascade,
|
||||
tx_hash text not null unique,
|
||||
chain_id integer not null default 137,
|
||||
from_address text not null,
|
||||
to_address text not null,
|
||||
block_number bigint,
|
||||
status text not null default 'submitted' check (status in ('submitted', 'confirmed', 'failed')),
|
||||
raw_receipt jsonb not null default '{}'::jsonb,
|
||||
raw_tx jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_payment_transactions_intent
|
||||
on public.payment_transactions(intent_id, created_at desc);
|
||||
|
||||
create or replace function public.sync_profile_from_auth()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
@@ -68,4 +147,3 @@ drop trigger if exists on_auth_user_created_polyweather on auth.users;
|
||||
create trigger on_auth_user_created_polyweather
|
||||
after insert on auth.users
|
||||
for each row execute function public.sync_profile_from_auth();
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
from .contract_checkout import (
|
||||
PAYMENT_CHECKOUT,
|
||||
PaymentCheckoutError,
|
||||
PaymentIntentRecord,
|
||||
WalletBindingRecord,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"PAYMENT_CHECKOUT",
|
||||
"PaymentCheckoutError",
|
||||
"PaymentIntentRecord",
|
||||
"WalletBindingRecord",
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+154
@@ -23,6 +23,7 @@ if _file_dir not in sys.path:
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel, Field
|
||||
from loguru import logger
|
||||
|
||||
from src.utils.config_loader import load_config
|
||||
@@ -35,6 +36,7 @@ from src.auth.supabase_entitlement import (
|
||||
SUPABASE_ENTITLEMENT,
|
||||
extract_bearer_token,
|
||||
)
|
||||
from src.payments import PAYMENT_CHECKOUT, PaymentCheckoutError
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Setup
|
||||
@@ -157,6 +159,52 @@ def _assert_entitlement(request: Request) -> None:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
|
||||
def _require_supabase_identity(request: Request) -> Dict[str, str]:
|
||||
if not SUPABASE_ENTITLEMENT.enabled:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="payment requires POLYWEATHER_AUTH_ENABLED=true",
|
||||
)
|
||||
if not SUPABASE_ENTITLEMENT.configured:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="payment requires SUPABASE_URL and SUPABASE_ANON_KEY",
|
||||
)
|
||||
token = extract_bearer_token(request.headers.get("authorization"))
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
identity = SUPABASE_ENTITLEMENT.get_identity(token)
|
||||
if not identity:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
return {"user_id": identity.user_id, "email": identity.email}
|
||||
|
||||
|
||||
class WalletChallengeRequest(BaseModel):
|
||||
address: str = Field(..., min_length=8)
|
||||
|
||||
|
||||
class WalletVerifyRequest(BaseModel):
|
||||
address: str = Field(..., min_length=8)
|
||||
nonce: str = Field(..., min_length=6)
|
||||
signature: str = Field(..., min_length=20)
|
||||
|
||||
|
||||
class CreatePaymentIntentRequest(BaseModel):
|
||||
plan_code: str = Field(default="pro_monthly", min_length=2)
|
||||
payment_mode: str = Field(default="strict")
|
||||
allowed_wallet: Optional[str] = None
|
||||
metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SubmitPaymentTxRequest(BaseModel):
|
||||
tx_hash: str = Field(..., min_length=10)
|
||||
from_address: str = Field(..., min_length=8)
|
||||
|
||||
|
||||
class ConfirmPaymentTxRequest(BaseModel):
|
||||
tx_hash: Optional[str] = None
|
||||
|
||||
|
||||
def _sf(v) -> Optional[float]:
|
||||
"""Safe float conversion."""
|
||||
if v is None:
|
||||
@@ -1058,6 +1106,112 @@ async def auth_me(request: Request):
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/payments/config")
|
||||
async def payment_config(request: Request):
|
||||
_assert_entitlement(request)
|
||||
try:
|
||||
return PAYMENT_CHECKOUT.get_config_payload()
|
||||
except PaymentCheckoutError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||
|
||||
|
||||
@app.get("/api/payments/wallets")
|
||||
async def payment_wallets(request: Request):
|
||||
_assert_entitlement(request)
|
||||
identity = _require_supabase_identity(request)
|
||||
try:
|
||||
wallets = PAYMENT_CHECKOUT.list_wallets(identity["user_id"])
|
||||
return {
|
||||
"wallets": [w.__dict__ for w in wallets],
|
||||
"chain_id": PAYMENT_CHECKOUT.chain_id,
|
||||
}
|
||||
except PaymentCheckoutError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||
|
||||
|
||||
@app.post("/api/payments/wallets/challenge")
|
||||
async def payment_wallet_challenge(request: Request, body: WalletChallengeRequest):
|
||||
_assert_entitlement(request)
|
||||
identity = _require_supabase_identity(request)
|
||||
try:
|
||||
challenge = PAYMENT_CHECKOUT.create_wallet_challenge(
|
||||
user_id=identity["user_id"],
|
||||
address=body.address,
|
||||
)
|
||||
return challenge
|
||||
except PaymentCheckoutError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||
|
||||
|
||||
@app.post("/api/payments/wallets/verify")
|
||||
async def payment_wallet_verify(request: Request, body: WalletVerifyRequest):
|
||||
_assert_entitlement(request)
|
||||
identity = _require_supabase_identity(request)
|
||||
try:
|
||||
bound = PAYMENT_CHECKOUT.verify_wallet_binding(
|
||||
user_id=identity["user_id"],
|
||||
address=body.address,
|
||||
nonce=body.nonce,
|
||||
signature=body.signature,
|
||||
)
|
||||
return {"wallet": bound.__dict__}
|
||||
except PaymentCheckoutError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||
|
||||
|
||||
@app.post("/api/payments/intents")
|
||||
async def payment_create_intent(request: Request, body: CreatePaymentIntentRequest):
|
||||
_assert_entitlement(request)
|
||||
identity = _require_supabase_identity(request)
|
||||
try:
|
||||
return PAYMENT_CHECKOUT.create_intent(
|
||||
user_id=identity["user_id"],
|
||||
plan_code=body.plan_code,
|
||||
payment_mode=body.payment_mode,
|
||||
allowed_wallet=body.allowed_wallet,
|
||||
metadata=body.metadata,
|
||||
)
|
||||
except PaymentCheckoutError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||
|
||||
|
||||
@app.post("/api/payments/intents/{intent_id}/submit")
|
||||
async def payment_submit_tx(
|
||||
request: Request,
|
||||
intent_id: str,
|
||||
body: SubmitPaymentTxRequest,
|
||||
):
|
||||
_assert_entitlement(request)
|
||||
identity = _require_supabase_identity(request)
|
||||
try:
|
||||
return PAYMENT_CHECKOUT.submit_intent_tx(
|
||||
user_id=identity["user_id"],
|
||||
intent_id=intent_id,
|
||||
tx_hash=body.tx_hash,
|
||||
from_address=body.from_address,
|
||||
)
|
||||
except PaymentCheckoutError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||
|
||||
|
||||
@app.post("/api/payments/intents/{intent_id}/confirm")
|
||||
async def payment_confirm_tx(
|
||||
request: Request,
|
||||
intent_id: str,
|
||||
body: ConfirmPaymentTxRequest,
|
||||
):
|
||||
_assert_entitlement(request)
|
||||
identity = _require_supabase_identity(request)
|
||||
try:
|
||||
return PAYMENT_CHECKOUT.confirm_intent_tx(
|
||||
user_id=identity["user_id"],
|
||||
intent_id=intent_id,
|
||||
tx_hash=body.tx_hash,
|
||||
)
|
||||
except PaymentCheckoutError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||
|
||||
|
||||
@app.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