diff --git a/.env.example b/.env.example index d205530..2d8c94d 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app/api/billing/portal/route.ts b/app/api/billing/portal/route.ts new file mode 100644 index 0000000..0b87e77 --- /dev/null +++ b/app/api/billing/portal/route.ts @@ -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 }); + } +} diff --git a/app/billing/page.tsx b/app/billing/page.tsx new file mode 100644 index 0000000..22e0941 --- /dev/null +++ b/app/billing/page.tsx @@ -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 ( +
+
+
+

+ + Billing Center +

+

订阅与账单管理

+

+ 查看当前套餐、权益额度,并可进入 Stripe 门户管理续费、取消和发票信息。 +

+
+ +
+
+

当前订阅

+
+

Active Plan

+

{currentPlan.name}

+

+ 月度出图额度:{formatLimit(currentPlan.monthlyImageLimit)} +

+
+ +
+ + 更换套餐 + + {currentPlan.id !== "starter" ? : null} +
+
+ +
+

订阅状态

+
+

+ + 当前账号:{session.user.name || session.user.email} +

+

+ + 已配置可下单套餐:{checkoutAvailableCount}/3 +

+

+ 订阅权益按 Webhook 自动写回账户额度(`monthlyImageLimit`)。 +

+
+
+
+ +
+

说明

+
    +
  • 如已完成支付但权益未刷新,可稍后重试或联系管理员检查 Webhook 配置。
  • +
  • 取消订阅后,到期会自动回退为 Starter 套餐额度。
  • +
  • 账单、付款方式、发票下载统一在 Stripe Portal 中管理。
  • +
+
+
+
+ ); +} diff --git a/app/lovart/pricing/page.tsx b/app/lovart/pricing/page.tsx index 0127504..9fffd03 100644 --- a/app/lovart/pricing/page.tsx +++ b/app/lovart/pricing/page.tsx @@ -133,7 +133,12 @@ export default async function LovartPricingPage({ searchParams }: PricingPagePro
{checkoutState === "success" ? (

- 支付已完成,正在同步你的订阅权益{selectedPlan ? `(${selectedPlan})` : ""}。如未生效,请稍后刷新。 + 支付已完成,正在同步你的订阅权益{selectedPlan ? `(${selectedPlan})` : ""}。如未生效,请稍后刷新, + 或前往 + + 订阅中心 + + 查看状态。

) : (

支付已取消,你可以随时重新发起订阅。

diff --git a/components/billing/manage-subscription-button.tsx b/components/billing/manage-subscription-button.tsx new file mode 100644 index 0000000..96acbe4 --- /dev/null +++ b/components/billing/manage-subscription-button.tsx @@ -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 ( + + ); +} diff --git a/components/sidebar-user-nav.tsx b/components/sidebar-user-nav.tsx index 8f5ab93..026e676 100644 --- a/components/sidebar-user-nav.tsx +++ b/components/sidebar-user-nav.tsx @@ -79,6 +79,17 @@ export function SidebarUserNav({ user }: { user: User }) { > 切换到 {resolvedTheme === "light" ? "深色" : "浅色"} 模式 + {!isGuest ? ( + { + router.push("/billing"); + }} + > + 订阅与账单 + + ) : null}