返回首页
🚀 独立开发 / OPC

Stripe Billing 集成:从 0 到订阅 SaaS 的完整流程

本文用 Next.js + Stripe Billing 演示从产品定价、订阅创建、webhook 处理到客户自服务门户的完整 SaaS 集成流程。

Stripe · Billing · 订阅 · SaaS · 独立开发
📰

今日技术简讯

📰 技术简讯 · 2026-06-11

今日聚合 6 条热门技术内容。

🤖 AI / LLM

1. Anthropic 推出 Skills Marketplace

2. Google 发布 Gemma 4 开源模型

  • 链接https://ai.google.dev/gemma
  • 来源:Google AI
  • 摘要:Gemma 4 27B 模型,开源 Apache 2.0,性能接近 Claude Sonnet 3.5。

⚙️ 后端 / 架构

3. ClickHouse 推出 ClickPipes

🚀 独立开发 / OPC

4. Stripe Billing 集成教程

  • 链接https://stripe.com/docs/billing
  • 来源:Stripe 官方
  • 摘要:从订阅创建到 webhook 处理的完整文档,开发者收藏率最高的教程。

5. ConvertKit 推出 Newsletter AI 助手

  • 链接https://convertkit.com/ai
  • 来源:ConvertKit
  • 摘要:AI 自动生成 newsletter 草稿,分析读者喜好,独立创作者的福音。

6. 国内独立开发者社区"独立铺"成立

  • 链接https://www.indiepu.com
  • 来源:社区
  • 摘要:100+ 独立开发者入驻,专注产品互推和经验分享。

数据来源:HN / Reddit / 各厂博客 采集时间:2026-06-11 09:00 (UTC+8)

📝

今日深度文

Stripe Billing 集成:从 0 到订阅 SaaS 的完整流程

一句话结论:Stripe 是事实标准的支付方案。集成需要 2-3 天,但踩坑点都在 webhook。

背景

Stripe 是全球最流行的支付平台之一,在独立开发者中尤其受欢迎:

  • 全球覆盖:47 个国家,135 种货币
  • 集成简单:API 设计优雅
  • 文档完善:开发者体验最佳
  • 合规无忧:PCI DSS Level 1

但 Stripe Billing(订阅)的集成比一次性支付复杂得多。本文给出完整流程。

核心概念

Customer     用户
Subscription 用户订阅某个 Plan
Plan         价格方案(月付 / 年付 / 按量)
Invoice      自动生成的账单
Webhook      Stripe 主动通知你的服务(订阅创建、续费、失败等)

完整集成步骤

Step 1:安装依赖

npm install stripe @stripe/stripe-js
npm install -D @types/stripe

Step 2:配置环境

# .env.local
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxx
NEXT_PUBLIC_BASE_URL=http://localhost:3000

Step 3:服务端 Stripe 客户端

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

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2025-08-27.basil',
  typescript: true,
});

// 定价配置(推荐放在配置中心而非 Stripe Dashboard)
export const PRICING = {
  starter: {
    name: 'Starter',
    monthly: { amount: 900, currency: 'usd' }, // $9.00/月
    yearly: { amount: 8640, currency: 'usd' }, // $86.40/年(省 20%)
    features: ['Up to 1,000 API calls/day', 'Community support'],
    stripePriceId: {
      monthly: 'price_starter_monthly_xxx',
      yearly: 'price_starter_yearly_xxx',
    },
  },
  pro: {
    name: 'Pro',
    monthly: { amount: 2900, currency: 'usd' }, // $29.00/月
    yearly: { amount: 27840, currency: 'usd' }, // $278.40/年
    features: ['Unlimited API calls', 'Priority support', 'Custom domains'],
    stripePriceId: {
      monthly: 'price_pro_monthly_xxx',
      yearly: 'price_pro_yearly_xxx',
    },
  },
} as const;

Step 4:定价页面

// app/pricing/page.tsx (Server Component)
import { PRICING } from '@/lib/stripe';
import { PricingCard } from '@/components/PricingCard';

export default function PricingPage() {
  return (
    <div className="container py-12">
      <h1 className="text-4xl font-bold text-center mb-12">定价</h1>
      <div className="grid md:grid-cols-3 gap-8">
        {Object.entries(PRICING).map(([key, plan]) => (
          <PricingCard key={key} planKey={key} plan={plan} />
        ))}
      </div>
    </div>
  );
}

// components/PricingCard.tsx (Client Component)
'use client';

import { useState } from 'react';
import { loadStripe } from '@stripe/stripe-js';
import { PRICING } from '@/lib/stripe';

const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);

export function PricingCard({ planKey, plan }: any) {
  const [interval, setInterval] = useState<'monthly' | 'yearly'>('monthly');
  const [loading, setLoading] = useState(false);
  
  const handleSubscribe = async () => {
    setLoading(true);
    try {
      // 1. 创建 checkout session
      const res = await fetch('/api/checkout', {
        method: 'POST',
        body: JSON.stringify({ planKey, interval }),
      });
      const { sessionId } = await res.json();
      
      // 2. 跳转到 Stripe Checkout
      const stripe = await stripePromise;
      await stripe!.redirectToCheckout({ sessionId });
    } catch (err) {
      console.error(err);
    } finally {
      setLoading(false);
    }
  };
  
  return (
    <div className="border rounded-lg p-6">
      <h3 className="text-2xl font-bold">{plan.name}</h3>
      <p className="text-4xl font-bold my-4">
        ${(plan[interval].amount / 100).toFixed(0)}
        <span className="text-sm text-gray-500">/{interval === 'monthly' ? '月' : '年'}</span>
      </p>
      <ul className="my-6 space-y-2">
        {plan.features.map((f: string) => (
          <li key={f}>✓ {f}</li>
        ))}
      </ul>
      <button
        onClick={handleSubscribe}
        disabled={loading}
        className="w-full bg-blue-600 text-white py-2 rounded"
      >
        {loading ? '加载中...' : '订阅'}
      </button>
    </div>
  );
}

Step 5:创建 Checkout Session

// app/api/checkout/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { stripe, PRICING } from '@/lib/stripe';
import { auth } from '@/lib/auth'; // 你的认证方案

export async function POST(req: NextRequest) {
  // 1. 验证用户登录
  const user = await auth(req);
  if (!user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }
  
  const { planKey, interval } = await req.json();
  const plan = PRICING[planKey];
  if (!plan) {
    return NextResponse.json({ error: 'Invalid plan' }, { status: 400 });
  }
  
  // 2. 创建或获取 Customer
  let customer;
  if (user.stripeCustomerId) {
    customer = await stripe.customers.retrieve(user.stripeCustomerId);
  } else {
    customer = await stripe.customers.create({
      email: user.email,
      name: user.name,
      metadata: { userId: user.id },
    });
    // 保存到数据库
    await db.users.update(user.id, { stripeCustomerId: customer.id });
  }
  
  // 3. 创建 Checkout Session
  const session = await stripe.checkout.sessions.create({
    customer: customer.id,
    mode: 'subscription',
    payment_method_types: ['card'],
    line_items: [{
      price: plan.stripePriceId[interval],
      quantity: 1,
    }],
    success_url: `${process.env.NEXT_PUBLIC_BASE_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.NEXT_PUBLIC_BASE_URL}/pricing`,
    metadata: {
      userId: user.id,
      planKey,
      interval,
    },
  });
  
  return NextResponse.json({ sessionId: session.id });
}

Step 6:Webhook 处理(最关键)

// app/api/webhook/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { headers } from 'next/headers';
import { db } from '@/lib/db';

export const config = {
  api: { bodyParser: false }, // Stripe 需要原始 body
};

export async function POST(req: NextRequest) {
  const body = await req.text();
  const sig = headers().get('stripe-signature')!;
  
  let event;
  try {
    event = stripe.webhooks.constructEvent(
      body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET!,
    );
  } catch (err) {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
  }
  
  // 处理各种事件
  switch (event.type) {
    case 'checkout.session.completed': {
      const session = event.data.object;
      const userId = session.metadata!.userId;
      
      // 用户完成首次订阅
      await db.subscriptions.create({
        userId,
        stripeSubscriptionId: session.subscription as string,
        status: 'active',
      });
      await db.users.update(userId, { plan: session.metadata!.planKey });
      break;
    }
    
    case 'invoice.paid': {
      const invoice = event.data.object;
      // 订阅续费成功
      await db.subscriptions.updateByStripeId(invoice.subscription as string, {
        status: 'active',
        currentPeriodEnd: new Date(invoice.lines.data[0].period.end * 1000),
      });
      break;
    }
    
    case 'invoice.payment_failed': {
      const invoice = event.data.object;
      // 续费失败,通知用户
      const subscription = await db.subscriptions.findByStripeId(
        invoice.subscription as string,
      );
      await sendEmail(subscription.userId, {
        subject: '支付失败,请更新支付方式',
        template: 'payment-failed',
      });
      break;
    }
    
    case 'customer.subscription.deleted': {
      const subscription = event.data.object;
      // 订阅取消
      await db.subscriptions.updateByStripeId(subscription.id, {
        status: 'canceled',
      });
      const sub = await db.subscriptions.findByStripeId(subscription.id);
      await db.users.update(sub.userId, { plan: 'free' });
      break;
    }
    
    case 'customer.subscription.updated': {
      const subscription = event.data.object;
      // 升降级
      await db.subscriptions.updateByStripeId(subscription.id, {
        status: subscription.status,
        currentPeriodEnd: new Date(subscription.current_period_end * 1000),
      });
      break;
    }
  }
  
  return NextResponse.json({ received: true });
}

Step 7:本地测试 webhook

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

# 登录
stripe login

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

# 输出类似:
# > Ready! Your webhook signing secret is whsec_xxx (use this in .env)

# 触发测试事件
stripe trigger checkout.session.completed

Step 8:客户自服务门户

// app/api/portal/route.ts
export async function POST(req: NextRequest) {
  const user = await auth(req);
  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  
  const session = await stripe.billingPortal.sessions.create({
    customer: user.stripeCustomerId,
    return_url: `${process.env.NEXT_PUBLIC_BASE_URL}/account`,
  });
  
  return NextResponse.json({ url: session.url });
}
// components/ManageSubscription.tsx
'use client';

export function ManageSubscription() {
  return (
    <button
      onClick={async () => {
        const res = await fetch('/api/portal', { method: 'POST' });
        const { url } = await res.json();
        window.location.href = url;
      }}
      className="bg-blue-600 text-white px-4 py-2 rounded"
    >
      管理订阅
    </button>
  );
}

5 个常见坑

坑 1:Webhook 签名验证失败

// ❌ 错误:用默认的 JSON parser
const body = await req.json();
event = stripe.webhooks.constructEvent(body, sig, secret); // 失败

// ✅ 正确:用原始 text
const body = await req.text();
event = stripe.webhooks.constructEvent(body, sig, secret);

坑 2:金额单位搞错

// ❌ 错误:金额写为 $9
amount: 9

// ✅ 正确:Stripe 用 cents
amount: 900  // $9.00

坑 3:未处理重复 webhook

Stripe 会最多重试 3 天,如果你的 webhook 返回 5xx,事件会被重发。

// ✅ 用 event.id 去重
const existingEvent = await db.webhookEvents.findById(event.id);
if (existingEvent) {
  return NextResponse.json({ received: true }); // 已处理,跳过
}
await db.webhookEvents.create({ id: event.id, type: event.type });
// ... 处理事件

坑 4:测试模式 vs 生产模式

# 测试模式的卡号
4242 4242 4242 4242  // 成功
4000 0000 0000 9995  // 余额不足
4000 0025 0000 3155  // 需要 3DS 验证

# 一定要在测试模式充分测试

坑 5:忘记处理 tax

2026 年起,很多国家要求 SaaS 收税:

// Stripe Tax(自动计算)
automatic_tax: { enabled: true },

// 或者使用 TaxJar / Avalara 集成

订阅指标计算

// lib/metrics.ts

// MRR (Monthly Recurring Revenue)
async function calculateMRR() {
  const subs = await db.subscriptions.find({ status: 'active' });
  
  return subs.reduce((total, sub) => {
    if (sub.interval === 'monthly') {
      return total + sub.amount;
    } else { // yearly
      return total + sub.amount / 12;
    }
  }, 0);
}

// Churn Rate(月度流失率)
async function calculateChurn(month: string) {
  const startCount = await db.subscriptions.count({
    status: 'active',
    createdAt: { $lt: new Date(month + '-01') },
  });
  const churned = await db.subscriptions.count({
    status: 'canceled',
    canceledAt: { 
      $gte: new Date(month + '-01'),
      $lt: new Date(month + '-31'),
    },
  });
  return churned / startCount;
}

// LTV (Lifetime Value)
async function calculateLTV() {
  const mrr = await calculateMRR();
  const churn = await calculateChurn(currentMonth());
  return mrr / (churn || 0.05); // 假设保底流失 5%
}

我的看法

Stripe Billing 是事实标准

  1. 集成简单:相比 Paddle / Lemon Squeezy,文档最完整
  2. 覆盖广:全球 47 国,无需自建支付
  3. 合规无忧:PCI DSS Level 1,税务自动化
  4. 成本可控:2.9% + $0.30/笔,没有月费

但不要假设 Stripe 解决一切

  • 国内支付:需要对接微信 / 支付宝(Ping++ / 虎皮椒支付)
  • B2B 大客户:可能需要 PO / 发票定制
  • 超高 ARPU:可以考虑直接对接银行

未来值得关注:

  • Stripe Crypto:加密货币支付
  • Stripe Issuing:发自己的信用卡
  • Stripe Treasury:银行账户服务

参考


本文代码基于 Next.js 16 + Stripe 2026-08 API 版本,集成示例可直接复制使用。

📚 同主题文章

🚀 独立开发 / OPC 分类更多