Skip to content

支付集成

支付方案选择

SaaS 产品的支付方案主要有两类:

方案代表产品模式适用场景
支付处理器Stripe你是商家,自己处理税务全球市场、灵活定价
商家记录Paddle, LemonSqueezy他们是商家,帮你处理税务简化合规、欧洲市场

Stripe vs Paddle

Stripe

  • 费率:2.9% + $0.30(美国)
  • 你负责税务合规
  • 完全控制定价和计费逻辑
  • 适合:美国市场、技术团队强

Paddle

  • 费率:5% + $0.50
  • 他们处理全球税务
  • 简化的集成
  • 适合:欧洲市场、独立开发者

本教程以 Stripe 为例,因为它是最灵活的方案。

Stripe 集成

1. 创建 Stripe 账户

  1. 访问 stripe.com 注册
  2. 完成业务验证
  3. 获取 API 密钥

2. 安装依赖

bash
pnpm add stripe @stripe/stripe-js

3. 配置环境变量

env
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...

4. 创建 Stripe 客户端

typescript
// lib/stripe.ts
import Stripe from 'stripe'

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-12-18.acacia',
  typescript: true,
})

订阅模式实现

1. 在 Stripe 创建产品和价格

可以在 Stripe Dashboard 手动创建,或用代码:

typescript
// scripts/create-products.ts
import { stripe } from '@/lib/stripe'

async function createProducts() {
  // 创建产品
  const product = await stripe.products.create({
    name: 'Pro Plan',
    description: '专业版订阅',
  })

  // 创建月付价格
  await stripe.prices.create({
    product: product.id,
    unit_amount: 2900, // $29.00
    currency: 'usd',
    recurring: { interval: 'month' },
  })

  // 创建年付价格
  await stripe.prices.create({
    product: product.id,
    unit_amount: 29000, // $290.00 (省 2 个月)
    currency: 'usd',
    recurring: { interval: 'year' },
  })
}

2. 创建 Checkout Session

typescript
// app/api/checkout/route.ts
import { auth } from '@/lib/auth'
import { stripe } from '@/lib/stripe'
import { db } from '@/lib/db'
import { NextResponse } from 'next/server'

export async function POST(request: Request) {
  const session = await auth()
  if (!session?.user) {
    return new NextResponse('未授权', { status: 401 })
  }

  const { priceId } = await request.json()

  // 获取或创建 Stripe 客户
  const user = await db.user.findUnique({
    where: { id: session.user.id },
    include: { subscription: true }
  })

  let customerId = user?.subscription?.stripeCustomerId

  if (!customerId) {
    const customer = await stripe.customers.create({
      email: session.user.email!,
      metadata: { userId: session.user.id }
    })
    customerId = customer.id
  }

  // 创建 Checkout Session
  const checkoutSession = await stripe.checkout.sessions.create({
    customer: customerId,
    mode: 'subscription',
    payment_method_types: ['card'],
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing?canceled=true`,
    metadata: { userId: session.user.id }
  })

  return NextResponse.json({ url: checkoutSession.url })
}

3. 定价页面

tsx
// app/(marketing)/pricing/page.tsx
'use client'

import { useState } from 'react'
import { useRouter } from 'next/navigation'

const plans = [
  {
    name: '免费版',
    price: 0,
    features: ['基础功能', '1 个项目', '社区支持'],
    priceId: null,
  },
  {
    name: '专业版',
    price: 29,
    features: ['所有功能', '无限项目', '优先支持', 'API 访问'],
    priceId: 'price_xxx', // 替换为真实 ID
    popular: true,
  },
  {
    name: '团队版',
    price: 99,
    features: ['专业版所有功能', '团队协作', '高级分析', '专属客服'],
    priceId: 'price_yyy',
  },
]

export default function PricingPage() {
  const [loading, setLoading] = useState(false)
  const router = useRouter()

  async function handleSubscribe(priceId: string) {
    setLoading(true)
    const res = await fetch('/api/checkout', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ priceId }),
    })
    const { url } = await res.json()
    router.push(url)
  }

  return (
    <div className="py-24">
      <h1 className="text-4xl font-bold text-center">选择适合你的方案</h1>
      <div className="mt-16 grid md:grid-cols-3 gap-8 max-w-5xl mx-auto">
        {plans.map((plan) => (
          <div
            key={plan.name}
            className={`p-8 rounded-lg border ${
              plan.popular ? 'border-blue-500 ring-2 ring-blue-500' : ''
            }`}
          >
            {plan.popular && (
              <span className="bg-blue-500 text-white px-3 py-1 rounded-full text-sm">
                最受欢迎
              </span>
            )}
            <h3 className="text-xl font-bold mt-4">{plan.name}</h3>
            <p className="text-4xl font-bold mt-2">
              ${plan.price}<span className="text-lg">/月</span>
            </p>
            <ul className="mt-6 space-y-3">
              {plan.features.map((feature) => (
                <li key={feature}>✓ {feature}</li>
              ))}
            </ul>
            <button
              onClick={() => plan.priceId && handleSubscribe(plan.priceId)}
              disabled={!plan.priceId || loading}
              className="mt-8 w-full py-2 bg-black text-white rounded"
            >
              {plan.priceId ? '立即订阅' : '免费开始'}
            </button>
          </div>
        ))}
      </div>
    </div>
  )
}

Webhook 处理

Stripe 通过 Webhook 通知你支付状态变化。这是最关键的部分。

创建 Webhook 端点

typescript
// app/api/webhooks/stripe/route.ts
import { headers } from 'next/headers'
import { stripe } from '@/lib/stripe'
import { db } from '@/lib/db'

export async function POST(request: Request) {
  const body = await request.text()
  const signature = headers().get('stripe-signature')!

  let event

  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch (err) {
    return new Response('Webhook 签名验证失败', { status: 400 })
  }

  // 处理不同事件
  switch (event.type) {
    case 'checkout.session.completed':
      await handleCheckoutComplete(event.data.object)
      break
    case 'customer.subscription.updated':
      await handleSubscriptionUpdate(event.data.object)
      break
    case 'customer.subscription.deleted':
      await handleSubscriptionCancel(event.data.object)
      break
  }

  return new Response('OK', { status: 200 })
}

async function handleCheckoutComplete(session: any) {
  const userId = session.metadata.userId
  const subscriptionId = session.subscription

  const subscription = await stripe.subscriptions.retrieve(subscriptionId)

  await db.subscription.upsert({
    where: { userId },
    create: {
      userId,
      stripeCustomerId: session.customer,
      stripeSubscriptionId: subscriptionId,
      stripePriceId: subscription.items.data[0].price.id,
      status: 'active',
      currentPeriodEnd: new Date(subscription.current_period_end * 1000),
    },
    update: {
      stripeSubscriptionId: subscriptionId,
      stripePriceId: subscription.items.data[0].price.id,
      status: 'active',
      currentPeriodEnd: new Date(subscription.current_period_end * 1000),
    },
  })
}

async function handleSubscriptionUpdate(subscription: any) {
  await db.subscription.update({
    where: { stripeSubscriptionId: subscription.id },
    data: {
      status: subscription.status,
      currentPeriodEnd: new Date(subscription.current_period_end * 1000),
    },
  })
}

async function handleSubscriptionCancel(subscription: any) {
  await db.subscription.update({
    where: { stripeSubscriptionId: subscription.id },
    data: { status: 'canceled' },
  })
}

本地测试 Webhook

bash
# 安装 Stripe CLI
brew install stripe/stripe-cli/stripe

# 登录
stripe login

# 转发 Webhook 到本地
stripe listen --forward-to localhost:3000/api/webhooks/stripe

客户门户

让用户自己管理订阅(升级、降级、取消)。

typescript
// app/api/billing/portal/route.ts
import { auth } from '@/lib/auth'
import { stripe } from '@/lib/stripe'
import { db } from '@/lib/db'
import { NextResponse } from 'next/server'

export async function POST() {
  const session = await auth()
  if (!session?.user) {
    return new NextResponse('未授权', { status: 401 })
  }

  const subscription = await db.subscription.findUnique({
    where: { userId: session.user.id }
  })

  if (!subscription?.stripeCustomerId) {
    return new NextResponse('未找到订阅', { status: 404 })
  }

  const portalSession = await stripe.billingPortal.sessions.create({
    customer: subscription.stripeCustomerId,
    return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/settings`,
  })

  return NextResponse.json({ url: portalSession.url })
}

检查订阅状态

typescript
// lib/subscription.ts
import { auth } from './auth'
import { db } from './db'

export async function getUserSubscription() {
  const session = await auth()
  if (!session?.user) return null

  const subscription = await db.subscription.findUnique({
    where: { userId: session.user.id }
  })

  const isActive = subscription?.status === 'active' &&
    subscription.currentPeriodEnd &&
    subscription.currentPeriodEnd > new Date()

  return {
    ...subscription,
    isActive,
  }
}

// 在页面中使用
export async function isPro() {
  const subscription = await getUserSubscription()
  return subscription?.isActive ?? false
}

下一步

支付系统完成后,准备部署上线。

继续:部署运营 →


← 返回用户系统 | 返回项目五

最近更新

基于 Apache 2.0 许可发布