feat: add billing center and stripe portal management
This commit is contained in:
parent
7432cfc890
commit
353c3d04ff
@ -46,6 +46,9 @@ STRIPE_SECRET_KEY=sk_test_xxx
|
||||
# Stripe Webhook 签名密钥(whsec_...)
|
||||
STRIPE_WEBHOOK_SECRET=whsec_xxx
|
||||
|
||||
# Stripe Billing Portal 返回地址(可选)
|
||||
STRIPE_BILLING_RETURN_URL=https://your-domain.com/billing
|
||||
|
||||
# 不同套餐对应的 Stripe Price ID
|
||||
STRIPE_PRICE_ID_BASIC=price_xxx_basic
|
||||
STRIPE_PRICE_ID_PRO=price_xxx_pro
|
||||
|
||||
65
app/api/billing/portal/route.ts
Normal file
65
app/api/billing/portal/route.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import {
|
||||
createStripeBillingPortalSession,
|
||||
findStripeCustomerIdByEmail,
|
||||
StripeRequestError,
|
||||
} from "@/lib/billing/stripe";
|
||||
|
||||
function getBaseUrl(request: NextRequest) {
|
||||
return (
|
||||
process.env.AUTH_URL ??
|
||||
`${request.nextUrl.protocol}//${request.headers.get("host") ?? request.nextUrl.host}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id || !session.user.email) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (session.user.type === "guest") {
|
||||
return NextResponse.json({ error: "Guest user cannot manage subscription" }, { status: 403 });
|
||||
}
|
||||
|
||||
const stripeSecretKey = process.env.STRIPE_SECRET_KEY;
|
||||
|
||||
if (!stripeSecretKey) {
|
||||
return NextResponse.json({ error: "Stripe is not configured" }, { status: 503 });
|
||||
}
|
||||
|
||||
const customerId = await findStripeCustomerIdByEmail({
|
||||
secretKey: stripeSecretKey,
|
||||
email: session.user.email,
|
||||
});
|
||||
|
||||
if (!customerId) {
|
||||
return NextResponse.json({ error: "No Stripe subscription account found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const returnUrl = process.env.STRIPE_BILLING_RETURN_URL
|
||||
? process.env.STRIPE_BILLING_RETURN_URL
|
||||
: new URL("/billing", getBaseUrl(request)).toString();
|
||||
|
||||
const portalSession = await createStripeBillingPortalSession({
|
||||
secretKey: stripeSecretKey,
|
||||
customerId,
|
||||
returnUrl,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
id: portalSession.id,
|
||||
url: portalSession.url,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof StripeRequestError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
console.error("[Billing Portal API] Error:", error);
|
||||
return NextResponse.json({ error: "Failed to create billing portal session" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
102
app/billing/page.tsx
Normal file
102
app/billing/page.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { CreditCard, ShieldCheck, Sparkles } from "lucide-react";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { ManageSubscriptionButton } from "@/components/billing/manage-subscription-button";
|
||||
import {
|
||||
getCheckoutEnabledPlans,
|
||||
resolvePlanByMonthlyImageLimit,
|
||||
} from "@/lib/billing/plans";
|
||||
import { getUserById } from "@/lib/db/queries";
|
||||
|
||||
function formatLimit(limit: number | null) {
|
||||
if (limit == null || limit === 0) {
|
||||
return "无限制";
|
||||
}
|
||||
|
||||
return `${limit.toLocaleString("zh-CN")} 张 / 月`;
|
||||
}
|
||||
|
||||
export default async function BillingPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
redirect("/login?callbackUrl=/billing");
|
||||
}
|
||||
|
||||
const user = await getUserById({ id: session.user.id });
|
||||
|
||||
if (!user) {
|
||||
redirect("/login?callbackUrl=/billing");
|
||||
}
|
||||
|
||||
const currentPlan = resolvePlanByMonthlyImageLimit(user.monthlyImageLimit);
|
||||
const checkoutEnabledPlans = getCheckoutEnabledPlans();
|
||||
const checkoutAvailableCount = Object.values(checkoutEnabledPlans).filter(Boolean).length;
|
||||
|
||||
return (
|
||||
<main className="min-h-dvh bg-[#050814] px-4 py-8 text-zinc-100 sm:px-6">
|
||||
<div className="mx-auto w-full max-w-4xl space-y-6">
|
||||
<section className="rounded-3xl border border-white/15 bg-white/[0.04] p-6 sm:p-8">
|
||||
<p className="inline-flex items-center gap-2 rounded-full border border-white/20 bg-white/5 px-3 py-1 text-xs text-zinc-200">
|
||||
<CreditCard className="h-3.5 w-3.5 text-cyan-300" />
|
||||
Billing Center
|
||||
</p>
|
||||
<h1 className="mt-4 text-3xl font-semibold text-white sm:text-4xl">订阅与账单管理</h1>
|
||||
<p className="mt-3 text-sm text-zinc-300 sm:text-base">
|
||||
查看当前套餐、权益额度,并可进入 Stripe 门户管理续费、取消和发票信息。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-[1.2fr_1fr]">
|
||||
<article className="rounded-2xl border border-white/15 bg-black/30 p-5">
|
||||
<h2 className="text-lg font-semibold text-white">当前订阅</h2>
|
||||
<div className="mt-4 rounded-xl border border-cyan-300/30 bg-cyan-400/10 p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-cyan-100">Active Plan</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-white">{currentPlan.name}</p>
|
||||
<p className="mt-2 text-sm text-zinc-200">
|
||||
月度出图额度:<span className="font-medium text-white">{formatLimit(currentPlan.monthlyImageLimit)}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/lovart/pricing"
|
||||
className="inline-flex items-center justify-center rounded-xl bg-white px-4 py-2 text-sm font-semibold text-zinc-900 hover:bg-zinc-200"
|
||||
>
|
||||
更换套餐
|
||||
</Link>
|
||||
{currentPlan.id !== "starter" ? <ManageSubscriptionButton /> : null}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="rounded-2xl border border-white/15 bg-black/30 p-5">
|
||||
<h2 className="text-lg font-semibold text-white">订阅状态</h2>
|
||||
<div className="mt-3 space-y-2 text-sm text-zinc-300">
|
||||
<p className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-cyan-300" />
|
||||
当前账号:{session.user.name || session.user.email}
|
||||
</p>
|
||||
<p className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-4 w-4 text-cyan-300" />
|
||||
已配置可下单套餐:{checkoutAvailableCount}/3
|
||||
</p>
|
||||
<p className="text-xs text-zinc-400">
|
||||
订阅权益按 Webhook 自动写回账户额度(`monthlyImageLimit`)。
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-white/15 bg-black/25 p-5 text-sm text-zinc-300">
|
||||
<h2 className="text-lg font-semibold text-white">说明</h2>
|
||||
<ul className="mt-3 space-y-2">
|
||||
<li>如已完成支付但权益未刷新,可稍后重试或联系管理员检查 Webhook 配置。</li>
|
||||
<li>取消订阅后,到期会自动回退为 Starter 套餐额度。</li>
|
||||
<li>账单、付款方式、发票下载统一在 Stripe Portal 中管理。</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@ -133,7 +133,12 @@ export default async function LovartPricingPage({ searchParams }: PricingPagePro
|
||||
<section className="mt-4 rounded-2xl border border-white/15 bg-white/[0.03] p-4">
|
||||
{checkoutState === "success" ? (
|
||||
<p className="text-sm text-emerald-200">
|
||||
支付已完成,正在同步你的订阅权益{selectedPlan ? `(${selectedPlan})` : ""}。如未生效,请稍后刷新。
|
||||
支付已完成,正在同步你的订阅权益{selectedPlan ? `(${selectedPlan})` : ""}。如未生效,请稍后刷新,
|
||||
或前往
|
||||
<Link className="ml-1 underline hover:text-emerald-100" href="/billing">
|
||||
订阅中心
|
||||
</Link>
|
||||
查看状态。
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-amber-200">支付已取消,你可以随时重新发起订阅。</p>
|
||||
|
||||
63
components/billing/manage-subscription-button.tsx
Normal file
63
components/billing/manage-subscription-button.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function ManageSubscriptionButton({
|
||||
className,
|
||||
}: {
|
||||
className?: string;
|
||||
}) {
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
|
||||
async function handleOpenPortal() {
|
||||
if (isPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPending(true);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/billing/portal", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const data = (await response.json()) as { error?: string; url?: string };
|
||||
|
||||
if (!response.ok || !data.url) {
|
||||
window.alert(data.error || "打开订阅管理失败,请稍后重试。");
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.assign(data.url);
|
||||
} catch (error) {
|
||||
console.error("[ManageSubscriptionButton] portal error", error);
|
||||
window.alert("打开订阅管理失败,请稍后重试。");
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center gap-2 rounded-xl border border-white/20 px-4 py-2 text-sm text-zinc-100 hover:border-white/40",
|
||||
isPending && "cursor-not-allowed opacity-70",
|
||||
className
|
||||
)}
|
||||
disabled={isPending}
|
||||
onClick={handleOpenPortal}
|
||||
type="button"
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
正在跳转...
|
||||
</>
|
||||
) : (
|
||||
"管理订阅"
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@ -79,6 +79,17 @@ export function SidebarUserNav({ user }: { user: User }) {
|
||||
>
|
||||
切换到 {resolvedTheme === "light" ? "深色" : "浅色"} 模式
|
||||
</DropdownMenuItem>
|
||||
{!isGuest ? (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
data-testid="user-nav-item-billing"
|
||||
onSelect={() => {
|
||||
router.push("/billing");
|
||||
}}
|
||||
>
|
||||
订阅与账单
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild data-testid="user-nav-item-auth">
|
||||
<button
|
||||
|
||||
@ -24,6 +24,8 @@
|
||||
| `/api/public/lovart?view=changelog` | 更新日志聚合数据 | Public API | 已接入 |
|
||||
| `POST /api/billing/checkout` | Stripe Checkout 会话创建 | 登录用户触发支付 | 已接入 |
|
||||
| `POST /api/billing/webhook/stripe` | Stripe 事件回调 | 更新用户套餐额度 | 已接入 |
|
||||
| `POST /api/billing/portal` | Stripe Billing Portal 会话创建 | 登录用户管理订阅 | 已接入 |
|
||||
| `/billing` | 用户订阅中心页面 | 查看套餐与进入账单门户 | 已接入 |
|
||||
|
||||
## 新增查询与 API
|
||||
|
||||
@ -43,6 +45,7 @@
|
||||
- 计费接口
|
||||
- `POST /api/billing/checkout`
|
||||
- `POST /api/billing/webhook/stripe`
|
||||
- `POST /api/billing/portal`
|
||||
|
||||
## 页面层联动点
|
||||
|
||||
@ -76,6 +79,7 @@
|
||||
- 需要在环境变量中配置:
|
||||
- `STRIPE_SECRET_KEY`
|
||||
- `STRIPE_WEBHOOK_SECRET`
|
||||
- `STRIPE_BILLING_RETURN_URL`(可选)
|
||||
- `STRIPE_PRICE_ID_BASIC`
|
||||
- `STRIPE_PRICE_ID_PRO`
|
||||
- `STRIPE_PRICE_ID_ULTIMATE`
|
||||
|
||||
@ -68,6 +68,35 @@ export function getMonthlyImageLimitByPlanId(planId: BillingPlanId) {
|
||||
return getBillingPlanById(planId)?.monthlyImageLimit ?? null;
|
||||
}
|
||||
|
||||
export function resolvePlanByMonthlyImageLimit(limit: number | null | undefined): BillingPlanDefinition {
|
||||
const ultimatePlan =
|
||||
BILLING_PLAN_DEFINITIONS.find((plan) => plan.id === "ultimate") || BILLING_PLAN_DEFINITIONS[0];
|
||||
|
||||
if (limit == null) {
|
||||
return ultimatePlan;
|
||||
}
|
||||
|
||||
const exactMatch = BILLING_PLAN_DEFINITIONS.find(
|
||||
(plan) => plan.monthlyImageLimit != null && plan.monthlyImageLimit === limit
|
||||
);
|
||||
|
||||
if (exactMatch) {
|
||||
return exactMatch;
|
||||
}
|
||||
|
||||
const sortedPlans = BILLING_PLAN_DEFINITIONS.filter((plan) => plan.monthlyImageLimit != null).sort(
|
||||
(a, b) => (a.monthlyImageLimit ?? 0) - (b.monthlyImageLimit ?? 0)
|
||||
);
|
||||
|
||||
for (const plan of sortedPlans) {
|
||||
if ((plan.monthlyImageLimit ?? 0) >= limit) {
|
||||
return plan;
|
||||
}
|
||||
}
|
||||
|
||||
return ultimatePlan;
|
||||
}
|
||||
|
||||
export function getStripePriceIdForPlan(planId: PaidBillingPlanId) {
|
||||
const envName = STRIPE_PRICE_ENV[planId];
|
||||
const value = process.env[envName];
|
||||
|
||||
@ -9,6 +9,18 @@ type StripeCheckoutSession = {
|
||||
metadata?: Record<string, string>;
|
||||
};
|
||||
|
||||
type StripeCustomerListResponse = {
|
||||
data?: Array<{
|
||||
id: string;
|
||||
email?: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
type StripePortalSession = {
|
||||
id: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type CheckoutSessionParams = {
|
||||
secretKey: string;
|
||||
priceId: string;
|
||||
@ -76,6 +88,80 @@ export async function createStripeCheckoutSession(params: CheckoutSessionParams)
|
||||
return data as StripeCheckoutSession;
|
||||
}
|
||||
|
||||
export async function findStripeCustomerIdByEmail({
|
||||
secretKey,
|
||||
email,
|
||||
}: {
|
||||
secretKey: string;
|
||||
email: string;
|
||||
}) {
|
||||
const url = new URL(`${STRIPE_API_BASE}/customers`);
|
||||
url.searchParams.set("email", email);
|
||||
url.searchParams.set("limit", "1");
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${secretKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
const data = (await response.json()) as StripeCustomerListResponse & {
|
||||
error?: { message?: string };
|
||||
};
|
||||
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
typeof data?.error?.message === "string"
|
||||
? data.error.message
|
||||
: "Failed to query Stripe customer";
|
||||
|
||||
throw new StripeRequestError(message, response.status);
|
||||
}
|
||||
|
||||
return data.data?.[0]?.id ?? null;
|
||||
}
|
||||
|
||||
export async function createStripeBillingPortalSession({
|
||||
secretKey,
|
||||
customerId,
|
||||
returnUrl,
|
||||
}: {
|
||||
secretKey: string;
|
||||
customerId: string;
|
||||
returnUrl: string;
|
||||
}) {
|
||||
const body = new URLSearchParams();
|
||||
body.set("customer", customerId);
|
||||
body.set("return_url", returnUrl);
|
||||
|
||||
const response = await fetch(`${STRIPE_API_BASE}/billing_portal/sessions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${secretKey}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
typeof data?.error?.message === "string"
|
||||
? data.error.message
|
||||
: "Stripe billing portal session creation failed";
|
||||
|
||||
throw new StripeRequestError(message, response.status);
|
||||
}
|
||||
|
||||
if (!data?.url || !data?.id) {
|
||||
throw new StripeRequestError("Stripe portal response missing URL", 502);
|
||||
}
|
||||
|
||||
return data as StripePortalSession;
|
||||
}
|
||||
|
||||
export function verifyStripeSignature({
|
||||
payload,
|
||||
signatureHeader,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user