mcpField
P
payment-integration

payment-integration

github.com/travisjneuman开发106136 次安装1 天前更新
代码仓库

关于 payment-integration

payment-integration 是为 Web 和移动应用开发者提供的支付与订阅集成技能,重点介绍 Stripe,也涵盖 RevenueCat 移动订阅。内容包括结账、Webhook 幂等处理、订阅生命周期和按用量计费等实现模式。

主要功能

  1. 01Stripe Checkout 与 Elements 集成
  2. 02Webhook 签名验证与幂等处理
  3. 03订阅生命周期管理
  4. 04客户自助计费门户
  5. 05按用量计费模式
  6. 06RevenueCat 移动订阅

适用场景

  1. 01为应用添加支付与订阅
  2. 02处理支付状态和重复 Webhook 事件
  3. 03构建按用量计费方案
  4. 04集成移动应用内购

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/travisjneuman/.claude --skill payment-integration

安装后在 Claude Code 中输入/payment-integration确认已加载。

详细介绍

支付集成

概述

此技能涵盖在 Web 和移动应用中实现支付处理和订阅计费。内容涉及 Stripe 集成(Checkout、Elements、PaymentIntents)、订阅生命周期管理、带幂等性的 Webhook 处理、PCI 合规、计量和按用量计费、税费计算、退款处理,以及用于移动订阅的 RevenueCat。

在添加支付处理、构建订阅计费、处理 Stripe Webhook、实现定价页面、管理支付失败或集成移动应用内购买时,请使用此技能。


核心原则

  1. 切勿处理原始银行卡数据 - 使用 Stripe Elements 或 Checkout,让卡号完全不经过你的服务器。这样可以保持在 PCI SAQ-A(最简单的合规级别),而非 SAQ-D。
  2. Webhook 是事实来源 - 切勿信任客户端的支付确认。客户端上的 PaymentIntent 成功并不意味着什么,只有 Webhook 处理程序确认 payment_intent.succeeded 后才算数。围绕 Webhook 事件构建系统。
  3. 处处实现幂等性 - Webhook 可能会被多次发送。每个 Webhook 处理程序都必须具备幂等性。使用 Stripe 的事件 ID 作为去重键。
  4. 妥善处理失败 - 支付可能因多种原因失败(余额不足、卡片过期、欺诈检测)。在订阅生命周期中构建重试流程、催款邮件和优雅降级机制。
  5. 使用 Stripe 的测试模式 - 使用测试 API 密钥、测试卡号和测试时钟来测试订阅生命周期。切勿使用真实支付方式进行测试。

关键模式

模式 1:使用 Stripe Checkout 进行订阅

适用场景: 希望 Stripe 处理整个结账界面,包括支付表单、优惠券代码和税费计算。

实现:

// Server - Create Checkout Session
// app/api/checkout/route.ts
import Stripe from "stripe";
import { NextRequest, NextResponse } from "next/server";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2024-06-20",
});

export async function POST(req: NextRequest) {
  const { priceId, userId, email } = await req.json();

  // Get or create Stripe customer
  let customerId = await getStripeCustomerId(userId);
  if (!customerId) {
    const customer = await stripe.customers.create({
      email,
      metadata: { userId },
    });
    customerId = customer.id;
    await saveStripeCustomerId(userId, customerId);
  }

  const session = await stripe.checkout.sessions.create({
    customer: customerId,
    mode: "subscription",
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${process.env.NEXT_PUBLIC_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`,
    subscription_data: {
      metadata: { userId },
      trial_period_days: 14,
    },
    // Enable automatic tax calculation
    automatic_tax: { enabled: true },
    // Allow promo codes
    allow_promotion_codes: true,
    // Collect billing address for tax
    billing_address_collection: "required",
    // Customer portal for self-service management
    customer_update: {
      address: "auto",
      name: "auto",
    },
  });

  return NextResponse.json({ url: session.url });
}
// Client - Redirect to Checkout
function PricingCard({ plan }: { plan: PricingPlan }) {
  const [loading, setLoading] = useState(false);

  const handleSubscribe = async () => {
    setLoading(true);
    try {
      const res = await fetch("/api/checkout", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          priceId: plan.stripePriceId,
          userId: user.id,
          email: user.email,
        }),
      });

      const { url } = await res.json();
      window.location.href = url; // Redirect to Stripe Checkout
    } catch (error) {
      toast.error("Failed to start checkout");
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="pricing-card">
      <h3>{plan.name}</h3>
      <p className="price">${plan.price}/mo</p>
      <ul>
        {plan.features.map((f) => (
          <li key={f}>{f}</li>
        ))}
      </ul>
      <button onClick={handleSubscribe} disabled={loading}>
        {loading ? "Redirecting..." : "Subscribe"}
      </button>
    </div>
  );
}

原因: Stripe Checkout 可处理 PCI 合规、3D Secure 身份验证、税费计算、促销代码和本地化。构建自己的结账表单需要数百小时的工作,还会带来持续的 PCI 合规负担。除非有充分理由构建自定义界面,否则请使用 Checkout。


模式 2:带幂等性的 Webhook 处理程序

适用场景: 任何 Stripe 集成。Webhook 是了解支付状态的唯一可靠方式。

实现:

// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

// Process each event type
const eventHandlers: Record<string, (event: Stripe.Event) => Promise<void>> = {
  "checkout.session.completed": async (event) => {
    const session = event.data.object as Stripe.Checkout.Session;
    const userId = session.metadata?.userId ?? session.subscription?.toString();

    await db.user.update({
      where: { stripeCustomerId: session.customer as string },
      data: {
        subscriptionId: session.subscription as string,
        subscriptionStatus: "active",
      },
    });
  },

  "customer.subscription.updated": async (event) => {
    const subscription = event.data.object as Stripe.Subscription;

    await db.user.update({
      where: { stripeCustomerId: subscription.customer as string },
      data: {
        subscriptionStatus: subscription.status,
        planId: subscription.items.data[0].price.id,
        currentPeriodEnd: new Date(subscription.current_period_end * 1000),
        cancelAtPeriodEnd: subscription.cancel_at_period_end,
      },
    });
  },

  "customer.subscription.deleted": async (event) => {
    const subscription = event.data.object as Stripe.Subscription;

    await db.user.update({
      where: { stripeCustomerId: subscription.customer as string },
      data: {
        subscriptionStatus: "canceled",
        planId: null,
      },
    });
  },

  "invoice.payment_failed": async (event) => {
    const invoice = event.data.object as Stripe.Invoice;

    await db.user.update({
      where: { stripeCustomerId: invoice.customer as string },
      data: { subscriptionStatus: "past_due" },
    });

    // Send dunning email
    await sendDunningEmail(invoice.customer as string, {
      amountDue: invoice.amount_due,
      nextRetry: invoice.next_payment_attempt
        ? new Date(invoice.next_payment_attempt * 1000)
        : null,
    });
  },

  "invoice.paid": async (event) => {
    const invoice = event.data.object as Stripe.Invoice;

    await db.user.update({
      where: { stripeCustomerId: invoice.customer as string },
      data: { subscriptionStatus: "active" },
    });
  },
};

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

  let event: Stripe.Event;

  // 1. Verify webhook signature (prevents spoofing)
  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    console.error("Webhook signature verification failed:", err);
    return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
  }

  // 2. Idempotency check - skip already-processed events
  const alreadyProcessed = await db.stripeEvent.findUnique({
    where: { eventId: event.id },
  });

  if (alreadyProcessed) {
    return NextResponse.json({ received: true });
  }

  // 3. Process the event
  const handler = eventHandlers[event.type];
  if (handler) {
    try {
      await handler(event);

      // 4. Record processed event for idempotency
      await db.stripeEvent.create({
        data: {
          eventId: event.id,
          type: event.type,
          processedAt: new Date(),
        },
      });
    } catch (err) {
      console.error(`Error processing ${event.type}:`, err);
      // Return 500 so Stripe retries
      return NextResponse.json({ error: "Processing failed" }, { status: 500 });
    }
  }

  return NextResponse.json({ received: true });
}

原因: Stripe 至少会发送一次 Webhook,因此可能出现重复。幂等性检查(存储已处理事件 ID)可防止重复处理。签名验证可防止伪造 Webhook 攻击。处理程序出错时返回 500,会触发 Stripe 以指数退避方式自动重试。


模式 3:用于自助服务的 Customer Portal

适用场景: 让客户自行管理订阅(升级、降级、取消、更新支付方式),无需联系支持人员。

实现:

// Create portal session
// app/api/billing/portal/route.ts
export async function POST(req: NextRequest) {
  const { userId } = await req.json();

  const user = await db.user.findUniqueOrThrow({
    where: { id: userId },
    select: { stripeCustomerId: true },
  });

  if (!user.stripeCustomerId) {
    return NextResponse.json({ error: "No billing account" }, { status: 400 });
  }

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

  return NextResponse.json({ url: session.url });
}
// Configure the portal in Stripe (do this once, via API or Dashboard)
await stripe.billingPortal.configurations.create({
  business_profile: {
    headline: "Manage your subscription",
  },
  features: {
    subscription_update: {
      enabled: true,
      default_allowed_updates: ["price", "quantity"],
      proration_behavior: "create_prorations",
      products: [
        {
          product: "prod_xxx",
          prices: ["price_monthly", "price_annual"],
        },
      ],
    },
    subscription_cancel: {
      enabled: true,
      mode: "at_period_end", // Don't cancel immediately
      cancellation_reason: {
        enabled: true,
        options: [
          "too_expensive",
          "missing_features",
          "switched_service",
          "unused",
          "other",
        ],
      },
    },
    payment_method_update: { enabled: true },
    invoice_history: { enabled: true },
  },
});

原因: 自助计费可减少 60-80% 的支持工单。Stripe 的 Customer Portal 是一种托管解决方案,可处理套餐变更、按比例计费、收集取消原因、更新支付方式和查看发票历史记录——所有这些都无需构建自定义界面。


模式 4:按用量计费

适用场景: 定价依据消耗量(API 调用、存储空间、计算分钟数、席位数),而非固定费率订阅。

实现:

// Report usage to Stripe
async function reportUsage(
  subscriptionItemId: string,
  quantity: number,
  timestamp?: number
): Promise<void> {
  await stripe.subscriptionItems.createUsageRecord(subscriptionItemId, {
    quantity,
    timestamp: timestamp ?? Math.floor(Date.now() / 1000),
    action: "increment", // Add to existing usage (vs "set" to replace)
  });
}

// Background job: aggregate and report usage hourly
async function reportHourlyUsage(): Promise<void> {
  const activeSubscriptions = await db.subscription.findMany({
    where: { status: "active", plan: { billingModel: "usage" } },
    include: { user: true },
  });

  for (const sub of activeSubscriptions) {
    const usage = await getHourlyUsage(sub.userId);

    if (usage > 0) {
      await reportUsage(sub.stripeSubscriptionItemId, usage);

      await db.usageReport.create({
        data: {
          subscriptionId: sub.id,
          quantity: usage,
          reportedAt: new Date(),
        },
      });
    }
  }
}

// Track usage in your application
async function trackApiCall(userId: string, endpoint: string): Promise<void> {
  // Increment usage counter (Redis for speed)
  const key = `usage:${userId}:${getCurrentHour()}`;
  await redis.incr(key);
  await redis.expire(key, 86400 * 7); // Keep for 7 days

  // Check if user is approaching their limit
  const currentUsage = await getCurrentMonthUsage(userId);
  const limit = await getUserPlanLimit(userId);

  if (currentUsage >= limit * 0.8) {
    await sendUsageWarningEmail(userId, currentUsage, limit);
  }

  if (currentUsage >= limit) {
    throw new UsageLimitExceededError(userId, currentUsage, limit);
  }
}

原因: 按用量计费可让费用与价值相匹配——客户按实际用量付费。按小时汇总可减少对 Stripe 的 API 调用,同时确保用量数据足够新,可用于开具发票。基于 Redis 的跟踪可为速率限制提供亚毫秒级的用量检查。


Stripe 测试卡参考

卡号场景
4242 4242 4242 4242支付成功
4000 0000 0000 3220需要 3D Secure 验证
4000 0000 0000 9995支付被拒(余额不足)
4000 0000 0000 0341添加到客户失败
4000 0025 0000 3155需要进行身份验证

使用任意未来的到期日期和任意 3 位 CVC。


反模式

反模式问题所在更好的做法
信任客户端的支付确认用户可以伪造成功状态以 Webhook 处理程序为准
在服务器上处理原始卡号需要符合 PCI SAQ-D(成本非常高)使用 Stripe Elements 或 Checkout
Webhook 处理程序没有幂等性可能重复扣款、重复开通服务根据事件 ID 去重
立即取消订阅用户会在计费周期结束前失去访问权限在周期结束时取消(cancel_at_period_end)
在应用中硬编码价格无法在不部署的情况下更改价格将价格 ID 存储在数据库或环境变量中
没有针对支付失败的催款流程收入悄然流失(非自愿流失)自动重试 + 催款邮件
使用真实支付方式进行测试可能产生真实扣款,且无法使用测试时钟仅使用 Stripe 测试模式

检查清单

  • Stripe API 密钥存储在环境变量中(绝不写入代码)
  • 已启用 Webhook 签名验证
  • Webhook 处理程序具有幂等性(按事件 ID 去重)
  • 已处理所有订阅状态变更(已创建、已更新、已取消、逾期未付)
  • 已实现支付失败催款流程(邮件 + 重试)
  • 已配置客户门户,以便用户自助管理账单
  • 所有开发和预发布环境均使用测试模式
  • 已确认 PCI 合规级别(使用 Stripe Checkout/Elements 时为 SAQ-A)
  • 已启用税费计算(Stripe Tax 或外部服务商)
  • 已为套餐变更配置按比例计费(升级/降级)
  • 已在 Stripe Dashboard 中注册 Webhook 端点
  • 已安装 Stripe CLI,以便在本地测试 Webhook(stripe listen --forward-to)

相关资源

  • 技能: authentication-patterns(用于账单的用户身份验证)、email-systems(催款邮件)
  • 技能: monitoring-observability(支付失败告警)
  • 规则: docs/reference/stacks/fullstack-nextjs-nestjs.md(API 路由模式)

评论

支持 Markdown