ProductReadyProductReady

支付与账单

为你的 ProductReady 应用添加支付处理和订阅计费功能

支付与账单

为你的 ProductReady 应用添加支付处理订阅计费功能。本指南涵盖:

  • ✅ 一次性支付
  • ✅ 循环订阅
  • ✅ 基于使用量的计费
  • ✅ 支付事件 Webhook
  • ✅ 客户门户

ProductReady 默认使用 Creem - 一个处理税务和合规的商户记录服务商。查看计费文档了解完整的多服务商设置。本指南展示如何添加 Stripe 作为替代方案。


快速设置

安装 Stripe

pnpm add stripe @stripe/stripe-js
pnpm add -D @types/stripe

获取 Stripe 密钥

  1. stripe.com 创建账户
  2. 从 Dashboard → Developers → API keys 获取 API 密钥
  3. 添加到 .env:
# .env
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_... # 从 webhooks 设置获取

创建 Stripe 客户端

创建 src/lib/stripe.ts:

import Stripe from 'stripe';

if (!process.env.STRIPE_SECRET_KEY) {
  throw new Error('Missing STRIPE_SECRET_KEY');
}

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
  apiVersion: '2024-11-20.acacia',
  typescript: true,
});

一次性支付

创建结账会话

添加 tRPC 端点用于创建结账:

// src/server/routers/payments.ts
import { stripe } from '~/lib/stripe';
import { createTRPCRouter, protectedProcedure } from '../trpc';
import { z } from 'zod';

export const paymentsRouter = createTRPCRouter({
  createCheckout: protectedProcedure
    .input(z.object({
      priceId: z.string(), // Stripe Price ID
      successUrl: z.string().url(),
      cancelUrl: z.string().url(),
    }))
    .mutation(async ({ ctx, input }) => {
      const session = await stripe.checkout.sessions.create({
        customer_email: ctx.session.user.email,
        line_items: [
          {
            price: input.priceId,
            quantity: 1,
          },
        ],
        mode: 'payment',
        success_url: input.successUrl,
        cancel_url: input.cancelUrl,
        metadata: {
          userId: ctx.session.user.id,
        },
      });

      return { url: session.url };
    }),
});

src/server/routers/index.ts 中注册路由器:

import { paymentsRouter } from './payments';

export const appRouter = createTRPCRouter({
  agentTasks: agentTasksRouter,
  posts: postsRouter,
  payments: paymentsRouter, // ← 添加这个
});

在前端使用

'use client';

import { trpc } from '~/lib/trpc/client';
import { Button } from 'kui/button';

export function BuyButton({ priceId }: { priceId: string }) {
  const createCheckout = trpc.payments.createCheckout.useMutation();

  async function handlePurchase() {
    const { url } = await createCheckout.mutateAsync({
      priceId,
      successUrl: `${window.location.origin}/success`,
      cancelUrl: `${window.location.origin}/pricing`,
    });

    if (url) {
      window.location.href = url;
    }
  }

  return (
    <Button onClick={handlePurchase} disabled={createCheckout.isPending}>
      {createCheckout.isPending ? '加载中...' : '立即购买'}
    </Button>
  );
}

订阅计费

创建订阅计划

在 Stripe Dashboard 或通过 API 创建产品:

// 创建产品(一次性设置)
const product = await stripe.products.create({
  name: 'Pro 计划',
  description: '完整访问所有功能',
});

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

订阅用户

// src/server/routers/subscriptions.ts
export const subscriptionsRouter = createTRPCRouter({
  subscribe: protectedProcedure
    .input(z.object({
      priceId: z.string(),
    }))
    .mutation(async ({ ctx, input }) => {
      // 创建或获取 Stripe 客户
      let customerId = ctx.session.user.stripeCustomerId;
      
      if (!customerId) {
        const customer = await stripe.customers.create({
          email: ctx.session.user.email,
          metadata: {
            userId: ctx.session.user.id,
          },
        });
        
        customerId = customer.id;
        
        // 保存到数据库
        await ctx.db
          .update(users)
          .set({ stripeCustomerId: customerId })
          .where(eq(users.id, ctx.session.user.id));
      }

      // 为订阅创建结账会话
      const session = await stripe.checkout.sessions.create({
        customer: customerId,
        line_items: [{ price: input.priceId, quantity: 1 }],
        mode: 'subscription',
        success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?subscribed=true`,
        cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
      });

      return { url: session.url };
    }),

  // 获取当前订阅
  getSubscription: protectedProcedure.query(async ({ ctx }) => {
    const customerId = ctx.session.user.stripeCustomerId;
    if (!customerId) return null;

    const subscriptions = await stripe.subscriptions.list({
      customer: customerId,
      status: 'active',
      limit: 1,
    });

    return subscriptions.data[0] || null;
  }),

  // 取消订阅
  cancel: protectedProcedure.mutation(async ({ ctx }) => {
    const customerId = ctx.session.user.stripeCustomerId;
    if (!customerId) throw new Error('未找到客户 ID');

    const subscriptions = await stripe.subscriptions.list({
      customer: customerId,
      status: 'active',
    });

    if (subscriptions.data[0]) {
      await stripe.subscriptions.cancel(subscriptions.data[0].id);
    }

    return { success: true };
  }),
});

Webhooks

在服务器端处理 Stripe 事件:

设置 Webhook 端点

创建 src/app/api/webhooks/stripe/route.ts:

import { headers } from 'next/headers';
import { stripe } from '~/lib/stripe';
import { db } from '~/db';
import { users } from '~/db/schema';
import { eq } from 'drizzle-orm';
import type Stripe from 'stripe';

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

  if (!signature) {
    return new Response('无签名', { status: 400 });
  }

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    return new Response(`Webhook 错误: ${err.message}`, { status: 400 });
  }

  // 处理事件
  switch (event.type) {
    case 'checkout.session.completed': {
      const session = event.data.object as Stripe.Checkout.Session;
      
      // 更新用户订阅状态
      if (session.mode === 'subscription') {
        await db
          .update(users)
          .set({
            subscriptionStatus: 'active',
            subscriptionId: session.subscription as string,
          })
          .where(eq(users.stripeCustomerId, session.customer as string));
      }
      break;
    }

    case 'customer.subscription.deleted': {
      const subscription = event.data.object as Stripe.Subscription;
      
      await db
        .update(users)
        .set({
          subscriptionStatus: 'cancelled',
        })
        .where(eq(users.stripeCustomerId, subscription.customer as string));
      break;
    }

    case 'customer.subscription.updated': {
      const subscription = event.data.object as Stripe.Subscription;
      
      await db
        .update(users)
        .set({
          subscriptionStatus: subscription.status,
        })
        .where(eq(users.stripeCustomerId, subscription.customer as string));
      break;
    }

    case 'invoice.payment_failed': {
      const invoice = event.data.object as Stripe.Invoice;
      
      // 发送电子邮件通知或处理支付失败
      console.error('支付失败:', invoice.id);
      break;
    }
  }

  return new Response(JSON.stringify({ received: true }), { status: 200 });
}

在 Stripe 中配置 Webhook

  1. 前往 Stripe Dashboard → Developers → Webhooks
  2. 添加端点: https://yourapp.com/api/webhooks/stripe
  3. 选择事件:
    • checkout.session.completed
    • customer.subscription.deleted
    • customer.subscription.updated
    • invoice.payment_failed
  4. 复制 webhook 签名密钥到 .env

客户门户

让用户管理他们的订阅:

export const subscriptionsRouter = createTRPCRouter({
  createPortalSession: protectedProcedure.mutation(async ({ ctx }) => {
    const customerId = ctx.session.user.stripeCustomerId;
    if (!customerId) throw new Error('未找到客户 ID');

    const session = await stripe.billingPortal.sessions.create({
      customer: customerId,
      return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/settings/billing`,
    });

    return { url: session.url };
  }),
});

在组件中使用:

export function ManageBillingButton() {
  const createPortal = trpc.subscriptions.createPortalSession.useMutation();

  async function handleClick() {
    const { url } = await createPortal.mutateAsync();
    window.location.href = url;
  }

  return (
    <Button onClick={handleClick}>
      管理订阅
    </Button>
  );
}

数据库架构

向用户表添加账单字段:

// src/db/schema/users.ts
export const users = pgTable('users', {
  // ... 现有字段 ...
  
  // Stripe 字段
  stripeCustomerId: text('stripe_customer_id'),
  subscriptionId: text('subscription_id'),
  subscriptionStatus: text('subscription_status')
    .$type<'active' | 'cancelled' | 'past_due' | 'trialing'>(),
  subscriptionPriceId: text('subscription_price_id'),
  subscriptionCurrentPeriodEnd: timestamp('subscription_current_period_end'),
});

运行迁移:

pnpm db:generate
pnpm db:migrate

定价页面示例

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

import { trpc } from '~/lib/trpc/client';
import { Button } from 'kui/button';
import { Card } from 'kui/card';

const plans = [
  {
    name: '入门版',
    price: '$9',
    priceId: 'price_starter_monthly',
    features: ['每月 10 个代理任务', '基础支持', '1 个团队成员'],
  },
  {
    name: '专业版',
    price: '$29',
    priceId: 'price_pro_monthly',
    features: ['每月 100 个代理任务', '优先支持', '5 个团队成员'],
  },
  {
    name: '企业版',
    price: '$99',
    priceId: 'price_enterprise_monthly',
    features: ['无限代理任务', '24/7 支持', '无限团队成员'],
  },
];

export default function PricingPage() {
  const subscribe = trpc.subscriptions.subscribe.useMutation();

  async function handleSubscribe(priceId: string) {
    const { url } = await subscribe.mutateAsync({ priceId });
    if (url) window.location.href = url;
  }

  return (
    <div className="container mx-auto py-12">
      <h1 className="text-4xl font-bold text-center mb-12">定价</h1>
      
      <div className="grid md:grid-cols-3 gap-8">
        {plans.map((plan) => (
          <Card key={plan.name} className="p-6">
            <h3 className="text-2xl font-bold">{plan.name}</h3>
            <p className="text-4xl font-bold my-4">{plan.price}<span className="text-sm">/月</span></p>
            
            <ul className="space-y-2 mb-6">
              {plan.features.map((feature) => (
                <li key={feature}>✓ {feature}</li>
              ))}
            </ul>
            
            <Button
              onClick={() => handleSubscribe(plan.priceId)}
              className="w-full"
            >
              订阅
            </Button>
          </Card>
        ))}
      </div>
    </div>
  );
}

功能限制

根据订阅限制功能:

// src/lib/subscription.ts
export function canUseFeature(user: User, feature: string): boolean {
  if (!user.subscriptionStatus || user.subscriptionStatus === 'cancelled') {
    return false; // 免费层
  }

  const limits = {
    starter: {
      agentTasks: 10,
      teamMembers: 1,
    },
    pro: {
      agentTasks: 100,
      teamMembers: 5,
    },
    enterprise: {
      agentTasks: Infinity,
      teamMembers: Infinity,
    },
  };

  // 根据订阅层级检查
  return true; // 实现你的逻辑
}

在 tRPC 中使用:

export const agentTasksRouter = createTRPCRouter({
  create: protectedProcedure
    .input(insertAgentTaskSchema)
    .mutation(async ({ ctx, input }) => {
      // 检查订阅限制
      if (!canUseFeature(ctx.session.user, 'createAgentTask')) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: '升级以创建更多代理任务',
        });
      }

      // 创建代理任务...
    }),
});

测试

使用 Stripe 测试模式:

# 测试卡号
4242 4242 4242 4242  # 成功
4000 0000 0000 0002  # 拒绝
4000 0025 0000 3155  # 3D 安全验证

本地测试 webhooks:

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

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

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

最佳实践

✅ 应该做

  • 使用 webhooks - 不要依赖客户端成功回调
  • 存储客户 ID - 在数据库中保存 Stripe 客户 ID
  • 处理失败 - 优雅地处理支付被拒绝
  • 彻底测试 - 使用测试模式和测试卡
  • 保护 webhooks - 验证 webhook 签名
  • 显示加载状态 - 结账可能需要几秒钟

❌ 不应该做

  • 不要信任客户端 - 在服务器端验证支付
  • 不要暴露密钥 - 永远不要发送到前端
  • 不要跳过 webhooks - 它们对可靠性至关重要
  • 不要忽略错误 - 记录和处理支付失败
  • 不要硬编码价格 - 使用 Stripe Price ID

下一步


准备好将你的 SaaS 变现了!🚀

On this page