feat: integrate Stripe payment with subscription plans
Add Stripe payment integration across the project: - Add @stripe/stripe-js dependency - Configure Stripe environment variables (secret key, publishable key, webhook secret) for dev, local, and production environments - Add Product/Price IDs for monthly, quarterly, and annual subscription tiers - Extend subscription plan data with voiceMinutesPerDay quotas (30/45/60 minutes per tier) - Update .gitignore to exclude implementation_plan.md
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* POST /api/payment/create-checkout-session
|
||||
*
|
||||
* 创建 Stripe Checkout Session(订阅模式)→ 返回 URL 给前端
|
||||
* 前端接到后 `window.location.href = url` 跳转到 Stripe 托管页
|
||||
*
|
||||
* 业务流程:
|
||||
* 1. 前端用户点 "Confirm the agreement and Activate"
|
||||
* 2. 前端 POST `{ planId, successUrl, cancelUrl }` 到本端点
|
||||
* 3. 本端调 `stripe.checkout.sessions.create({...})` 创建 session
|
||||
* 4. 返回 `{ url: session.url }` 给前端
|
||||
* 5. 前端 `window.location.href = url` 跳转
|
||||
* 6. Stripe 收银台结束后回跳到 success_url / cancel_url
|
||||
* 7. 同时 Stripe 发送 `checkout.session.completed` webhook → 后端设置 user.isVip + voiceMinutes
|
||||
*/
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { SUBSCRIPTION_PLANS } from "@/data/constants/subscription-plans";
|
||||
import type { SubscriptionPlanId } from "@/data/constants/subscription-plans";
|
||||
import { getPlanPriceId } from "@/lib/stripe/stripe-products";
|
||||
import { getStripeClient } from "@/lib/stripe/stripe-client";
|
||||
|
||||
interface CreateCheckoutRequestBody {
|
||||
planId: SubscriptionPlanId;
|
||||
successUrl: string;
|
||||
cancelUrl: string;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
let body: CreateCheckoutRequestBody;
|
||||
try {
|
||||
body = (await req.json()) as CreateCheckoutRequestBody;
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid JSON body" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// 入参验证
|
||||
if (!body.planId || !body.successUrl || !body.cancelUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required fields: planId, successUrl, cancelUrl" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (!SUBSCRIPTION_PLANS.find((p) => p.id === body.planId)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Unknown planId: ${body.planId}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// 验证 successUrl / cancelUrl 来自同源(防 open redirect)
|
||||
// 注:本轮先不做验证(前端和后端同源)—— 未来如果要开放给第三方请加 `same-origin` 检查
|
||||
|
||||
// 拿到 Stripe Price ID(从 env)
|
||||
let priceId: string;
|
||||
try {
|
||||
priceId = getPlanPriceId(body.planId);
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: (e as Error).message },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// 调 Stripe API 创建 Checkout Session
|
||||
const stripe = getStripeClient();
|
||||
try {
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: "subscription",
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
success_url: body.successUrl,
|
||||
cancel_url: body.cancelUrl,
|
||||
// 让 webhook 能识别用户 + 套餐(后端接收 webhook 时从 metadata 取)
|
||||
metadata: {
|
||||
planId: body.planId,
|
||||
// TODO: 加 userId(如果已知)—— 前端可在 body 传 userId
|
||||
},
|
||||
// 允许 promo codes(如果 Stripe dashboard 开了)
|
||||
allow_promotion_codes: true,
|
||||
// billing_address_collection: "auto" —— 根据 Stripe dashboard 配置
|
||||
});
|
||||
|
||||
return NextResponse.json({ url: session.url });
|
||||
} catch (e) {
|
||||
const err = e as { message?: string; type?: string; code?: string };
|
||||
console.error("[api/create-checkout-session] Stripe error", {
|
||||
message: err.message,
|
||||
type: err.type,
|
||||
code: err.code,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: err.message ?? "Failed to create checkout session",
|
||||
type: err.type,
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* POST /api/payment/customer-portal
|
||||
*
|
||||
* 创建 Stripe Customer Portal Session → 返回 URL 给前端
|
||||
*
|
||||
* 为什么需要 Customer Portal:
|
||||
* - Stripe 托管的"用户自助管理"页(取消 / 换 plan / 查看账单 / 更新卡)
|
||||
* - 不需要我们自己写 UI
|
||||
* - 最常用的就是 "取消订阅"(避免客服)
|
||||
*
|
||||
* 需要的参数:`customerId`(从 User.stripeCustomerId 取)—— webhook 会设置
|
||||
* - 如果为 null:返错("先购买")
|
||||
*/
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { getStripeClient } from "@/lib/stripe/stripe-client";
|
||||
|
||||
interface CreatePortalRequestBody {
|
||||
customerId: string | null;
|
||||
returnUrl: string;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
let body: CreatePortalRequestBody;
|
||||
try {
|
||||
body = (await req.json()) as CreatePortalRequestBody;
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid JSON body" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// 入参验证
|
||||
if (!body.customerId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing customerId. Please subscribe first before managing subscription." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (!body.returnUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing returnUrl" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// 调 Stripe API 创建 Customer Portal Session
|
||||
const stripe = getStripeClient();
|
||||
try {
|
||||
const session = await stripe.billingPortal.sessions.create({
|
||||
customer: body.customerId,
|
||||
return_url: body.returnUrl,
|
||||
});
|
||||
|
||||
return NextResponse.json({ url: session.url });
|
||||
} catch (e) {
|
||||
const err = e as { message?: string; type?: string; code?: string };
|
||||
console.error("[api/customer-portal] Stripe error", {
|
||||
customerId: body.customerId,
|
||||
message: err.message,
|
||||
type: err.type,
|
||||
code: err.code,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: err.message ?? "Failed to create customer portal session",
|
||||
type: err.type,
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* POST /api/payment/webhook
|
||||
*
|
||||
* 接收 Stripe webhook → 验签 → 分发到 handler
|
||||
*
|
||||
* 关键设计:
|
||||
* - 必须用 raw body 验签(不能让 Next.js 解析 JSON,所以用 `request.text()` 取原文)
|
||||
* - 校验 `stripe-signature` 头(用 `STRIPE_WEBHOOK_SECRET`)
|
||||
* - `stripe.webhooks.constructEvent(rawBody, sigHeader, secret)` 会抛错(如果签名不匹配)
|
||||
* - 分发到 `dispatchStripeEvent()` → 4 个 handler 之一
|
||||
*
|
||||
* 配置 Stripe webhook 端点:
|
||||
* - URL:https://cozsweet.com/api/payment/webhook
|
||||
* - 监听的事件:
|
||||
* - checkout.session.completed
|
||||
* - customer.subscription.created
|
||||
* - customer.subscription.updated
|
||||
* - customer.subscription.deleted
|
||||
* - invoice.payment_succeeded
|
||||
*
|
||||
* 本地开发用 Stripe CLI 转发:
|
||||
* stripe listen --forward-to localhost:3000/api/payment/webhook
|
||||
* 输出的 `whsec_xxx` → 写到 .env.local 的 `STRIPE_WEBHOOK_SECRET`
|
||||
*/
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import type Stripe from "stripe";
|
||||
|
||||
import { dispatchStripeEvent } from "@/lib/stripe/stripe-events";
|
||||
import { getStripeClient } from "@/lib/stripe/stripe-client";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
if (!webhookSecret) {
|
||||
console.error("[api/webhook] STRIPE_WEBHOOK_SECRET is not set");
|
||||
return NextResponse.json(
|
||||
{ error: "Webhook secret not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// 取 raw body(验签用 raw bytes,不能 JSON.parse 后再 stringify —— 会丢空格、字节顺序等)
|
||||
const rawBody = await req.text();
|
||||
const sigHeader = req.headers.get("stripe-signature");
|
||||
if (!sigHeader) {
|
||||
console.error("[api/webhook] Missing stripe-signature header");
|
||||
return NextResponse.json(
|
||||
{ error: "Missing stripe-signature header" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// 校验签名(不匹配会抛错)
|
||||
const stripe = getStripeClient();
|
||||
let event: Stripe.Event;
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(rawBody, sigHeader, webhookSecret);
|
||||
} catch (e) {
|
||||
const err = e as Error;
|
||||
console.error("[api/webhook] Signature verification failed", {
|
||||
message: err.message,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: `Webhook signature verification failed: ${err.message}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// 分发到 handler
|
||||
console.log("[api/webhook] Received event", {
|
||||
eventId: event.id,
|
||||
type: event.type,
|
||||
created: new Date(event.created * 1000).toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
const handled = await dispatchStripeEvent(event);
|
||||
if (!handled) {
|
||||
console.log(`[api/webhook] Ignored event type: ${event.type}`);
|
||||
}
|
||||
} catch (e) {
|
||||
const err = e as Error;
|
||||
console.error("[api/webhook] Handler error", {
|
||||
eventId: event.id,
|
||||
type: event.type,
|
||||
message: err.message,
|
||||
stack: err.stack,
|
||||
});
|
||||
// 返 500 让 Stripe 重试(幂等保护:handler 自己用 event.id 去重)
|
||||
return NextResponse.json(
|
||||
{ error: "Handler error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// 返 200 让 Stripe 标记为已接收(即使 handler 没做什么也返 200 —— 避免无谓重试)
|
||||
return NextResponse.json({ received: true });
|
||||
}
|
||||
|
||||
/** 关闭 body parser 验签 raw body 需要 raw text(不能被 Next.js 的 bodyParser 解析)*/
|
||||
export const dynamic = "force-dynamic"; // 关闭静态优化(避免 body 被缓存)
|
||||
Reference in New Issue
Block a user